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
|
## Processing Pipeline
|
||||||
|
|
||||||
- [x] `P1` **Noise gate** — Suppress small color changes below threshold, preventing shimmer on static content
|
|
||||||
- [x] `P1` **Color temperature filter** — Already covered by existing Color Correction filter (2000-10000K)
|
|
||||||
- [ ] `P1` **Zone grouping** — Merge adjacent LEDs into logical groups sharing one averaged color
|
- [ ] `P1` **Zone grouping** — Merge adjacent LEDs into logical groups sharing one averaged color
|
||||||
- Complexity: medium — doesn't fit the PP filter model (operates on extracted LED colors, not images); needs a new param on calibration/color-strip-source config + PixelMapper changes
|
- Complexity: medium — doesn't fit the PP filter model (operates on extracted LED colors, not images); needs a new param on calibration/color-strip-source config + PixelMapper changes
|
||||||
- Impact: high — smooths out single-LED noise, visually cleaner ambilight on sparse strips
|
- Impact: high — smooths out single-LED noise, visually cleaner ambilight on sparse strips
|
||||||
- [x] `P2` **Palette quantization** — Force output to match a user-defined palette (preset or custom hex)
|
|
||||||
- [x] `P2` **Drag-and-drop filter ordering** — Reorder postprocessing filter chains visually
|
|
||||||
- [ ] `P3` **Transition effects** — Crossfade, wipe, or dissolve between sources/profiles instead of instant cut
|
- [ ] `P3` **Transition effects** — Crossfade, wipe, or dissolve between sources/profiles instead of instant cut
|
||||||
- Complexity: large — requires a new transition layer concept in ProcessorManager; must blend two live streams simultaneously during switch, coordinating start/stop timing
|
- Complexity: large — requires a new transition layer concept in ProcessorManager; must blend two live streams simultaneously during switch, coordinating start/stop timing
|
||||||
- Impact: medium — polishes profile switching UX but ambient lighting rarely switches sources frequently
|
- Impact: medium — polishes profile switching UX but ambient lighting rarely switches sources frequently
|
||||||
|
|
||||||
## Output Targets
|
## Output Targets
|
||||||
|
|
||||||
- [x] `P1` **Rename `picture-targets` to `output-targets`** — Rename API endpoints and internal references for clarity
|
|
||||||
- [x] `P2` **OpenRGB** — Control PC peripherals (keyboard, mouse, RAM, fans) as ambient targets
|
|
||||||
- [ ] `P2` **Art-Net / sACN (E1.31)** — Stage/theatrical lighting protocols, DMX controllers
|
- [ ] `P2` **Art-Net / sACN (E1.31)** — Stage/theatrical lighting protocols, DMX controllers
|
||||||
- Complexity: medium — UDP-based protocols with well-documented specs; similar architecture to DDP client; needs DMX universe/channel mapping UI
|
- Complexity: medium — UDP-based protocols with well-documented specs; similar architecture to DDP client; needs DMX universe/channel mapping UI
|
||||||
- Impact: medium — opens stage/theatrical use case, niche but differentiating
|
- Impact: medium — opens stage/theatrical use case, niche but differentiating
|
||||||
@@ -28,12 +22,6 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort
|
|||||||
- [ ] `P2` **Webhook/MQTT trigger** — Let external systems activate profiles without HA integration
|
- [ ] `P2` **Webhook/MQTT trigger** — Let external systems activate profiles without HA integration
|
||||||
- Complexity: low-medium — webhook: simple FastAPI endpoint calling SceneActivator; MQTT: add `asyncio-mqtt` dependency + subscription loop
|
- Complexity: low-medium — webhook: simple FastAPI endpoint calling SceneActivator; MQTT: add `asyncio-mqtt` dependency + subscription loop
|
||||||
- Impact: high — key integration point for home automation users without Home Assistant
|
- Impact: high — key integration point for home automation users without Home Assistant
|
||||||
- [ ] `P2` **WebSocket event bus** — Broadcast all state changes over a single WS channel
|
|
||||||
- Complexity: low-medium — ProcessorManager already emits events; add a WS endpoint that fans out JSON events to connected clients
|
|
||||||
- Impact: medium — enables real-time dashboards, mobile apps, and third-party integrations
|
|
||||||
- [x] `P3` **Notification reactive** — Flash/pulse on OS notifications (optional app filter)
|
|
||||||
- Complexity: large — OS-level notification listener (platform-specific: Win32 `WinToast`/`pystray`, macOS `pyobjc`); needs a new "effect source" type that triggers color pulses
|
|
||||||
- Impact: low-medium — fun but niche; platform-dependent maintenance burden
|
|
||||||
|
|
||||||
## Multi-Display
|
## Multi-Display
|
||||||
|
|
||||||
@@ -49,53 +37,20 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort
|
|||||||
- [ ] `P3` **SCRCPY capture engine** — Implement SCRCPY-based screen capture for Android devices
|
- [ ] `P3` **SCRCPY capture engine** — Implement SCRCPY-based screen capture for Android devices
|
||||||
- Complexity: large — external dependency on scrcpy binary; need to manage subprocess lifecycle, parse video stream (ffmpeg/AV pipe), handle device connect/disconnect
|
- Complexity: large — external dependency on scrcpy binary; need to manage subprocess lifecycle, parse video stream (ffmpeg/AV pipe), handle device connect/disconnect
|
||||||
- Impact: medium — enables phone screen mirroring to ambient lighting; appeals to mobile gaming use case
|
- Impact: medium — enables phone screen mirroring to ambient lighting; appeals to mobile gaming use case
|
||||||
- [x] `P3` **Camera / webcam** — Border-sampling from camera feed for video calls or room-reactive lighting
|
|
||||||
|
|
||||||
## Code Health (from review 2026-03-09)
|
## Code Health
|
||||||
|
|
||||||
### Bugs
|
|
||||||
|
|
||||||
- [x] `P1` **Thread safety: dict mutation during iteration** — composite_stream.py / mapped_stream.py `_sub_streams.clear()` crashes processing loop
|
|
||||||
- [x] `P1` **Thread safety: SyncClockRuntime.get_time() race** — compound read without lock causes time double-counting
|
|
||||||
- [x] `P1` **Thread safety: SyncClockManager unprotected dicts** — `_runtimes`/`_ref_counts` mutated from multiple threads without lock
|
|
||||||
- [x] `P1` **Clock ref-count corruption on hot-swap** — `_release_clock` reads new clock_id from store instead of old one
|
|
||||||
- [x] `P1` **Path traversal guard** — `auto_backup.py` uses string checks instead of `Path.resolve().is_relative_to()`
|
|
||||||
- [x] `P2` **Crash doesn't fire state_change event** — fatal exception path in `wled_target_processor.py` doesn't notify dashboard
|
|
||||||
- [x] `P2` **WS broadcast client mismatch** — `kc_target_processor.py` `zip(clients, results)` can pair wrong clients after concurrent removal
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
|
|
||||||
- [x] `P1` **Triple FFT for mono audio** — `analysis.py` runs 3 identical FFTs when audio is mono (2x wasted CPU)
|
|
||||||
- [x] `P2` **Per-frame np.array() from list** — `ddp_client.py:195` allocates new numpy array every frame
|
|
||||||
- [x] `P2` **frame_time recomputed every loop iteration** — `1.0/fps` in 8 stream files, should be cached
|
|
||||||
- [x] `P2` **Effect/composite/mapped streams hardcoded to 30 FPS** — ignores target FPS, bottlenecks 60 FPS targets
|
|
||||||
- [x] `P3` **Spectrum .copy() per audio chunk** — `analysis.py` ~258 array allocations/sec for read-only consumers
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
|
|
||||||
- [x] `P2` **12 store classes with duplicated boilerplate** — no base class; `BaseJsonStore[T]` would eliminate ~60%
|
|
||||||
- [x] `P2` **DeviceStore.save() uses unsafe temp file** — fixed-path `.tmp` instead of `atomic_write_json`
|
|
||||||
- [x] `P2` **Route code directly mutates ProcessorManager internals** — `devices.py` accesses `manager._devices` in 13+ places
|
|
||||||
- [x] `P2` **scene_activator.py accesses ProcessorManager._processors directly** — bypasses public API
|
|
||||||
- [x] `P3` **datetime.utcnow() deprecated** — 88 call sites in 42 files, should use `datetime.now(timezone.utc)`
|
|
||||||
- [x] `P3` **color-strips.js 1900+ lines** — should be split into separate modules
|
|
||||||
- [x] `P3` **No DataCache for color strip sources** — fetched with raw fetchWithAuth in 5+ places
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- [x] `P1` **Auto-restart crashed processing loops** — add backoff-based restart when `_processing_loop` dies
|
|
||||||
- [ ] `P1` **"Start All" targets button** — "Stop All" exists but "Start All" is missing
|
- [ ] `P1` **"Start All" targets button** — "Stop All" exists but "Start All" is missing
|
||||||
- [ ] `P2` **Manual backup trigger endpoint** — `POST /system/auto-backup/trigger` (~5 lines)
|
- [ ] `P2` **Manual backup trigger endpoint** — `POST /system/auto-backup/trigger` (~5 lines)
|
||||||
- [ ] `P2` **Scene snapshot should capture device brightness** — `software_brightness` not saved/restored
|
- [ ] `P2` **Scene snapshot should capture device brightness** — `software_brightness` not saved/restored
|
||||||
- [ ] `P2` **Device health WebSocket events** — eliminate 5-30s poll latency for online/offline detection
|
|
||||||
- [ ] `P2` **Distinguish "crashed" vs "stopped" in dashboard** — `metrics.last_error` is already populated
|
- [ ] `P2` **Distinguish "crashed" vs "stopped" in dashboard** — `metrics.last_error` is already populated
|
||||||
- [ ] `P3` **Home Assistant MQTT discovery** — publish auto-discovery payloads; MQTT infra already exists
|
- [ ] `P3` **Home Assistant MQTT discovery** — publish auto-discovery payloads; MQTT infra already exists
|
||||||
- [ ] `P3` **CSS source import/export** — share individual sources without full config backup
|
- [ ] `P3` **CSS source import/export** — share individual sources without full config backup
|
||||||
- [ ] `P3` **Exponential backoff on events WS reconnect** — currently fixed 3s retry
|
|
||||||
|
|
||||||
## UX
|
## UX
|
||||||
|
|
||||||
- [x] `P2` **Tags / groups for cards** — Assign tags to devices, targets, and sources; filter and group cards by tag
|
|
||||||
- [x] `P3` **PWA / mobile layout** — Mobile-first layout + "Add to Home Screen" manifest
|
|
||||||
- [ ] `P1` **Collapse dashboard running target stats** — Show only FPS chart by default; uptime, errors, and pipeline timings in an expandable section collapsed by default
|
- [ ] `P1` **Collapse dashboard running target stats** — Show only FPS chart by default; uptime, errors, and pipeline timings in an expandable section collapsed by default
|
||||||
- [x] `P1` **Review protocol badge on LED target cards** — Review and improve the protocol badge display on LED target cards
|
- [ ] `P1` **Review new CSS types (Daylight & Candlelight)** — End-to-end review: create via UI, assign to targets, verify LED rendering, check edge cases (0 candles, extreme latitude, real-time toggle)
|
||||||
|
- [ ] `P1` **Daylight brightness value source** — New value source type that reports a 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
|
return _sync_clock_manager
|
||||||
|
|
||||||
|
|
||||||
|
def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
|
||||||
|
"""Fire an entity_changed event via the ProcessorManager event bus.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity_type: e.g. "device", "output_target", "color_strip_source"
|
||||||
|
action: "created", "updated", or "deleted"
|
||||||
|
entity_id: The entity's unique ID
|
||||||
|
"""
|
||||||
|
if _processor_manager is not None:
|
||||||
|
_processor_manager.fire_event({
|
||||||
|
"type": "entity_changed",
|
||||||
|
"entity_type": entity_type,
|
||||||
|
"action": action,
|
||||||
|
"id": entity_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def init_dependencies(
|
def init_dependencies(
|
||||||
device_store: DeviceStore,
|
device_store: DeviceStore,
|
||||||
template_store: TemplateStore,
|
template_store: TemplateStore,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from starlette.websockets import WebSocket, WebSocketDisconnect
|
|||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
get_audio_source_store,
|
get_audio_source_store,
|
||||||
get_audio_template_store,
|
get_audio_template_store,
|
||||||
get_color_strip_store,
|
get_color_strip_store,
|
||||||
@@ -84,6 +85,7 @@ async def create_audio_source(
|
|||||||
audio_template_id=data.audio_template_id,
|
audio_template_id=data.audio_template_id,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("audio_source", "created", source.id)
|
||||||
return _to_response(source)
|
return _to_response(source)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@@ -123,6 +125,7 @@ async def update_audio_source(
|
|||||||
audio_template_id=data.audio_template_id,
|
audio_template_id=data.audio_template_id,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("audio_source", "updated", source_id)
|
||||||
return _to_response(source)
|
return _to_response(source)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@@ -146,6 +149,7 @@ async def delete_audio_source(
|
|||||||
)
|
)
|
||||||
|
|
||||||
store.delete_source(source_id)
|
store.delete_source(source_id)
|
||||||
|
fire_entity_event("audio_source", "deleted", source_id)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query
|
|||||||
from starlette.websockets import WebSocket, WebSocketDisconnect
|
from starlette.websockets import WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.api.dependencies import get_audio_template_store, get_audio_source_store, get_processor_manager
|
from wled_controller.api.dependencies import fire_entity_event, get_audio_template_store, get_audio_source_store, get_processor_manager
|
||||||
from wled_controller.api.schemas.audio_templates import (
|
from wled_controller.api.schemas.audio_templates import (
|
||||||
AudioEngineInfo,
|
AudioEngineInfo,
|
||||||
AudioEngineListResponse,
|
AudioEngineListResponse,
|
||||||
@@ -66,6 +66,7 @@ async def create_audio_template(
|
|||||||
engine_config=data.engine_config, description=data.description,
|
engine_config=data.engine_config, description=data.description,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("audio_template", "created", template.id)
|
||||||
return AudioTemplateResponse(
|
return AudioTemplateResponse(
|
||||||
id=template.id, name=template.name, engine_type=template.engine_type,
|
id=template.id, name=template.name, engine_type=template.engine_type,
|
||||||
engine_config=template.engine_config, tags=getattr(template, 'tags', []),
|
engine_config=template.engine_config, tags=getattr(template, 'tags', []),
|
||||||
@@ -112,6 +113,7 @@ async def update_audio_template(
|
|||||||
engine_type=data.engine_type, engine_config=data.engine_config,
|
engine_type=data.engine_type, engine_config=data.engine_config,
|
||||||
description=data.description, tags=data.tags,
|
description=data.description, tags=data.tags,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("audio_template", "updated", template_id)
|
||||||
return AudioTemplateResponse(
|
return AudioTemplateResponse(
|
||||||
id=t.id, name=t.name, engine_type=t.engine_type,
|
id=t.id, name=t.name, engine_type=t.engine_type,
|
||||||
engine_config=t.engine_config, tags=getattr(t, 'tags', []),
|
engine_config=t.engine_config, tags=getattr(t, 'tags', []),
|
||||||
@@ -135,6 +137,7 @@ async def delete_audio_template(
|
|||||||
"""Delete an audio template."""
|
"""Delete an audio template."""
|
||||||
try:
|
try:
|
||||||
store.delete_template(template_id, audio_source_store=audio_source_store)
|
store.delete_template(template_id, audio_source_store=audio_source_store)
|
||||||
|
fire_entity_event("audio_template", "deleted", template_id)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
|||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
get_automation_engine,
|
get_automation_engine,
|
||||||
get_automation_store,
|
get_automation_store,
|
||||||
get_scene_preset_store,
|
get_scene_preset_store,
|
||||||
@@ -174,6 +175,7 @@ async def create_automation(
|
|||||||
if automation.enabled:
|
if automation.enabled:
|
||||||
await engine.trigger_evaluate()
|
await engine.trigger_evaluate()
|
||||||
|
|
||||||
|
fire_entity_event("automation", "created", automation.id)
|
||||||
return _automation_to_response(automation, engine, request)
|
return _automation_to_response(automation, engine, request)
|
||||||
|
|
||||||
|
|
||||||
@@ -273,6 +275,7 @@ async def update_automation(
|
|||||||
if automation.enabled:
|
if automation.enabled:
|
||||||
await engine.trigger_evaluate()
|
await engine.trigger_evaluate()
|
||||||
|
|
||||||
|
fire_entity_event("automation", "updated", automation_id)
|
||||||
return _automation_to_response(automation, engine, request)
|
return _automation_to_response(automation, engine, request)
|
||||||
|
|
||||||
|
|
||||||
@@ -296,6 +299,8 @@ async def delete_automation(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
fire_entity_event("automation", "deleted", automation_id)
|
||||||
|
|
||||||
|
|
||||||
# ===== Enable/Disable =====
|
# ===== Enable/Disable =====
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSock
|
|||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
get_color_strip_store,
|
get_color_strip_store,
|
||||||
get_picture_source_store,
|
get_picture_source_store,
|
||||||
get_output_target_store,
|
get_output_target_store,
|
||||||
@@ -99,6 +100,10 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
|
|||||||
app_filter_mode=getattr(source, "app_filter_mode", None),
|
app_filter_mode=getattr(source, "app_filter_mode", None),
|
||||||
app_filter_list=getattr(source, "app_filter_list", None),
|
app_filter_list=getattr(source, "app_filter_list", None),
|
||||||
os_listener=getattr(source, "os_listener", None),
|
os_listener=getattr(source, "os_listener", None),
|
||||||
|
speed=getattr(source, "speed", None),
|
||||||
|
use_real_time=getattr(source, "use_real_time", None),
|
||||||
|
latitude=getattr(source, "latitude", None),
|
||||||
|
num_candles=getattr(source, "num_candles", None),
|
||||||
overlay_active=overlay_active,
|
overlay_active=overlay_active,
|
||||||
tags=getattr(source, 'tags', []),
|
tags=getattr(source, 'tags', []),
|
||||||
created_at=source.created_at,
|
created_at=source.created_at,
|
||||||
@@ -191,8 +196,13 @@ async def create_color_strip_source(
|
|||||||
app_filter_mode=data.app_filter_mode,
|
app_filter_mode=data.app_filter_mode,
|
||||||
app_filter_list=data.app_filter_list,
|
app_filter_list=data.app_filter_list,
|
||||||
os_listener=data.os_listener,
|
os_listener=data.os_listener,
|
||||||
|
speed=data.speed,
|
||||||
|
use_real_time=data.use_real_time,
|
||||||
|
latitude=data.latitude,
|
||||||
|
num_candles=data.num_candles,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("color_strip_source", "created", source.id)
|
||||||
return _css_to_response(source)
|
return _css_to_response(source)
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -275,6 +285,10 @@ async def update_color_strip_source(
|
|||||||
app_filter_mode=data.app_filter_mode,
|
app_filter_mode=data.app_filter_mode,
|
||||||
app_filter_list=data.app_filter_list,
|
app_filter_list=data.app_filter_list,
|
||||||
os_listener=data.os_listener,
|
os_listener=data.os_listener,
|
||||||
|
speed=data.speed,
|
||||||
|
use_real_time=data.use_real_time,
|
||||||
|
latitude=data.latitude,
|
||||||
|
num_candles=data.num_candles,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -284,6 +298,7 @@ async def update_color_strip_source(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not hot-reload CSS stream {source_id}: {e}")
|
logger.warning(f"Could not hot-reload CSS stream {source_id}: {e}")
|
||||||
|
|
||||||
|
fire_entity_event("color_strip_source", "updated", source_id)
|
||||||
return _css_to_response(source)
|
return _css_to_response(source)
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -327,6 +342,7 @@ async def delete_color_strip_source(
|
|||||||
"Remove it from the mapped source(s) first.",
|
"Remove it from the mapped source(s) first.",
|
||||||
)
|
)
|
||||||
store.delete_source(source_id)
|
store.delete_source(source_id)
|
||||||
|
fire_entity_event("color_strip_source", "deleted", source_id)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from wled_controller.core.devices.led_client import (
|
|||||||
get_provider,
|
get_provider,
|
||||||
)
|
)
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
get_device_store,
|
get_device_store,
|
||||||
get_output_target_store,
|
get_output_target_store,
|
||||||
get_processor_manager,
|
get_processor_manager,
|
||||||
@@ -146,6 +147,7 @@ async def create_device(
|
|||||||
zone_mode=device.zone_mode,
|
zone_mode=device.zone_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fire_entity_event("device", "created", device.id)
|
||||||
return _device_to_response(device)
|
return _device_to_response(device)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
@@ -332,6 +334,7 @@ async def update_device(
|
|||||||
if update_data.zone_mode is not None:
|
if update_data.zone_mode is not None:
|
||||||
ds.zone_mode = update_data.zone_mode
|
ds.zone_mode = update_data.zone_mode
|
||||||
|
|
||||||
|
fire_entity_event("device", "updated", device_id)
|
||||||
return _device_to_response(device)
|
return _device_to_response(device)
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -369,6 +372,7 @@ async def delete_device(
|
|||||||
# Delete from storage
|
# Delete from storage
|
||||||
store.delete_device(device_id)
|
store.delete_device(device_id)
|
||||||
|
|
||||||
|
fire_entity_event("device", "deleted", device_id)
|
||||||
logger.info(f"Deleted device {device_id}")
|
logger.info(f"Deleted device {device_id}")
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from PIL import Image
|
|||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
get_color_strip_store,
|
get_color_strip_store,
|
||||||
get_device_store,
|
get_device_store,
|
||||||
get_pattern_template_store,
|
get_pattern_template_store,
|
||||||
@@ -181,6 +182,7 @@ async def create_target(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning(f"Could not register target {target.id} in processor manager: {e}")
|
logger.warning(f"Could not register target {target.id} in processor manager: {e}")
|
||||||
|
|
||||||
|
fire_entity_event("output_target", "created", target.id)
|
||||||
return _target_to_response(target)
|
return _target_to_response(target)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
@@ -319,6 +321,7 @@ async def update_target(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
fire_entity_event("output_target", "updated", target_id)
|
||||||
return _target_to_response(target)
|
return _target_to_response(target)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
@@ -354,6 +357,7 @@ async def delete_target(
|
|||||||
# Delete from store
|
# Delete from store
|
||||||
target_store.delete_target(target_id)
|
target_store.delete_target(target_id)
|
||||||
|
|
||||||
|
fire_entity_event("output_target", "deleted", target_id)
|
||||||
logger.info(f"Deleted target {target_id}")
|
logger.info(f"Deleted target {target_id}")
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from fastapi import APIRouter, HTTPException, Depends
|
|||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
get_pattern_template_store,
|
get_pattern_template_store,
|
||||||
get_output_target_store,
|
get_output_target_store,
|
||||||
)
|
)
|
||||||
@@ -73,6 +74,7 @@ async def create_pattern_template(
|
|||||||
description=data.description,
|
description=data.description,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("pattern_template", "created", template.id)
|
||||||
return _pat_template_to_response(template)
|
return _pat_template_to_response(template)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@@ -117,6 +119,7 @@ async def update_pattern_template(
|
|||||||
description=data.description,
|
description=data.description,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("pattern_template", "updated", template_id)
|
||||||
return _pat_template_to_response(template)
|
return _pat_template_to_response(template)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@@ -143,6 +146,7 @@ async def delete_pattern_template(
|
|||||||
"Please reassign those targets before deleting.",
|
"Please reassign those targets before deleting.",
|
||||||
)
|
)
|
||||||
store.delete_template(template_id)
|
store.delete_template(template_id)
|
||||||
|
fire_entity_event("pattern_template", "deleted", template_id)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from fastapi.responses import Response
|
|||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
get_picture_source_store,
|
get_picture_source_store,
|
||||||
get_output_target_store,
|
get_output_target_store,
|
||||||
get_pp_template_store,
|
get_pp_template_store,
|
||||||
@@ -199,6 +200,7 @@ async def create_picture_source(
|
|||||||
description=data.description,
|
description=data.description,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("picture_source", "created", stream.id)
|
||||||
return _stream_to_response(stream)
|
return _stream_to_response(stream)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -244,6 +246,7 @@ async def update_picture_source(
|
|||||||
description=data.description,
|
description=data.description,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("picture_source", "updated", stream_id)
|
||||||
return _stream_to_response(stream)
|
return _stream_to_response(stream)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@@ -271,6 +274,7 @@ async def delete_picture_source(
|
|||||||
"Please reassign those targets before deleting.",
|
"Please reassign those targets before deleting.",
|
||||||
)
|
)
|
||||||
store.delete_stream(stream_id)
|
store.delete_stream(stream_id)
|
||||||
|
fire_entity_event("picture_source", "deleted", stream_id)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSock
|
|||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
get_picture_source_store,
|
get_picture_source_store,
|
||||||
get_pp_template_store,
|
get_pp_template_store,
|
||||||
get_template_store,
|
get_template_store,
|
||||||
@@ -84,6 +85,7 @@ async def create_pp_template(
|
|||||||
description=data.description,
|
description=data.description,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("pp_template", "created", template.id)
|
||||||
return _pp_template_to_response(template)
|
return _pp_template_to_response(template)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@@ -123,6 +125,7 @@ async def update_pp_template(
|
|||||||
description=data.description,
|
description=data.description,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("pp_template", "updated", template_id)
|
||||||
return _pp_template_to_response(template)
|
return _pp_template_to_response(template)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@@ -150,6 +153,7 @@ async def delete_pp_template(
|
|||||||
"Please reassign those streams before deleting.",
|
"Please reassign those streams before deleting.",
|
||||||
)
|
)
|
||||||
store.delete_template(template_id)
|
store.delete_template(template_id)
|
||||||
|
fire_entity_event("pp_template", "deleted", template_id)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
get_output_target_store,
|
get_output_target_store,
|
||||||
get_processor_manager,
|
get_processor_manager,
|
||||||
get_scene_preset_store,
|
get_scene_preset_store,
|
||||||
@@ -87,6 +88,7 @@ async def create_scene_preset(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
fire_entity_event("scene_preset", "created", preset.id)
|
||||||
return _preset_to_response(preset)
|
return _preset_to_response(preset)
|
||||||
|
|
||||||
|
|
||||||
@@ -175,6 +177,7 @@ async def update_scene_preset(
|
|||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404 if "not found" in str(e).lower() else 400, detail=str(e))
|
raise HTTPException(status_code=404 if "not found" in str(e).lower() else 400, detail=str(e))
|
||||||
|
fire_entity_event("scene_preset", "updated", preset_id)
|
||||||
return _preset_to_response(preset)
|
return _preset_to_response(preset)
|
||||||
|
|
||||||
|
|
||||||
@@ -194,6 +197,7 @@ async def delete_scene_preset(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
fire_entity_event("scene_preset", "deleted", preset_id)
|
||||||
|
|
||||||
|
|
||||||
# ===== Recapture =====
|
# ===== Recapture =====
|
||||||
@@ -259,4 +263,5 @@ async def activate_scene_preset(
|
|||||||
if not errors:
|
if not errors:
|
||||||
logger.info(f"Scene preset '{preset.name}' activated successfully")
|
logger.info(f"Scene preset '{preset.name}' activated successfully")
|
||||||
|
|
||||||
|
fire_entity_event("scene_preset", "updated", preset_id)
|
||||||
return ActivateResponse(status=status, errors=errors)
|
return ActivateResponse(status=status, errors=errors)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
get_color_strip_store,
|
get_color_strip_store,
|
||||||
get_sync_clock_manager,
|
get_sync_clock_manager,
|
||||||
get_sync_clock_store,
|
get_sync_clock_store,
|
||||||
@@ -70,6 +71,7 @@ async def create_sync_clock(
|
|||||||
description=data.description,
|
description=data.description,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("sync_clock", "created", clock.id)
|
||||||
return _to_response(clock, manager)
|
return _to_response(clock, manager)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@@ -110,6 +112,7 @@ async def update_sync_clock(
|
|||||||
# Hot-update runtime speed
|
# Hot-update runtime speed
|
||||||
if data.speed is not None:
|
if data.speed is not None:
|
||||||
manager.update_speed(clock_id, clock.speed)
|
manager.update_speed(clock_id, clock.speed)
|
||||||
|
fire_entity_event("sync_clock", "updated", clock_id)
|
||||||
return _to_response(clock, manager)
|
return _to_response(clock, manager)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@@ -133,6 +136,7 @@ async def delete_sync_clock(
|
|||||||
)
|
)
|
||||||
manager.release_all_for(clock_id)
|
manager.release_all_for(clock_id)
|
||||||
store.delete_clock(clock_id)
|
store.delete_clock(clock_id)
|
||||||
|
fire_entity_event("sync_clock", "deleted", clock_id)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
@@ -152,6 +156,7 @@ async def pause_sync_clock(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
manager.pause(clock_id)
|
manager.pause(clock_id)
|
||||||
|
fire_entity_event("sync_clock", "updated", clock_id)
|
||||||
return _to_response(clock, manager)
|
return _to_response(clock, manager)
|
||||||
|
|
||||||
|
|
||||||
@@ -168,6 +173,7 @@ async def resume_sync_clock(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
manager.resume(clock_id)
|
manager.resume(clock_id)
|
||||||
|
fire_entity_event("sync_clock", "updated", clock_id)
|
||||||
return _to_response(clock, manager)
|
return _to_response(clock, manager)
|
||||||
|
|
||||||
|
|
||||||
@@ -184,4 +190,5 @@ async def reset_sync_clock(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
manager.reset(clock_id)
|
manager.reset(clock_id)
|
||||||
|
fire_entity_event("sync_clock", "updated", clock_id)
|
||||||
return _to_response(clock, manager)
|
return _to_response(clock, manager)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSock
|
|||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
get_picture_source_store,
|
get_picture_source_store,
|
||||||
get_pp_template_store,
|
get_pp_template_store,
|
||||||
get_template_store,
|
get_template_store,
|
||||||
@@ -96,6 +97,7 @@ async def create_template(
|
|||||||
tags=template_data.tags,
|
tags=template_data.tags,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fire_entity_event("capture_template", "created", template.id)
|
||||||
return TemplateResponse(
|
return TemplateResponse(
|
||||||
id=template.id,
|
id=template.id,
|
||||||
name=template.name,
|
name=template.name,
|
||||||
@@ -156,6 +158,7 @@ async def update_template(
|
|||||||
tags=update_data.tags,
|
tags=update_data.tags,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fire_entity_event("capture_template", "updated", template_id)
|
||||||
return TemplateResponse(
|
return TemplateResponse(
|
||||||
id=template.id,
|
id=template.id,
|
||||||
name=template.name,
|
name=template.name,
|
||||||
@@ -202,6 +205,7 @@ async def delete_template(
|
|||||||
|
|
||||||
# Proceed with deletion
|
# Proceed with deletion
|
||||||
template_store.delete_template(template_id)
|
template_store.delete_template(template_id)
|
||||||
|
fire_entity_event("capture_template", "deleted", template_id)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise # Re-raise HTTP exceptions as-is
|
raise # Re-raise HTTP exceptions as-is
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSock
|
|||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
get_output_target_store,
|
get_output_target_store,
|
||||||
get_processor_manager,
|
get_processor_manager,
|
||||||
get_value_source_store,
|
get_value_source_store,
|
||||||
@@ -100,6 +101,7 @@ async def create_value_source(
|
|||||||
auto_gain=data.auto_gain,
|
auto_gain=data.auto_gain,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
)
|
)
|
||||||
|
fire_entity_event("value_source", "created", source.id)
|
||||||
return _to_response(source)
|
return _to_response(source)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@@ -150,6 +152,7 @@ async def update_value_source(
|
|||||||
)
|
)
|
||||||
# Hot-reload running value streams
|
# Hot-reload running value streams
|
||||||
pm.update_value_source(source_id)
|
pm.update_value_source(source_id)
|
||||||
|
fire_entity_event("value_source", "updated", source_id)
|
||||||
return _to_response(source)
|
return _to_response(source)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@@ -174,6 +177,7 @@ async def delete_value_source(
|
|||||||
)
|
)
|
||||||
|
|
||||||
store.delete_source(source_id)
|
store.delete_source(source_id)
|
||||||
|
fire_entity_event("value_source", "deleted", source_id)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class ColorStripSourceCreate(BaseModel):
|
|||||||
"""Request to create a color strip source."""
|
"""Request to create a color strip source."""
|
||||||
|
|
||||||
name: str = Field(description="Source name", min_length=1, max_length=100)
|
name: str = Field(description="Source name", min_length=1, max_length=100)
|
||||||
source_type: Literal["picture", "picture_advanced", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input", "notification"] = Field(default="picture", description="Source type")
|
source_type: Literal["picture", "picture_advanced", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input", "notification", "daylight", "candlelight"] = Field(default="picture", description="Source type")
|
||||||
# picture-type fields
|
# picture-type fields
|
||||||
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
|
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
|
||||||
brightness: float = Field(default=1.0, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0)
|
brightness: float = Field(default=1.0, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0)
|
||||||
@@ -95,6 +95,12 @@ class ColorStripSourceCreate(BaseModel):
|
|||||||
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
|
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
|
||||||
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
|
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
|
||||||
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
|
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
|
||||||
|
# daylight-type fields
|
||||||
|
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0)
|
||||||
|
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
|
||||||
|
latitude: Optional[float] = Field(None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0)
|
||||||
|
# candlelight-type fields
|
||||||
|
num_candles: Optional[int] = Field(None, description="Number of independent candle sources (1-20)", ge=1, le=20)
|
||||||
# sync clock
|
# sync clock
|
||||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
||||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
@@ -149,6 +155,12 @@ class ColorStripSourceUpdate(BaseModel):
|
|||||||
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
|
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
|
||||||
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
|
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
|
||||||
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
|
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
|
||||||
|
# daylight-type fields
|
||||||
|
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0)
|
||||||
|
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
|
||||||
|
latitude: Optional[float] = Field(None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0)
|
||||||
|
# candlelight-type fields
|
||||||
|
num_candles: Optional[int] = Field(None, description="Number of independent candle sources (1-20)", ge=1, le=20)
|
||||||
# sync clock
|
# sync clock
|
||||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
||||||
tags: Optional[List[str]] = None
|
tags: Optional[List[str]] = None
|
||||||
@@ -205,6 +217,12 @@ class ColorStripSourceResponse(BaseModel):
|
|||||||
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
|
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
|
||||||
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
|
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
|
||||||
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
|
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
|
||||||
|
# daylight-type fields
|
||||||
|
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier")
|
||||||
|
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
|
||||||
|
latitude: Optional[float] = Field(None, description="Latitude for daylight timing")
|
||||||
|
# candlelight-type fields
|
||||||
|
num_candles: Optional[int] = Field(None, description="Number of independent candle sources")
|
||||||
# sync clock
|
# sync clock
|
||||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
||||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
|||||||
@@ -393,7 +393,7 @@ class AutomationEngine:
|
|||||||
|
|
||||||
def _fire_event(self, automation_id: str, action: str) -> None:
|
def _fire_event(self, automation_id: str, action: str) -> None:
|
||||||
try:
|
try:
|
||||||
self._manager._fire_event({
|
self._manager.fire_event({
|
||||||
"type": "automation_state_changed",
|
"type": "automation_state_changed",
|
||||||
"automation_id": automation_id,
|
"automation_id": automation_id,
|
||||||
"action": action,
|
"action": action,
|
||||||
|
|||||||
247
server/src/wled_controller/core/processing/candlelight_stream.py
Normal file
247
server/src/wled_controller/core/processing/candlelight_stream.py
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
"""Candlelight LED stream — realistic per-LED candle flickering.
|
||||||
|
|
||||||
|
Implements CandlelightColorStripStream which produces warm, organic
|
||||||
|
flickering across all LEDs using layered sine waves and value noise.
|
||||||
|
Each "candle" is an independent flicker source that illuminates
|
||||||
|
nearby LEDs with smooth falloff.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from wled_controller.core.processing.color_strip_stream import ColorStripStream
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
from wled_controller.utils.timer import high_resolution_timer
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# ── Simple hash-based noise ──────────────────────────────────────────
|
||||||
|
|
||||||
|
_PERM = np.arange(256, dtype=np.int32)
|
||||||
|
_rng = np.random.RandomState(seed=17)
|
||||||
|
_rng.shuffle(_PERM)
|
||||||
|
_PERM = np.concatenate([_PERM, _PERM]) # 512 entries for wrap-free indexing
|
||||||
|
|
||||||
|
|
||||||
|
def _noise1d(x: np.ndarray) -> np.ndarray:
|
||||||
|
"""Fast 1-D value noise (vectorized). Returns float32 in [0, 1]."""
|
||||||
|
xi = x.astype(np.int32) & 255
|
||||||
|
xf = x - np.floor(x)
|
||||||
|
# Smoothstep
|
||||||
|
u = xf * xf * (3.0 - 2.0 * xf)
|
||||||
|
a = _PERM[xi].astype(np.float32) / 255.0
|
||||||
|
b = _PERM[xi + 1].astype(np.float32) / 255.0
|
||||||
|
return a + u * (b - a)
|
||||||
|
|
||||||
|
|
||||||
|
class CandlelightColorStripStream(ColorStripStream):
|
||||||
|
"""Color strip stream simulating realistic candle flickering.
|
||||||
|
|
||||||
|
Each LED flickers independently with warm tones. Multiple
|
||||||
|
"candle sources" are distributed along the strip, each generating
|
||||||
|
its own flicker pattern with smooth spatial falloff.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, source):
|
||||||
|
self._colors_lock = threading.Lock()
|
||||||
|
self._running = False
|
||||||
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
self._fps = 30
|
||||||
|
self._frame_time = 1.0 / 30
|
||||||
|
self._clock = None
|
||||||
|
self._led_count = 1
|
||||||
|
self._auto_size = True
|
||||||
|
# Scratch arrays
|
||||||
|
self._s_bright: Optional[np.ndarray] = None
|
||||||
|
self._s_noise: Optional[np.ndarray] = None
|
||||||
|
self._s_x: Optional[np.ndarray] = None
|
||||||
|
self._pool_n = 0
|
||||||
|
self._update_from_source(source)
|
||||||
|
|
||||||
|
def _update_from_source(self, source) -> None:
|
||||||
|
raw_color = getattr(source, "color", None)
|
||||||
|
self._color = raw_color if isinstance(raw_color, list) and len(raw_color) == 3 else [255, 147, 41]
|
||||||
|
self._intensity = float(getattr(source, "intensity", 1.0))
|
||||||
|
self._num_candles = max(1, int(getattr(source, "num_candles", 3)))
|
||||||
|
self._speed = float(getattr(source, "speed", 1.0))
|
||||||
|
_lc = getattr(source, "led_count", 0)
|
||||||
|
self._auto_size = not _lc
|
||||||
|
self._led_count = _lc if _lc and _lc > 0 else 1
|
||||||
|
with self._colors_lock:
|
||||||
|
self._colors: Optional[np.ndarray] = None
|
||||||
|
|
||||||
|
def configure(self, device_led_count: int) -> None:
|
||||||
|
if self._auto_size and device_led_count > 0:
|
||||||
|
new_count = max(self._led_count, device_led_count)
|
||||||
|
if new_count != self._led_count:
|
||||||
|
self._led_count = new_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target_fps(self) -> int:
|
||||||
|
return self._fps
|
||||||
|
|
||||||
|
@property
|
||||||
|
def led_count(self) -> int:
|
||||||
|
return self._led_count
|
||||||
|
|
||||||
|
def set_capture_fps(self, fps: int) -> None:
|
||||||
|
self._fps = max(1, min(90, fps))
|
||||||
|
self._frame_time = 1.0 / self._fps
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
self._running = True
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._animate_loop,
|
||||||
|
name="css-candlelight",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
logger.info(f"CandlelightColorStripStream started (leds={self._led_count}, candles={self._num_candles})")
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._running = False
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=5.0)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning("CandlelightColorStripStream thread did not terminate within 5s")
|
||||||
|
self._thread = None
|
||||||
|
logger.info("CandlelightColorStripStream stopped")
|
||||||
|
|
||||||
|
def get_latest_colors(self) -> Optional[np.ndarray]:
|
||||||
|
with self._colors_lock:
|
||||||
|
return self._colors
|
||||||
|
|
||||||
|
def update_source(self, source) -> None:
|
||||||
|
from wled_controller.storage.color_strip_source import CandlelightColorStripSource
|
||||||
|
if isinstance(source, CandlelightColorStripSource):
|
||||||
|
prev_led_count = self._led_count if self._auto_size else None
|
||||||
|
self._update_from_source(source)
|
||||||
|
if prev_led_count and self._auto_size:
|
||||||
|
self._led_count = prev_led_count
|
||||||
|
logger.info("CandlelightColorStripStream params updated in-place")
|
||||||
|
|
||||||
|
def set_clock(self, clock) -> None:
|
||||||
|
self._clock = clock
|
||||||
|
|
||||||
|
# ── Animation loop ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _animate_loop(self) -> None:
|
||||||
|
_pool_n = 0
|
||||||
|
_buf_a = _buf_b = None
|
||||||
|
_use_a = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
with high_resolution_timer():
|
||||||
|
while self._running:
|
||||||
|
wall_start = time.perf_counter()
|
||||||
|
frame_time = self._frame_time
|
||||||
|
try:
|
||||||
|
clock = self._clock
|
||||||
|
if clock:
|
||||||
|
if not clock.is_running:
|
||||||
|
time.sleep(0.1)
|
||||||
|
continue
|
||||||
|
t = clock.get_time()
|
||||||
|
speed = clock.speed * self._speed
|
||||||
|
else:
|
||||||
|
t = wall_start
|
||||||
|
speed = self._speed
|
||||||
|
|
||||||
|
n = self._led_count
|
||||||
|
if n != _pool_n:
|
||||||
|
_pool_n = n
|
||||||
|
_buf_a = np.empty((n, 3), dtype=np.uint8)
|
||||||
|
_buf_b = np.empty((n, 3), dtype=np.uint8)
|
||||||
|
self._s_bright = np.empty(n, dtype=np.float32)
|
||||||
|
self._s_noise = np.empty(n, dtype=np.float32)
|
||||||
|
self._s_x = np.arange(n, dtype=np.float32)
|
||||||
|
|
||||||
|
buf = _buf_a if _use_a else _buf_b
|
||||||
|
_use_a = not _use_a
|
||||||
|
|
||||||
|
self._render_candlelight(buf, n, t, speed)
|
||||||
|
|
||||||
|
with self._colors_lock:
|
||||||
|
self._colors = buf
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"CandlelightColorStripStream animation error: {e}")
|
||||||
|
|
||||||
|
elapsed = time.perf_counter() - wall_start
|
||||||
|
time.sleep(max(frame_time - elapsed, 0.001))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Fatal CandlelightColorStripStream loop error: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
def _render_candlelight(self, buf: np.ndarray, n: int, t: float, speed: float) -> None:
|
||||||
|
"""Render candle flickering into buf (n, 3) uint8.
|
||||||
|
|
||||||
|
Algorithm:
|
||||||
|
- Place num_candles evenly along the strip
|
||||||
|
- Each candle has independent layered-sine flicker
|
||||||
|
- Spatial falloff: LEDs near a candle are brighter
|
||||||
|
- Per-LED noise adds individual variation
|
||||||
|
- Final brightness modulates the base warm color
|
||||||
|
"""
|
||||||
|
intensity = self._intensity
|
||||||
|
num_candles = self._num_candles
|
||||||
|
base_r, base_g, base_b = self._color[0], self._color[1], self._color[2]
|
||||||
|
|
||||||
|
bright = self._s_bright
|
||||||
|
bright[:] = 0.0
|
||||||
|
|
||||||
|
# Candle positions: evenly distributed
|
||||||
|
if num_candles == 1:
|
||||||
|
positions = [n / 2.0]
|
||||||
|
else:
|
||||||
|
positions = [i * (n - 1) / (num_candles - 1) for i in range(num_candles)]
|
||||||
|
|
||||||
|
x = self._s_x[:n]
|
||||||
|
|
||||||
|
for ci, pos in enumerate(positions):
|
||||||
|
# Independent flicker for this candle: layered sines at different frequencies
|
||||||
|
# Use candle index as phase offset for independence
|
||||||
|
offset = ci * 137.5 # golden-angle offset for non-repeating
|
||||||
|
flicker = (
|
||||||
|
0.40 * math.sin(2 * math.pi * speed * t * 3.7 + offset)
|
||||||
|
+ 0.25 * math.sin(2 * math.pi * speed * t * 7.3 + offset * 0.7)
|
||||||
|
+ 0.15 * math.sin(2 * math.pi * speed * t * 13.1 + offset * 1.3)
|
||||||
|
+ 0.10 * math.sin(2 * math.pi * speed * t * 1.9 + offset * 0.3)
|
||||||
|
)
|
||||||
|
# Normalize flicker to [0.3, 1.0] range (candles never fully go dark)
|
||||||
|
candle_brightness = 0.65 + 0.35 * flicker * intensity
|
||||||
|
|
||||||
|
# Spatial falloff: Gaussian centered on candle position
|
||||||
|
# sigma proportional to strip length / num_candles
|
||||||
|
sigma = max(n / (num_candles * 2.0), 2.0)
|
||||||
|
dist = x - pos
|
||||||
|
falloff = np.exp(-0.5 * (dist * dist) / (sigma * sigma))
|
||||||
|
|
||||||
|
bright += candle_brightness * falloff
|
||||||
|
|
||||||
|
# Per-LED noise for individual variation
|
||||||
|
noise_x = x * 0.3 + t * speed * 5.0
|
||||||
|
noise = _noise1d(noise_x)
|
||||||
|
# Modulate brightness with noise (±15%)
|
||||||
|
bright *= (0.85 + 0.30 * noise)
|
||||||
|
|
||||||
|
# Clamp to [0, 1]
|
||||||
|
np.clip(bright, 0.0, 1.0, out=bright)
|
||||||
|
|
||||||
|
# Apply base color with brightness modulation
|
||||||
|
# Candles emit warmer (more red, less blue) at lower brightness
|
||||||
|
# Add slight color variation: dimmer = warmer
|
||||||
|
warm_shift = (1.0 - bright) * 0.3
|
||||||
|
r = bright * base_r
|
||||||
|
g = bright * base_g * (1.0 - warm_shift * 0.5)
|
||||||
|
b = bright * base_b * (1.0 - warm_shift)
|
||||||
|
|
||||||
|
buf[:, 0] = np.clip(r, 0, 255).astype(np.uint8)
|
||||||
|
buf[:, 1] = np.clip(g, 0, 255).astype(np.uint8)
|
||||||
|
buf[:, 2] = np.clip(b, 0, 255).astype(np.uint8)
|
||||||
@@ -21,6 +21,8 @@ from wled_controller.core.processing.color_strip_stream import (
|
|||||||
from wled_controller.core.processing.effect_stream import EffectColorStripStream
|
from wled_controller.core.processing.effect_stream import EffectColorStripStream
|
||||||
from wled_controller.core.processing.api_input_stream import ApiInputColorStripStream
|
from wled_controller.core.processing.api_input_stream import ApiInputColorStripStream
|
||||||
from wled_controller.core.processing.notification_stream import NotificationColorStripStream
|
from wled_controller.core.processing.notification_stream import NotificationColorStripStream
|
||||||
|
from wled_controller.core.processing.daylight_stream import DaylightColorStripStream
|
||||||
|
from wled_controller.core.processing.candlelight_stream import CandlelightColorStripStream
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -33,6 +35,8 @@ _SIMPLE_STREAM_MAP = {
|
|||||||
"effect": EffectColorStripStream,
|
"effect": EffectColorStripStream,
|
||||||
"api_input": ApiInputColorStripStream,
|
"api_input": ApiInputColorStripStream,
|
||||||
"notification": NotificationColorStripStream,
|
"notification": NotificationColorStripStream,
|
||||||
|
"daylight": DaylightColorStripStream,
|
||||||
|
"candlelight": CandlelightColorStripStream,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
221
server/src/wled_controller/core/processing/daylight_stream.py
Normal file
221
server/src/wled_controller/core/processing/daylight_stream.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
"""Daylight cycle LED stream — simulates natural daylight color temperature.
|
||||||
|
|
||||||
|
Implements DaylightColorStripStream which produces a uniform LED color array
|
||||||
|
that transitions through dawn, daylight, sunset, and night over a continuous
|
||||||
|
24-hour cycle. Can use real wall-clock time or a configurable simulation speed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from wled_controller.core.processing.color_strip_stream import ColorStripStream
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
from wled_controller.utils.timer import high_resolution_timer
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# ── Daylight color table ────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Maps hour-of-day (0–24) to RGB color. Interpolated linearly between
|
||||||
|
# control points. Colors approximate natural daylight color temperature
|
||||||
|
# from warm sunrise tones through cool midday to warm sunset and dim night.
|
||||||
|
#
|
||||||
|
# Format: (hour, R, G, B)
|
||||||
|
_DAYLIGHT_CURVE = [
|
||||||
|
(0.0, 10, 10, 30), # midnight — deep blue
|
||||||
|
(4.0, 10, 10, 40), # pre-dawn — dark blue
|
||||||
|
(5.5, 40, 20, 60), # first light — purple hint
|
||||||
|
(6.0, 255, 100, 30), # sunrise — warm orange
|
||||||
|
(7.0, 255, 170, 80), # early morning — golden
|
||||||
|
(8.0, 255, 220, 160), # morning — warm white
|
||||||
|
(10.0, 255, 245, 230), # mid-morning — neutral warm
|
||||||
|
(12.0, 240, 248, 255), # noon — cool white / slight blue
|
||||||
|
(14.0, 255, 250, 240), # afternoon — neutral
|
||||||
|
(16.0, 255, 230, 180), # late afternoon — warm
|
||||||
|
(17.5, 255, 180, 100), # pre-sunset — golden
|
||||||
|
(18.5, 255, 100, 40), # sunset — deep orange
|
||||||
|
(19.0, 200, 60, 40), # late sunset — red
|
||||||
|
(19.5, 100, 30, 60), # dusk — purple
|
||||||
|
(20.0, 40, 20, 60), # twilight — dark purple
|
||||||
|
(21.0, 15, 15, 45), # night — dark blue
|
||||||
|
(24.0, 10, 10, 30), # midnight (wrap)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Pre-build a (1440, 3) uint8 LUT — one entry per minute of the day
|
||||||
|
_daylight_lut: Optional[np.ndarray] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_daylight_lut() -> np.ndarray:
|
||||||
|
global _daylight_lut
|
||||||
|
if _daylight_lut is not None:
|
||||||
|
return _daylight_lut
|
||||||
|
|
||||||
|
lut = np.zeros((1440, 3), dtype=np.uint8)
|
||||||
|
for minute in range(1440):
|
||||||
|
hour = minute / 60.0
|
||||||
|
# Find surrounding control points
|
||||||
|
prev = _DAYLIGHT_CURVE[0]
|
||||||
|
nxt = _DAYLIGHT_CURVE[-1]
|
||||||
|
for i in range(len(_DAYLIGHT_CURVE) - 1):
|
||||||
|
if _DAYLIGHT_CURVE[i][0] <= hour <= _DAYLIGHT_CURVE[i + 1][0]:
|
||||||
|
prev = _DAYLIGHT_CURVE[i]
|
||||||
|
nxt = _DAYLIGHT_CURVE[i + 1]
|
||||||
|
break
|
||||||
|
span = nxt[0] - prev[0]
|
||||||
|
t = (hour - prev[0]) / span if span > 0 else 0.0
|
||||||
|
# Smooth interpolation (smoothstep)
|
||||||
|
t = t * t * (3 - 2 * t)
|
||||||
|
for ch in range(3):
|
||||||
|
lut[minute, ch] = int(prev[ch + 1] + (nxt[ch + 1] - prev[ch + 1]) * t + 0.5)
|
||||||
|
|
||||||
|
_daylight_lut = lut
|
||||||
|
return lut
|
||||||
|
|
||||||
|
|
||||||
|
class DaylightColorStripStream(ColorStripStream):
|
||||||
|
"""Color strip stream simulating a 24-hour daylight cycle.
|
||||||
|
|
||||||
|
All LEDs display the same color at any moment. The color smoothly
|
||||||
|
transitions through a pre-defined daylight curve.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, source):
|
||||||
|
self._colors_lock = threading.Lock()
|
||||||
|
self._running = False
|
||||||
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
self._fps = 10 # low FPS — transitions are slow
|
||||||
|
self._frame_time = 1.0 / 10
|
||||||
|
self._clock = None
|
||||||
|
self._led_count = 1
|
||||||
|
self._auto_size = True
|
||||||
|
self._lut = _get_daylight_lut()
|
||||||
|
self._update_from_source(source)
|
||||||
|
|
||||||
|
def _update_from_source(self, source) -> None:
|
||||||
|
self._speed = float(getattr(source, "speed", 1.0))
|
||||||
|
self._use_real_time = bool(getattr(source, "use_real_time", False))
|
||||||
|
self._latitude = float(getattr(source, "latitude", 50.0))
|
||||||
|
_lc = getattr(source, "led_count", 0)
|
||||||
|
self._auto_size = not _lc
|
||||||
|
self._led_count = _lc if _lc and _lc > 0 else 1
|
||||||
|
with self._colors_lock:
|
||||||
|
self._colors: Optional[np.ndarray] = None
|
||||||
|
|
||||||
|
def configure(self, device_led_count: int) -> None:
|
||||||
|
if self._auto_size and device_led_count > 0:
|
||||||
|
new_count = max(self._led_count, device_led_count)
|
||||||
|
if new_count != self._led_count:
|
||||||
|
self._led_count = new_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target_fps(self) -> int:
|
||||||
|
return self._fps
|
||||||
|
|
||||||
|
@property
|
||||||
|
def led_count(self) -> int:
|
||||||
|
return self._led_count
|
||||||
|
|
||||||
|
def set_capture_fps(self, fps: int) -> None:
|
||||||
|
self._fps = max(1, min(30, fps))
|
||||||
|
self._frame_time = 1.0 / self._fps
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
self._running = True
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._animate_loop,
|
||||||
|
name="css-daylight",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
logger.info(f"DaylightColorStripStream started (leds={self._led_count})")
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._running = False
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=5.0)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning("DaylightColorStripStream thread did not terminate within 5s")
|
||||||
|
self._thread = None
|
||||||
|
logger.info("DaylightColorStripStream stopped")
|
||||||
|
|
||||||
|
def get_latest_colors(self) -> Optional[np.ndarray]:
|
||||||
|
with self._colors_lock:
|
||||||
|
return self._colors
|
||||||
|
|
||||||
|
def update_source(self, source) -> None:
|
||||||
|
from wled_controller.storage.color_strip_source import DaylightColorStripSource
|
||||||
|
if isinstance(source, DaylightColorStripSource):
|
||||||
|
prev_led_count = self._led_count if self._auto_size else None
|
||||||
|
self._update_from_source(source)
|
||||||
|
if prev_led_count and self._auto_size:
|
||||||
|
self._led_count = prev_led_count
|
||||||
|
logger.info("DaylightColorStripStream params updated in-place")
|
||||||
|
|
||||||
|
def set_clock(self, clock) -> None:
|
||||||
|
self._clock = clock
|
||||||
|
|
||||||
|
# ── Animation loop ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _animate_loop(self) -> None:
|
||||||
|
_pool_n = 0
|
||||||
|
_buf_a = _buf_b = None
|
||||||
|
_use_a = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
with high_resolution_timer():
|
||||||
|
while self._running:
|
||||||
|
wall_start = time.perf_counter()
|
||||||
|
frame_time = self._frame_time
|
||||||
|
try:
|
||||||
|
clock = self._clock
|
||||||
|
if clock:
|
||||||
|
if not clock.is_running:
|
||||||
|
time.sleep(0.1)
|
||||||
|
continue
|
||||||
|
t = clock.get_time()
|
||||||
|
speed = clock.speed
|
||||||
|
else:
|
||||||
|
t = wall_start
|
||||||
|
speed = self._speed
|
||||||
|
|
||||||
|
n = self._led_count
|
||||||
|
if n != _pool_n:
|
||||||
|
_pool_n = n
|
||||||
|
_buf_a = np.empty((n, 3), dtype=np.uint8)
|
||||||
|
_buf_b = np.empty((n, 3), dtype=np.uint8)
|
||||||
|
|
||||||
|
buf = _buf_a if _use_a else _buf_b
|
||||||
|
_use_a = not _use_a
|
||||||
|
|
||||||
|
if self._use_real_time:
|
||||||
|
# Use actual wall-clock time
|
||||||
|
import datetime
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
minute_of_day = now.hour * 60 + now.minute + now.second / 60.0
|
||||||
|
else:
|
||||||
|
# Simulated cycle: speed=1.0 → full 24h in ~240s (4 min)
|
||||||
|
cycle_seconds = 240.0 / max(speed, 0.01)
|
||||||
|
phase = (t % cycle_seconds) / cycle_seconds # 0..1
|
||||||
|
minute_of_day = phase * 1440.0
|
||||||
|
|
||||||
|
idx = int(minute_of_day) % 1440
|
||||||
|
color = self._lut[idx]
|
||||||
|
buf[:] = color
|
||||||
|
|
||||||
|
with self._colors_lock:
|
||||||
|
self._colors = buf
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"DaylightColorStripStream animation error: {e}")
|
||||||
|
|
||||||
|
elapsed = time.perf_counter() - wall_start
|
||||||
|
time.sleep(max(frame_time - elapsed, 0.001))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Fatal DaylightColorStripStream loop error: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
self._running = False
|
||||||
@@ -158,7 +158,7 @@ class ProcessorManager:
|
|||||||
device_store=self._device_store,
|
device_store=self._device_store,
|
||||||
color_strip_stream_manager=self._color_strip_stream_manager,
|
color_strip_stream_manager=self._color_strip_stream_manager,
|
||||||
value_stream_manager=self._value_stream_manager,
|
value_stream_manager=self._value_stream_manager,
|
||||||
fire_event=self._fire_event,
|
fire_event=self.fire_event,
|
||||||
get_device_info=self._get_device_info,
|
get_device_info=self._get_device_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -203,8 +203,8 @@ class ProcessorManager:
|
|||||||
if queue in self._event_queues:
|
if queue in self._event_queues:
|
||||||
self._event_queues.remove(queue)
|
self._event_queues.remove(queue)
|
||||||
|
|
||||||
def _fire_event(self, event: dict) -> None:
|
def fire_event(self, event: dict) -> None:
|
||||||
"""Push event to all subscribers (non-blocking)."""
|
"""Push event to all subscribers (non-blocking). Public API for route handlers."""
|
||||||
for q in self._event_queues:
|
for q in self._event_queues:
|
||||||
try:
|
try:
|
||||||
q.put_nowait(event)
|
q.put_nowait(event)
|
||||||
@@ -854,7 +854,7 @@ class ProcessorManager:
|
|||||||
f"[AUTO-RESTART] Target {target_id} crashed {rs.attempts} times "
|
f"[AUTO-RESTART] Target {target_id} crashed {rs.attempts} times "
|
||||||
f"in {now - rs.first_crash_time:.0f}s — giving up"
|
f"in {now - rs.first_crash_time:.0f}s — giving up"
|
||||||
)
|
)
|
||||||
self._fire_event({
|
self.fire_event({
|
||||||
"type": "state_change",
|
"type": "state_change",
|
||||||
"target_id": target_id,
|
"target_id": target_id,
|
||||||
"processing": False,
|
"processing": False,
|
||||||
@@ -872,7 +872,7 @@ class ProcessorManager:
|
|||||||
f"{_RESTART_MAX_ATTEMPTS}), restarting in {backoff:.1f}s"
|
f"{_RESTART_MAX_ATTEMPTS}), restarting in {backoff:.1f}s"
|
||||||
)
|
)
|
||||||
|
|
||||||
self._fire_event({
|
self.fire_event({
|
||||||
"type": "state_change",
|
"type": "state_change",
|
||||||
"target_id": target_id,
|
"target_id": target_id,
|
||||||
"processing": False,
|
"processing": False,
|
||||||
@@ -916,7 +916,7 @@ class ProcessorManager:
|
|||||||
await self.start_processing(target_id)
|
await self.start_processing(target_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[AUTO-RESTART] Failed to restart {target_id}: {e}")
|
logger.error(f"[AUTO-RESTART] Failed to restart {target_id}: {e}")
|
||||||
self._fire_event({
|
self.fire_event({
|
||||||
"type": "state_change",
|
"type": "state_change",
|
||||||
"target_id": target_id,
|
"target_id": target_id,
|
||||||
"processing": False,
|
"processing": False,
|
||||||
@@ -1050,11 +1050,21 @@ class ProcessorManager:
|
|||||||
state = self._devices.get(device_id)
|
state = self._devices.get(device_id)
|
||||||
if not state:
|
if not state:
|
||||||
return
|
return
|
||||||
|
prev_online = state.health.online
|
||||||
client = await self._get_http_client()
|
client = await self._get_http_client()
|
||||||
state.health = await check_device_health(
|
state.health = await check_device_health(
|
||||||
state.device_type, state.device_url, client, state.health,
|
state.device_type, state.device_url, client, state.health,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Fire event when online status changes
|
||||||
|
if state.health.online != prev_online:
|
||||||
|
self.fire_event({
|
||||||
|
"type": "device_health_changed",
|
||||||
|
"device_id": device_id,
|
||||||
|
"online": state.health.online,
|
||||||
|
"latency_ms": state.health.latency_ms,
|
||||||
|
})
|
||||||
|
|
||||||
# Auto-sync LED count
|
# Auto-sync LED count
|
||||||
reported = state.health.device_led_count
|
reported = state.health.device_led_count
|
||||||
if reported and reported != state.led_count and self._device_store:
|
if reported and reported != state.led_count and self._device_store:
|
||||||
|
|||||||
@@ -618,19 +618,15 @@ textarea:focus-visible {
|
|||||||
.icon-select-popup {
|
.icon-select-popup {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
max-height: 0;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: max-height 0.2s ease, opacity 0.15s ease, margin 0.2s ease;
|
transition: opacity 0.15s ease;
|
||||||
margin-top: 0;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.icon-select-popup.open {
|
.icon-select-popup.open {
|
||||||
max-height: 600px;
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
.icon-select-popup.open.settled {
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-select-grid {
|
.icon-select-grid {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
toggleDashboardSection, changeDashboardPollInterval,
|
toggleDashboardSection, changeDashboardPollInterval,
|
||||||
} from './features/dashboard.js';
|
} from './features/dashboard.js';
|
||||||
import { startEventsWS, stopEventsWS } from './core/events-ws.js';
|
import { startEventsWS, stopEventsWS } from './core/events-ws.js';
|
||||||
|
import { startEntityEventListeners } from './core/entity-events.js';
|
||||||
import {
|
import {
|
||||||
startPerfPolling, stopPerfPolling,
|
startPerfPolling, stopPerfPolling,
|
||||||
} from './features/perf-charts.js';
|
} from './features/perf-charts.js';
|
||||||
@@ -108,7 +109,7 @@ import {
|
|||||||
// Layer 5: color-strip sources
|
// Layer 5: color-strip sources
|
||||||
import {
|
import {
|
||||||
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
|
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
|
||||||
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, onCSSClockChange,
|
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, onCSSClockChange, onDaylightRealTimeChange,
|
||||||
colorCycleAddColor, colorCycleRemoveColor,
|
colorCycleAddColor, colorCycleRemoveColor,
|
||||||
compositeAddLayer, compositeRemoveLayer,
|
compositeAddLayer, compositeRemoveLayer,
|
||||||
mappedAddZone, mappedRemoveZone,
|
mappedAddZone, mappedRemoveZone,
|
||||||
@@ -376,6 +377,7 @@ Object.assign(window, {
|
|||||||
onEffectTypeChange,
|
onEffectTypeChange,
|
||||||
onCSSClockChange,
|
onCSSClockChange,
|
||||||
onAnimationTypeChange,
|
onAnimationTypeChange,
|
||||||
|
onDaylightRealTimeChange,
|
||||||
colorCycleAddColor,
|
colorCycleAddColor,
|
||||||
colorCycleRemoveColor,
|
colorCycleRemoveColor,
|
||||||
compositeAddLayer,
|
compositeAddLayer,
|
||||||
@@ -555,6 +557,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
|
|
||||||
// Start global events WebSocket and auto-refresh
|
// Start global events WebSocket and auto-refresh
|
||||||
startEventsWS();
|
startEventsWS();
|
||||||
|
startEntityEventListeners();
|
||||||
startAutoRefresh();
|
startAutoRefresh();
|
||||||
|
|
||||||
// Show getting-started tutorial on first visit
|
// Show getting-started tutorial on first visit
|
||||||
|
|||||||
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,
|
* Global events WebSocket — stays connected while logged in,
|
||||||
* dispatches DOM custom events that feature modules can listen to.
|
* dispatches DOM custom events that feature modules can listen to.
|
||||||
*
|
*
|
||||||
* Events dispatched: server:state_change, server:automation_state_changed
|
* Events dispatched:
|
||||||
|
* server:state_change — target processing start/stop/crash
|
||||||
|
* server:automation_state_changed — automation activated/deactivated
|
||||||
|
* server:entity_changed — entity CRUD (create/update/delete)
|
||||||
|
* server:device_health_changed — device online/offline status change
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiKey } from './state.js';
|
import { apiKey } from './state.js';
|
||||||
|
|
||||||
let _ws = null;
|
let _ws = null;
|
||||||
let _reconnectTimer = null;
|
let _reconnectTimer = null;
|
||||||
|
let _reconnectDelay = 1000; // start at 1s, exponential backoff to 30s
|
||||||
|
const _RECONNECT_MIN = 1000;
|
||||||
|
const _RECONNECT_MAX = 30000;
|
||||||
|
|
||||||
export function startEventsWS() {
|
export function startEventsWS() {
|
||||||
stopEventsWS();
|
stopEventsWS();
|
||||||
@@ -19,6 +26,9 @@ export function startEventsWS() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
_ws = new WebSocket(url);
|
_ws = new WebSocket(url);
|
||||||
|
_ws.onopen = () => {
|
||||||
|
_reconnectDelay = _RECONNECT_MIN; // reset backoff on successful connection
|
||||||
|
};
|
||||||
_ws.onmessage = (event) => {
|
_ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
@@ -27,7 +37,8 @@ export function startEventsWS() {
|
|||||||
};
|
};
|
||||||
_ws.onclose = () => {
|
_ws.onclose = () => {
|
||||||
_ws = null;
|
_ws = null;
|
||||||
_reconnectTimer = setTimeout(startEventsWS, 3000);
|
_reconnectTimer = setTimeout(startEventsWS, _reconnectDelay);
|
||||||
|
_reconnectDelay = Math.min(_reconnectDelay * 2, _RECONNECT_MAX);
|
||||||
};
|
};
|
||||||
_ws.onerror = () => {};
|
_ws.onerror = () => {};
|
||||||
} catch {
|
} catch {
|
||||||
@@ -45,4 +56,5 @@ export function stopEventsWS() {
|
|||||||
_ws.close();
|
_ws.close();
|
||||||
_ws = null;
|
_ws = null;
|
||||||
}
|
}
|
||||||
|
_reconnectDelay = _RECONNECT_MIN;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,4 +72,5 @@ export const download = '<path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2
|
|||||||
export const undo2 = '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5 5.5 5.5 0 0 1-5.5 5.5H11"/>';
|
export const undo2 = '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5 5.5 5.5 0 0 1-5.5 5.5H11"/>';
|
||||||
export const power = '<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" x2="12" y1="2" y2="12"/>';
|
export const power = '<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" x2="12" y1="2" y2="12"/>';
|
||||||
export const wifi = '<path d="M12 20h.01"/><path d="M2 8.82a15 15 0 0 1 20 0"/><path d="M5 12.859a10 10 0 0 1 14 0"/><path d="M8.5 16.429a5 5 0 0 1 7 0"/>';
|
export const wifi = '<path d="M12 20h.01"/><path d="M2 8.82a15 15 0 0 1 20 0"/><path d="M5 12.859a10 10 0 0 1 14 0"/><path d="M8.5 16.429a5 5 0 0 1 7 0"/>';
|
||||||
|
export const flame = '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>';
|
||||||
export const usb = '<circle cx="10" cy="7" r="1"/><circle cx="4" cy="20" r="1"/><path d="M4.7 19.3 19 5"/><path d="m21 3-3 1 2 2Z"/><path d="M10 8v3a1 1 0 0 1-1 1H4"/><path d="M14 12v2a1 1 0 0 0 1 1h3"/><circle cx="20" cy="15" r="1"/>';
|
export const usb = '<circle cx="10" cy="7" r="1"/><circle cx="4" cy="20" r="1"/><path d="M4.7 19.3 19 5"/><path d="m21 3-3 1 2 2Z"/><path d="M10 8v3a1 1 0 0 1-1 1H4"/><path d="M14 12v2a1 1 0 0 0 1 1h3"/><circle cx="20" cy="15" r="1"/>';
|
||||||
|
|||||||
@@ -18,14 +18,12 @@
|
|||||||
* Call sel.setValue(v) to change programmatically, sel.destroy() to remove.
|
* Call sel.setValue(v) to change programmatically, sel.destroy() to remove.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { t } from './i18n.js';
|
|
||||||
|
|
||||||
const POPUP_CLASS = 'icon-select-popup';
|
const POPUP_CLASS = 'icon-select-popup';
|
||||||
|
|
||||||
/** Close every open icon-select popup. */
|
/** Close every open icon-select popup. */
|
||||||
export function closeAllIconSelects() {
|
export function closeAllIconSelects() {
|
||||||
document.querySelectorAll(`.${POPUP_CLASS}`).forEach(p => {
|
document.querySelectorAll(`.${POPUP_CLASS}`).forEach(p => {
|
||||||
p.classList.remove('open', 'settled');
|
p.classList.remove('open');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +76,6 @@ export class IconSelect {
|
|||||||
this._popup = document.createElement('div');
|
this._popup = document.createElement('div');
|
||||||
this._popup.className = POPUP_CLASS;
|
this._popup.className = POPUP_CLASS;
|
||||||
this._popup.addEventListener('click', (e) => e.stopPropagation());
|
this._popup.addEventListener('click', (e) => e.stopPropagation());
|
||||||
this._popup.addEventListener('transitionend', this._onTransitionEnd);
|
|
||||||
this._popup.innerHTML = this._buildGrid();
|
this._popup.innerHTML = this._buildGrid();
|
||||||
document.body.appendChild(this._popup);
|
document.body.appendChild(this._popup);
|
||||||
|
|
||||||
@@ -86,7 +83,8 @@ export class IconSelect {
|
|||||||
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||||
cell.addEventListener('click', () => {
|
cell.addEventListener('click', () => {
|
||||||
this.setValue(cell.dataset.value, true);
|
this.setValue(cell.dataset.value, true);
|
||||||
this._popup.classList.remove('open', 'settled');
|
this._popup.classList.remove('open');
|
||||||
|
this._removeScrollListener();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -127,17 +125,32 @@ export class IconSelect {
|
|||||||
|
|
||||||
_positionPopup() {
|
_positionPopup() {
|
||||||
const rect = this._trigger.getBoundingClientRect();
|
const rect = this._trigger.getBoundingClientRect();
|
||||||
this._popup.style.left = rect.left + 'px';
|
const gap = 6; // visual gap between trigger and popup
|
||||||
this._popup.style.width = Math.max(rect.width, 200) + 'px';
|
const pad = 8; // min distance from viewport edge
|
||||||
|
const popupW = Math.max(rect.width, 200);
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom - gap - pad;
|
||||||
|
const spaceAbove = rect.top - gap - pad;
|
||||||
|
|
||||||
// Check if there's enough space below, otherwise open upward
|
// Determine direction
|
||||||
const spaceBelow = window.innerHeight - rect.bottom;
|
const openUp = spaceBelow < 200 && spaceAbove > spaceBelow;
|
||||||
const spaceAbove = rect.top;
|
const available = openUp ? spaceAbove : spaceBelow;
|
||||||
if (spaceBelow < 250 && spaceAbove > spaceBelow) {
|
|
||||||
|
// Horizontal: clamp so popup doesn't overflow right edge
|
||||||
|
let left = rect.left;
|
||||||
|
if (left + popupW > window.innerWidth - pad) {
|
||||||
|
left = window.innerWidth - pad - popupW;
|
||||||
|
}
|
||||||
|
if (left < pad) left = pad;
|
||||||
|
|
||||||
|
this._popup.style.left = left + 'px';
|
||||||
|
this._popup.style.width = popupW + 'px';
|
||||||
|
this._popup.style.maxHeight = available + 'px';
|
||||||
|
|
||||||
|
if (openUp) {
|
||||||
this._popup.style.top = '';
|
this._popup.style.top = '';
|
||||||
this._popup.style.bottom = (window.innerHeight - rect.top) + 'px';
|
this._popup.style.bottom = (window.innerHeight - rect.top + gap) + 'px';
|
||||||
} else {
|
} else {
|
||||||
this._popup.style.top = rect.bottom + 'px';
|
this._popup.style.top = (rect.bottom + gap) + 'px';
|
||||||
this._popup.style.bottom = '';
|
this._popup.style.bottom = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,14 +161,39 @@ export class IconSelect {
|
|||||||
if (!wasOpen) {
|
if (!wasOpen) {
|
||||||
this._positionPopup();
|
this._positionPopup();
|
||||||
this._popup.classList.add('open');
|
this._popup.classList.add('open');
|
||||||
|
this._addScrollListener();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_onTransitionEnd = (e) => {
|
/** Close popup when any scrollable ancestor scrolls (prevents stale position). */
|
||||||
if (e.propertyName === 'max-height' && this._popup.classList.contains('open')) {
|
_addScrollListener() {
|
||||||
this._popup.classList.add('settled');
|
if (this._scrollHandler) return;
|
||||||
|
this._scrollHandler = () => {
|
||||||
|
this._popup.classList.remove('open');
|
||||||
|
this._removeScrollListener();
|
||||||
|
};
|
||||||
|
// Listen on capture phase to catch scroll on any ancestor
|
||||||
|
let el = this._trigger.parentNode;
|
||||||
|
this._scrollTargets = [];
|
||||||
|
while (el && el !== document) {
|
||||||
|
if (el.scrollHeight > el.clientHeight || el.classList?.contains('modal-content')) {
|
||||||
|
el.addEventListener('scroll', this._scrollHandler, { passive: true });
|
||||||
|
this._scrollTargets.push(el);
|
||||||
|
}
|
||||||
|
el = el.parentNode;
|
||||||
}
|
}
|
||||||
};
|
window.addEventListener('scroll', this._scrollHandler, { passive: true });
|
||||||
|
this._scrollTargets.push(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
_removeScrollListener() {
|
||||||
|
if (!this._scrollHandler) return;
|
||||||
|
for (const el of this._scrollTargets) {
|
||||||
|
el.removeEventListener('scroll', this._scrollHandler);
|
||||||
|
}
|
||||||
|
this._scrollTargets = [];
|
||||||
|
this._scrollHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Change the value programmatically. */
|
/** Change the value programmatically. */
|
||||||
setValue(value, fireChange = false) {
|
setValue(value, fireChange = false) {
|
||||||
@@ -175,7 +213,7 @@ export class IconSelect {
|
|||||||
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||||
cell.addEventListener('click', () => {
|
cell.addEventListener('click', () => {
|
||||||
this.setValue(cell.dataset.value, true);
|
this.setValue(cell.dataset.value, true);
|
||||||
this._popup.classList.remove('open', 'settled');
|
this._popup.classList.remove('open');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
this._syncTrigger();
|
this._syncTrigger();
|
||||||
@@ -183,6 +221,7 @@ export class IconSelect {
|
|||||||
|
|
||||||
/** Remove the enhancement, restore native <select>. */
|
/** Remove the enhancement, restore native <select>. */
|
||||||
destroy() {
|
destroy() {
|
||||||
|
this._removeScrollListener();
|
||||||
this._trigger.remove();
|
this._trigger.remove();
|
||||||
this._popup.remove();
|
this._popup.remove();
|
||||||
this._select.style.display = '';
|
this._select.style.display = '';
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ const _colorStripTypeIcons = {
|
|||||||
audio: _svg(P.music), audio_visualization: _svg(P.music),
|
audio: _svg(P.music), audio_visualization: _svg(P.music),
|
||||||
api_input: _svg(P.send),
|
api_input: _svg(P.send),
|
||||||
notification: _svg(P.bellRing),
|
notification: _svg(P.bellRing),
|
||||||
|
daylight: _svg(P.sun),
|
||||||
|
candlelight: _svg(P.flame),
|
||||||
};
|
};
|
||||||
const _valueSourceTypeIcons = {
|
const _valueSourceTypeIcons = {
|
||||||
static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music),
|
static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music),
|
||||||
|
|||||||
@@ -80,6 +80,13 @@ class CSSEditorModal extends Modal {
|
|||||||
notification_filter_list: document.getElementById('css-editor-notification-filter-list').value,
|
notification_filter_list: document.getElementById('css-editor-notification-filter-list').value,
|
||||||
notification_app_colors: JSON.stringify(_notificationAppColors),
|
notification_app_colors: JSON.stringify(_notificationAppColors),
|
||||||
clock_id: document.getElementById('css-editor-clock').value,
|
clock_id: document.getElementById('css-editor-clock').value,
|
||||||
|
daylight_speed: document.getElementById('css-editor-daylight-speed').value,
|
||||||
|
daylight_use_real_time: document.getElementById('css-editor-daylight-real-time').checked,
|
||||||
|
daylight_latitude: document.getElementById('css-editor-daylight-latitude').value,
|
||||||
|
candlelight_color: document.getElementById('css-editor-candlelight-color').value,
|
||||||
|
candlelight_intensity: document.getElementById('css-editor-candlelight-intensity').value,
|
||||||
|
candlelight_num_candles: document.getElementById('css-editor-candlelight-num-candles').value,
|
||||||
|
candlelight_speed: document.getElementById('css-editor-candlelight-speed').value,
|
||||||
tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []),
|
tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -99,7 +106,7 @@ let _cssClockEntitySelect = null;
|
|||||||
const CSS_TYPE_KEYS = [
|
const CSS_TYPE_KEYS = [
|
||||||
'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle',
|
'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle',
|
||||||
'effect', 'composite', 'mapped', 'audio',
|
'effect', 'composite', 'mapped', 'audio',
|
||||||
'api_input', 'notification',
|
'api_input', 'notification', 'daylight', 'candlelight',
|
||||||
];
|
];
|
||||||
|
|
||||||
function _buildCSSTypeItems() {
|
function _buildCSSTypeItems() {
|
||||||
@@ -148,6 +155,8 @@ export function onCSSTypeChange() {
|
|||||||
document.getElementById('css-editor-audio-section').style.display = type === 'audio' ? '' : 'none';
|
document.getElementById('css-editor-audio-section').style.display = type === 'audio' ? '' : 'none';
|
||||||
document.getElementById('css-editor-api-input-section').style.display = type === 'api_input' ? '' : 'none';
|
document.getElementById('css-editor-api-input-section').style.display = type === 'api_input' ? '' : 'none';
|
||||||
document.getElementById('css-editor-notification-section').style.display = type === 'notification' ? '' : 'none';
|
document.getElementById('css-editor-notification-section').style.display = type === 'notification' ? '' : 'none';
|
||||||
|
document.getElementById('css-editor-daylight-section').style.display = type === 'daylight' ? '' : 'none';
|
||||||
|
document.getElementById('css-editor-candlelight-section').style.display = type === 'candlelight' ? '' : 'none';
|
||||||
|
|
||||||
if (isPictureType) _ensureInterpolationIconSelect();
|
if (isPictureType) _ensureInterpolationIconSelect();
|
||||||
if (type === 'effect') {
|
if (type === 'effect') {
|
||||||
@@ -197,8 +206,8 @@ export function onCSSTypeChange() {
|
|||||||
document.getElementById('css-editor-led-count-group').style.display =
|
document.getElementById('css-editor-led-count-group').style.display =
|
||||||
hasLedCount.includes(type) ? '' : 'none';
|
hasLedCount.includes(type) ? '' : 'none';
|
||||||
|
|
||||||
// Sync clock — shown for animated types (static, gradient, color_cycle, effect)
|
// Sync clock — shown for animated types
|
||||||
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect'];
|
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight'];
|
||||||
document.getElementById('css-editor-clock-group').style.display = clockTypes.includes(type) ? '' : 'none';
|
document.getElementById('css-editor-clock-group').style.display = clockTypes.includes(type) ? '' : 'none';
|
||||||
if (clockTypes.includes(type)) _populateClockDropdown();
|
if (clockTypes.includes(type)) _populateClockDropdown();
|
||||||
|
|
||||||
@@ -274,6 +283,17 @@ function _syncAnimationSpeedState() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Daylight real-time toggle helper ─────────────────────────── */
|
||||||
|
|
||||||
|
export function onDaylightRealTimeChange() {
|
||||||
|
_syncDaylightSpeedVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _syncDaylightSpeedVisibility() {
|
||||||
|
const isRealTime = document.getElementById('css-editor-daylight-real-time').checked;
|
||||||
|
document.getElementById('css-editor-daylight-speed-group').style.display = isRealTime ? 'none' : '';
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Gradient strip preview helper ────────────────────────────── */
|
/* ── Gradient strip preview helper ────────────────────────────── */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1039,6 +1059,23 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
|||||||
</span>
|
</span>
|
||||||
${appCount > 0 ? `<span class="stream-card-prop">${ICON_PALETTE} ${appCount} ${t('color_strip.notification.app_count')}</span>` : ''}
|
${appCount > 0 ? `<span class="stream-card-prop">${ICON_PALETTE} ${appCount} ${t('color_strip.notification.app_count')}</span>` : ''}
|
||||||
`;
|
`;
|
||||||
|
} else if (source.source_type === 'daylight') {
|
||||||
|
const useRealTime = source.use_real_time;
|
||||||
|
const speedVal = (source.speed ?? 1.0).toFixed(1);
|
||||||
|
propsHtml = `
|
||||||
|
<span class="stream-card-prop">${useRealTime ? '🕐 ' + t('color_strip.daylight.real_time') : '⏩ ' + speedVal + 'x'}</span>
|
||||||
|
${clockBadge}
|
||||||
|
`;
|
||||||
|
} else if (source.source_type === 'candlelight') {
|
||||||
|
const hexColor = rgbArrayToHex(source.color || [255, 147, 41]);
|
||||||
|
const numCandles = source.num_candles ?? 3;
|
||||||
|
propsHtml = `
|
||||||
|
<span class="stream-card-prop" title="${t('color_strip.candlelight.color')}">
|
||||||
|
<span style="display:inline-block;width:14px;height:14px;background:${hexColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${hexColor.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span class="stream-card-prop">${numCandles} ${t('color_strip.candlelight.num_candles')}</span>
|
||||||
|
${clockBadge}
|
||||||
|
`;
|
||||||
} else if (isPictureAdvanced) {
|
} else if (isPictureAdvanced) {
|
||||||
const cal = source.calibration || {};
|
const cal = source.calibration || {};
|
||||||
const lines = cal.lines || [];
|
const lines = cal.lines || [];
|
||||||
@@ -1073,7 +1110,9 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const icon = getColorStripIcon(source.source_type);
|
const icon = getColorStripIcon(source.source_type);
|
||||||
const isPictureKind = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio && !isApiInput && !isNotification);
|
const isDaylight = source.source_type === 'daylight';
|
||||||
|
const isCandlelight = source.source_type === 'candlelight';
|
||||||
|
const isPictureKind = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio && !isApiInput && !isNotification && !isDaylight && !isCandlelight);
|
||||||
const calibrationBtn = isPictureKind
|
const calibrationBtn = isPictureKind
|
||||||
? `<button class="btn btn-icon btn-secondary" onclick="${isPictureAdvanced ? `showAdvancedCalibration('${source.id}')` : `showCSSCalibration('${source.id}')`}" title="${t('calibration.title')}">${ICON_CALIBRATION}</button>`
|
? `<button class="btn btn-icon btn-secondary" onclick="${isPictureAdvanced ? `showAdvancedCalibration('${source.id}')` : `showCSSCalibration('${source.id}')`}" title="${t('calibration.title')}">${ICON_CALIBRATION}</button>`
|
||||||
: '';
|
: '';
|
||||||
@@ -1217,6 +1256,20 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
|
|||||||
_showApiInputEndpoints(css.id);
|
_showApiInputEndpoints(css.id);
|
||||||
} else if (sourceType === 'notification') {
|
} else if (sourceType === 'notification') {
|
||||||
_loadNotificationState(css);
|
_loadNotificationState(css);
|
||||||
|
} else if (sourceType === 'daylight') {
|
||||||
|
document.getElementById('css-editor-daylight-speed').value = css.speed ?? 1.0;
|
||||||
|
document.getElementById('css-editor-daylight-speed-val').textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
|
||||||
|
document.getElementById('css-editor-daylight-real-time').checked = css.use_real_time || false;
|
||||||
|
document.getElementById('css-editor-daylight-latitude').value = css.latitude ?? 50.0;
|
||||||
|
document.getElementById('css-editor-daylight-latitude-val').textContent = parseFloat(css.latitude ?? 50.0).toFixed(0);
|
||||||
|
_syncDaylightSpeedVisibility();
|
||||||
|
} else if (sourceType === 'candlelight') {
|
||||||
|
document.getElementById('css-editor-candlelight-color').value = rgbArrayToHex(css.color || [255, 147, 41]);
|
||||||
|
document.getElementById('css-editor-candlelight-intensity').value = css.intensity ?? 1.0;
|
||||||
|
document.getElementById('css-editor-candlelight-intensity-val').textContent = parseFloat(css.intensity ?? 1.0).toFixed(1);
|
||||||
|
document.getElementById('css-editor-candlelight-num-candles').value = css.num_candles ?? 3;
|
||||||
|
document.getElementById('css-editor-candlelight-speed').value = css.speed ?? 1.0;
|
||||||
|
document.getElementById('css-editor-candlelight-speed-val').textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
|
||||||
} else {
|
} else {
|
||||||
if (sourceType === 'picture') sourceSelect.value = css.picture_source_id || '';
|
if (sourceType === 'picture') sourceSelect.value = css.picture_source_id || '';
|
||||||
|
|
||||||
@@ -1313,6 +1366,19 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
|
|||||||
document.getElementById('css-editor-api-input-timeout-val').textContent = '5.0';
|
document.getElementById('css-editor-api-input-timeout-val').textContent = '5.0';
|
||||||
_showApiInputEndpoints(null);
|
_showApiInputEndpoints(null);
|
||||||
_resetNotificationState();
|
_resetNotificationState();
|
||||||
|
// Daylight defaults
|
||||||
|
document.getElementById('css-editor-daylight-speed').value = 1.0;
|
||||||
|
document.getElementById('css-editor-daylight-speed-val').textContent = '1.0';
|
||||||
|
document.getElementById('css-editor-daylight-real-time').checked = false;
|
||||||
|
document.getElementById('css-editor-daylight-latitude').value = 50.0;
|
||||||
|
document.getElementById('css-editor-daylight-latitude-val').textContent = '50';
|
||||||
|
// Candlelight defaults
|
||||||
|
document.getElementById('css-editor-candlelight-color').value = '#ff9329';
|
||||||
|
document.getElementById('css-editor-candlelight-intensity').value = 1.0;
|
||||||
|
document.getElementById('css-editor-candlelight-intensity-val').textContent = '1.0';
|
||||||
|
document.getElementById('css-editor-candlelight-num-candles').value = 3;
|
||||||
|
document.getElementById('css-editor-candlelight-speed').value = 1.0;
|
||||||
|
document.getElementById('css-editor-candlelight-speed-val').textContent = '1.0';
|
||||||
document.getElementById('css-editor-title').innerHTML = `${ICON_FILM} ${t('color_strip.add')}`;
|
document.getElementById('css-editor-title').innerHTML = `${ICON_FILM} ${t('color_strip.add')}`;
|
||||||
document.getElementById('css-editor-gradient-preset').value = '';
|
document.getElementById('css-editor-gradient-preset').value = '';
|
||||||
gradientInit([
|
gradientInit([
|
||||||
@@ -1473,6 +1539,23 @@ export async function saveCSSEditor() {
|
|||||||
app_colors: _notificationGetAppColorsDict(),
|
app_colors: _notificationGetAppColorsDict(),
|
||||||
};
|
};
|
||||||
if (!cssId) payload.source_type = 'notification';
|
if (!cssId) payload.source_type = 'notification';
|
||||||
|
} else if (sourceType === 'daylight') {
|
||||||
|
payload = {
|
||||||
|
name,
|
||||||
|
speed: parseFloat(document.getElementById('css-editor-daylight-speed').value),
|
||||||
|
use_real_time: document.getElementById('css-editor-daylight-real-time').checked,
|
||||||
|
latitude: parseFloat(document.getElementById('css-editor-daylight-latitude').value),
|
||||||
|
};
|
||||||
|
if (!cssId) payload.source_type = 'daylight';
|
||||||
|
} else if (sourceType === 'candlelight') {
|
||||||
|
payload = {
|
||||||
|
name,
|
||||||
|
color: hexToRgbArray(document.getElementById('css-editor-candlelight-color').value),
|
||||||
|
intensity: parseFloat(document.getElementById('css-editor-candlelight-intensity').value),
|
||||||
|
num_candles: parseInt(document.getElementById('css-editor-candlelight-num-candles').value) || 3,
|
||||||
|
speed: parseFloat(document.getElementById('css-editor-candlelight-speed').value),
|
||||||
|
};
|
||||||
|
if (!cssId) payload.source_type = 'candlelight';
|
||||||
} else if (sourceType === 'picture_advanced') {
|
} else if (sourceType === 'picture_advanced') {
|
||||||
payload = {
|
payload = {
|
||||||
name,
|
name,
|
||||||
@@ -1501,7 +1584,7 @@ export async function saveCSSEditor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Attach clock_id for animated types
|
// Attach clock_id for animated types
|
||||||
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect'];
|
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight'];
|
||||||
if (clockTypes.includes(sourceType)) {
|
if (clockTypes.includes(sourceType)) {
|
||||||
const clockVal = document.getElementById('css-editor-clock').value;
|
const clockVal = document.getElementById('css-editor-clock').value;
|
||||||
payload.clock_id = clockVal || null;
|
payload.clock_id = clockVal || null;
|
||||||
|
|||||||
@@ -840,6 +840,13 @@ function _debouncedDashboardReload(forceFullRender = false) {
|
|||||||
|
|
||||||
document.addEventListener('server:state_change', () => _debouncedDashboardReload());
|
document.addEventListener('server:state_change', () => _debouncedDashboardReload());
|
||||||
document.addEventListener('server:automation_state_changed', () => _debouncedDashboardReload(true));
|
document.addEventListener('server:automation_state_changed', () => _debouncedDashboardReload(true));
|
||||||
|
document.addEventListener('server:device_health_changed', () => _debouncedDashboardReload());
|
||||||
|
|
||||||
|
const _DASHBOARD_ENTITY_TYPES = new Set(['output_target', 'automation', 'scene_preset', 'sync_clock', 'device']);
|
||||||
|
document.addEventListener('server:entity_changed', (e) => {
|
||||||
|
const { entity_type } = e.detail || {};
|
||||||
|
if (_DASHBOARD_ENTITY_TYPES.has(entity_type)) _debouncedDashboardReload(true);
|
||||||
|
});
|
||||||
|
|
||||||
// Re-render dashboard when language changes
|
// Re-render dashboard when language changes
|
||||||
document.addEventListener('languageChanged', () => {
|
document.addEventListener('languageChanged', () => {
|
||||||
|
|||||||
@@ -913,6 +913,28 @@
|
|||||||
"color_strip.notification.endpoint.hint": "Use this URL to trigger notifications from external systems. POST with optional JSON body: {\"app\": \"AppName\", \"color\": \"#FF0000\"}.",
|
"color_strip.notification.endpoint.hint": "Use this URL to trigger notifications from external systems. POST with optional JSON body: {\"app\": \"AppName\", \"color\": \"#FF0000\"}.",
|
||||||
"color_strip.notification.save_first": "Save the source first to see the webhook endpoint URL.",
|
"color_strip.notification.save_first": "Save the source first to see the webhook endpoint URL.",
|
||||||
"color_strip.notification.app_count": "apps",
|
"color_strip.notification.app_count": "apps",
|
||||||
|
"color_strip.type.daylight": "Daylight Cycle",
|
||||||
|
"color_strip.type.daylight.desc": "Simulates natural daylight over 24 hours",
|
||||||
|
"color_strip.type.daylight.hint": "Simulates the sun's color temperature throughout a 24-hour day/night cycle — from warm sunrise to cool daylight to warm sunset and dim night.",
|
||||||
|
"color_strip.daylight.speed": "Speed:",
|
||||||
|
"color_strip.daylight.speed.hint": "Cycle speed multiplier. 1.0 = full day/night cycle in ~4 minutes. Higher values cycle faster.",
|
||||||
|
"color_strip.daylight.use_real_time": "Use Real Time:",
|
||||||
|
"color_strip.daylight.use_real_time.hint": "When enabled, LED color matches the actual time of day on this computer. Speed setting is ignored.",
|
||||||
|
"color_strip.daylight.real_time": "Real Time",
|
||||||
|
"color_strip.daylight.latitude": "Latitude:",
|
||||||
|
"color_strip.daylight.latitude.hint": "Your geographic latitude (-90 to 90). Affects sunrise/sunset timing in real-time mode.",
|
||||||
|
"color_strip.type.candlelight": "Candlelight",
|
||||||
|
"color_strip.type.candlelight.desc": "Realistic flickering candle simulation",
|
||||||
|
"color_strip.type.candlelight.hint": "Simulates realistic candle flickering across all LEDs with warm tones and organic flicker patterns.",
|
||||||
|
"color_strip.candlelight.color": "Base Color:",
|
||||||
|
"color_strip.candlelight.color.hint": "The warm base color of the candle flame. Default is a natural warm amber.",
|
||||||
|
"color_strip.candlelight.intensity": "Flicker Intensity:",
|
||||||
|
"color_strip.candlelight.intensity.hint": "How much the candles flicker. Low values produce a gentle glow, high values simulate a windy candle.",
|
||||||
|
"color_strip.candlelight.num_candles_label": "Number of Candles:",
|
||||||
|
"color_strip.candlelight.num_candles": "candles",
|
||||||
|
"color_strip.candlelight.num_candles.hint": "How many independent candle sources along the strip. Each flickers with its own pattern.",
|
||||||
|
"color_strip.candlelight.speed": "Flicker Speed:",
|
||||||
|
"color_strip.candlelight.speed.hint": "Speed of the flicker animation. Higher values produce faster, more restless flames.",
|
||||||
"color_strip.composite.layers": "Layers:",
|
"color_strip.composite.layers": "Layers:",
|
||||||
"color_strip.composite.layers.hint": "Stack multiple color strip sources. First layer is the bottom, last is the top. Each layer can have its own blend mode and opacity.",
|
"color_strip.composite.layers.hint": "Stack multiple color strip sources. First layer is the bottom, last is the top. Each layer can have its own blend mode and opacity.",
|
||||||
"color_strip.composite.add_layer": "+ Add Layer",
|
"color_strip.composite.add_layer": "+ Add Layer",
|
||||||
|
|||||||
@@ -913,6 +913,28 @@
|
|||||||
"color_strip.notification.endpoint.hint": "URL для запуска уведомлений из внешних систем. POST с JSON телом: {\"app\": \"AppName\", \"color\": \"#FF0000\"}.",
|
"color_strip.notification.endpoint.hint": "URL для запуска уведомлений из внешних систем. POST с JSON телом: {\"app\": \"AppName\", \"color\": \"#FF0000\"}.",
|
||||||
"color_strip.notification.save_first": "Сначала сохраните источник, чтобы увидеть URL вебхука.",
|
"color_strip.notification.save_first": "Сначала сохраните источник, чтобы увидеть URL вебхука.",
|
||||||
"color_strip.notification.app_count": "прилож.",
|
"color_strip.notification.app_count": "прилож.",
|
||||||
|
"color_strip.type.daylight": "Дневной цикл",
|
||||||
|
"color_strip.type.daylight.desc": "Имитация естественного дневного света за 24 часа",
|
||||||
|
"color_strip.type.daylight.hint": "Имитирует цветовую температуру солнца в течение суток — от тёплого рассвета до прохладного дневного света, заката и ночи.",
|
||||||
|
"color_strip.daylight.speed": "Скорость:",
|
||||||
|
"color_strip.daylight.speed.hint": "Множитель скорости цикла. 1.0 = полный цикл день/ночь за ~4 минуты.",
|
||||||
|
"color_strip.daylight.use_real_time": "Реальное время:",
|
||||||
|
"color_strip.daylight.use_real_time.hint": "Если включено, цвет LED соответствует реальному времени суток. Настройка скорости игнорируется.",
|
||||||
|
"color_strip.daylight.real_time": "Реальное время",
|
||||||
|
"color_strip.daylight.latitude": "Широта:",
|
||||||
|
"color_strip.daylight.latitude.hint": "Географическая широта (-90 до 90). Влияет на время восхода/заката в режиме реального времени.",
|
||||||
|
"color_strip.type.candlelight": "Свечи",
|
||||||
|
"color_strip.type.candlelight.desc": "Реалистичная имитация мерцания свечей",
|
||||||
|
"color_strip.type.candlelight.hint": "Реалистичное мерцание свечей с тёплыми тонами и органическими паттернами.",
|
||||||
|
"color_strip.candlelight.color": "Базовый цвет:",
|
||||||
|
"color_strip.candlelight.color.hint": "Тёплый базовый цвет пламени свечи. По умолчанию — натуральный тёплый янтарь.",
|
||||||
|
"color_strip.candlelight.intensity": "Интенсивность мерцания:",
|
||||||
|
"color_strip.candlelight.intensity.hint": "Сила мерцания свечей. Низкие значения — мягкое свечение, высокие — свеча на ветру.",
|
||||||
|
"color_strip.candlelight.num_candles_label": "Количество свечей:",
|
||||||
|
"color_strip.candlelight.num_candles": "свечей",
|
||||||
|
"color_strip.candlelight.num_candles.hint": "Сколько независимых источников свечей вдоль ленты. Каждый мерцает по-своему.",
|
||||||
|
"color_strip.candlelight.speed": "Скорость мерцания:",
|
||||||
|
"color_strip.candlelight.speed.hint": "Скорость анимации мерцания. Большие значения — более быстрое, беспокойное пламя.",
|
||||||
"color_strip.composite.layers": "Слои:",
|
"color_strip.composite.layers": "Слои:",
|
||||||
"color_strip.composite.layers.hint": "Наложение нескольких источников. Первый слой — нижний, последний — верхний. Каждый слой может иметь свой режим смешивания и прозрачность.",
|
"color_strip.composite.layers.hint": "Наложение нескольких источников. Первый слой — нижний, последний — верхний. Каждый слой может иметь свой режим смешивания и прозрачность.",
|
||||||
"color_strip.composite.add_layer": "+ Добавить слой",
|
"color_strip.composite.add_layer": "+ Добавить слой",
|
||||||
|
|||||||
@@ -913,6 +913,28 @@
|
|||||||
"color_strip.notification.endpoint.hint": "使用此 URL 从外部系统触发通知。POST 请求可选 JSON:{\"app\": \"AppName\", \"color\": \"#FF0000\"}。",
|
"color_strip.notification.endpoint.hint": "使用此 URL 从外部系统触发通知。POST 请求可选 JSON:{\"app\": \"AppName\", \"color\": \"#FF0000\"}。",
|
||||||
"color_strip.notification.save_first": "请先保存源以查看 Webhook 端点 URL。",
|
"color_strip.notification.save_first": "请先保存源以查看 Webhook 端点 URL。",
|
||||||
"color_strip.notification.app_count": "个应用",
|
"color_strip.notification.app_count": "个应用",
|
||||||
|
"color_strip.type.daylight": "日光循环",
|
||||||
|
"color_strip.type.daylight.desc": "模拟24小时自然日光变化",
|
||||||
|
"color_strip.type.daylight.hint": "模拟太阳在24小时内的色温变化——从温暖的日出到冷白的日光,再到温暖的日落和昏暗的夜晚。",
|
||||||
|
"color_strip.daylight.speed": "速度:",
|
||||||
|
"color_strip.daylight.speed.hint": "循环速度倍数。1.0 = 约4分钟完成一个完整的昼夜循环。",
|
||||||
|
"color_strip.daylight.use_real_time": "使用实时时间:",
|
||||||
|
"color_strip.daylight.use_real_time.hint": "启用后,LED颜色匹配计算机的实际时间。速度设置将被忽略。",
|
||||||
|
"color_strip.daylight.real_time": "实时",
|
||||||
|
"color_strip.daylight.latitude": "纬度:",
|
||||||
|
"color_strip.daylight.latitude.hint": "地理纬度(-90到90)。影响实时模式下的日出/日落时间。",
|
||||||
|
"color_strip.type.candlelight": "烛光",
|
||||||
|
"color_strip.type.candlelight.desc": "逼真的烛光闪烁模拟",
|
||||||
|
"color_strip.type.candlelight.hint": "在所有LED上模拟逼真的蜡烛闪烁,具有温暖色调和有机闪烁模式。",
|
||||||
|
"color_strip.candlelight.color": "基础颜色:",
|
||||||
|
"color_strip.candlelight.color.hint": "蜡烛火焰的温暖基础颜色。默认为自然温暖的琥珀色。",
|
||||||
|
"color_strip.candlelight.intensity": "闪烁强度:",
|
||||||
|
"color_strip.candlelight.intensity.hint": "蜡烛闪烁程度。低值产生柔和光芒,高值模拟风中的蜡烛。",
|
||||||
|
"color_strip.candlelight.num_candles_label": "蜡烛数量:",
|
||||||
|
"color_strip.candlelight.num_candles": "支蜡烛",
|
||||||
|
"color_strip.candlelight.num_candles.hint": "灯带上独立蜡烛光源的数量。每支蜡烛有自己的闪烁模式。",
|
||||||
|
"color_strip.candlelight.speed": "闪烁速度:",
|
||||||
|
"color_strip.candlelight.speed.hint": "闪烁动画的速度。较高的值产生更快、更不安定的火焰。",
|
||||||
"color_strip.composite.layers": "图层:",
|
"color_strip.composite.layers": "图层:",
|
||||||
"color_strip.composite.layers.hint": "叠加多个色带源。第一个图层在底部,最后一个在顶部。每个图层可以有自己的混合模式和不透明度。",
|
"color_strip.composite.layers.hint": "叠加多个色带源。第一个图层在底部,最后一个在顶部。每个图层可以有自己的混合模式和不透明度。",
|
||||||
"color_strip.composite.add_layer": "+ 添加图层",
|
"color_strip.composite.add_layer": "+ 添加图层",
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ Current types:
|
|||||||
AudioColorStripSource — audio-reactive visualization (spectrum, beat pulse, VU meter)
|
AudioColorStripSource — audio-reactive visualization (spectrum, beat pulse, VU meter)
|
||||||
ApiInputColorStripSource — receives raw LED colors from external clients via REST/WebSocket
|
ApiInputColorStripSource — receives raw LED colors from external clients via REST/WebSocket
|
||||||
NotificationColorStripSource — fires one-shot visual alerts (flash, pulse, sweep) via API
|
NotificationColorStripSource — fires one-shot visual alerts (flash, pulse, sweep) via API
|
||||||
|
DaylightColorStripSource — simulates natural daylight color temperature over a 24-hour cycle
|
||||||
|
CandlelightColorStripSource — realistic per-LED candle flickering with warm glow
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -93,6 +95,12 @@ class ColorStripSource:
|
|||||||
"app_filter_mode": None,
|
"app_filter_mode": None,
|
||||||
"app_filter_list": None,
|
"app_filter_list": None,
|
||||||
"os_listener": None,
|
"os_listener": None,
|
||||||
|
# daylight-type fields
|
||||||
|
"speed": None,
|
||||||
|
"use_real_time": None,
|
||||||
|
"latitude": None,
|
||||||
|
# candlelight-type fields
|
||||||
|
"num_candles": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -244,6 +252,32 @@ class ColorStripSource:
|
|||||||
os_listener=bool(data.get("os_listener", False)),
|
os_listener=bool(data.get("os_listener", False)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if source_type == "daylight":
|
||||||
|
return DaylightColorStripSource(
|
||||||
|
id=sid, name=name, source_type="daylight",
|
||||||
|
created_at=created_at, updated_at=updated_at, description=description,
|
||||||
|
clock_id=clock_id, tags=tags,
|
||||||
|
speed=float(data.get("speed") or 1.0),
|
||||||
|
use_real_time=bool(data.get("use_real_time", False)),
|
||||||
|
latitude=float(data.get("latitude") or 50.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
if source_type == "candlelight":
|
||||||
|
raw_color = data.get("color")
|
||||||
|
color = (
|
||||||
|
raw_color if isinstance(raw_color, list) and len(raw_color) == 3
|
||||||
|
else [255, 147, 41]
|
||||||
|
)
|
||||||
|
return CandlelightColorStripSource(
|
||||||
|
id=sid, name=name, source_type="candlelight",
|
||||||
|
created_at=created_at, updated_at=updated_at, description=description,
|
||||||
|
clock_id=clock_id, tags=tags,
|
||||||
|
color=color,
|
||||||
|
intensity=float(data.get("intensity") or 1.0),
|
||||||
|
num_candles=int(data.get("num_candles") or 3),
|
||||||
|
speed=float(data.get("speed") or 1.0),
|
||||||
|
)
|
||||||
|
|
||||||
# Shared picture-type field extraction
|
# Shared picture-type field extraction
|
||||||
_picture_kwargs = dict(
|
_picture_kwargs = dict(
|
||||||
tags=tags,
|
tags=tags,
|
||||||
@@ -567,3 +601,52 @@ class NotificationColorStripSource(ColorStripSource):
|
|||||||
d["app_filter_list"] = list(self.app_filter_list)
|
d["app_filter_list"] = list(self.app_filter_list)
|
||||||
d["os_listener"] = self.os_listener
|
d["os_listener"] = self.os_listener
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DaylightColorStripSource(ColorStripSource):
|
||||||
|
"""Color strip source that simulates natural daylight over a 24-hour cycle.
|
||||||
|
|
||||||
|
All LEDs receive the same color at any point in time, smoothly
|
||||||
|
transitioning through dawn (warm orange), daylight (cool white),
|
||||||
|
sunset (warm red/orange), and night (dim blue).
|
||||||
|
LED count auto-sizes from the connected device.
|
||||||
|
|
||||||
|
When use_real_time is True, the current wall-clock hour determines
|
||||||
|
the color; speed is ignored. When False, speed controls how fast
|
||||||
|
a full 24-hour cycle plays (1.0 ≈ 4 minutes per full cycle).
|
||||||
|
"""
|
||||||
|
|
||||||
|
speed: float = 1.0 # cycle speed (ignored when use_real_time)
|
||||||
|
use_real_time: bool = False # use actual time of day
|
||||||
|
latitude: float = 50.0 # latitude for sunrise/sunset timing (-90..90)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
d = super().to_dict()
|
||||||
|
d["speed"] = self.speed
|
||||||
|
d["use_real_time"] = self.use_real_time
|
||||||
|
d["latitude"] = self.latitude
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CandlelightColorStripSource(ColorStripSource):
|
||||||
|
"""Color strip source that simulates realistic candle flickering.
|
||||||
|
|
||||||
|
Each LED or group of LEDs flickers independently with warm tones.
|
||||||
|
Uses layered noise for organic, non-repeating flicker patterns.
|
||||||
|
LED count auto-sizes from the connected device.
|
||||||
|
"""
|
||||||
|
|
||||||
|
color: list = field(default_factory=lambda: [255, 147, 41]) # warm candle base [R,G,B]
|
||||||
|
intensity: float = 1.0 # flicker intensity (0.1–2.0)
|
||||||
|
num_candles: int = 3 # number of independent candle sources
|
||||||
|
speed: float = 1.0 # flicker speed multiplier
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
d = super().to_dict()
|
||||||
|
d["color"] = list(self.color)
|
||||||
|
d["intensity"] = self.intensity
|
||||||
|
d["num_candles"] = self.num_candles
|
||||||
|
d["speed"] = self.speed
|
||||||
|
return d
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ from wled_controller.storage.color_strip_source import (
|
|||||||
AdvancedPictureColorStripSource,
|
AdvancedPictureColorStripSource,
|
||||||
ApiInputColorStripSource,
|
ApiInputColorStripSource,
|
||||||
AudioColorStripSource,
|
AudioColorStripSource,
|
||||||
|
CandlelightColorStripSource,
|
||||||
ColorCycleColorStripSource,
|
ColorCycleColorStripSource,
|
||||||
ColorStripSource,
|
ColorStripSource,
|
||||||
CompositeColorStripSource,
|
CompositeColorStripSource,
|
||||||
|
DaylightColorStripSource,
|
||||||
EffectColorStripSource,
|
EffectColorStripSource,
|
||||||
GradientColorStripSource,
|
GradientColorStripSource,
|
||||||
MappedColorStripSource,
|
MappedColorStripSource,
|
||||||
@@ -82,6 +84,12 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]):
|
|||||||
app_filter_mode: Optional[str] = None,
|
app_filter_mode: Optional[str] = None,
|
||||||
app_filter_list: Optional[list] = None,
|
app_filter_list: Optional[list] = None,
|
||||||
os_listener: Optional[bool] = None,
|
os_listener: Optional[bool] = None,
|
||||||
|
# daylight-type fields
|
||||||
|
speed: Optional[float] = None,
|
||||||
|
use_real_time: Optional[bool] = None,
|
||||||
|
latitude: Optional[float] = None,
|
||||||
|
# candlelight-type fields
|
||||||
|
num_candles: Optional[int] = None,
|
||||||
tags: Optional[List[str]] = None,
|
tags: Optional[List[str]] = None,
|
||||||
) -> ColorStripSource:
|
) -> ColorStripSource:
|
||||||
"""Create a new color strip source.
|
"""Create a new color strip source.
|
||||||
@@ -235,6 +243,34 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]):
|
|||||||
app_filter_list=app_filter_list if isinstance(app_filter_list, list) else [],
|
app_filter_list=app_filter_list if isinstance(app_filter_list, list) else [],
|
||||||
os_listener=bool(os_listener) if os_listener is not None else False,
|
os_listener=bool(os_listener) if os_listener is not None else False,
|
||||||
)
|
)
|
||||||
|
elif source_type == "daylight":
|
||||||
|
source = DaylightColorStripSource(
|
||||||
|
id=source_id,
|
||||||
|
name=name,
|
||||||
|
source_type="daylight",
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
description=description,
|
||||||
|
clock_id=clock_id,
|
||||||
|
speed=float(speed) if speed is not None else 1.0,
|
||||||
|
use_real_time=bool(use_real_time) if use_real_time is not None else False,
|
||||||
|
latitude=float(latitude) if latitude is not None else 50.0,
|
||||||
|
)
|
||||||
|
elif source_type == "candlelight":
|
||||||
|
rgb = color if isinstance(color, list) and len(color) == 3 else [255, 147, 41]
|
||||||
|
source = CandlelightColorStripSource(
|
||||||
|
id=source_id,
|
||||||
|
name=name,
|
||||||
|
source_type="candlelight",
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
description=description,
|
||||||
|
clock_id=clock_id,
|
||||||
|
color=rgb,
|
||||||
|
intensity=float(intensity) if intensity else 1.0,
|
||||||
|
num_candles=int(num_candles) if num_candles is not None else 3,
|
||||||
|
speed=float(speed) if speed is not None else 1.0,
|
||||||
|
)
|
||||||
elif source_type == "picture_advanced":
|
elif source_type == "picture_advanced":
|
||||||
if calibration is None:
|
if calibration is None:
|
||||||
calibration = CalibrationConfig(mode="advanced")
|
calibration = CalibrationConfig(mode="advanced")
|
||||||
@@ -326,6 +362,12 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]):
|
|||||||
app_filter_mode: Optional[str] = None,
|
app_filter_mode: Optional[str] = None,
|
||||||
app_filter_list: Optional[list] = None,
|
app_filter_list: Optional[list] = None,
|
||||||
os_listener: Optional[bool] = None,
|
os_listener: Optional[bool] = None,
|
||||||
|
# daylight-type fields
|
||||||
|
speed: Optional[float] = None,
|
||||||
|
use_real_time: Optional[bool] = None,
|
||||||
|
latitude: Optional[float] = None,
|
||||||
|
# candlelight-type fields
|
||||||
|
num_candles: Optional[int] = None,
|
||||||
tags: Optional[List[str]] = None,
|
tags: Optional[List[str]] = None,
|
||||||
) -> ColorStripSource:
|
) -> ColorStripSource:
|
||||||
"""Update an existing color strip source.
|
"""Update an existing color strip source.
|
||||||
@@ -452,6 +494,22 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]):
|
|||||||
source.app_filter_list = app_filter_list
|
source.app_filter_list = app_filter_list
|
||||||
if os_listener is not None:
|
if os_listener is not None:
|
||||||
source.os_listener = bool(os_listener)
|
source.os_listener = bool(os_listener)
|
||||||
|
elif isinstance(source, DaylightColorStripSource):
|
||||||
|
if speed is not None:
|
||||||
|
source.speed = float(speed)
|
||||||
|
if use_real_time is not None:
|
||||||
|
source.use_real_time = bool(use_real_time)
|
||||||
|
if latitude is not None:
|
||||||
|
source.latitude = float(latitude)
|
||||||
|
elif isinstance(source, CandlelightColorStripSource):
|
||||||
|
if color is not None and isinstance(color, list) and len(color) == 3:
|
||||||
|
source.color = color
|
||||||
|
if intensity is not None:
|
||||||
|
source.intensity = float(intensity)
|
||||||
|
if num_candles is not None:
|
||||||
|
source.num_candles = int(num_candles)
|
||||||
|
if speed is not None:
|
||||||
|
source.speed = float(speed)
|
||||||
|
|
||||||
source.updated_at = datetime.now(timezone.utc)
|
source.updated_at = datetime.now(timezone.utc)
|
||||||
self._save()
|
self._save()
|
||||||
|
|||||||
@@ -32,6 +32,8 @@
|
|||||||
<option value="audio" data-i18n="color_strip.type.audio">Audio Reactive</option>
|
<option value="audio" data-i18n="color_strip.type.audio">Audio Reactive</option>
|
||||||
<option value="api_input" data-i18n="color_strip.type.api_input">API Input</option>
|
<option value="api_input" data-i18n="color_strip.type.api_input">API Input</option>
|
||||||
<option value="notification" data-i18n="color_strip.type.notification">Notification</option>
|
<option value="notification" data-i18n="color_strip.type.notification">Notification</option>
|
||||||
|
<option value="daylight" data-i18n="color_strip.type.daylight">Daylight Cycle</option>
|
||||||
|
<option value="candlelight" data-i18n="color_strip.type.candlelight">Candlelight</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -534,6 +536,77 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Daylight Cycle section -->
|
||||||
|
<div id="css-editor-daylight-section" style="display:none">
|
||||||
|
<div id="css-editor-daylight-speed-group" class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="css-editor-daylight-speed"><span data-i18n="color_strip.daylight.speed">Speed:</span> <span id="css-editor-daylight-speed-val">1.0</span></label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="color_strip.daylight.speed.hint">Cycle speed multiplier. 1.0 = full day/night cycle in ~4 minutes. Higher values cycle faster.</small>
|
||||||
|
<input type="range" id="css-editor-daylight-speed" min="0.1" max="10" step="0.1" value="1.0"
|
||||||
|
oninput="document.getElementById('css-editor-daylight-speed-val').textContent = parseFloat(this.value).toFixed(1)">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="css-editor-daylight-real-time" data-i18n="color_strip.daylight.use_real_time">Use Real Time:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="color_strip.daylight.use_real_time.hint">When enabled, LED color matches the actual time of day. Speed is ignored.</small>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="css-editor-daylight-real-time" onchange="onDaylightRealTimeChange()">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="css-editor-daylight-latitude"><span data-i18n="color_strip.daylight.latitude">Latitude:</span> <span id="css-editor-daylight-latitude-val">50</span>°</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="color_strip.daylight.latitude.hint">Your geographic latitude (-90 to 90). Affects sunrise/sunset timing in real-time mode.</small>
|
||||||
|
<input type="range" id="css-editor-daylight-latitude" min="-90" max="90" step="1" value="50"
|
||||||
|
oninput="document.getElementById('css-editor-daylight-latitude-val').textContent = parseInt(this.value)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Candlelight section -->
|
||||||
|
<div id="css-editor-candlelight-section" style="display:none">
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="css-editor-candlelight-color" data-i18n="color_strip.candlelight.color">Base Color:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.color.hint">The warm base color of the candle flame. Default is a natural warm amber.</small>
|
||||||
|
<input type="color" id="css-editor-candlelight-color" value="#ff9329">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="css-editor-candlelight-intensity"><span data-i18n="color_strip.candlelight.intensity">Flicker Intensity:</span> <span id="css-editor-candlelight-intensity-val">1.0</span></label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.intensity.hint">How much the candles flicker. Low values = gentle glow, high values = windy candle.</small>
|
||||||
|
<input type="range" id="css-editor-candlelight-intensity" min="0.1" max="2.0" step="0.1" value="1.0"
|
||||||
|
oninput="document.getElementById('css-editor-candlelight-intensity-val').textContent = parseFloat(this.value).toFixed(1)">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="css-editor-candlelight-num-candles" data-i18n="color_strip.candlelight.num_candles_label">Number of Candles:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.num_candles.hint">How many independent candle sources along the strip. Each flickers with its own pattern. More candles = more variation.</small>
|
||||||
|
<input type="number" id="css-editor-candlelight-num-candles" min="1" max="20" step="1" value="3">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="css-editor-candlelight-speed"><span data-i18n="color_strip.candlelight.speed">Flicker Speed:</span> <span id="css-editor-candlelight-speed-val">1.0</span></label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="color_strip.candlelight.speed.hint">Speed of the flicker animation. Higher values produce faster, more restless flames.</small>
|
||||||
|
<input type="range" id="css-editor-candlelight-speed" min="0.1" max="5.0" step="0.1" value="1.0"
|
||||||
|
oninput="document.getElementById('css-editor-candlelight-speed-val').textContent = parseFloat(this.value).toFixed(1)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Shared LED count field -->
|
<!-- Shared LED count field -->
|
||||||
<div id="css-editor-led-count-group" class="form-group">
|
<div id="css-editor-led-count-group" class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
|
|||||||
Reference in New Issue
Block a user