Compare commits
4 Commits
954e37c2ca
...
73562cd525
| Author | SHA1 | Date | |
|---|---|---|---|
| 73562cd525 | |||
| 1ce25caa35 | |||
| 7b4b455c7d | |||
| 37c80f01af |
55
TODO.md
55
TODO.md
@@ -4,21 +4,15 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort
|
||||
|
||||
## 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
|
||||
- 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
|
||||
- [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
|
||||
- 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
|
||||
|
||||
## 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
|
||||
- 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
|
||||
@@ -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
|
||||
- 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
|
||||
- [ ] `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
|
||||
|
||||
@@ -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
|
||||
- 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
|
||||
- [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
|
||||
- [ ] `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` **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
|
||||
- [ ] `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` **Exponential backoff on events WS reconnect** — currently fixed 3s retry
|
||||
|
||||
## 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
|
||||
- [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 0–255 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
|
||||
|
||||
@@ -157,6 +157,23 @@ def get_sync_clock_manager() -> SyncClockManager:
|
||||
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(
|
||||
device_store: DeviceStore,
|
||||
template_store: TemplateStore,
|
||||
|
||||
@@ -9,6 +9,7 @@ from starlette.websockets import WebSocket, WebSocketDisconnect
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_audio_source_store,
|
||||
get_audio_template_store,
|
||||
get_color_strip_store,
|
||||
@@ -84,6 +85,7 @@ async def create_audio_source(
|
||||
audio_template_id=data.audio_template_id,
|
||||
tags=data.tags,
|
||||
)
|
||||
fire_entity_event("audio_source", "created", source.id)
|
||||
return _to_response(source)
|
||||
except ValueError as 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,
|
||||
tags=data.tags,
|
||||
)
|
||||
fire_entity_event("audio_source", "updated", source_id)
|
||||
return _to_response(source)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -146,6 +149,7 @@ async def delete_audio_source(
|
||||
)
|
||||
|
||||
store.delete_source(source_id)
|
||||
fire_entity_event("audio_source", "deleted", source_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from starlette.websockets import WebSocket, WebSocketDisconnect
|
||||
|
||||
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 (
|
||||
AudioEngineInfo,
|
||||
AudioEngineListResponse,
|
||||
@@ -66,6 +66,7 @@ async def create_audio_template(
|
||||
engine_config=data.engine_config, description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
fire_entity_event("audio_template", "created", template.id)
|
||||
return AudioTemplateResponse(
|
||||
id=template.id, name=template.name, engine_type=template.engine_type,
|
||||
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,
|
||||
description=data.description, tags=data.tags,
|
||||
)
|
||||
fire_entity_event("audio_template", "updated", template_id)
|
||||
return AudioTemplateResponse(
|
||||
id=t.id, name=t.name, engine_type=t.engine_type,
|
||||
engine_config=t.engine_config, tags=getattr(t, 'tags', []),
|
||||
@@ -135,6 +137,7 @@ async def delete_audio_template(
|
||||
"""Delete an audio template."""
|
||||
try:
|
||||
store.delete_template(template_id, audio_source_store=audio_source_store)
|
||||
fire_entity_event("audio_template", "deleted", template_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
|
||||
@@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_automation_engine,
|
||||
get_automation_store,
|
||||
get_scene_preset_store,
|
||||
@@ -174,6 +175,7 @@ async def create_automation(
|
||||
if automation.enabled:
|
||||
await engine.trigger_evaluate()
|
||||
|
||||
fire_entity_event("automation", "created", automation.id)
|
||||
return _automation_to_response(automation, engine, request)
|
||||
|
||||
|
||||
@@ -273,6 +275,7 @@ async def update_automation(
|
||||
if automation.enabled:
|
||||
await engine.trigger_evaluate()
|
||||
|
||||
fire_entity_event("automation", "updated", automation_id)
|
||||
return _automation_to_response(automation, engine, request)
|
||||
|
||||
|
||||
@@ -296,6 +299,8 @@ async def delete_automation(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
fire_entity_event("automation", "deleted", automation_id)
|
||||
|
||||
|
||||
# ===== Enable/Disable =====
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSock
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_color_strip_store,
|
||||
get_picture_source_store,
|
||||
get_output_target_store,
|
||||
@@ -99,6 +100,10 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
|
||||
app_filter_mode=getattr(source, "app_filter_mode", None),
|
||||
app_filter_list=getattr(source, "app_filter_list", None),
|
||||
os_listener=getattr(source, "os_listener", None),
|
||||
speed=getattr(source, "speed", None),
|
||||
use_real_time=getattr(source, "use_real_time", None),
|
||||
latitude=getattr(source, "latitude", None),
|
||||
num_candles=getattr(source, "num_candles", None),
|
||||
overlay_active=overlay_active,
|
||||
tags=getattr(source, 'tags', []),
|
||||
created_at=source.created_at,
|
||||
@@ -191,8 +196,13 @@ async def create_color_strip_source(
|
||||
app_filter_mode=data.app_filter_mode,
|
||||
app_filter_list=data.app_filter_list,
|
||||
os_listener=data.os_listener,
|
||||
speed=data.speed,
|
||||
use_real_time=data.use_real_time,
|
||||
latitude=data.latitude,
|
||||
num_candles=data.num_candles,
|
||||
tags=data.tags,
|
||||
)
|
||||
fire_entity_event("color_strip_source", "created", source.id)
|
||||
return _css_to_response(source)
|
||||
|
||||
except ValueError as e:
|
||||
@@ -275,6 +285,10 @@ async def update_color_strip_source(
|
||||
app_filter_mode=data.app_filter_mode,
|
||||
app_filter_list=data.app_filter_list,
|
||||
os_listener=data.os_listener,
|
||||
speed=data.speed,
|
||||
use_real_time=data.use_real_time,
|
||||
latitude=data.latitude,
|
||||
num_candles=data.num_candles,
|
||||
tags=data.tags,
|
||||
)
|
||||
|
||||
@@ -284,6 +298,7 @@ async def update_color_strip_source(
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not hot-reload CSS stream {source_id}: {e}")
|
||||
|
||||
fire_entity_event("color_strip_source", "updated", source_id)
|
||||
return _css_to_response(source)
|
||||
|
||||
except ValueError as e:
|
||||
@@ -327,6 +342,7 @@ async def delete_color_strip_source(
|
||||
"Remove it from the mapped source(s) first.",
|
||||
)
|
||||
store.delete_source(source_id)
|
||||
fire_entity_event("color_strip_source", "deleted", source_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
|
||||
@@ -12,6 +12,7 @@ from wled_controller.core.devices.led_client import (
|
||||
get_provider,
|
||||
)
|
||||
from wled_controller.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_device_store,
|
||||
get_output_target_store,
|
||||
get_processor_manager,
|
||||
@@ -146,6 +147,7 @@ async def create_device(
|
||||
zone_mode=device.zone_mode,
|
||||
)
|
||||
|
||||
fire_entity_event("device", "created", device.id)
|
||||
return _device_to_response(device)
|
||||
|
||||
except HTTPException:
|
||||
@@ -332,6 +334,7 @@ async def update_device(
|
||||
if update_data.zone_mode is not None:
|
||||
ds.zone_mode = update_data.zone_mode
|
||||
|
||||
fire_entity_event("device", "updated", device_id)
|
||||
return _device_to_response(device)
|
||||
|
||||
except ValueError as e:
|
||||
@@ -369,6 +372,7 @@ async def delete_device(
|
||||
# Delete from storage
|
||||
store.delete_device(device_id)
|
||||
|
||||
fire_entity_event("device", "deleted", device_id)
|
||||
logger.info(f"Deleted device {device_id}")
|
||||
|
||||
except HTTPException:
|
||||
|
||||
@@ -12,6 +12,7 @@ from PIL import Image
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_color_strip_store,
|
||||
get_device_store,
|
||||
get_pattern_template_store,
|
||||
@@ -181,6 +182,7 @@ async def create_target(
|
||||
except ValueError as 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)
|
||||
|
||||
except HTTPException:
|
||||
@@ -319,6 +321,7 @@ async def update_target(
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
fire_entity_event("output_target", "updated", target_id)
|
||||
return _target_to_response(target)
|
||||
|
||||
except HTTPException:
|
||||
@@ -354,6 +357,7 @@ async def delete_target(
|
||||
# Delete from store
|
||||
target_store.delete_target(target_id)
|
||||
|
||||
fire_entity_event("output_target", "deleted", target_id)
|
||||
logger.info(f"Deleted target {target_id}")
|
||||
|
||||
except ValueError as e:
|
||||
|
||||
@@ -4,6 +4,7 @@ from fastapi import APIRouter, HTTPException, Depends
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_pattern_template_store,
|
||||
get_output_target_store,
|
||||
)
|
||||
@@ -73,6 +74,7 @@ async def create_pattern_template(
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
fire_entity_event("pattern_template", "created", template.id)
|
||||
return _pat_template_to_response(template)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -117,6 +119,7 @@ async def update_pattern_template(
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
fire_entity_event("pattern_template", "updated", template_id)
|
||||
return _pat_template_to_response(template)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -143,6 +146,7 @@ async def delete_pattern_template(
|
||||
"Please reassign those targets before deleting.",
|
||||
)
|
||||
store.delete_template(template_id)
|
||||
fire_entity_event("pattern_template", "deleted", template_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
|
||||
@@ -12,6 +12,7 @@ from fastapi.responses import Response
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_picture_source_store,
|
||||
get_output_target_store,
|
||||
get_pp_template_store,
|
||||
@@ -199,6 +200,7 @@ async def create_picture_source(
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
fire_entity_event("picture_source", "created", stream.id)
|
||||
return _stream_to_response(stream)
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -244,6 +246,7 @@ async def update_picture_source(
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
fire_entity_event("picture_source", "updated", stream_id)
|
||||
return _stream_to_response(stream)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -271,6 +274,7 @@ async def delete_picture_source(
|
||||
"Please reassign those targets before deleting.",
|
||||
)
|
||||
store.delete_stream(stream_id)
|
||||
fire_entity_event("picture_source", "deleted", stream_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
|
||||
@@ -11,6 +11,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSock
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_picture_source_store,
|
||||
get_pp_template_store,
|
||||
get_template_store,
|
||||
@@ -84,6 +85,7 @@ async def create_pp_template(
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
fire_entity_event("pp_template", "created", template.id)
|
||||
return _pp_template_to_response(template)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -123,6 +125,7 @@ async def update_pp_template(
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
fire_entity_event("pp_template", "updated", template_id)
|
||||
return _pp_template_to_response(template)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -150,6 +153,7 @@ async def delete_pp_template(
|
||||
"Please reassign those streams before deleting.",
|
||||
)
|
||||
store.delete_template(template_id)
|
||||
fire_entity_event("pp_template", "deleted", template_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_output_target_store,
|
||||
get_processor_manager,
|
||||
get_scene_preset_store,
|
||||
@@ -87,6 +88,7 @@ async def create_scene_preset(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
fire_entity_event("scene_preset", "created", preset.id)
|
||||
return _preset_to_response(preset)
|
||||
|
||||
|
||||
@@ -175,6 +177,7 @@ async def update_scene_preset(
|
||||
)
|
||||
except ValueError as 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)
|
||||
|
||||
|
||||
@@ -194,6 +197,7 @@ async def delete_scene_preset(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
fire_entity_event("scene_preset", "deleted", preset_id)
|
||||
|
||||
|
||||
# ===== Recapture =====
|
||||
@@ -259,4 +263,5 @@ async def activate_scene_preset(
|
||||
if not errors:
|
||||
logger.info(f"Scene preset '{preset.name}' activated successfully")
|
||||
|
||||
fire_entity_event("scene_preset", "updated", preset_id)
|
||||
return ActivateResponse(status=status, errors=errors)
|
||||
|
||||
@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_color_strip_store,
|
||||
get_sync_clock_manager,
|
||||
get_sync_clock_store,
|
||||
@@ -70,6 +71,7 @@ async def create_sync_clock(
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
fire_entity_event("sync_clock", "created", clock.id)
|
||||
return _to_response(clock, manager)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -110,6 +112,7 @@ async def update_sync_clock(
|
||||
# Hot-update runtime speed
|
||||
if data.speed is not None:
|
||||
manager.update_speed(clock_id, clock.speed)
|
||||
fire_entity_event("sync_clock", "updated", clock_id)
|
||||
return _to_response(clock, manager)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -133,6 +136,7 @@ async def delete_sync_clock(
|
||||
)
|
||||
manager.release_all_for(clock_id)
|
||||
store.delete_clock(clock_id)
|
||||
fire_entity_event("sync_clock", "deleted", clock_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@@ -152,6 +156,7 @@ async def pause_sync_clock(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
manager.pause(clock_id)
|
||||
fire_entity_event("sync_clock", "updated", clock_id)
|
||||
return _to_response(clock, manager)
|
||||
|
||||
|
||||
@@ -168,6 +173,7 @@ async def resume_sync_clock(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
manager.resume(clock_id)
|
||||
fire_entity_event("sync_clock", "updated", clock_id)
|
||||
return _to_response(clock, manager)
|
||||
|
||||
|
||||
@@ -184,4 +190,5 @@ async def reset_sync_clock(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
manager.reset(clock_id)
|
||||
fire_entity_event("sync_clock", "updated", clock_id)
|
||||
return _to_response(clock, manager)
|
||||
|
||||
@@ -10,6 +10,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSock
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_picture_source_store,
|
||||
get_pp_template_store,
|
||||
get_template_store,
|
||||
@@ -96,6 +97,7 @@ async def create_template(
|
||||
tags=template_data.tags,
|
||||
)
|
||||
|
||||
fire_entity_event("capture_template", "created", template.id)
|
||||
return TemplateResponse(
|
||||
id=template.id,
|
||||
name=template.name,
|
||||
@@ -156,6 +158,7 @@ async def update_template(
|
||||
tags=update_data.tags,
|
||||
)
|
||||
|
||||
fire_entity_event("capture_template", "updated", template_id)
|
||||
return TemplateResponse(
|
||||
id=template.id,
|
||||
name=template.name,
|
||||
@@ -202,6 +205,7 @@ async def delete_template(
|
||||
|
||||
# Proceed with deletion
|
||||
template_store.delete_template(template_id)
|
||||
fire_entity_event("capture_template", "deleted", template_id)
|
||||
|
||||
except HTTPException:
|
||||
raise # Re-raise HTTP exceptions as-is
|
||||
|
||||
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSock
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_output_target_store,
|
||||
get_processor_manager,
|
||||
get_value_source_store,
|
||||
@@ -100,6 +101,7 @@ async def create_value_source(
|
||||
auto_gain=data.auto_gain,
|
||||
tags=data.tags,
|
||||
)
|
||||
fire_entity_event("value_source", "created", source.id)
|
||||
return _to_response(source)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -150,6 +152,7 @@ async def update_value_source(
|
||||
)
|
||||
# Hot-reload running value streams
|
||||
pm.update_value_source(source_id)
|
||||
fire_entity_event("value_source", "updated", source_id)
|
||||
return _to_response(source)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -174,6 +177,7 @@ async def delete_value_source(
|
||||
)
|
||||
|
||||
store.delete_source(source_id)
|
||||
fire_entity_event("value_source", "deleted", source_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ class ColorStripSourceCreate(BaseModel):
|
||||
"""Request to create a color strip source."""
|
||||
|
||||
name: str = Field(description="Source name", min_length=1, max_length=100)
|
||||
source_type: Literal["picture", "picture_advanced", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input", "notification"] = Field(default="picture", description="Source type")
|
||||
source_type: Literal["picture", "picture_advanced", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input", "notification", "daylight", "candlelight"] = Field(default="picture", description="Source type")
|
||||
# picture-type fields
|
||||
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
|
||||
brightness: float = Field(default=1.0, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0)
|
||||
@@ -95,6 +95,12 @@ class ColorStripSourceCreate(BaseModel):
|
||||
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
|
||||
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
|
||||
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
|
||||
# daylight-type fields
|
||||
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0)
|
||||
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
|
||||
latitude: Optional[float] = Field(None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0)
|
||||
# candlelight-type fields
|
||||
num_candles: Optional[int] = Field(None, description="Number of independent candle sources (1-20)", ge=1, le=20)
|
||||
# sync clock
|
||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
@@ -149,6 +155,12 @@ class ColorStripSourceUpdate(BaseModel):
|
||||
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
|
||||
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
|
||||
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
|
||||
# daylight-type fields
|
||||
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0)
|
||||
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
|
||||
latitude: Optional[float] = Field(None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0)
|
||||
# candlelight-type fields
|
||||
num_candles: Optional[int] = Field(None, description="Number of independent candle sources (1-20)", ge=1, le=20)
|
||||
# sync clock
|
||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
||||
tags: Optional[List[str]] = None
|
||||
@@ -205,6 +217,12 @@ class ColorStripSourceResponse(BaseModel):
|
||||
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
|
||||
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
|
||||
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
|
||||
# daylight-type fields
|
||||
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier")
|
||||
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
|
||||
latitude: Optional[float] = Field(None, description="Latitude for daylight timing")
|
||||
# candlelight-type fields
|
||||
num_candles: Optional[int] = Field(None, description="Number of independent candle sources")
|
||||
# sync clock
|
||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
@@ -393,7 +393,7 @@ class AutomationEngine:
|
||||
|
||||
def _fire_event(self, automation_id: str, action: str) -> None:
|
||||
try:
|
||||
self._manager._fire_event({
|
||||
self._manager.fire_event({
|
||||
"type": "automation_state_changed",
|
||||
"automation_id": automation_id,
|
||||
"action": action,
|
||||
|
||||
247
server/src/wled_controller/core/processing/candlelight_stream.py
Normal file
247
server/src/wled_controller/core/processing/candlelight_stream.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""Candlelight LED stream — realistic per-LED candle flickering.
|
||||
|
||||
Implements CandlelightColorStripStream which produces warm, organic
|
||||
flickering across all LEDs using layered sine waves and value noise.
|
||||
Each "candle" is an independent flicker source that illuminates
|
||||
nearby LEDs with smooth falloff.
|
||||
"""
|
||||
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.processing.color_strip_stream import ColorStripStream
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.utils.timer import high_resolution_timer
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# ── Simple hash-based noise ──────────────────────────────────────────
|
||||
|
||||
_PERM = np.arange(256, dtype=np.int32)
|
||||
_rng = np.random.RandomState(seed=17)
|
||||
_rng.shuffle(_PERM)
|
||||
_PERM = np.concatenate([_PERM, _PERM]) # 512 entries for wrap-free indexing
|
||||
|
||||
|
||||
def _noise1d(x: np.ndarray) -> np.ndarray:
|
||||
"""Fast 1-D value noise (vectorized). Returns float32 in [0, 1]."""
|
||||
xi = x.astype(np.int32) & 255
|
||||
xf = x - np.floor(x)
|
||||
# Smoothstep
|
||||
u = xf * xf * (3.0 - 2.0 * xf)
|
||||
a = _PERM[xi].astype(np.float32) / 255.0
|
||||
b = _PERM[xi + 1].astype(np.float32) / 255.0
|
||||
return a + u * (b - a)
|
||||
|
||||
|
||||
class CandlelightColorStripStream(ColorStripStream):
|
||||
"""Color strip stream simulating realistic candle flickering.
|
||||
|
||||
Each LED flickers independently with warm tones. Multiple
|
||||
"candle sources" are distributed along the strip, each generating
|
||||
its own flicker pattern with smooth spatial falloff.
|
||||
"""
|
||||
|
||||
def __init__(self, source):
|
||||
self._colors_lock = threading.Lock()
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._fps = 30
|
||||
self._frame_time = 1.0 / 30
|
||||
self._clock = None
|
||||
self._led_count = 1
|
||||
self._auto_size = True
|
||||
# Scratch arrays
|
||||
self._s_bright: Optional[np.ndarray] = None
|
||||
self._s_noise: Optional[np.ndarray] = None
|
||||
self._s_x: Optional[np.ndarray] = None
|
||||
self._pool_n = 0
|
||||
self._update_from_source(source)
|
||||
|
||||
def _update_from_source(self, source) -> None:
|
||||
raw_color = getattr(source, "color", None)
|
||||
self._color = raw_color if isinstance(raw_color, list) and len(raw_color) == 3 else [255, 147, 41]
|
||||
self._intensity = float(getattr(source, "intensity", 1.0))
|
||||
self._num_candles = max(1, int(getattr(source, "num_candles", 3)))
|
||||
self._speed = float(getattr(source, "speed", 1.0))
|
||||
_lc = getattr(source, "led_count", 0)
|
||||
self._auto_size = not _lc
|
||||
self._led_count = _lc if _lc and _lc > 0 else 1
|
||||
with self._colors_lock:
|
||||
self._colors: Optional[np.ndarray] = None
|
||||
|
||||
def configure(self, device_led_count: int) -> None:
|
||||
if self._auto_size and device_led_count > 0:
|
||||
new_count = max(self._led_count, device_led_count)
|
||||
if new_count != self._led_count:
|
||||
self._led_count = new_count
|
||||
|
||||
@property
|
||||
def target_fps(self) -> int:
|
||||
return self._fps
|
||||
|
||||
@property
|
||||
def led_count(self) -> int:
|
||||
return self._led_count
|
||||
|
||||
def set_capture_fps(self, fps: int) -> None:
|
||||
self._fps = max(1, min(90, fps))
|
||||
self._frame_time = 1.0 / self._fps
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(
|
||||
target=self._animate_loop,
|
||||
name="css-candlelight",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
logger.info(f"CandlelightColorStripStream started (leds={self._led_count}, candles={self._num_candles})")
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=5.0)
|
||||
if self._thread.is_alive():
|
||||
logger.warning("CandlelightColorStripStream thread did not terminate within 5s")
|
||||
self._thread = None
|
||||
logger.info("CandlelightColorStripStream stopped")
|
||||
|
||||
def get_latest_colors(self) -> Optional[np.ndarray]:
|
||||
with self._colors_lock:
|
||||
return self._colors
|
||||
|
||||
def update_source(self, source) -> None:
|
||||
from wled_controller.storage.color_strip_source import CandlelightColorStripSource
|
||||
if isinstance(source, CandlelightColorStripSource):
|
||||
prev_led_count = self._led_count if self._auto_size else None
|
||||
self._update_from_source(source)
|
||||
if prev_led_count and self._auto_size:
|
||||
self._led_count = prev_led_count
|
||||
logger.info("CandlelightColorStripStream params updated in-place")
|
||||
|
||||
def set_clock(self, clock) -> None:
|
||||
self._clock = clock
|
||||
|
||||
# ── Animation loop ──────────────────────────────────────────────
|
||||
|
||||
def _animate_loop(self) -> None:
|
||||
_pool_n = 0
|
||||
_buf_a = _buf_b = None
|
||||
_use_a = True
|
||||
|
||||
try:
|
||||
with high_resolution_timer():
|
||||
while self._running:
|
||||
wall_start = time.perf_counter()
|
||||
frame_time = self._frame_time
|
||||
try:
|
||||
clock = self._clock
|
||||
if clock:
|
||||
if not clock.is_running:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
t = clock.get_time()
|
||||
speed = clock.speed * self._speed
|
||||
else:
|
||||
t = wall_start
|
||||
speed = self._speed
|
||||
|
||||
n = self._led_count
|
||||
if n != _pool_n:
|
||||
_pool_n = n
|
||||
_buf_a = np.empty((n, 3), dtype=np.uint8)
|
||||
_buf_b = np.empty((n, 3), dtype=np.uint8)
|
||||
self._s_bright = np.empty(n, dtype=np.float32)
|
||||
self._s_noise = np.empty(n, dtype=np.float32)
|
||||
self._s_x = np.arange(n, dtype=np.float32)
|
||||
|
||||
buf = _buf_a if _use_a else _buf_b
|
||||
_use_a = not _use_a
|
||||
|
||||
self._render_candlelight(buf, n, t, speed)
|
||||
|
||||
with self._colors_lock:
|
||||
self._colors = buf
|
||||
except Exception as e:
|
||||
logger.error(f"CandlelightColorStripStream animation error: {e}")
|
||||
|
||||
elapsed = time.perf_counter() - wall_start
|
||||
time.sleep(max(frame_time - elapsed, 0.001))
|
||||
except Exception as e:
|
||||
logger.error(f"Fatal CandlelightColorStripStream loop error: {e}", exc_info=True)
|
||||
finally:
|
||||
self._running = False
|
||||
|
||||
def _render_candlelight(self, buf: np.ndarray, n: int, t: float, speed: float) -> None:
|
||||
"""Render candle flickering into buf (n, 3) uint8.
|
||||
|
||||
Algorithm:
|
||||
- Place num_candles evenly along the strip
|
||||
- Each candle has independent layered-sine flicker
|
||||
- Spatial falloff: LEDs near a candle are brighter
|
||||
- Per-LED noise adds individual variation
|
||||
- Final brightness modulates the base warm color
|
||||
"""
|
||||
intensity = self._intensity
|
||||
num_candles = self._num_candles
|
||||
base_r, base_g, base_b = self._color[0], self._color[1], self._color[2]
|
||||
|
||||
bright = self._s_bright
|
||||
bright[:] = 0.0
|
||||
|
||||
# Candle positions: evenly distributed
|
||||
if num_candles == 1:
|
||||
positions = [n / 2.0]
|
||||
else:
|
||||
positions = [i * (n - 1) / (num_candles - 1) for i in range(num_candles)]
|
||||
|
||||
x = self._s_x[:n]
|
||||
|
||||
for ci, pos in enumerate(positions):
|
||||
# Independent flicker for this candle: layered sines at different frequencies
|
||||
# Use candle index as phase offset for independence
|
||||
offset = ci * 137.5 # golden-angle offset for non-repeating
|
||||
flicker = (
|
||||
0.40 * math.sin(2 * math.pi * speed * t * 3.7 + offset)
|
||||
+ 0.25 * math.sin(2 * math.pi * speed * t * 7.3 + offset * 0.7)
|
||||
+ 0.15 * math.sin(2 * math.pi * speed * t * 13.1 + offset * 1.3)
|
||||
+ 0.10 * math.sin(2 * math.pi * speed * t * 1.9 + offset * 0.3)
|
||||
)
|
||||
# Normalize flicker to [0.3, 1.0] range (candles never fully go dark)
|
||||
candle_brightness = 0.65 + 0.35 * flicker * intensity
|
||||
|
||||
# Spatial falloff: Gaussian centered on candle position
|
||||
# sigma proportional to strip length / num_candles
|
||||
sigma = max(n / (num_candles * 2.0), 2.0)
|
||||
dist = x - pos
|
||||
falloff = np.exp(-0.5 * (dist * dist) / (sigma * sigma))
|
||||
|
||||
bright += candle_brightness * falloff
|
||||
|
||||
# Per-LED noise for individual variation
|
||||
noise_x = x * 0.3 + t * speed * 5.0
|
||||
noise = _noise1d(noise_x)
|
||||
# Modulate brightness with noise (±15%)
|
||||
bright *= (0.85 + 0.30 * noise)
|
||||
|
||||
# Clamp to [0, 1]
|
||||
np.clip(bright, 0.0, 1.0, out=bright)
|
||||
|
||||
# Apply base color with brightness modulation
|
||||
# Candles emit warmer (more red, less blue) at lower brightness
|
||||
# Add slight color variation: dimmer = warmer
|
||||
warm_shift = (1.0 - bright) * 0.3
|
||||
r = bright * base_r
|
||||
g = bright * base_g * (1.0 - warm_shift * 0.5)
|
||||
b = bright * base_b * (1.0 - warm_shift)
|
||||
|
||||
buf[:, 0] = np.clip(r, 0, 255).astype(np.uint8)
|
||||
buf[:, 1] = np.clip(g, 0, 255).astype(np.uint8)
|
||||
buf[:, 2] = np.clip(b, 0, 255).astype(np.uint8)
|
||||
@@ -21,6 +21,8 @@ from wled_controller.core.processing.color_strip_stream import (
|
||||
from wled_controller.core.processing.effect_stream import EffectColorStripStream
|
||||
from wled_controller.core.processing.api_input_stream import ApiInputColorStripStream
|
||||
from wled_controller.core.processing.notification_stream import NotificationColorStripStream
|
||||
from wled_controller.core.processing.daylight_stream import DaylightColorStripStream
|
||||
from wled_controller.core.processing.candlelight_stream import CandlelightColorStripStream
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -33,6 +35,8 @@ _SIMPLE_STREAM_MAP = {
|
||||
"effect": EffectColorStripStream,
|
||||
"api_input": ApiInputColorStripStream,
|
||||
"notification": NotificationColorStripStream,
|
||||
"daylight": DaylightColorStripStream,
|
||||
"candlelight": CandlelightColorStripStream,
|
||||
}
|
||||
|
||||
|
||||
|
||||
221
server/src/wled_controller/core/processing/daylight_stream.py
Normal file
221
server/src/wled_controller/core/processing/daylight_stream.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""Daylight cycle LED stream — simulates natural daylight color temperature.
|
||||
|
||||
Implements DaylightColorStripStream which produces a uniform LED color array
|
||||
that transitions through dawn, daylight, sunset, and night over a continuous
|
||||
24-hour cycle. Can use real wall-clock time or a configurable simulation speed.
|
||||
"""
|
||||
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.processing.color_strip_stream import ColorStripStream
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.utils.timer import high_resolution_timer
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# ── Daylight color table ────────────────────────────────────────────────
|
||||
#
|
||||
# Maps hour-of-day (0–24) to RGB color. Interpolated linearly between
|
||||
# control points. Colors approximate natural daylight color temperature
|
||||
# from warm sunrise tones through cool midday to warm sunset and dim night.
|
||||
#
|
||||
# Format: (hour, R, G, B)
|
||||
_DAYLIGHT_CURVE = [
|
||||
(0.0, 10, 10, 30), # midnight — deep blue
|
||||
(4.0, 10, 10, 40), # pre-dawn — dark blue
|
||||
(5.5, 40, 20, 60), # first light — purple hint
|
||||
(6.0, 255, 100, 30), # sunrise — warm orange
|
||||
(7.0, 255, 170, 80), # early morning — golden
|
||||
(8.0, 255, 220, 160), # morning — warm white
|
||||
(10.0, 255, 245, 230), # mid-morning — neutral warm
|
||||
(12.0, 240, 248, 255), # noon — cool white / slight blue
|
||||
(14.0, 255, 250, 240), # afternoon — neutral
|
||||
(16.0, 255, 230, 180), # late afternoon — warm
|
||||
(17.5, 255, 180, 100), # pre-sunset — golden
|
||||
(18.5, 255, 100, 40), # sunset — deep orange
|
||||
(19.0, 200, 60, 40), # late sunset — red
|
||||
(19.5, 100, 30, 60), # dusk — purple
|
||||
(20.0, 40, 20, 60), # twilight — dark purple
|
||||
(21.0, 15, 15, 45), # night — dark blue
|
||||
(24.0, 10, 10, 30), # midnight (wrap)
|
||||
]
|
||||
|
||||
# Pre-build a (1440, 3) uint8 LUT — one entry per minute of the day
|
||||
_daylight_lut: Optional[np.ndarray] = None
|
||||
|
||||
|
||||
def _get_daylight_lut() -> np.ndarray:
|
||||
global _daylight_lut
|
||||
if _daylight_lut is not None:
|
||||
return _daylight_lut
|
||||
|
||||
lut = np.zeros((1440, 3), dtype=np.uint8)
|
||||
for minute in range(1440):
|
||||
hour = minute / 60.0
|
||||
# Find surrounding control points
|
||||
prev = _DAYLIGHT_CURVE[0]
|
||||
nxt = _DAYLIGHT_CURVE[-1]
|
||||
for i in range(len(_DAYLIGHT_CURVE) - 1):
|
||||
if _DAYLIGHT_CURVE[i][0] <= hour <= _DAYLIGHT_CURVE[i + 1][0]:
|
||||
prev = _DAYLIGHT_CURVE[i]
|
||||
nxt = _DAYLIGHT_CURVE[i + 1]
|
||||
break
|
||||
span = nxt[0] - prev[0]
|
||||
t = (hour - prev[0]) / span if span > 0 else 0.0
|
||||
# Smooth interpolation (smoothstep)
|
||||
t = t * t * (3 - 2 * t)
|
||||
for ch in range(3):
|
||||
lut[minute, ch] = int(prev[ch + 1] + (nxt[ch + 1] - prev[ch + 1]) * t + 0.5)
|
||||
|
||||
_daylight_lut = lut
|
||||
return lut
|
||||
|
||||
|
||||
class DaylightColorStripStream(ColorStripStream):
|
||||
"""Color strip stream simulating a 24-hour daylight cycle.
|
||||
|
||||
All LEDs display the same color at any moment. The color smoothly
|
||||
transitions through a pre-defined daylight curve.
|
||||
"""
|
||||
|
||||
def __init__(self, source):
|
||||
self._colors_lock = threading.Lock()
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._fps = 10 # low FPS — transitions are slow
|
||||
self._frame_time = 1.0 / 10
|
||||
self._clock = None
|
||||
self._led_count = 1
|
||||
self._auto_size = True
|
||||
self._lut = _get_daylight_lut()
|
||||
self._update_from_source(source)
|
||||
|
||||
def _update_from_source(self, source) -> None:
|
||||
self._speed = float(getattr(source, "speed", 1.0))
|
||||
self._use_real_time = bool(getattr(source, "use_real_time", False))
|
||||
self._latitude = float(getattr(source, "latitude", 50.0))
|
||||
_lc = getattr(source, "led_count", 0)
|
||||
self._auto_size = not _lc
|
||||
self._led_count = _lc if _lc and _lc > 0 else 1
|
||||
with self._colors_lock:
|
||||
self._colors: Optional[np.ndarray] = None
|
||||
|
||||
def configure(self, device_led_count: int) -> None:
|
||||
if self._auto_size and device_led_count > 0:
|
||||
new_count = max(self._led_count, device_led_count)
|
||||
if new_count != self._led_count:
|
||||
self._led_count = new_count
|
||||
|
||||
@property
|
||||
def target_fps(self) -> int:
|
||||
return self._fps
|
||||
|
||||
@property
|
||||
def led_count(self) -> int:
|
||||
return self._led_count
|
||||
|
||||
def set_capture_fps(self, fps: int) -> None:
|
||||
self._fps = max(1, min(30, fps))
|
||||
self._frame_time = 1.0 / self._fps
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(
|
||||
target=self._animate_loop,
|
||||
name="css-daylight",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
logger.info(f"DaylightColorStripStream started (leds={self._led_count})")
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=5.0)
|
||||
if self._thread.is_alive():
|
||||
logger.warning("DaylightColorStripStream thread did not terminate within 5s")
|
||||
self._thread = None
|
||||
logger.info("DaylightColorStripStream stopped")
|
||||
|
||||
def get_latest_colors(self) -> Optional[np.ndarray]:
|
||||
with self._colors_lock:
|
||||
return self._colors
|
||||
|
||||
def update_source(self, source) -> None:
|
||||
from wled_controller.storage.color_strip_source import DaylightColorStripSource
|
||||
if isinstance(source, DaylightColorStripSource):
|
||||
prev_led_count = self._led_count if self._auto_size else None
|
||||
self._update_from_source(source)
|
||||
if prev_led_count and self._auto_size:
|
||||
self._led_count = prev_led_count
|
||||
logger.info("DaylightColorStripStream params updated in-place")
|
||||
|
||||
def set_clock(self, clock) -> None:
|
||||
self._clock = clock
|
||||
|
||||
# ── Animation loop ──────────────────────────────────────────────
|
||||
|
||||
def _animate_loop(self) -> None:
|
||||
_pool_n = 0
|
||||
_buf_a = _buf_b = None
|
||||
_use_a = True
|
||||
|
||||
try:
|
||||
with high_resolution_timer():
|
||||
while self._running:
|
||||
wall_start = time.perf_counter()
|
||||
frame_time = self._frame_time
|
||||
try:
|
||||
clock = self._clock
|
||||
if clock:
|
||||
if not clock.is_running:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
t = clock.get_time()
|
||||
speed = clock.speed
|
||||
else:
|
||||
t = wall_start
|
||||
speed = self._speed
|
||||
|
||||
n = self._led_count
|
||||
if n != _pool_n:
|
||||
_pool_n = n
|
||||
_buf_a = np.empty((n, 3), dtype=np.uint8)
|
||||
_buf_b = np.empty((n, 3), dtype=np.uint8)
|
||||
|
||||
buf = _buf_a if _use_a else _buf_b
|
||||
_use_a = not _use_a
|
||||
|
||||
if self._use_real_time:
|
||||
# Use actual wall-clock time
|
||||
import datetime
|
||||
now = datetime.datetime.now()
|
||||
minute_of_day = now.hour * 60 + now.minute + now.second / 60.0
|
||||
else:
|
||||
# Simulated cycle: speed=1.0 → full 24h in ~240s (4 min)
|
||||
cycle_seconds = 240.0 / max(speed, 0.01)
|
||||
phase = (t % cycle_seconds) / cycle_seconds # 0..1
|
||||
minute_of_day = phase * 1440.0
|
||||
|
||||
idx = int(minute_of_day) % 1440
|
||||
color = self._lut[idx]
|
||||
buf[:] = color
|
||||
|
||||
with self._colors_lock:
|
||||
self._colors = buf
|
||||
except Exception as e:
|
||||
logger.error(f"DaylightColorStripStream animation error: {e}")
|
||||
|
||||
elapsed = time.perf_counter() - wall_start
|
||||
time.sleep(max(frame_time - elapsed, 0.001))
|
||||
except Exception as e:
|
||||
logger.error(f"Fatal DaylightColorStripStream loop error: {e}", exc_info=True)
|
||||
finally:
|
||||
self._running = False
|
||||
@@ -158,7 +158,7 @@ class ProcessorManager:
|
||||
device_store=self._device_store,
|
||||
color_strip_stream_manager=self._color_strip_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,
|
||||
)
|
||||
|
||||
@@ -203,8 +203,8 @@ class ProcessorManager:
|
||||
if queue in self._event_queues:
|
||||
self._event_queues.remove(queue)
|
||||
|
||||
def _fire_event(self, event: dict) -> None:
|
||||
"""Push event to all subscribers (non-blocking)."""
|
||||
def fire_event(self, event: dict) -> None:
|
||||
"""Push event to all subscribers (non-blocking). Public API for route handlers."""
|
||||
for q in self._event_queues:
|
||||
try:
|
||||
q.put_nowait(event)
|
||||
@@ -854,7 +854,7 @@ class ProcessorManager:
|
||||
f"[AUTO-RESTART] Target {target_id} crashed {rs.attempts} times "
|
||||
f"in {now - rs.first_crash_time:.0f}s — giving up"
|
||||
)
|
||||
self._fire_event({
|
||||
self.fire_event({
|
||||
"type": "state_change",
|
||||
"target_id": target_id,
|
||||
"processing": False,
|
||||
@@ -872,7 +872,7 @@ class ProcessorManager:
|
||||
f"{_RESTART_MAX_ATTEMPTS}), restarting in {backoff:.1f}s"
|
||||
)
|
||||
|
||||
self._fire_event({
|
||||
self.fire_event({
|
||||
"type": "state_change",
|
||||
"target_id": target_id,
|
||||
"processing": False,
|
||||
@@ -916,7 +916,7 @@ class ProcessorManager:
|
||||
await self.start_processing(target_id)
|
||||
except Exception as e:
|
||||
logger.error(f"[AUTO-RESTART] Failed to restart {target_id}: {e}")
|
||||
self._fire_event({
|
||||
self.fire_event({
|
||||
"type": "state_change",
|
||||
"target_id": target_id,
|
||||
"processing": False,
|
||||
@@ -1050,11 +1050,21 @@ class ProcessorManager:
|
||||
state = self._devices.get(device_id)
|
||||
if not state:
|
||||
return
|
||||
prev_online = state.health.online
|
||||
client = await self._get_http_client()
|
||||
state.health = await check_device_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
|
||||
reported = state.health.device_led_count
|
||||
if reported and reported != state.led_count and self._device_store:
|
||||
|
||||
@@ -618,19 +618,15 @@ textarea:focus-visible {
|
||||
.icon-select-popup {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transition: max-height 0.2s ease, opacity 0.15s ease, margin 0.2s ease;
|
||||
margin-top: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.icon-select-popup.open {
|
||||
max-height: 600px;
|
||||
opacity: 1;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.icon-select-popup.open.settled {
|
||||
overflow-y: auto;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.icon-select-grid {
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
toggleDashboardSection, changeDashboardPollInterval,
|
||||
} from './features/dashboard.js';
|
||||
import { startEventsWS, stopEventsWS } from './core/events-ws.js';
|
||||
import { startEntityEventListeners } from './core/entity-events.js';
|
||||
import {
|
||||
startPerfPolling, stopPerfPolling,
|
||||
} from './features/perf-charts.js';
|
||||
@@ -108,7 +109,7 @@ import {
|
||||
// Layer 5: color-strip sources
|
||||
import {
|
||||
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
|
||||
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, onCSSClockChange,
|
||||
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, onCSSClockChange, onDaylightRealTimeChange,
|
||||
colorCycleAddColor, colorCycleRemoveColor,
|
||||
compositeAddLayer, compositeRemoveLayer,
|
||||
mappedAddZone, mappedRemoveZone,
|
||||
@@ -376,6 +377,7 @@ Object.assign(window, {
|
||||
onEffectTypeChange,
|
||||
onCSSClockChange,
|
||||
onAnimationTypeChange,
|
||||
onDaylightRealTimeChange,
|
||||
colorCycleAddColor,
|
||||
colorCycleRemoveColor,
|
||||
compositeAddLayer,
|
||||
@@ -555,6 +557,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
|
||||
// Start global events WebSocket and auto-refresh
|
||||
startEventsWS();
|
||||
startEntityEventListeners();
|
||||
startAutoRefresh();
|
||||
|
||||
// Show getting-started tutorial on first visit
|
||||
|
||||
61
server/src/wled_controller/static/js/core/entity-events.js
Normal file
61
server/src/wled_controller/static/js/core/entity-events.js
Normal 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);
|
||||
}
|
||||
@@ -2,13 +2,20 @@
|
||||
* Global events WebSocket — stays connected while logged in,
|
||||
* 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';
|
||||
|
||||
let _ws = 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() {
|
||||
stopEventsWS();
|
||||
@@ -19,6 +26,9 @@ export function startEventsWS() {
|
||||
|
||||
try {
|
||||
_ws = new WebSocket(url);
|
||||
_ws.onopen = () => {
|
||||
_reconnectDelay = _RECONNECT_MIN; // reset backoff on successful connection
|
||||
};
|
||||
_ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
@@ -27,7 +37,8 @@ export function startEventsWS() {
|
||||
};
|
||||
_ws.onclose = () => {
|
||||
_ws = null;
|
||||
_reconnectTimer = setTimeout(startEventsWS, 3000);
|
||||
_reconnectTimer = setTimeout(startEventsWS, _reconnectDelay);
|
||||
_reconnectDelay = Math.min(_reconnectDelay * 2, _RECONNECT_MAX);
|
||||
};
|
||||
_ws.onerror = () => {};
|
||||
} catch {
|
||||
@@ -45,4 +56,5 @@ export function stopEventsWS() {
|
||||
_ws.close();
|
||||
_ws = null;
|
||||
}
|
||||
_reconnectDelay = _RECONNECT_MIN;
|
||||
}
|
||||
|
||||
@@ -72,4 +72,5 @@ export const download = '<path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2
|
||||
export const undo2 = '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5 5.5 5.5 0 0 1-5.5 5.5H11"/>';
|
||||
export const power = '<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" x2="12" y1="2" y2="12"/>';
|
||||
export const wifi = '<path d="M12 20h.01"/><path d="M2 8.82a15 15 0 0 1 20 0"/><path d="M5 12.859a10 10 0 0 1 14 0"/><path d="M8.5 16.429a5 5 0 0 1 7 0"/>';
|
||||
export const flame = '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>';
|
||||
export const usb = '<circle cx="10" cy="7" r="1"/><circle cx="4" cy="20" r="1"/><path d="M4.7 19.3 19 5"/><path d="m21 3-3 1 2 2Z"/><path d="M10 8v3a1 1 0 0 1-1 1H4"/><path d="M14 12v2a1 1 0 0 0 1 1h3"/><circle cx="20" cy="15" r="1"/>';
|
||||
|
||||
@@ -18,14 +18,12 @@
|
||||
* Call sel.setValue(v) to change programmatically, sel.destroy() to remove.
|
||||
*/
|
||||
|
||||
import { t } from './i18n.js';
|
||||
|
||||
const POPUP_CLASS = 'icon-select-popup';
|
||||
|
||||
/** Close every open icon-select popup. */
|
||||
export function closeAllIconSelects() {
|
||||
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.className = POPUP_CLASS;
|
||||
this._popup.addEventListener('click', (e) => e.stopPropagation());
|
||||
this._popup.addEventListener('transitionend', this._onTransitionEnd);
|
||||
this._popup.innerHTML = this._buildGrid();
|
||||
document.body.appendChild(this._popup);
|
||||
|
||||
@@ -86,7 +83,8 @@ export class IconSelect {
|
||||
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
cell.addEventListener('click', () => {
|
||||
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() {
|
||||
const rect = this._trigger.getBoundingClientRect();
|
||||
this._popup.style.left = rect.left + 'px';
|
||||
this._popup.style.width = Math.max(rect.width, 200) + 'px';
|
||||
const gap = 6; // visual gap between trigger and popup
|
||||
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
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
if (spaceBelow < 250 && spaceAbove > spaceBelow) {
|
||||
// Determine direction
|
||||
const openUp = spaceBelow < 200 && spaceAbove > spaceBelow;
|
||||
const available = openUp ? 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.bottom = (window.innerHeight - rect.top) + 'px';
|
||||
this._popup.style.bottom = (window.innerHeight - rect.top + gap) + 'px';
|
||||
} else {
|
||||
this._popup.style.top = rect.bottom + 'px';
|
||||
this._popup.style.top = (rect.bottom + gap) + 'px';
|
||||
this._popup.style.bottom = '';
|
||||
}
|
||||
}
|
||||
@@ -148,14 +161,39 @@ export class IconSelect {
|
||||
if (!wasOpen) {
|
||||
this._positionPopup();
|
||||
this._popup.classList.add('open');
|
||||
this._addScrollListener();
|
||||
}
|
||||
}
|
||||
|
||||
_onTransitionEnd = (e) => {
|
||||
if (e.propertyName === 'max-height' && this._popup.classList.contains('open')) {
|
||||
this._popup.classList.add('settled');
|
||||
}
|
||||
/** Close popup when any scrollable ancestor scrolls (prevents stale position). */
|
||||
_addScrollListener() {
|
||||
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. */
|
||||
setValue(value, fireChange = false) {
|
||||
@@ -175,7 +213,7 @@ export class IconSelect {
|
||||
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
cell.addEventListener('click', () => {
|
||||
this.setValue(cell.dataset.value, true);
|
||||
this._popup.classList.remove('open', 'settled');
|
||||
this._popup.classList.remove('open');
|
||||
});
|
||||
});
|
||||
this._syncTrigger();
|
||||
@@ -183,6 +221,7 @@ export class IconSelect {
|
||||
|
||||
/** Remove the enhancement, restore native <select>. */
|
||||
destroy() {
|
||||
this._removeScrollListener();
|
||||
this._trigger.remove();
|
||||
this._popup.remove();
|
||||
this._select.style.display = '';
|
||||
|
||||
@@ -24,6 +24,8 @@ const _colorStripTypeIcons = {
|
||||
audio: _svg(P.music), audio_visualization: _svg(P.music),
|
||||
api_input: _svg(P.send),
|
||||
notification: _svg(P.bellRing),
|
||||
daylight: _svg(P.sun),
|
||||
candlelight: _svg(P.flame),
|
||||
};
|
||||
const _valueSourceTypeIcons = {
|
||||
static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music),
|
||||
|
||||
@@ -80,6 +80,13 @@ class CSSEditorModal extends Modal {
|
||||
notification_filter_list: document.getElementById('css-editor-notification-filter-list').value,
|
||||
notification_app_colors: JSON.stringify(_notificationAppColors),
|
||||
clock_id: document.getElementById('css-editor-clock').value,
|
||||
daylight_speed: document.getElementById('css-editor-daylight-speed').value,
|
||||
daylight_use_real_time: document.getElementById('css-editor-daylight-real-time').checked,
|
||||
daylight_latitude: document.getElementById('css-editor-daylight-latitude').value,
|
||||
candlelight_color: document.getElementById('css-editor-candlelight-color').value,
|
||||
candlelight_intensity: document.getElementById('css-editor-candlelight-intensity').value,
|
||||
candlelight_num_candles: document.getElementById('css-editor-candlelight-num-candles').value,
|
||||
candlelight_speed: document.getElementById('css-editor-candlelight-speed').value,
|
||||
tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
@@ -99,7 +106,7 @@ let _cssClockEntitySelect = null;
|
||||
const CSS_TYPE_KEYS = [
|
||||
'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle',
|
||||
'effect', 'composite', 'mapped', 'audio',
|
||||
'api_input', 'notification',
|
||||
'api_input', 'notification', 'daylight', 'candlelight',
|
||||
];
|
||||
|
||||
function _buildCSSTypeItems() {
|
||||
@@ -148,6 +155,8 @@ export function onCSSTypeChange() {
|
||||
document.getElementById('css-editor-audio-section').style.display = type === 'audio' ? '' : 'none';
|
||||
document.getElementById('css-editor-api-input-section').style.display = type === 'api_input' ? '' : 'none';
|
||||
document.getElementById('css-editor-notification-section').style.display = type === 'notification' ? '' : 'none';
|
||||
document.getElementById('css-editor-daylight-section').style.display = type === 'daylight' ? '' : 'none';
|
||||
document.getElementById('css-editor-candlelight-section').style.display = type === 'candlelight' ? '' : 'none';
|
||||
|
||||
if (isPictureType) _ensureInterpolationIconSelect();
|
||||
if (type === 'effect') {
|
||||
@@ -197,8 +206,8 @@ export function onCSSTypeChange() {
|
||||
document.getElementById('css-editor-led-count-group').style.display =
|
||||
hasLedCount.includes(type) ? '' : 'none';
|
||||
|
||||
// Sync clock — shown for animated types (static, gradient, color_cycle, effect)
|
||||
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect'];
|
||||
// Sync clock — shown for animated types
|
||||
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight'];
|
||||
document.getElementById('css-editor-clock-group').style.display = clockTypes.includes(type) ? '' : 'none';
|
||||
if (clockTypes.includes(type)) _populateClockDropdown();
|
||||
|
||||
@@ -274,6 +283,17 @@ function _syncAnimationSpeedState() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Daylight real-time toggle helper ─────────────────────────── */
|
||||
|
||||
export function onDaylightRealTimeChange() {
|
||||
_syncDaylightSpeedVisibility();
|
||||
}
|
||||
|
||||
function _syncDaylightSpeedVisibility() {
|
||||
const isRealTime = document.getElementById('css-editor-daylight-real-time').checked;
|
||||
document.getElementById('css-editor-daylight-speed-group').style.display = isRealTime ? 'none' : '';
|
||||
}
|
||||
|
||||
/* ── Gradient strip preview helper ────────────────────────────── */
|
||||
|
||||
/**
|
||||
@@ -1039,6 +1059,23 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
||||
</span>
|
||||
${appCount > 0 ? `<span class="stream-card-prop">${ICON_PALETTE} ${appCount} ${t('color_strip.notification.app_count')}</span>` : ''}
|
||||
`;
|
||||
} else if (source.source_type === 'daylight') {
|
||||
const useRealTime = source.use_real_time;
|
||||
const speedVal = (source.speed ?? 1.0).toFixed(1);
|
||||
propsHtml = `
|
||||
<span class="stream-card-prop">${useRealTime ? '🕐 ' + t('color_strip.daylight.real_time') : '⏩ ' + speedVal + 'x'}</span>
|
||||
${clockBadge}
|
||||
`;
|
||||
} else if (source.source_type === 'candlelight') {
|
||||
const hexColor = rgbArrayToHex(source.color || [255, 147, 41]);
|
||||
const numCandles = source.num_candles ?? 3;
|
||||
propsHtml = `
|
||||
<span class="stream-card-prop" title="${t('color_strip.candlelight.color')}">
|
||||
<span style="display:inline-block;width:14px;height:14px;background:${hexColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${hexColor.toUpperCase()}
|
||||
</span>
|
||||
<span class="stream-card-prop">${numCandles} ${t('color_strip.candlelight.num_candles')}</span>
|
||||
${clockBadge}
|
||||
`;
|
||||
} else if (isPictureAdvanced) {
|
||||
const cal = source.calibration || {};
|
||||
const lines = cal.lines || [];
|
||||
@@ -1073,7 +1110,9 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
||||
}
|
||||
|
||||
const icon = getColorStripIcon(source.source_type);
|
||||
const isPictureKind = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio && !isApiInput && !isNotification);
|
||||
const isDaylight = source.source_type === 'daylight';
|
||||
const isCandlelight = source.source_type === 'candlelight';
|
||||
const isPictureKind = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio && !isApiInput && !isNotification && !isDaylight && !isCandlelight);
|
||||
const calibrationBtn = isPictureKind
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="${isPictureAdvanced ? `showAdvancedCalibration('${source.id}')` : `showCSSCalibration('${source.id}')`}" title="${t('calibration.title')}">${ICON_CALIBRATION}</button>`
|
||||
: '';
|
||||
@@ -1217,6 +1256,20 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
|
||||
_showApiInputEndpoints(css.id);
|
||||
} else if (sourceType === 'notification') {
|
||||
_loadNotificationState(css);
|
||||
} else if (sourceType === 'daylight') {
|
||||
document.getElementById('css-editor-daylight-speed').value = css.speed ?? 1.0;
|
||||
document.getElementById('css-editor-daylight-speed-val').textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
|
||||
document.getElementById('css-editor-daylight-real-time').checked = css.use_real_time || false;
|
||||
document.getElementById('css-editor-daylight-latitude').value = css.latitude ?? 50.0;
|
||||
document.getElementById('css-editor-daylight-latitude-val').textContent = parseFloat(css.latitude ?? 50.0).toFixed(0);
|
||||
_syncDaylightSpeedVisibility();
|
||||
} else if (sourceType === 'candlelight') {
|
||||
document.getElementById('css-editor-candlelight-color').value = rgbArrayToHex(css.color || [255, 147, 41]);
|
||||
document.getElementById('css-editor-candlelight-intensity').value = css.intensity ?? 1.0;
|
||||
document.getElementById('css-editor-candlelight-intensity-val').textContent = parseFloat(css.intensity ?? 1.0).toFixed(1);
|
||||
document.getElementById('css-editor-candlelight-num-candles').value = css.num_candles ?? 3;
|
||||
document.getElementById('css-editor-candlelight-speed').value = css.speed ?? 1.0;
|
||||
document.getElementById('css-editor-candlelight-speed-val').textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
|
||||
} else {
|
||||
if (sourceType === 'picture') sourceSelect.value = css.picture_source_id || '';
|
||||
|
||||
@@ -1313,6 +1366,19 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
|
||||
document.getElementById('css-editor-api-input-timeout-val').textContent = '5.0';
|
||||
_showApiInputEndpoints(null);
|
||||
_resetNotificationState();
|
||||
// Daylight defaults
|
||||
document.getElementById('css-editor-daylight-speed').value = 1.0;
|
||||
document.getElementById('css-editor-daylight-speed-val').textContent = '1.0';
|
||||
document.getElementById('css-editor-daylight-real-time').checked = false;
|
||||
document.getElementById('css-editor-daylight-latitude').value = 50.0;
|
||||
document.getElementById('css-editor-daylight-latitude-val').textContent = '50';
|
||||
// Candlelight defaults
|
||||
document.getElementById('css-editor-candlelight-color').value = '#ff9329';
|
||||
document.getElementById('css-editor-candlelight-intensity').value = 1.0;
|
||||
document.getElementById('css-editor-candlelight-intensity-val').textContent = '1.0';
|
||||
document.getElementById('css-editor-candlelight-num-candles').value = 3;
|
||||
document.getElementById('css-editor-candlelight-speed').value = 1.0;
|
||||
document.getElementById('css-editor-candlelight-speed-val').textContent = '1.0';
|
||||
document.getElementById('css-editor-title').innerHTML = `${ICON_FILM} ${t('color_strip.add')}`;
|
||||
document.getElementById('css-editor-gradient-preset').value = '';
|
||||
gradientInit([
|
||||
@@ -1473,6 +1539,23 @@ export async function saveCSSEditor() {
|
||||
app_colors: _notificationGetAppColorsDict(),
|
||||
};
|
||||
if (!cssId) payload.source_type = 'notification';
|
||||
} else if (sourceType === 'daylight') {
|
||||
payload = {
|
||||
name,
|
||||
speed: parseFloat(document.getElementById('css-editor-daylight-speed').value),
|
||||
use_real_time: document.getElementById('css-editor-daylight-real-time').checked,
|
||||
latitude: parseFloat(document.getElementById('css-editor-daylight-latitude').value),
|
||||
};
|
||||
if (!cssId) payload.source_type = 'daylight';
|
||||
} else if (sourceType === 'candlelight') {
|
||||
payload = {
|
||||
name,
|
||||
color: hexToRgbArray(document.getElementById('css-editor-candlelight-color').value),
|
||||
intensity: parseFloat(document.getElementById('css-editor-candlelight-intensity').value),
|
||||
num_candles: parseInt(document.getElementById('css-editor-candlelight-num-candles').value) || 3,
|
||||
speed: parseFloat(document.getElementById('css-editor-candlelight-speed').value),
|
||||
};
|
||||
if (!cssId) payload.source_type = 'candlelight';
|
||||
} else if (sourceType === 'picture_advanced') {
|
||||
payload = {
|
||||
name,
|
||||
@@ -1501,7 +1584,7 @@ export async function saveCSSEditor() {
|
||||
}
|
||||
|
||||
// Attach clock_id for animated types
|
||||
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect'];
|
||||
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight'];
|
||||
if (clockTypes.includes(sourceType)) {
|
||||
const clockVal = document.getElementById('css-editor-clock').value;
|
||||
payload.clock_id = clockVal || null;
|
||||
|
||||
@@ -840,6 +840,13 @@ function _debouncedDashboardReload(forceFullRender = false) {
|
||||
|
||||
document.addEventListener('server:state_change', () => _debouncedDashboardReload());
|
||||
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
|
||||
document.addEventListener('languageChanged', () => {
|
||||
|
||||
@@ -913,6 +913,28 @@
|
||||
"color_strip.notification.endpoint.hint": "Use this URL to trigger notifications from external systems. POST with optional JSON body: {\"app\": \"AppName\", \"color\": \"#FF0000\"}.",
|
||||
"color_strip.notification.save_first": "Save the source first to see the webhook endpoint URL.",
|
||||
"color_strip.notification.app_count": "apps",
|
||||
"color_strip.type.daylight": "Daylight Cycle",
|
||||
"color_strip.type.daylight.desc": "Simulates natural daylight over 24 hours",
|
||||
"color_strip.type.daylight.hint": "Simulates the sun's color temperature throughout a 24-hour day/night cycle — from warm sunrise to cool daylight to warm sunset and dim night.",
|
||||
"color_strip.daylight.speed": "Speed:",
|
||||
"color_strip.daylight.speed.hint": "Cycle speed multiplier. 1.0 = full day/night cycle in ~4 minutes. Higher values cycle faster.",
|
||||
"color_strip.daylight.use_real_time": "Use Real Time:",
|
||||
"color_strip.daylight.use_real_time.hint": "When enabled, LED color matches the actual time of day on this computer. Speed setting is ignored.",
|
||||
"color_strip.daylight.real_time": "Real Time",
|
||||
"color_strip.daylight.latitude": "Latitude:",
|
||||
"color_strip.daylight.latitude.hint": "Your geographic latitude (-90 to 90). Affects sunrise/sunset timing in real-time mode.",
|
||||
"color_strip.type.candlelight": "Candlelight",
|
||||
"color_strip.type.candlelight.desc": "Realistic flickering candle simulation",
|
||||
"color_strip.type.candlelight.hint": "Simulates realistic candle flickering across all LEDs with warm tones and organic flicker patterns.",
|
||||
"color_strip.candlelight.color": "Base Color:",
|
||||
"color_strip.candlelight.color.hint": "The warm base color of the candle flame. Default is a natural warm amber.",
|
||||
"color_strip.candlelight.intensity": "Flicker Intensity:",
|
||||
"color_strip.candlelight.intensity.hint": "How much the candles flicker. Low values produce a gentle glow, high values simulate a windy candle.",
|
||||
"color_strip.candlelight.num_candles_label": "Number of Candles:",
|
||||
"color_strip.candlelight.num_candles": "candles",
|
||||
"color_strip.candlelight.num_candles.hint": "How many independent candle sources along the strip. Each flickers with its own pattern.",
|
||||
"color_strip.candlelight.speed": "Flicker Speed:",
|
||||
"color_strip.candlelight.speed.hint": "Speed of the flicker animation. Higher values produce faster, more restless flames.",
|
||||
"color_strip.composite.layers": "Layers:",
|
||||
"color_strip.composite.layers.hint": "Stack multiple color strip sources. First layer is the bottom, last is the top. Each layer can have its own blend mode and opacity.",
|
||||
"color_strip.composite.add_layer": "+ Add Layer",
|
||||
|
||||
@@ -913,6 +913,28 @@
|
||||
"color_strip.notification.endpoint.hint": "URL для запуска уведомлений из внешних систем. POST с JSON телом: {\"app\": \"AppName\", \"color\": \"#FF0000\"}.",
|
||||
"color_strip.notification.save_first": "Сначала сохраните источник, чтобы увидеть URL вебхука.",
|
||||
"color_strip.notification.app_count": "прилож.",
|
||||
"color_strip.type.daylight": "Дневной цикл",
|
||||
"color_strip.type.daylight.desc": "Имитация естественного дневного света за 24 часа",
|
||||
"color_strip.type.daylight.hint": "Имитирует цветовую температуру солнца в течение суток — от тёплого рассвета до прохладного дневного света, заката и ночи.",
|
||||
"color_strip.daylight.speed": "Скорость:",
|
||||
"color_strip.daylight.speed.hint": "Множитель скорости цикла. 1.0 = полный цикл день/ночь за ~4 минуты.",
|
||||
"color_strip.daylight.use_real_time": "Реальное время:",
|
||||
"color_strip.daylight.use_real_time.hint": "Если включено, цвет LED соответствует реальному времени суток. Настройка скорости игнорируется.",
|
||||
"color_strip.daylight.real_time": "Реальное время",
|
||||
"color_strip.daylight.latitude": "Широта:",
|
||||
"color_strip.daylight.latitude.hint": "Географическая широта (-90 до 90). Влияет на время восхода/заката в режиме реального времени.",
|
||||
"color_strip.type.candlelight": "Свечи",
|
||||
"color_strip.type.candlelight.desc": "Реалистичная имитация мерцания свечей",
|
||||
"color_strip.type.candlelight.hint": "Реалистичное мерцание свечей с тёплыми тонами и органическими паттернами.",
|
||||
"color_strip.candlelight.color": "Базовый цвет:",
|
||||
"color_strip.candlelight.color.hint": "Тёплый базовый цвет пламени свечи. По умолчанию — натуральный тёплый янтарь.",
|
||||
"color_strip.candlelight.intensity": "Интенсивность мерцания:",
|
||||
"color_strip.candlelight.intensity.hint": "Сила мерцания свечей. Низкие значения — мягкое свечение, высокие — свеча на ветру.",
|
||||
"color_strip.candlelight.num_candles_label": "Количество свечей:",
|
||||
"color_strip.candlelight.num_candles": "свечей",
|
||||
"color_strip.candlelight.num_candles.hint": "Сколько независимых источников свечей вдоль ленты. Каждый мерцает по-своему.",
|
||||
"color_strip.candlelight.speed": "Скорость мерцания:",
|
||||
"color_strip.candlelight.speed.hint": "Скорость анимации мерцания. Большие значения — более быстрое, беспокойное пламя.",
|
||||
"color_strip.composite.layers": "Слои:",
|
||||
"color_strip.composite.layers.hint": "Наложение нескольких источников. Первый слой — нижний, последний — верхний. Каждый слой может иметь свой режим смешивания и прозрачность.",
|
||||
"color_strip.composite.add_layer": "+ Добавить слой",
|
||||
|
||||
@@ -913,6 +913,28 @@
|
||||
"color_strip.notification.endpoint.hint": "使用此 URL 从外部系统触发通知。POST 请求可选 JSON:{\"app\": \"AppName\", \"color\": \"#FF0000\"}。",
|
||||
"color_strip.notification.save_first": "请先保存源以查看 Webhook 端点 URL。",
|
||||
"color_strip.notification.app_count": "个应用",
|
||||
"color_strip.type.daylight": "日光循环",
|
||||
"color_strip.type.daylight.desc": "模拟24小时自然日光变化",
|
||||
"color_strip.type.daylight.hint": "模拟太阳在24小时内的色温变化——从温暖的日出到冷白的日光,再到温暖的日落和昏暗的夜晚。",
|
||||
"color_strip.daylight.speed": "速度:",
|
||||
"color_strip.daylight.speed.hint": "循环速度倍数。1.0 = 约4分钟完成一个完整的昼夜循环。",
|
||||
"color_strip.daylight.use_real_time": "使用实时时间:",
|
||||
"color_strip.daylight.use_real_time.hint": "启用后,LED颜色匹配计算机的实际时间。速度设置将被忽略。",
|
||||
"color_strip.daylight.real_time": "实时",
|
||||
"color_strip.daylight.latitude": "纬度:",
|
||||
"color_strip.daylight.latitude.hint": "地理纬度(-90到90)。影响实时模式下的日出/日落时间。",
|
||||
"color_strip.type.candlelight": "烛光",
|
||||
"color_strip.type.candlelight.desc": "逼真的烛光闪烁模拟",
|
||||
"color_strip.type.candlelight.hint": "在所有LED上模拟逼真的蜡烛闪烁,具有温暖色调和有机闪烁模式。",
|
||||
"color_strip.candlelight.color": "基础颜色:",
|
||||
"color_strip.candlelight.color.hint": "蜡烛火焰的温暖基础颜色。默认为自然温暖的琥珀色。",
|
||||
"color_strip.candlelight.intensity": "闪烁强度:",
|
||||
"color_strip.candlelight.intensity.hint": "蜡烛闪烁程度。低值产生柔和光芒,高值模拟风中的蜡烛。",
|
||||
"color_strip.candlelight.num_candles_label": "蜡烛数量:",
|
||||
"color_strip.candlelight.num_candles": "支蜡烛",
|
||||
"color_strip.candlelight.num_candles.hint": "灯带上独立蜡烛光源的数量。每支蜡烛有自己的闪烁模式。",
|
||||
"color_strip.candlelight.speed": "闪烁速度:",
|
||||
"color_strip.candlelight.speed.hint": "闪烁动画的速度。较高的值产生更快、更不安定的火焰。",
|
||||
"color_strip.composite.layers": "图层:",
|
||||
"color_strip.composite.layers.hint": "叠加多个色带源。第一个图层在底部,最后一个在顶部。每个图层可以有自己的混合模式和不透明度。",
|
||||
"color_strip.composite.add_layer": "+ 添加图层",
|
||||
|
||||
@@ -13,6 +13,8 @@ Current types:
|
||||
AudioColorStripSource — audio-reactive visualization (spectrum, beat pulse, VU meter)
|
||||
ApiInputColorStripSource — receives raw LED colors from external clients via REST/WebSocket
|
||||
NotificationColorStripSource — fires one-shot visual alerts (flash, pulse, sweep) via API
|
||||
DaylightColorStripSource — simulates natural daylight color temperature over a 24-hour cycle
|
||||
CandlelightColorStripSource — realistic per-LED candle flickering with warm glow
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
@@ -93,6 +95,12 @@ class ColorStripSource:
|
||||
"app_filter_mode": None,
|
||||
"app_filter_list": None,
|
||||
"os_listener": None,
|
||||
# daylight-type fields
|
||||
"speed": None,
|
||||
"use_real_time": None,
|
||||
"latitude": None,
|
||||
# candlelight-type fields
|
||||
"num_candles": None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -244,6 +252,32 @@ class ColorStripSource:
|
||||
os_listener=bool(data.get("os_listener", False)),
|
||||
)
|
||||
|
||||
if source_type == "daylight":
|
||||
return DaylightColorStripSource(
|
||||
id=sid, name=name, source_type="daylight",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
clock_id=clock_id, tags=tags,
|
||||
speed=float(data.get("speed") or 1.0),
|
||||
use_real_time=bool(data.get("use_real_time", False)),
|
||||
latitude=float(data.get("latitude") or 50.0),
|
||||
)
|
||||
|
||||
if source_type == "candlelight":
|
||||
raw_color = data.get("color")
|
||||
color = (
|
||||
raw_color if isinstance(raw_color, list) and len(raw_color) == 3
|
||||
else [255, 147, 41]
|
||||
)
|
||||
return CandlelightColorStripSource(
|
||||
id=sid, name=name, source_type="candlelight",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
clock_id=clock_id, tags=tags,
|
||||
color=color,
|
||||
intensity=float(data.get("intensity") or 1.0),
|
||||
num_candles=int(data.get("num_candles") or 3),
|
||||
speed=float(data.get("speed") or 1.0),
|
||||
)
|
||||
|
||||
# Shared picture-type field extraction
|
||||
_picture_kwargs = dict(
|
||||
tags=tags,
|
||||
@@ -567,3 +601,52 @@ class NotificationColorStripSource(ColorStripSource):
|
||||
d["app_filter_list"] = list(self.app_filter_list)
|
||||
d["os_listener"] = self.os_listener
|
||||
return d
|
||||
|
||||
|
||||
@dataclass
|
||||
class DaylightColorStripSource(ColorStripSource):
|
||||
"""Color strip source that simulates natural daylight over a 24-hour cycle.
|
||||
|
||||
All LEDs receive the same color at any point in time, smoothly
|
||||
transitioning through dawn (warm orange), daylight (cool white),
|
||||
sunset (warm red/orange), and night (dim blue).
|
||||
LED count auto-sizes from the connected device.
|
||||
|
||||
When use_real_time is True, the current wall-clock hour determines
|
||||
the color; speed is ignored. When False, speed controls how fast
|
||||
a full 24-hour cycle plays (1.0 ≈ 4 minutes per full cycle).
|
||||
"""
|
||||
|
||||
speed: float = 1.0 # cycle speed (ignored when use_real_time)
|
||||
use_real_time: bool = False # use actual time of day
|
||||
latitude: float = 50.0 # latitude for sunrise/sunset timing (-90..90)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["speed"] = self.speed
|
||||
d["use_real_time"] = self.use_real_time
|
||||
d["latitude"] = self.latitude
|
||||
return d
|
||||
|
||||
|
||||
@dataclass
|
||||
class CandlelightColorStripSource(ColorStripSource):
|
||||
"""Color strip source that simulates realistic candle flickering.
|
||||
|
||||
Each LED or group of LEDs flickers independently with warm tones.
|
||||
Uses layered noise for organic, non-repeating flicker patterns.
|
||||
LED count auto-sizes from the connected device.
|
||||
"""
|
||||
|
||||
color: list = field(default_factory=lambda: [255, 147, 41]) # warm candle base [R,G,B]
|
||||
intensity: float = 1.0 # flicker intensity (0.1–2.0)
|
||||
num_candles: int = 3 # number of independent candle sources
|
||||
speed: float = 1.0 # flicker speed multiplier
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["color"] = list(self.color)
|
||||
d["intensity"] = self.intensity
|
||||
d["num_candles"] = self.num_candles
|
||||
d["speed"] = self.speed
|
||||
return d
|
||||
|
||||
@@ -10,9 +10,11 @@ from wled_controller.storage.color_strip_source import (
|
||||
AdvancedPictureColorStripSource,
|
||||
ApiInputColorStripSource,
|
||||
AudioColorStripSource,
|
||||
CandlelightColorStripSource,
|
||||
ColorCycleColorStripSource,
|
||||
ColorStripSource,
|
||||
CompositeColorStripSource,
|
||||
DaylightColorStripSource,
|
||||
EffectColorStripSource,
|
||||
GradientColorStripSource,
|
||||
MappedColorStripSource,
|
||||
@@ -82,6 +84,12 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]):
|
||||
app_filter_mode: Optional[str] = None,
|
||||
app_filter_list: Optional[list] = None,
|
||||
os_listener: Optional[bool] = None,
|
||||
# daylight-type fields
|
||||
speed: Optional[float] = None,
|
||||
use_real_time: Optional[bool] = None,
|
||||
latitude: Optional[float] = None,
|
||||
# candlelight-type fields
|
||||
num_candles: Optional[int] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> ColorStripSource:
|
||||
"""Create a new color strip source.
|
||||
@@ -235,6 +243,34 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]):
|
||||
app_filter_list=app_filter_list if isinstance(app_filter_list, list) else [],
|
||||
os_listener=bool(os_listener) if os_listener is not None else False,
|
||||
)
|
||||
elif source_type == "daylight":
|
||||
source = DaylightColorStripSource(
|
||||
id=source_id,
|
||||
name=name,
|
||||
source_type="daylight",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
clock_id=clock_id,
|
||||
speed=float(speed) if speed is not None else 1.0,
|
||||
use_real_time=bool(use_real_time) if use_real_time is not None else False,
|
||||
latitude=float(latitude) if latitude is not None else 50.0,
|
||||
)
|
||||
elif source_type == "candlelight":
|
||||
rgb = color if isinstance(color, list) and len(color) == 3 else [255, 147, 41]
|
||||
source = CandlelightColorStripSource(
|
||||
id=source_id,
|
||||
name=name,
|
||||
source_type="candlelight",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
clock_id=clock_id,
|
||||
color=rgb,
|
||||
intensity=float(intensity) if intensity else 1.0,
|
||||
num_candles=int(num_candles) if num_candles is not None else 3,
|
||||
speed=float(speed) if speed is not None else 1.0,
|
||||
)
|
||||
elif source_type == "picture_advanced":
|
||||
if calibration is None:
|
||||
calibration = CalibrationConfig(mode="advanced")
|
||||
@@ -326,6 +362,12 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]):
|
||||
app_filter_mode: Optional[str] = None,
|
||||
app_filter_list: Optional[list] = None,
|
||||
os_listener: Optional[bool] = None,
|
||||
# daylight-type fields
|
||||
speed: Optional[float] = None,
|
||||
use_real_time: Optional[bool] = None,
|
||||
latitude: Optional[float] = None,
|
||||
# candlelight-type fields
|
||||
num_candles: Optional[int] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> ColorStripSource:
|
||||
"""Update an existing color strip source.
|
||||
@@ -452,6 +494,22 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]):
|
||||
source.app_filter_list = app_filter_list
|
||||
if os_listener is not None:
|
||||
source.os_listener = bool(os_listener)
|
||||
elif isinstance(source, DaylightColorStripSource):
|
||||
if speed is not None:
|
||||
source.speed = float(speed)
|
||||
if use_real_time is not None:
|
||||
source.use_real_time = bool(use_real_time)
|
||||
if latitude is not None:
|
||||
source.latitude = float(latitude)
|
||||
elif isinstance(source, CandlelightColorStripSource):
|
||||
if color is not None and isinstance(color, list) and len(color) == 3:
|
||||
source.color = color
|
||||
if intensity is not None:
|
||||
source.intensity = float(intensity)
|
||||
if num_candles is not None:
|
||||
source.num_candles = int(num_candles)
|
||||
if speed is not None:
|
||||
source.speed = float(speed)
|
||||
|
||||
source.updated_at = datetime.now(timezone.utc)
|
||||
self._save()
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
<option value="audio" data-i18n="color_strip.type.audio">Audio Reactive</option>
|
||||
<option value="api_input" data-i18n="color_strip.type.api_input">API Input</option>
|
||||
<option value="notification" data-i18n="color_strip.type.notification">Notification</option>
|
||||
<option value="daylight" data-i18n="color_strip.type.daylight">Daylight Cycle</option>
|
||||
<option value="candlelight" data-i18n="color_strip.type.candlelight">Candlelight</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -534,6 +536,77 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Daylight Cycle section -->
|
||||
<div id="css-editor-daylight-section" style="display:none">
|
||||
<div id="css-editor-daylight-speed-group" class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-daylight-speed"><span data-i18n="color_strip.daylight.speed">Speed:</span> <span id="css-editor-daylight-speed-val">1.0</span></label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.daylight.speed.hint">Cycle speed multiplier. 1.0 = full day/night cycle in ~4 minutes. Higher values cycle faster.</small>
|
||||
<input type="range" id="css-editor-daylight-speed" min="0.1" max="10" step="0.1" value="1.0"
|
||||
oninput="document.getElementById('css-editor-daylight-speed-val').textContent = parseFloat(this.value).toFixed(1)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-daylight-real-time" data-i18n="color_strip.daylight.use_real_time">Use Real Time:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.daylight.use_real_time.hint">When enabled, LED color matches the actual time of day. Speed is ignored.</small>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="css-editor-daylight-real-time" onchange="onDaylightRealTimeChange()">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-daylight-latitude"><span data-i18n="color_strip.daylight.latitude">Latitude:</span> <span id="css-editor-daylight-latitude-val">50</span>°</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.daylight.latitude.hint">Your geographic latitude (-90 to 90). Affects sunrise/sunset timing in real-time mode.</small>
|
||||
<input type="range" id="css-editor-daylight-latitude" min="-90" max="90" step="1" value="50"
|
||||
oninput="document.getElementById('css-editor-daylight-latitude-val').textContent = parseInt(this.value)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Candlelight section -->
|
||||
<div id="css-editor-candlelight-section" style="display:none">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-candlelight-color" data-i18n="color_strip.candlelight.color">Base Color:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.color.hint">The warm base color of the candle flame. Default is a natural warm amber.</small>
|
||||
<input type="color" id="css-editor-candlelight-color" value="#ff9329">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-candlelight-intensity"><span data-i18n="color_strip.candlelight.intensity">Flicker Intensity:</span> <span id="css-editor-candlelight-intensity-val">1.0</span></label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.intensity.hint">How much the candles flicker. Low values = gentle glow, high values = windy candle.</small>
|
||||
<input type="range" id="css-editor-candlelight-intensity" min="0.1" max="2.0" step="0.1" value="1.0"
|
||||
oninput="document.getElementById('css-editor-candlelight-intensity-val').textContent = parseFloat(this.value).toFixed(1)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-candlelight-num-candles" data-i18n="color_strip.candlelight.num_candles_label">Number of Candles:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.num_candles.hint">How many independent candle sources along the strip. Each flickers with its own pattern. More candles = more variation.</small>
|
||||
<input type="number" id="css-editor-candlelight-num-candles" min="1" max="20" step="1" value="3">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-candlelight-speed"><span data-i18n="color_strip.candlelight.speed">Flicker Speed:</span> <span id="css-editor-candlelight-speed-val">1.0</span></label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.speed.hint">Speed of the flicker animation. Higher values produce faster, more restless flames.</small>
|
||||
<input type="range" id="css-editor-candlelight-speed" min="0.1" max="5.0" step="0.1" value="1.0"
|
||||
oninput="document.getElementById('css-editor-candlelight-speed-val').textContent = parseFloat(this.value).toFixed(1)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shared LED count field -->
|
||||
<div id="css-editor-led-count-group" class="form-group">
|
||||
<div class="label-row">
|
||||
|
||||
Reference in New Issue
Block a user