Add profile conditions, scene presets, MQTT integration, and Scenes tab

Feature 1 — Profile Conditions: time-of-day, system idle (Win32
GetLastInputInfo), and display state (GUID_CONSOLE_DISPLAY_STATE)
condition types for automatic profile activation.

Feature 2 — Scene Presets: snapshot/restore system that captures target
running states, device brightness, and profile enables. Server-side
capture with 5-step activation order. Dedicated Scenes tab with
CardSection-based card grid, command palette integration, and dashboard
quick-activate section.

Feature 3 — MQTT Integration: MQTTService singleton with aiomqtt,
MQTTLEDClient device provider for pixel output, MQTT profile condition
type with topic/payload matching, and frontend support for MQTT device
type and condition editor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 16:57:42 +03:00
parent bd8d7a019f
commit 2e747b5ece
38 changed files with 2269 additions and 32 deletions

View File

@@ -20,6 +20,15 @@ storage:
picture_targets_file: "data/picture_targets.json" picture_targets_file: "data/picture_targets.json"
pattern_templates_file: "data/pattern_templates.json" pattern_templates_file: "data/pattern_templates.json"
mqtt:
enabled: false
broker_host: "localhost"
broker_port: 1883
username: ""
password: ""
client_id: "ledgrab"
base_topic: "ledgrab"
logging: logging:
format: "json" # json or text format: "json" # json or text
file: "logs/wled_controller.log" file: "logs/wled_controller.log"

View File

@@ -44,6 +44,7 @@ dependencies = [
"nvidia-ml-py>=12.0.0", "nvidia-ml-py>=12.0.0",
"PyAudioWPatch>=0.2.12; sys_platform == 'win32'", "PyAudioWPatch>=0.2.12; sys_platform == 'win32'",
"sounddevice>=0.5", "sounddevice>=0.5",
"aiomqtt>=2.0.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -15,6 +15,7 @@ from .routes.audio_sources import router as audio_sources_router
from .routes.audio_templates import router as audio_templates_router from .routes.audio_templates import router as audio_templates_router
from .routes.value_sources import router as value_sources_router from .routes.value_sources import router as value_sources_router
from .routes.profiles import router as profiles_router from .routes.profiles import router as profiles_router
from .routes.scene_presets import router as scene_presets_router
router = APIRouter() router = APIRouter()
router.include_router(system_router) router.include_router(system_router)
@@ -30,5 +31,6 @@ router.include_router(audio_templates_router)
router.include_router(value_sources_router) router.include_router(value_sources_router)
router.include_router(picture_targets_router) router.include_router(picture_targets_router)
router.include_router(profiles_router) router.include_router(profiles_router)
router.include_router(scene_presets_router)
__all__ = ["router"] __all__ = ["router"]

View File

@@ -12,6 +12,7 @@ from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.storage.audio_template_store import AudioTemplateStore from wled_controller.storage.audio_template_store import AudioTemplateStore
from wled_controller.storage.value_source_store import ValueSourceStore from wled_controller.storage.value_source_store import ValueSourceStore
from wled_controller.storage.profile_store import ProfileStore from wled_controller.storage.profile_store import ProfileStore
from wled_controller.storage.scene_preset_store import ScenePresetStore
from wled_controller.core.profiles.profile_engine import ProfileEngine from wled_controller.core.profiles.profile_engine import ProfileEngine
from wled_controller.core.backup.auto_backup import AutoBackupEngine from wled_controller.core.backup.auto_backup import AutoBackupEngine
@@ -29,6 +30,7 @@ _audio_template_store: AudioTemplateStore | None = None
_value_source_store: ValueSourceStore | None = None _value_source_store: ValueSourceStore | None = None
_processor_manager: ProcessorManager | None = None _processor_manager: ProcessorManager | None = None
_profile_store: ProfileStore | None = None _profile_store: ProfileStore | None = None
_scene_preset_store: ScenePresetStore | None = None
_profile_engine: ProfileEngine | None = None _profile_engine: ProfileEngine | None = None
@@ -116,6 +118,13 @@ def get_profile_store() -> ProfileStore:
return _profile_store return _profile_store
def get_scene_preset_store() -> ScenePresetStore:
"""Get scene preset store dependency."""
if _scene_preset_store is None:
raise RuntimeError("Scene preset store not initialized")
return _scene_preset_store
def get_profile_engine() -> ProfileEngine: def get_profile_engine() -> ProfileEngine:
"""Get profile engine dependency.""" """Get profile engine dependency."""
if _profile_engine is None: if _profile_engine is None:
@@ -143,6 +152,7 @@ def init_dependencies(
audio_template_store: AudioTemplateStore | None = None, audio_template_store: AudioTemplateStore | None = None,
value_source_store: ValueSourceStore | None = None, value_source_store: ValueSourceStore | None = None,
profile_store: ProfileStore | None = None, profile_store: ProfileStore | None = None,
scene_preset_store: ScenePresetStore | None = None,
profile_engine: ProfileEngine | None = None, profile_engine: ProfileEngine | None = None,
auto_backup_engine: AutoBackupEngine | None = None, auto_backup_engine: AutoBackupEngine | None = None,
): ):
@@ -150,7 +160,7 @@ def init_dependencies(
global _device_store, _template_store, _processor_manager global _device_store, _template_store, _processor_manager
global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store
global _color_strip_store, _audio_source_store, _audio_template_store global _color_strip_store, _audio_source_store, _audio_template_store
global _value_source_store, _profile_store, _profile_engine, _auto_backup_engine global _value_source_store, _profile_store, _scene_preset_store, _profile_engine, _auto_backup_engine
_device_store = device_store _device_store = device_store
_template_store = template_store _template_store = template_store
_processor_manager = processor_manager _processor_manager = processor_manager
@@ -163,5 +173,6 @@ def init_dependencies(
_audio_template_store = audio_template_store _audio_template_store = audio_template_store
_value_source_store = value_source_store _value_source_store = value_source_store
_profile_store = profile_store _profile_store = profile_store
_scene_preset_store = scene_preset_store
_profile_engine = profile_engine _profile_engine = profile_engine
_auto_backup_engine = auto_backup_engine _auto_backup_engine = auto_backup_engine

View File

@@ -17,7 +17,15 @@ from wled_controller.api.schemas.profiles import (
) )
from wled_controller.core.profiles.profile_engine import ProfileEngine from wled_controller.core.profiles.profile_engine import ProfileEngine
from wled_controller.storage.picture_target_store import PictureTargetStore from wled_controller.storage.picture_target_store import PictureTargetStore
from wled_controller.storage.profile import ApplicationCondition, Condition from wled_controller.storage.profile import (
AlwaysCondition,
ApplicationCondition,
Condition,
DisplayStateCondition,
MQTTCondition,
SystemIdleCondition,
TimeOfDayCondition,
)
from wled_controller.storage.profile_store import ProfileStore from wled_controller.storage.profile_store import ProfileStore
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
@@ -28,11 +36,33 @@ router = APIRouter()
# ===== Helpers ===== # ===== Helpers =====
def _condition_from_schema(s: ConditionSchema) -> Condition: def _condition_from_schema(s: ConditionSchema) -> Condition:
if s.condition_type == "always":
return AlwaysCondition()
if s.condition_type == "application": if s.condition_type == "application":
return ApplicationCondition( return ApplicationCondition(
apps=s.apps or [], apps=s.apps or [],
match_type=s.match_type or "running", match_type=s.match_type or "running",
) )
if s.condition_type == "time_of_day":
return TimeOfDayCondition(
start_time=s.start_time or "00:00",
end_time=s.end_time or "23:59",
)
if s.condition_type == "system_idle":
return SystemIdleCondition(
idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5,
when_idle=s.when_idle if s.when_idle is not None else True,
)
if s.condition_type == "display_state":
return DisplayStateCondition(
state=s.state or "on",
)
if s.condition_type == "mqtt":
return MQTTCondition(
topic=s.topic or "",
payload=s.payload or "",
match_mode=s.match_mode or "exact",
)
raise ValueError(f"Unknown condition type: {s.condition_type}") raise ValueError(f"Unknown condition type: {s.condition_type}")

View File

@@ -0,0 +1,387 @@
"""Scene preset API routes — CRUD, capture, activate, recapture."""
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
get_device_store,
get_picture_target_store,
get_processor_manager,
get_profile_engine,
get_profile_store,
get_scene_preset_store,
)
from wled_controller.api.schemas.scene_presets import (
ActivateResponse,
ScenePresetCreate,
ScenePresetListResponse,
ScenePresetResponse,
ScenePresetUpdate,
)
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore
from wled_controller.storage.picture_target_store import PictureTargetStore
from wled_controller.storage.profile_store import ProfileStore
from wled_controller.storage.scene_preset import (
DeviceBrightnessSnapshot,
ProfileSnapshot,
ScenePreset,
TargetSnapshot,
)
from wled_controller.storage.scene_preset_store import ScenePresetStore
from wled_controller.core.profiles.profile_engine import ProfileEngine
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
# ===== Helpers =====
def _capture_snapshot(
target_store: PictureTargetStore,
device_store: DeviceStore,
profile_store: ProfileStore,
processor_manager: ProcessorManager,
) -> tuple:
"""Capture current system state as snapshot lists."""
targets = []
for t in target_store.get_all_targets():
proc = processor_manager._processors.get(t.id)
running = proc.is_running if proc else False
targets.append(TargetSnapshot(
target_id=t.id,
running=running,
color_strip_source_id=getattr(t, "color_strip_source_id", ""),
brightness_value_source_id=getattr(t, "brightness_value_source_id", ""),
fps=getattr(t, "fps", 30),
auto_start=getattr(t, "auto_start", False),
))
devices = []
for d in device_store.get_all_devices():
devices.append(DeviceBrightnessSnapshot(
device_id=d.id,
software_brightness=getattr(d, "software_brightness", 255),
))
profiles = []
for p in profile_store.get_all_profiles():
profiles.append(ProfileSnapshot(
profile_id=p.id,
enabled=p.enabled,
))
return targets, devices, profiles
def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse:
return ScenePresetResponse(
id=preset.id,
name=preset.name,
description=preset.description,
color=preset.color,
targets=[{
"target_id": t.target_id,
"running": t.running,
"color_strip_source_id": t.color_strip_source_id,
"brightness_value_source_id": t.brightness_value_source_id,
"fps": t.fps,
"auto_start": t.auto_start,
} for t in preset.targets],
devices=[{
"device_id": d.device_id,
"software_brightness": d.software_brightness,
} for d in preset.devices],
profiles=[{
"profile_id": p.profile_id,
"enabled": p.enabled,
} for p in preset.profiles],
order=preset.order,
created_at=preset.created_at,
updated_at=preset.updated_at,
)
# ===== CRUD =====
@router.post(
"/api/v1/scene-presets",
response_model=ScenePresetResponse,
tags=["Scene Presets"],
status_code=201,
)
async def create_scene_preset(
data: ScenePresetCreate,
_auth: AuthRequired,
store: ScenePresetStore = Depends(get_scene_preset_store),
target_store: PictureTargetStore = Depends(get_picture_target_store),
device_store: DeviceStore = Depends(get_device_store),
profile_store: ProfileStore = Depends(get_profile_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Capture current state as a new scene preset."""
targets, devices, profiles = _capture_snapshot(
target_store, device_store, profile_store, manager,
)
now = datetime.utcnow()
preset = ScenePreset(
id=f"scene_{uuid.uuid4().hex[:8]}",
name=data.name,
description=data.description,
color=data.color,
targets=targets,
devices=devices,
profiles=profiles,
order=store.count(),
created_at=now,
updated_at=now,
)
try:
preset = store.create_preset(preset)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return _preset_to_response(preset)
@router.get(
"/api/v1/scene-presets",
response_model=ScenePresetListResponse,
tags=["Scene Presets"],
)
async def list_scene_presets(
_auth: AuthRequired,
store: ScenePresetStore = Depends(get_scene_preset_store),
):
"""List all scene presets."""
presets = store.get_all_presets()
return ScenePresetListResponse(
presets=[_preset_to_response(p) for p in presets],
count=len(presets),
)
@router.get(
"/api/v1/scene-presets/{preset_id}",
response_model=ScenePresetResponse,
tags=["Scene Presets"],
)
async def get_scene_preset(
preset_id: str,
_auth: AuthRequired,
store: ScenePresetStore = Depends(get_scene_preset_store),
):
"""Get a single scene preset."""
try:
preset = store.get_preset(preset_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return _preset_to_response(preset)
@router.put(
"/api/v1/scene-presets/{preset_id}",
response_model=ScenePresetResponse,
tags=["Scene Presets"],
)
async def update_scene_preset(
preset_id: str,
data: ScenePresetUpdate,
_auth: AuthRequired,
store: ScenePresetStore = Depends(get_scene_preset_store),
):
"""Update scene preset metadata."""
try:
preset = store.update_preset(
preset_id,
name=data.name,
description=data.description,
color=data.color,
order=data.order,
)
except ValueError as e:
raise HTTPException(status_code=404 if "not found" in str(e).lower() else 400, detail=str(e))
return _preset_to_response(preset)
@router.delete(
"/api/v1/scene-presets/{preset_id}",
status_code=204,
tags=["Scene Presets"],
)
async def delete_scene_preset(
preset_id: str,
_auth: AuthRequired,
store: ScenePresetStore = Depends(get_scene_preset_store),
):
"""Delete a scene preset."""
try:
store.delete_preset(preset_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
# ===== Recapture =====
@router.post(
"/api/v1/scene-presets/{preset_id}/recapture",
response_model=ScenePresetResponse,
tags=["Scene Presets"],
)
async def recapture_scene_preset(
preset_id: str,
_auth: AuthRequired,
store: ScenePresetStore = Depends(get_scene_preset_store),
target_store: PictureTargetStore = Depends(get_picture_target_store),
device_store: DeviceStore = Depends(get_device_store),
profile_store: ProfileStore = Depends(get_profile_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Re-capture current state into an existing preset (updates snapshot)."""
targets, devices, profiles = _capture_snapshot(
target_store, device_store, profile_store, manager,
)
new_snapshot = ScenePreset(
id=preset_id,
name="",
targets=targets,
devices=devices,
profiles=profiles,
)
try:
preset = store.recapture_preset(preset_id, new_snapshot)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return _preset_to_response(preset)
# ===== Activate =====
@router.post(
"/api/v1/scene-presets/{preset_id}/activate",
response_model=ActivateResponse,
tags=["Scene Presets"],
)
async def activate_scene_preset(
preset_id: str,
_auth: AuthRequired,
store: ScenePresetStore = Depends(get_scene_preset_store),
target_store: PictureTargetStore = Depends(get_picture_target_store),
device_store: DeviceStore = Depends(get_device_store),
profile_store: ProfileStore = Depends(get_profile_store),
engine: ProfileEngine = Depends(get_profile_engine),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Activate a scene preset — restore the captured state."""
try:
preset = store.get_preset(preset_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
errors = []
# 1. Toggle profile enable states
for ps in preset.profiles:
try:
p = profile_store.get_profile(ps.profile_id)
if p.enabled != ps.enabled:
if not ps.enabled:
await engine.deactivate_if_active(ps.profile_id)
profile_store.update_profile(ps.profile_id, enabled=ps.enabled)
except ValueError:
errors.append(f"Profile {ps.profile_id} not found (skipped)")
except Exception as e:
errors.append(f"Profile {ps.profile_id}: {e}")
# 2. Stop targets that should be stopped
for ts in preset.targets:
if not ts.running:
try:
proc = manager._processors.get(ts.target_id)
if proc and proc.is_running:
await manager.stop_processing(ts.target_id)
except Exception as e:
errors.append(f"Stop target {ts.target_id}: {e}")
# 3. Update target configs (CSS, brightness source, FPS)
for ts in preset.targets:
try:
target = target_store.get_target(ts.target_id)
changed = {}
if getattr(target, "color_strip_source_id", None) != ts.color_strip_source_id:
changed["color_strip_source_id"] = ts.color_strip_source_id
if getattr(target, "brightness_value_source_id", None) != ts.brightness_value_source_id:
changed["brightness_value_source_id"] = ts.brightness_value_source_id
if getattr(target, "fps", None) != ts.fps:
changed["fps"] = ts.fps
if getattr(target, "auto_start", None) != ts.auto_start:
changed["auto_start"] = ts.auto_start
if changed:
target.update_fields(**changed)
target_store.update_target(ts.target_id, **changed)
# Sync live processor if running
proc = manager._processors.get(ts.target_id)
if proc and proc.is_running:
css_changed = "color_strip_source_id" in changed
bvs_changed = "brightness_value_source_id" in changed
settings_changed = "fps" in changed
if css_changed:
target.sync_with_manager(manager, settings_changed=False, css_changed=True)
if bvs_changed:
target.sync_with_manager(manager, settings_changed=False, brightness_vs_changed=True)
if settings_changed:
target.sync_with_manager(manager, settings_changed=True)
except ValueError:
errors.append(f"Target {ts.target_id} not found (skipped)")
except Exception as e:
errors.append(f"Target {ts.target_id} config: {e}")
# 4. Start targets that should be running
for ts in preset.targets:
if ts.running:
try:
proc = manager._processors.get(ts.target_id)
if not proc or not proc.is_running:
await manager.start_processing(ts.target_id)
except Exception as e:
errors.append(f"Start target {ts.target_id}: {e}")
# 5. Set device brightness
for ds in preset.devices:
try:
device = device_store.get_device(ds.device_id)
if device.software_brightness != ds.software_brightness:
device_store.update_device(ds.device_id, software_brightness=ds.software_brightness)
# Update live processor brightness
dev_state = manager._devices.get(ds.device_id)
if dev_state:
dev_state.software_brightness = ds.software_brightness
except ValueError:
errors.append(f"Device {ds.device_id} not found (skipped)")
except Exception as e:
errors.append(f"Device {ds.device_id} brightness: {e}")
# Trigger profile re-evaluation after all changes
try:
await engine.trigger_evaluate()
except Exception as e:
errors.append(f"Profile re-evaluation: {e}")
status = "activated" if not errors else "partial"
if errors:
logger.warning(f"Scene preset {preset_id} activation errors: {errors}")
else:
logger.info(f"Scene preset '{preset.name}' activated successfully")
return ActivateResponse(status=status, errors=errors)

View File

@@ -272,6 +272,7 @@ STORE_MAP = {
"audio_templates": "audio_templates_file", "audio_templates": "audio_templates_file",
"value_sources": "value_sources_file", "value_sources": "value_sources_file",
"profiles": "profiles_file", "profiles": "profiles_file",
"scene_presets": "scene_presets_file",
} }
_SERVER_DIR = Path(__file__).resolve().parents[4] _SERVER_DIR = Path(__file__).resolve().parents[4]

View File

@@ -10,8 +10,21 @@ class ConditionSchema(BaseModel):
"""A single condition within a profile.""" """A single condition within a profile."""
condition_type: str = Field(description="Condition type discriminator (e.g. 'application')") condition_type: str = Field(description="Condition type discriminator (e.g. 'application')")
# Application condition fields
apps: Optional[List[str]] = Field(None, description="Process names (for application condition)") apps: Optional[List[str]] = Field(None, description="Process names (for application condition)")
match_type: Optional[str] = Field(None, description="'running' or 'topmost' (for application condition)") match_type: Optional[str] = Field(None, description="'running' or 'topmost' (for application condition)")
# Time-of-day condition fields
start_time: Optional[str] = Field(None, description="Start time HH:MM (for time_of_day condition)")
end_time: Optional[str] = Field(None, description="End time HH:MM (for time_of_day condition)")
# System idle condition fields
idle_minutes: Optional[int] = Field(None, description="Idle timeout in minutes (for system_idle condition)")
when_idle: Optional[bool] = Field(None, description="True=active when idle (for system_idle condition)")
# Display state condition fields
state: Optional[str] = Field(None, description="'on' or 'off' (for display_state condition)")
# MQTT condition fields
topic: Optional[str] = Field(None, description="MQTT topic to watch (for mqtt condition)")
payload: Optional[str] = Field(None, description="Expected payload value (for mqtt condition)")
match_mode: Optional[str] = Field(None, description="'exact', 'contains', or 'regex' (for mqtt condition)")
class ProfileCreate(BaseModel): class ProfileCreate(BaseModel):

View File

@@ -0,0 +1,67 @@
"""Scene preset API schemas."""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class TargetSnapshotSchema(BaseModel):
target_id: str
running: bool = False
color_strip_source_id: str = ""
brightness_value_source_id: str = ""
fps: int = 30
auto_start: bool = False
class DeviceBrightnessSnapshotSchema(BaseModel):
device_id: str
software_brightness: int = 255
class ProfileSnapshotSchema(BaseModel):
profile_id: str
enabled: bool = True
class ScenePresetCreate(BaseModel):
"""Create a scene preset by capturing current state."""
name: str = Field(description="Preset name", min_length=1, max_length=100)
description: str = Field(default="", max_length=500)
color: str = Field(default="#4fc3f7", description="Card accent color")
class ScenePresetUpdate(BaseModel):
"""Update scene preset metadata (not snapshot data — use recapture for that)."""
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500)
color: Optional[str] = None
order: Optional[int] = None
class ScenePresetResponse(BaseModel):
"""Scene preset with full snapshot data."""
id: str
name: str
description: str
color: str
targets: List[TargetSnapshotSchema]
devices: List[DeviceBrightnessSnapshotSchema]
profiles: List[ProfileSnapshotSchema]
order: int
created_at: datetime
updated_at: datetime
class ScenePresetListResponse(BaseModel):
presets: List[ScenePresetResponse]
count: int
class ActivateResponse(BaseModel):
status: str = Field(description="'activated' or 'partial'")
errors: List[str] = Field(default_factory=list)

View File

@@ -38,6 +38,19 @@ class StorageConfig(BaseSettings):
audio_templates_file: str = "data/audio_templates.json" audio_templates_file: str = "data/audio_templates.json"
value_sources_file: str = "data/value_sources.json" value_sources_file: str = "data/value_sources.json"
profiles_file: str = "data/profiles.json" profiles_file: str = "data/profiles.json"
scene_presets_file: str = "data/scene_presets.json"
class MQTTConfig(BaseSettings):
"""MQTT broker configuration."""
enabled: bool = False
broker_host: str = "localhost"
broker_port: int = 1883
username: str = ""
password: str = ""
client_id: str = "ledgrab"
base_topic: str = "ledgrab"
class LoggingConfig(BaseSettings): class LoggingConfig(BaseSettings):
@@ -61,6 +74,7 @@ class Config(BaseSettings):
server: ServerConfig = Field(default_factory=ServerConfig) server: ServerConfig = Field(default_factory=ServerConfig)
auth: AuthConfig = Field(default_factory=AuthConfig) auth: AuthConfig = Field(default_factory=AuthConfig)
storage: StorageConfig = Field(default_factory=StorageConfig) storage: StorageConfig = Field(default_factory=StorageConfig)
mqtt: MQTTConfig = Field(default_factory=MQTTConfig)
logging: LoggingConfig = Field(default_factory=LoggingConfig) logging: LoggingConfig = Field(default_factory=LoggingConfig)
@classmethod @classmethod

View File

@@ -282,5 +282,8 @@ def _register_builtin_providers():
from wled_controller.core.devices.mock_provider import MockDeviceProvider from wled_controller.core.devices.mock_provider import MockDeviceProvider
register_provider(MockDeviceProvider()) register_provider(MockDeviceProvider())
from wled_controller.core.devices.mqtt_provider import MQTTDeviceProvider
register_provider(MQTTDeviceProvider())
_register_builtin_providers() _register_builtin_providers()

View File

@@ -0,0 +1,98 @@
"""MQTT LED client — publishes pixel data to an MQTT topic."""
import json
from typing import List, Optional, Tuple, Union
import numpy as np
from wled_controller.core.devices.led_client import DeviceHealth, LEDClient
from wled_controller.utils import get_logger
logger = get_logger(__name__)
# Singleton reference — injected from main.py at startup
_mqtt_service = None
def set_mqtt_service(service) -> None:
global _mqtt_service
_mqtt_service = service
def get_mqtt_service():
return _mqtt_service
def parse_mqtt_url(url: str) -> str:
"""Extract topic from an mqtt:// URL.
Format: mqtt://topic/path (broker connection is global via config)
"""
if url.startswith("mqtt://"):
return url[7:]
return url
class MQTTLEDClient(LEDClient):
"""Publishes JSON pixel data to an MQTT topic via the shared service."""
def __init__(self, url: str, led_count: int = 0, **kwargs):
self._topic = parse_mqtt_url(url)
self._led_count = led_count
self._connected = False
async def connect(self) -> bool:
svc = _mqtt_service
if svc is None or not svc.is_enabled:
raise ConnectionError("MQTT service not available")
if not svc.is_connected:
raise ConnectionError("MQTT service not connected to broker")
self._connected = True
return True
async def close(self) -> None:
self._connected = False
@property
def is_connected(self) -> bool:
return self._connected and _mqtt_service is not None and _mqtt_service.is_connected
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> bool:
svc = _mqtt_service
if svc is None or not svc.is_connected:
return False
if isinstance(pixels, np.ndarray):
pixel_list = pixels.tolist()
else:
pixel_list = list(pixels)
payload = json.dumps({
"pixels": pixel_list,
"brightness": brightness,
"led_count": len(pixel_list),
})
await svc.publish(self._topic, payload, retain=False, qos=0)
return True
@classmethod
async def check_health(
cls,
url: str,
http_client,
prev_health=None,
) -> DeviceHealth:
from datetime import datetime
svc = _mqtt_service
if svc is None or not svc.is_enabled:
return DeviceHealth(online=False, error="MQTT disabled", last_checked=datetime.utcnow())
return DeviceHealth(
online=svc.is_connected,
last_checked=datetime.utcnow(),
error=None if svc.is_connected else "MQTT broker disconnected",
)

View File

@@ -0,0 +1,51 @@
"""MQTT device provider — factory, validation, health checks."""
from datetime import datetime
from typing import List, Optional, Tuple
from wled_controller.core.devices.led_client import (
DeviceHealth,
DiscoveredDevice,
LEDClient,
LEDDeviceProvider,
)
from wled_controller.core.devices.mqtt_client import (
MQTTLEDClient,
get_mqtt_service,
parse_mqtt_url,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class MQTTDeviceProvider(LEDDeviceProvider):
"""Provider for MQTT-based LED devices."""
@property
def device_type(self) -> str:
return "mqtt"
@property
def capabilities(self) -> set:
return {"manual_led_count"}
def create_client(self, url: str, **kwargs) -> LEDClient:
return MQTTLEDClient(url, **kwargs)
async def check_health(
self, url: str, http_client, prev_health=None,
) -> DeviceHealth:
return await MQTTLEDClient.check_health(url, http_client, prev_health)
async def validate_device(self, url: str) -> dict:
"""Validate MQTT device URL (topic path)."""
topic = parse_mqtt_url(url)
if not topic or topic == "/":
raise ValueError("MQTT topic cannot be empty")
# Can't auto-detect LED count — require manual entry
return {}
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
# MQTT devices are not auto-discoverable
return []

View File

@@ -0,0 +1,176 @@
"""Singleton async MQTT service — shared broker connection for all features."""
import asyncio
import json
from typing import Callable, Dict, Optional, Set
import aiomqtt
from wled_controller.config import MQTTConfig
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class MQTTService:
"""Manages a persistent MQTT broker connection.
Features:
- Publish messages (retained or transient)
- Subscribe to topics with callback dispatch
- Topic value cache for synchronous reads (profile condition evaluation)
- Auto-reconnect loop
- Birth / will messages for online status
"""
def __init__(self, config: MQTTConfig):
self._config = config
self._client: Optional[aiomqtt.Client] = None
self._task: Optional[asyncio.Task] = None
self._connected = False
# Subscription management
self._subscriptions: Dict[str, Set[Callable]] = {} # topic -> set of callbacks
self._topic_cache: Dict[str, str] = {} # topic -> last payload string
# Pending publishes queued while disconnected
self._publish_queue: asyncio.Queue = asyncio.Queue(maxsize=1000)
@property
def is_connected(self) -> bool:
return self._connected
@property
def is_enabled(self) -> bool:
return self._config.enabled
async def start(self) -> None:
if not self._config.enabled:
logger.info("MQTT service disabled in configuration")
return
if self._task is not None:
return
self._task = asyncio.create_task(self._connection_loop())
logger.info(f"MQTT service starting — broker {self._config.broker_host}:{self._config.broker_port}")
async def stop(self) -> None:
if self._task is None:
return
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
self._connected = False
logger.info("MQTT service stopped")
async def publish(self, topic: str, payload: str, retain: bool = False, qos: int = 0) -> None:
"""Publish a message. If disconnected, queue for later delivery."""
if not self._config.enabled:
return
if self._connected and self._client:
try:
await self._client.publish(topic, payload, retain=retain, qos=qos)
return
except Exception as e:
logger.warning(f"MQTT publish failed ({topic}): {e}")
# Queue for retry
try:
self._publish_queue.put_nowait((topic, payload, retain, qos))
except asyncio.QueueFull:
pass
async def subscribe(self, topic: str, callback: Callable) -> None:
"""Subscribe to a topic. Callback receives (topic: str, payload: str)."""
if topic not in self._subscriptions:
self._subscriptions[topic] = set()
self._subscriptions[topic].add(callback)
# Subscribe on the live client if connected
if self._connected and self._client:
try:
await self._client.subscribe(topic)
except Exception as e:
logger.warning(f"MQTT subscribe failed ({topic}): {e}")
def get_last_value(self, topic: str) -> Optional[str]:
"""Get cached last value for a topic (synchronous — for profile evaluation)."""
return self._topic_cache.get(topic)
async def _connection_loop(self) -> None:
"""Persistent connection loop with auto-reconnect."""
base_topic = self._config.base_topic
will_topic = f"{base_topic}/status"
will_payload = "offline"
while True:
try:
async with aiomqtt.Client(
hostname=self._config.broker_host,
port=self._config.broker_port,
username=self._config.username or None,
password=self._config.password or None,
identifier=self._config.client_id,
will=aiomqtt.Will(
topic=will_topic,
payload=will_payload,
retain=True,
),
) as client:
self._client = client
self._connected = True
logger.info("MQTT connected to broker")
# Publish birth message
await client.publish(will_topic, "online", retain=True)
# Re-subscribe to all registered topics
for topic in self._subscriptions:
await client.subscribe(topic)
# Drain pending publishes
while not self._publish_queue.empty():
try:
t, p, r, q = self._publish_queue.get_nowait()
await client.publish(t, p, retain=r, qos=q)
except Exception:
break
# Message receive loop
async for msg in client.messages:
topic_str = str(msg.topic)
payload_str = msg.payload.decode("utf-8", errors="replace") if msg.payload else ""
self._topic_cache[topic_str] = payload_str
# Dispatch to callbacks
for sub_topic, callbacks in self._subscriptions.items():
if aiomqtt.Topic(sub_topic).matches(msg.topic):
for cb in callbacks:
try:
if asyncio.iscoroutinefunction(cb):
asyncio.create_task(cb(topic_str, payload_str))
else:
cb(topic_str, payload_str)
except Exception as e:
logger.error(f"MQTT callback error ({topic_str}): {e}")
except asyncio.CancelledError:
break
except Exception as e:
self._connected = False
self._client = None
logger.warning(f"MQTT connection lost: {e}. Reconnecting in 5s...")
await asyncio.sleep(5)
# ===== State exposure helpers =====
async def publish_target_state(self, target_id: str, state: dict) -> None:
"""Publish target state to MQTT (called from event handler)."""
topic = f"{self._config.base_topic}/target/{target_id}/state"
await self.publish(topic, json.dumps(state), retain=True)
async def publish_profile_state(self, profile_id: str, action: str) -> None:
"""Publish profile state change to MQTT."""
topic = f"{self._config.base_topic}/profile/{profile_id}/state"
await self.publish(topic, json.dumps({"action": action}), retain=True)

View File

@@ -9,6 +9,7 @@ import ctypes
import ctypes.wintypes import ctypes.wintypes
import os import os
import sys import sys
import threading
from typing import Optional, Set from typing import Optional, Set
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
@@ -21,6 +22,148 @@ _IS_WINDOWS = sys.platform == "win32"
class PlatformDetector: class PlatformDetector:
"""Detect running processes and the foreground window's process.""" """Detect running processes and the foreground window's process."""
def __init__(self) -> None:
self._display_on: bool = True
self._display_listener_started = False
if _IS_WINDOWS:
t = threading.Thread(target=self._display_power_listener, daemon=True)
t.start()
# ---- Display power state (event-driven) ----
def _display_power_listener(self) -> None:
"""Background thread: hidden window that receives display power events."""
try:
user32 = ctypes.windll.user32
WNDPROC = ctypes.WINFUNCTYPE(
ctypes.c_long,
ctypes.wintypes.HWND,
ctypes.c_uint,
ctypes.wintypes.WPARAM,
ctypes.wintypes.LPARAM,
)
WM_POWERBROADCAST = 0x0218
PBT_POWERSETTINGCHANGE = 0x8013
class POWERBROADCAST_SETTING(ctypes.Structure):
_fields_ = [
("PowerSetting", ctypes.c_ubyte * 16), # GUID
("DataLength", ctypes.wintypes.DWORD),
("Data", ctypes.c_ubyte * 1),
]
# GUID_CONSOLE_DISPLAY_STATE = {6FE69556-704A-47A0-8F24-C28D936FDA47}
GUID_CONSOLE_DISPLAY_STATE = (ctypes.c_ubyte * 16)(
0x56, 0x95, 0xE6, 0x6F, 0x4A, 0x70, 0xA0, 0x47,
0x8F, 0x24, 0xC2, 0x8D, 0x93, 0x6F, 0xDA, 0x47,
)
def wnd_proc(hwnd, msg, wparam, lparam):
if msg == WM_POWERBROADCAST and wparam == PBT_POWERSETTINGCHANGE:
try:
setting = ctypes.cast(
lparam, ctypes.POINTER(POWERBROADCAST_SETTING)
).contents
# Data: 0=off, 1=on, 2=dimmed (treat dimmed as on)
value = setting.Data[0]
self._display_on = value != 0
except Exception:
pass
return 0
return user32.DefWindowProcW(hwnd, msg, wparam, lparam)
wnd_proc_cb = WNDPROC(wnd_proc)
# Register window class
class WNDCLASSEXW(ctypes.Structure):
_fields_ = [
("cbSize", ctypes.c_uint),
("style", ctypes.c_uint),
("lpfnWndProc", WNDPROC),
("cbClsExtra", ctypes.c_int),
("cbWndExtra", ctypes.c_int),
("hInstance", ctypes.wintypes.HINSTANCE),
("hIcon", ctypes.wintypes.HICON),
("hCursor", ctypes.wintypes.HANDLE),
("hbrBackground", ctypes.wintypes.HBRUSH),
("lpszMenuName", ctypes.wintypes.LPCWSTR),
("lpszClassName", ctypes.wintypes.LPCWSTR),
("hIconSm", ctypes.wintypes.HICON),
]
wc = WNDCLASSEXW()
wc.cbSize = ctypes.sizeof(WNDCLASSEXW)
wc.lpfnWndProc = wnd_proc_cb
wc.lpszClassName = "LedGrabDisplayMonitor"
wc.hInstance = ctypes.windll.kernel32.GetModuleHandleW(None)
atom = user32.RegisterClassExW(ctypes.byref(wc))
if not atom:
logger.warning("Failed to register display monitor window class")
return
HWND_MESSAGE = ctypes.wintypes.HWND(-3)
hwnd = user32.CreateWindowExW(
0, wc.lpszClassName, "LedGrab Display Monitor",
0, 0, 0, 0, 0, HWND_MESSAGE, None, wc.hInstance, None,
)
if not hwnd:
logger.warning("Failed to create display monitor hidden window")
return
# Register for display power notifications
user32.RegisterPowerSettingNotification(
hwnd, ctypes.byref(GUID_CONSOLE_DISPLAY_STATE), 0
)
self._display_listener_started = True
logger.debug("Display power listener started")
# Message pump
msg = ctypes.wintypes.MSG()
while user32.GetMessageW(ctypes.byref(msg), None, 0, 0) > 0:
user32.TranslateMessage(ctypes.byref(msg))
user32.DispatchMessageW(ctypes.byref(msg))
except Exception as e:
logger.error(f"Display power listener failed: {e}")
def _get_display_power_state_sync(self) -> Optional[str]:
"""Get display power state: 'on' or 'off'. Returns None if unavailable."""
if not _IS_WINDOWS:
return None
return "on" if self._display_on else "off"
# ---- System idle detection ----
def _get_idle_seconds_sync(self) -> Optional[float]:
"""Get system idle time in seconds (keyboard/mouse inactivity).
Returns None if detection is unavailable.
"""
if not _IS_WINDOWS:
return None
try:
class LASTINPUTINFO(ctypes.Structure):
_fields_ = [
("cbSize", ctypes.c_uint),
("dwTime", ctypes.c_uint),
]
lii = LASTINPUTINFO()
lii.cbSize = ctypes.sizeof(LASTINPUTINFO)
if not ctypes.windll.user32.GetLastInputInfo(ctypes.byref(lii)):
return None
millis = ctypes.windll.kernel32.GetTickCount() - lii.dwTime
return millis / 1000.0
except Exception as e:
logger.error(f"Failed to get idle time: {e}")
return None
# ---- Process detection ----
def _get_running_processes_sync(self) -> Set[str]: def _get_running_processes_sync(self) -> Set[str]:
"""Get set of lowercase process names via Win32 EnumProcesses. """Get set of lowercase process names via Win32 EnumProcesses.

View File

@@ -1,11 +1,21 @@
"""Profile engine — background loop that evaluates conditions and manages targets.""" """Profile engine — background loop that evaluates conditions and manages targets."""
import asyncio import asyncio
import re
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Dict, Optional, Set from typing import Dict, Optional, Set
from wled_controller.core.profiles.platform_detector import PlatformDetector from wled_controller.core.profiles.platform_detector import PlatformDetector
from wled_controller.storage.profile import AlwaysCondition, ApplicationCondition, Condition, Profile from wled_controller.storage.profile import (
AlwaysCondition,
ApplicationCondition,
Condition,
DisplayStateCondition,
MQTTCondition,
Profile,
SystemIdleCondition,
TimeOfDayCondition,
)
from wled_controller.storage.profile_store import ProfileStore from wled_controller.storage.profile_store import ProfileStore
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
@@ -15,11 +25,13 @@ logger = get_logger(__name__)
class ProfileEngine: class ProfileEngine:
"""Evaluates profile conditions and starts/stops targets accordingly.""" """Evaluates profile conditions and starts/stops targets accordingly."""
def __init__(self, profile_store: ProfileStore, processor_manager, poll_interval: float = 1.0): def __init__(self, profile_store: ProfileStore, processor_manager, poll_interval: float = 1.0,
mqtt_service=None):
self._store = profile_store self._store = profile_store
self._manager = processor_manager self._manager = processor_manager
self._poll_interval = poll_interval self._poll_interval = poll_interval
self._detector = PlatformDetector() self._detector = PlatformDetector()
self._mqtt_service = mqtt_service
self._task: Optional[asyncio.Task] = None self._task: Optional[asyncio.Task] = None
self._eval_lock = asyncio.Lock() self._eval_lock = asyncio.Lock()
@@ -70,11 +82,12 @@ class ProfileEngine:
def _detect_all_sync( def _detect_all_sync(
self, needs_running: bool, needs_topmost: bool, needs_fullscreen: bool, self, needs_running: bool, needs_topmost: bool, needs_fullscreen: bool,
needs_idle: bool, needs_display_state: bool,
) -> tuple: ) -> tuple:
"""Run all platform detection in a single thread call. """Run all platform detection in a single thread call.
Batching the three detection calls into one executor submission reduces Batching detection calls into one executor submission reduces
event-loop wake-ups from 3 to 1, minimising asyncio.sleep() jitter in event-loop wake-ups, minimising asyncio.sleep() jitter in
latency-sensitive processing loops. latency-sensitive processing loops.
""" """
running_procs = self._detector._get_running_processes_sync() if needs_running else set() running_procs = self._detector._get_running_processes_sync() if needs_running else set()
@@ -83,7 +96,9 @@ class ProfileEngine:
else: else:
topmost_proc, topmost_fullscreen = None, False topmost_proc, topmost_fullscreen = None, False
fullscreen_procs = self._detector._get_fullscreen_processes_sync() if needs_fullscreen else set() fullscreen_procs = self._detector._get_fullscreen_processes_sync() if needs_fullscreen else set()
return running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs idle_seconds = self._detector._get_idle_seconds_sync() if needs_idle else None
display_state = self._detector._get_display_power_state_sync() if needs_display_state else None
return running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs, idle_seconds, display_state
async def _evaluate_all_locked(self) -> None: async def _evaluate_all_locked(self) -> None:
profiles = self._store.get_all_profiles() profiles = self._store.get_all_profiles()
@@ -95,23 +110,30 @@ class ProfileEngine:
# Determine which detection methods are actually needed # Determine which detection methods are actually needed
match_types_used: set = set() match_types_used: set = set()
needs_idle = False
needs_display_state = False
for p in profiles: for p in profiles:
if p.enabled: if p.enabled:
for c in p.conditions: for c in p.conditions:
mt = getattr(c, "match_type", "running") if isinstance(c, ApplicationCondition):
match_types_used.add(mt) match_types_used.add(c.match_type)
elif isinstance(c, SystemIdleCondition):
needs_idle = True
elif isinstance(c, DisplayStateCondition):
needs_display_state = True
needs_running = "running" in match_types_used needs_running = "running" in match_types_used
needs_topmost = bool(match_types_used & {"topmost", "topmost_fullscreen"}) needs_topmost = bool(match_types_used & {"topmost", "topmost_fullscreen"})
needs_fullscreen = "fullscreen" in match_types_used needs_fullscreen = "fullscreen" in match_types_used
# Single executor call for all platform detection (avoids 3 separate # Single executor call for all platform detection
# event-loop roundtrips that can jitter processing-loop timing)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs = ( (running_procs, topmost_proc, topmost_fullscreen,
fullscreen_procs, idle_seconds, display_state) = (
await loop.run_in_executor( await loop.run_in_executor(
None, self._detect_all_sync, None, self._detect_all_sync,
needs_running, needs_topmost, needs_fullscreen, needs_running, needs_topmost, needs_fullscreen,
needs_idle, needs_display_state,
) )
) )
@@ -121,7 +143,9 @@ class ProfileEngine:
should_be_active = ( should_be_active = (
profile.enabled profile.enabled
and (len(profile.conditions) == 0 and (len(profile.conditions) == 0
or self._evaluate_conditions(profile, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs)) or self._evaluate_conditions(
profile, running_procs, topmost_proc, topmost_fullscreen,
fullscreen_procs, idle_seconds, display_state))
) )
is_active = profile.id in self._active_profiles is_active = profile.id in self._active_profiles
@@ -143,9 +167,13 @@ class ProfileEngine:
self, profile: Profile, running_procs: Set[str], self, profile: Profile, running_procs: Set[str],
topmost_proc: Optional[str], topmost_fullscreen: bool, topmost_proc: Optional[str], topmost_fullscreen: bool,
fullscreen_procs: Set[str], fullscreen_procs: Set[str],
idle_seconds: Optional[float], display_state: Optional[str],
) -> bool: ) -> bool:
results = [ results = [
self._evaluate_condition(c, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs) self._evaluate_condition(
c, running_procs, topmost_proc, topmost_fullscreen,
fullscreen_procs, idle_seconds, display_state,
)
for c in profile.conditions for c in profile.conditions
] ]
@@ -157,11 +185,63 @@ class ProfileEngine:
self, condition: Condition, running_procs: Set[str], self, condition: Condition, running_procs: Set[str],
topmost_proc: Optional[str], topmost_fullscreen: bool, topmost_proc: Optional[str], topmost_fullscreen: bool,
fullscreen_procs: Set[str], fullscreen_procs: Set[str],
idle_seconds: Optional[float], display_state: Optional[str],
) -> bool: ) -> bool:
if isinstance(condition, AlwaysCondition): if isinstance(condition, AlwaysCondition):
return True return True
if isinstance(condition, ApplicationCondition): if isinstance(condition, ApplicationCondition):
return self._evaluate_app_condition(condition, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs) return self._evaluate_app_condition(condition, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs)
if isinstance(condition, TimeOfDayCondition):
return self._evaluate_time_of_day(condition)
if isinstance(condition, SystemIdleCondition):
return self._evaluate_idle(condition, idle_seconds)
if isinstance(condition, DisplayStateCondition):
return self._evaluate_display_state(condition, display_state)
if isinstance(condition, MQTTCondition):
return self._evaluate_mqtt(condition)
return False
@staticmethod
def _evaluate_time_of_day(condition: TimeOfDayCondition) -> bool:
now = datetime.now()
current = now.hour * 60 + now.minute
parts_s = condition.start_time.split(":")
parts_e = condition.end_time.split(":")
start = int(parts_s[0]) * 60 + int(parts_s[1])
end = int(parts_e[0]) * 60 + int(parts_e[1])
if start <= end:
return start <= current <= end
# Overnight range (e.g. 22:00 → 06:00)
return current >= start or current <= end
@staticmethod
def _evaluate_idle(condition: SystemIdleCondition, idle_seconds: Optional[float]) -> bool:
if idle_seconds is None:
return False
is_idle = idle_seconds >= (condition.idle_minutes * 60)
return is_idle if condition.when_idle else not is_idle
@staticmethod
def _evaluate_display_state(condition: DisplayStateCondition, display_state: Optional[str]) -> bool:
if display_state is None:
return False
return display_state == condition.state
def _evaluate_mqtt(self, condition: MQTTCondition) -> bool:
if self._mqtt_service is None or not self._mqtt_service.is_connected:
return False
value = self._mqtt_service.get_last_value(condition.topic)
if value is None:
return False
if condition.match_mode == "exact":
return value == condition.payload
if condition.match_mode == "contains":
return condition.payload in value
if condition.match_mode == "regex":
try:
return bool(re.search(condition.payload, value))
except re.error:
return False
return False return False
def _evaluate_app_condition( def _evaluate_app_condition(

View File

@@ -28,7 +28,10 @@ from wled_controller.storage.audio_template_store import AudioTemplateStore
import wled_controller.core.audio # noqa: F401 — trigger engine auto-registration import wled_controller.core.audio # noqa: F401 — trigger engine auto-registration
from wled_controller.storage.value_source_store import ValueSourceStore from wled_controller.storage.value_source_store import ValueSourceStore
from wled_controller.storage.profile_store import ProfileStore from wled_controller.storage.profile_store import ProfileStore
from wled_controller.storage.scene_preset_store import ScenePresetStore
from wled_controller.core.profiles.profile_engine import ProfileEngine from wled_controller.core.profiles.profile_engine import ProfileEngine
from wled_controller.core.mqtt.mqtt_service import MQTTService
from wled_controller.core.devices.mqtt_client import set_mqtt_service
from wled_controller.core.backup.auto_backup import AutoBackupEngine from wled_controller.core.backup.auto_backup import AutoBackupEngine
from wled_controller.api.routes.system import STORE_MAP from wled_controller.api.routes.system import STORE_MAP
from wled_controller.utils import setup_logging, get_logger from wled_controller.utils import setup_logging, get_logger
@@ -52,6 +55,7 @@ audio_source_store = AudioSourceStore(config.storage.audio_sources_file)
audio_template_store = AudioTemplateStore(config.storage.audio_templates_file) audio_template_store = AudioTemplateStore(config.storage.audio_templates_file)
value_source_store = ValueSourceStore(config.storage.value_sources_file) value_source_store = ValueSourceStore(config.storage.value_sources_file)
profile_store = ProfileStore(config.storage.profiles_file) profile_store = ProfileStore(config.storage.profiles_file)
scene_preset_store = ScenePresetStore(config.storage.scene_presets_file)
# Migrate embedded audio config from CSS entities to audio sources # Migrate embedded audio config from CSS entities to audio sources
audio_source_store.migrate_from_css(color_strip_store) audio_source_store.migrate_from_css(color_strip_store)
@@ -100,8 +104,12 @@ async def lifespan(app: FastAPI):
logger.info(f"Authorized clients: {client_labels}") logger.info(f"Authorized clients: {client_labels}")
logger.info("All API requests require valid Bearer token authentication") logger.info("All API requests require valid Bearer token authentication")
# Create profile engine (needs processor_manager) # Create MQTT service (shared broker connection)
profile_engine = ProfileEngine(profile_store, processor_manager) mqtt_service = MQTTService(config.mqtt)
set_mqtt_service(mqtt_service)
# Create profile engine (needs processor_manager + mqtt_service)
profile_engine = ProfileEngine(profile_store, processor_manager, mqtt_service=mqtt_service)
# Create auto-backup engine # Create auto-backup engine
auto_backup_engine = AutoBackupEngine( auto_backup_engine = AutoBackupEngine(
@@ -123,6 +131,7 @@ async def lifespan(app: FastAPI):
audio_template_store=audio_template_store, audio_template_store=audio_template_store,
value_source_store=value_source_store, value_source_store=value_source_store,
profile_store=profile_store, profile_store=profile_store,
scene_preset_store=scene_preset_store,
profile_engine=profile_engine, profile_engine=profile_engine,
auto_backup_engine=auto_backup_engine, auto_backup_engine=auto_backup_engine,
) )
@@ -162,6 +171,9 @@ async def lifespan(app: FastAPI):
# Start background health monitoring for all devices # Start background health monitoring for all devices
await processor_manager.start_health_monitoring() await processor_manager.start_health_monitoring()
# Start MQTT service (broker connection for output, triggers, state)
await mqtt_service.start()
# Start profile engine (evaluates conditions and auto-starts/stops targets) # Start profile engine (evaluates conditions and auto-starts/stops targets)
await profile_engine.start() await profile_engine.start()
@@ -206,6 +218,12 @@ async def lifespan(app: FastAPI):
except Exception as e: except Exception as e:
logger.error(f"Error stopping processors: {e}") logger.error(f"Error stopping processors: {e}")
# Stop MQTT service
try:
await mqtt_service.stop()
except Exception as e:
logger.error(f"Error stopping MQTT service: {e}")
# Create FastAPI application # Create FastAPI application
app = FastAPI( app = FastAPI(
title="LED Grab", title="LED Grab",

View File

@@ -82,6 +82,11 @@ import {
toggleProfileEnabled, toggleProfileTargets, deleteProfile, toggleProfileEnabled, toggleProfileTargets, deleteProfile,
expandAllProfileSections, collapseAllProfileSections, expandAllProfileSections, collapseAllProfileSections,
} from './features/profiles.js'; } from './features/profiles.js';
import {
loadScenes, expandAllSceneSections, collapseAllSceneSections,
openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor,
activateScenePreset, recaptureScenePreset, deleteScenePreset,
} from './features/scene-presets.js';
// Layer 5: device-discovery, targets // Layer 5: device-discovery, targets
import { import {
@@ -307,6 +312,18 @@ Object.assign(window, {
expandAllProfileSections, expandAllProfileSections,
collapseAllProfileSections, collapseAllProfileSections,
// scene presets
loadScenes,
expandAllSceneSections,
collapseAllSceneSections,
openScenePresetCapture,
editScenePreset,
saveScenePreset,
closeScenePresetEditor,
activateScenePreset,
recaptureScenePreset,
deleteScenePreset,
// device-discovery // device-discovery
onDeviceTypeChanged, onDeviceTypeChanged,
updateBaudFpsHint, updateBaudFpsHint,
@@ -422,9 +439,9 @@ document.addEventListener('keydown', (e) => {
return; return;
} }
// Tab shortcuts: Ctrl+1..4 (skip when typing in inputs) // Tab shortcuts: Ctrl+1..5 (skip when typing in inputs)
if (!inInput && e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey) { if (!inInput && e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey) {
const tabMap = { '1': 'dashboard', '2': 'profiles', '3': 'targets', '4': 'streams' }; const tabMap = { '1': 'dashboard', '2': 'profiles', '3': 'targets', '4': 'streams', '5': 'scenes' };
const tab = tabMap[e.key]; const tab = tabMap[e.key];
if (tab) { if (tab) {
e.preventDefault(); e.preventDefault();

View File

@@ -78,6 +78,10 @@ export function isMockDevice(type) {
return type === 'mock'; return type === 'mock';
} }
export function isMqttDevice(type) {
return type === 'mqtt';
}
export function handle401Error() { export function handle401Error() {
if (!apiKey) return; // Already handled or no session if (!apiKey) return; // Already handled or no session
localStorage.removeItem('wled_api_key'); localStorage.removeItem('wled_api_key');

View File

@@ -7,7 +7,7 @@ import { t } from './i18n.js';
import { navigateToCard } from './navigation.js'; import { navigateToCard } from './navigation.js';
import { import {
getTargetTypeIcon, getPictureSourceIcon, getColorStripIcon, getAudioSourceIcon, getTargetTypeIcon, getPictureSourceIcon, getColorStripIcon, getAudioSourceIcon,
ICON_DEVICE, ICON_TARGET, ICON_PROFILE, ICON_VALUE_SOURCE, ICON_DEVICE, ICON_TARGET, ICON_PROFILE, ICON_VALUE_SOURCE, ICON_SCENE,
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_PATTERN_TEMPLATE, ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_PATTERN_TEMPLATE,
} from './icons.js'; } from './icons.js';
@@ -31,7 +31,7 @@ function _mapEntities(data, mapFn) {
} }
function _buildItems(results) { function _buildItems(results) {
const [devices, targets, css, profiles, capTempl, ppTempl, patTempl, audioSrc, valSrc, streams] = results; const [devices, targets, css, profiles, capTempl, ppTempl, patTempl, audioSrc, valSrc, streams, scenePresets] = results;
const items = []; const items = [];
_mapEntities(devices, d => items.push({ _mapEntities(devices, d => items.push({
@@ -99,6 +99,11 @@ function _buildItems(results) {
}); });
}); });
_mapEntities(scenePresets, sp => items.push({
name: sp.name, detail: sp.description || '', group: 'scenes', icon: ICON_SCENE,
nav: ['scenes', null, 'scenes', 'data-scene-id', sp.id],
}));
return items; return items;
} }
@@ -114,6 +119,7 @@ const _responseKeys = [
['/audio-sources', 'sources'], ['/audio-sources', 'sources'],
['/value-sources', 'sources'], ['/value-sources', 'sources'],
['/picture-sources', 'streams'], ['/picture-sources', 'streams'],
['/scene-presets', 'presets'],
]; ];
async function _fetchAllEntities() { async function _fetchAllEntities() {
@@ -132,7 +138,7 @@ async function _fetchAllEntities() {
const _groupOrder = [ const _groupOrder = [
'devices', 'targets', 'kc_targets', 'css', 'profiles', 'devices', 'targets', 'kc_targets', 'css', 'profiles',
'streams', 'capture_templates', 'pp_templates', 'pattern_templates', 'streams', 'capture_templates', 'pp_templates', 'pattern_templates',
'audio', 'value', 'audio', 'value', 'scenes',
]; ];
const _groupRank = new Map(_groupOrder.map((g, i) => [g, i])); const _groupRank = new Map(_groupOrder.map((g, i) => [g, i]));

View File

@@ -144,3 +144,5 @@ export const ICON_ROTATE_CW = _svg(P.rotateCw);
export const ICON_ROTATE_CCW = _svg(P.rotateCcw); export const ICON_ROTATE_CCW = _svg(P.rotateCcw);
export const ICON_DOWNLOAD = _svg(P.download); export const ICON_DOWNLOAD = _svg(P.download);
export const ICON_UNDO = _svg(P.undo2); export const ICON_UNDO = _svg(P.undo2);
export const ICON_SCENE = _svg(P.sparkles);
export const ICON_CAPTURE = _svg(P.camera);

View File

@@ -90,6 +90,7 @@ function _triggerTabLoad(tab) {
else if (tab === 'profiles' && typeof window.loadProfiles === 'function') window.loadProfiles(); else if (tab === 'profiles' && typeof window.loadProfiles === 'function') window.loadProfiles();
else if (tab === 'streams' && typeof window.loadPictureSources === 'function') window.loadPictureSources(); else if (tab === 'streams' && typeof window.loadPictureSources === 'function') window.loadPictureSources();
else if (tab === 'targets' && typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); else if (tab === 'targets' && typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
else if (tab === 'scenes' && typeof window.loadScenes === 'function') window.loadScenes();
} }
function _showDimOverlay(duration) { function _showDimOverlay(duration) {

View File

@@ -13,6 +13,7 @@ import {
ICON_TARGET, ICON_PROFILE, ICON_CLOCK, ICON_WARNING, ICON_OK, ICON_TARGET, ICON_PROFILE, ICON_CLOCK, ICON_WARNING, ICON_OK,
ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_AUTOSTART, ICON_HELP, ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_AUTOSTART, ICON_HELP,
} from '../core/icons.js'; } from '../core/icons.js';
import { loadScenePresets, renderScenePresetsSection } from './scene-presets.js';
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed'; const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
const MAX_FPS_SAMPLES = 120; const MAX_FPS_SAMPLES = 120;
@@ -373,13 +374,14 @@ export async function loadDashboard(forceFullRender = false) {
try { try {
// Fire all requests in a single batch to avoid sequential RTTs // Fire all requests in a single batch to avoid sequential RTTs
const [targetsResp, profilesResp, devicesResp, cssResp, batchStatesResp, batchMetricsResp] = await Promise.all([ const [targetsResp, profilesResp, devicesResp, cssResp, batchStatesResp, batchMetricsResp, scenePresets] = await Promise.all([
fetchWithAuth('/picture-targets'), fetchWithAuth('/picture-targets'),
fetchWithAuth('/profiles').catch(() => null), fetchWithAuth('/profiles').catch(() => null),
fetchWithAuth('/devices').catch(() => null), fetchWithAuth('/devices').catch(() => null),
fetchWithAuth('/color-strip-sources').catch(() => null), fetchWithAuth('/color-strip-sources').catch(() => null),
fetchWithAuth('/picture-targets/batch/states').catch(() => null), fetchWithAuth('/picture-targets/batch/states').catch(() => null),
fetchWithAuth('/picture-targets/batch/metrics').catch(() => null), fetchWithAuth('/picture-targets/batch/metrics').catch(() => null),
loadScenePresets(),
]); ]);
const targetsData = await targetsResp.json(); const targetsData = await targetsResp.json();
@@ -401,7 +403,7 @@ export async function loadDashboard(forceFullRender = false) {
let runningIds = []; let runningIds = [];
let newAutoStartIds = ''; let newAutoStartIds = '';
if (targets.length === 0 && profiles.length === 0) { if (targets.length === 0 && profiles.length === 0 && scenePresets.length === 0) {
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`; dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
} else { } else {
const enriched = targets.map(target => ({ const enriched = targets.map(target => ({
@@ -496,6 +498,17 @@ export async function loadDashboard(forceFullRender = false) {
</div>`; </div>`;
} }
// Scene Presets section
if (scenePresets.length > 0) {
const sceneSec = renderScenePresetsSection(scenePresets);
if (sceneSec) {
dynamicHtml += `<div class="dashboard-section">
${_sectionHeader('scenes', t('dashboard.section.scenes'), scenePresets.length, sceneSec.headerExtra)}
${_sectionContent('scenes', sceneSec.content)}
</div>`;
}
}
if (targets.length > 0) { if (targets.length > 0) {
let targetsInner = ''; let targetsInner = '';

View File

@@ -6,7 +6,7 @@ import {
_discoveryScanRunning, set_discoveryScanRunning, _discoveryScanRunning, set_discoveryScanRunning,
_discoveryCache, set_discoveryCache, _discoveryCache, set_discoveryCache,
} from '../core/state.js'; } from '../core/state.js';
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, escapeHtml } from '../core/api.js'; import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { showToast } from '../core/ui.js'; import { showToast } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
@@ -43,7 +43,29 @@ export function onDeviceTypeChanged() {
const ledTypeGroup = document.getElementById('device-led-type-group'); const ledTypeGroup = document.getElementById('device-led-type-group');
const sendLatencyGroup = document.getElementById('device-send-latency-group'); const sendLatencyGroup = document.getElementById('device-send-latency-group');
if (isMockDevice(deviceType)) { // URL label / hint / placeholder — adapt per device type
const urlLabel = document.getElementById('device-url-label');
const urlHint = document.getElementById('device-url-hint');
const scanBtn = document.getElementById('scan-network-btn');
if (isMqttDevice(deviceType)) {
// MQTT: show URL (topic), LED count; hide serial/baud/led-type/latency/discovery
urlGroup.style.display = '';
urlInput.setAttribute('required', '');
serialGroup.style.display = 'none';
serialSelect.removeAttribute('required');
ledCountGroup.style.display = '';
baudRateGroup.style.display = 'none';
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
if (discoverySection) discoverySection.style.display = 'none';
if (scanBtn) scanBtn.style.display = 'none';
// Relabel URL field as "Topic"
if (urlLabel) urlLabel.textContent = t('device.mqtt_topic');
if (urlHint) urlHint.textContent = t('device.mqtt_topic.hint');
urlInput.placeholder = t('device.mqtt_topic.placeholder') || 'mqtt://ledgrab/device/living-room';
} else if (isMockDevice(deviceType)) {
urlGroup.style.display = 'none'; urlGroup.style.display = 'none';
urlInput.removeAttribute('required'); urlInput.removeAttribute('required');
serialGroup.style.display = 'none'; serialGroup.style.display = 'none';
@@ -53,6 +75,7 @@ export function onDeviceTypeChanged() {
if (ledTypeGroup) ledTypeGroup.style.display = ''; if (ledTypeGroup) ledTypeGroup.style.display = '';
if (sendLatencyGroup) sendLatencyGroup.style.display = ''; if (sendLatencyGroup) sendLatencyGroup.style.display = '';
if (discoverySection) discoverySection.style.display = 'none'; if (discoverySection) discoverySection.style.display = 'none';
if (scanBtn) scanBtn.style.display = 'none';
} else if (isSerialDevice(deviceType)) { } else if (isSerialDevice(deviceType)) {
urlGroup.style.display = 'none'; urlGroup.style.display = 'none';
urlInput.removeAttribute('required'); urlInput.removeAttribute('required');
@@ -62,6 +85,7 @@ export function onDeviceTypeChanged() {
baudRateGroup.style.display = ''; baudRateGroup.style.display = '';
if (ledTypeGroup) ledTypeGroup.style.display = 'none'; if (ledTypeGroup) ledTypeGroup.style.display = 'none';
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none'; if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
if (scanBtn) scanBtn.style.display = 'none';
// Hide discovery list — serial port dropdown replaces it // Hide discovery list — serial port dropdown replaces it
if (discoverySection) discoverySection.style.display = 'none'; if (discoverySection) discoverySection.style.display = 'none';
// Populate from cache or show placeholder (lazy-load on focus) // Populate from cache or show placeholder (lazy-load on focus)
@@ -85,6 +109,11 @@ export function onDeviceTypeChanged() {
baudRateGroup.style.display = 'none'; baudRateGroup.style.display = 'none';
if (ledTypeGroup) ledTypeGroup.style.display = 'none'; if (ledTypeGroup) ledTypeGroup.style.display = 'none';
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none'; if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
if (scanBtn) scanBtn.style.display = '';
// Restore default URL label/hint/placeholder
if (urlLabel) urlLabel.textContent = t('device.url');
if (urlHint) urlHint.textContent = t('device.url.hint');
urlInput.placeholder = t('device.url.placeholder') || 'http://192.168.1.100';
// Show cached results or trigger scan for WLED // Show cached results or trigger scan for WLED
if (deviceType in _discoveryCache) { if (deviceType in _discoveryCache) {
_renderDiscoveryList(); _renderDiscoveryList();
@@ -316,6 +345,11 @@ export async function handleAddDevice(event) {
url = document.getElementById('device-url').value.trim(); url = document.getElementById('device-url').value.trim();
} }
// MQTT: ensure mqtt:// prefix
if (isMqttDevice(deviceType) && url && !url.startsWith('mqtt://')) {
url = 'mqtt://' + url;
}
if (!name || (!isMockDevice(deviceType) && !url)) { if (!name || (!isMockDevice(deviceType) && !url)) {
error.textContent = t('device_discovery.error.fill_all_fields'); error.textContent = t('device_discovery.error.fill_all_fields');
error.style.display = 'block'; error.style.display = 'block';

View File

@@ -5,7 +5,7 @@
import { import {
_deviceBrightnessCache, updateDeviceBrightness, _deviceBrightnessCache, updateDeviceBrightness,
} from '../core/state.js'; } from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice } from '../core/api.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice } from '../core/api.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js'; import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
@@ -174,22 +174,36 @@ export async function showSettings(deviceId) {
document.getElementById('settings-health-interval').value = device.state_check_interval ?? 30; document.getElementById('settings-health-interval').value = device.state_check_interval ?? 30;
const isMock = isMockDevice(device.device_type); const isMock = isMockDevice(device.device_type);
const isMqtt = isMqttDevice(device.device_type);
const urlGroup = document.getElementById('settings-url-group'); const urlGroup = document.getElementById('settings-url-group');
const serialGroup = document.getElementById('settings-serial-port-group'); const serialGroup = document.getElementById('settings-serial-port-group');
const urlLabel = urlGroup.querySelector('label[for="settings-device-url"]');
const urlHint = urlGroup.querySelector('.input-hint');
const urlInput = document.getElementById('settings-device-url');
if (isMock) { if (isMock) {
urlGroup.style.display = 'none'; urlGroup.style.display = 'none';
document.getElementById('settings-device-url').removeAttribute('required'); urlInput.removeAttribute('required');
serialGroup.style.display = 'none'; serialGroup.style.display = 'none';
} else if (isAdalight) { } else if (isAdalight) {
urlGroup.style.display = 'none'; urlGroup.style.display = 'none';
document.getElementById('settings-device-url').removeAttribute('required'); urlInput.removeAttribute('required');
serialGroup.style.display = ''; serialGroup.style.display = '';
_populateSettingsSerialPorts(device.url); _populateSettingsSerialPorts(device.url);
} else { } else {
urlGroup.style.display = ''; urlGroup.style.display = '';
document.getElementById('settings-device-url').setAttribute('required', ''); urlInput.setAttribute('required', '');
document.getElementById('settings-device-url').value = device.url; urlInput.value = device.url;
serialGroup.style.display = 'none'; serialGroup.style.display = 'none';
// Relabel for MQTT
if (isMqtt) {
if (urlLabel) urlLabel.textContent = t('device.mqtt_topic');
if (urlHint) urlHint.textContent = t('device.mqtt_topic.hint');
urlInput.placeholder = t('device.mqtt_topic.placeholder') || 'mqtt://ledgrab/device/living-room';
} else {
if (urlLabel) urlLabel.textContent = t('device.url');
if (urlHint) urlHint.textContent = t('settings.url.hint');
urlInput.placeholder = t('device.url.placeholder') || 'http://192.168.1.100';
}
} }
const ledCountGroup = document.getElementById('settings-led-count-group'); const ledCountGroup = document.getElementById('settings-led-count-group');

View File

@@ -9,7 +9,7 @@ import { showToast, showConfirm, setTabRefreshing } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
import { CardSection } from '../core/card-sections.js'; import { CardSection } from '../core/card-sections.js';
import { updateTabBadge } from './tabs.js'; import { updateTabBadge } from './tabs.js';
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_TARGET, ICON_PROFILE, ICON_HELP, ICON_OK } from '../core/icons.js'; import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_TARGET, ICON_PROFILE, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO } from '../core/icons.js';
class ProfileEditorModal extends Modal { class ProfileEditorModal extends Modal {
constructor() { super('profile-editor-modal'); } constructor() { super('profile-editor-modal'); }
@@ -116,6 +116,20 @@ function createProfileCard(profile, runningTargetIds = new Set()) {
const matchLabel = t('profiles.condition.application.match_type.' + (c.match_type || 'running')); const matchLabel = t('profiles.condition.application.match_type.' + (c.match_type || 'running'));
return `<span class="stream-card-prop stream-card-prop-full">${t('profiles.condition.application')}: ${apps} (${matchLabel})</span>`; return `<span class="stream-card-prop stream-card-prop-full">${t('profiles.condition.application')}: ${apps} (${matchLabel})</span>`;
} }
if (c.condition_type === 'time_of_day') {
return `<span class="stream-card-prop">${ICON_CLOCK} ${c.start_time || '00:00'} ${c.end_time || '23:59'}</span>`;
}
if (c.condition_type === 'system_idle') {
const mode = c.when_idle !== false ? t('profiles.condition.system_idle.when_idle') : t('profiles.condition.system_idle.when_active');
return `<span class="stream-card-prop">${ICON_TIMER} ${c.idle_minutes || 5}m (${mode})</span>`;
}
if (c.condition_type === 'display_state') {
const stateLabel = t('profiles.condition.display_state.' + (c.state || 'on'));
return `<span class="stream-card-prop">${ICON_MONITOR} ${t('profiles.condition.display_state')}: ${stateLabel}</span>`;
}
if (c.condition_type === 'mqtt') {
return `<span class="stream-card-prop stream-card-prop-full">${ICON_RADIO} ${t('profiles.condition.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}</span>`;
}
return `<span class="stream-card-prop">${c.condition_type}</span>`; return `<span class="stream-card-prop">${c.condition_type}</span>`;
}); });
const logicLabel = profile.condition_logic === 'and' ? t('profiles.logic.and') : t('profiles.logic.or'); const logicLabel = profile.condition_logic === 'and' ? t('profiles.logic.and') : t('profiles.logic.or');
@@ -259,6 +273,10 @@ function addProfileConditionRow(condition) {
<select class="condition-type-select"> <select class="condition-type-select">
<option value="always" ${condType === 'always' ? 'selected' : ''}>${t('profiles.condition.always')}</option> <option value="always" ${condType === 'always' ? 'selected' : ''}>${t('profiles.condition.always')}</option>
<option value="application" ${condType === 'application' ? 'selected' : ''}>${t('profiles.condition.application')}</option> <option value="application" ${condType === 'application' ? 'selected' : ''}>${t('profiles.condition.application')}</option>
<option value="time_of_day" ${condType === 'time_of_day' ? 'selected' : ''}>${t('profiles.condition.time_of_day')}</option>
<option value="system_idle" ${condType === 'system_idle' ? 'selected' : ''}>${t('profiles.condition.system_idle')}</option>
<option value="display_state" ${condType === 'display_state' ? 'selected' : ''}>${t('profiles.condition.display_state')}</option>
<option value="mqtt" ${condType === 'mqtt' ? 'selected' : ''}>${t('profiles.condition.mqtt')}</option>
</select> </select>
<button type="button" class="btn-remove-condition" onclick="this.closest('.profile-condition-row').remove()" title="Remove">&#x2715;</button> <button type="button" class="btn-remove-condition" onclick="this.closest('.profile-condition-row').remove()" title="Remove">&#x2715;</button>
</div> </div>
@@ -273,6 +291,81 @@ function addProfileConditionRow(condition) {
container.innerHTML = `<small class="condition-always-desc">${t('profiles.condition.always.hint')}</small>`; container.innerHTML = `<small class="condition-always-desc">${t('profiles.condition.always.hint')}</small>`;
return; return;
} }
if (type === 'time_of_day') {
const startTime = data.start_time || '00:00';
const endTime = data.end_time || '23:59';
container.innerHTML = `
<div class="condition-fields">
<div class="condition-field">
<label>${t('profiles.condition.time_of_day.start_time')}</label>
<input type="time" class="condition-start-time" value="${startTime}">
</div>
<div class="condition-field">
<label>${t('profiles.condition.time_of_day.end_time')}</label>
<input type="time" class="condition-end-time" value="${endTime}">
</div>
<small class="condition-always-desc">${t('profiles.condition.time_of_day.overnight_hint')}</small>
</div>`;
return;
}
if (type === 'system_idle') {
const idleMinutes = data.idle_minutes ?? 5;
const whenIdle = data.when_idle ?? true;
container.innerHTML = `
<div class="condition-fields">
<div class="condition-field">
<label>${t('profiles.condition.system_idle.idle_minutes')}</label>
<input type="number" class="condition-idle-minutes" min="1" max="999" value="${idleMinutes}">
</div>
<div class="condition-field">
<label>${t('profiles.condition.system_idle.mode')}</label>
<select class="condition-when-idle">
<option value="true" ${whenIdle ? 'selected' : ''}>${t('profiles.condition.system_idle.when_idle')}</option>
<option value="false" ${!whenIdle ? 'selected' : ''}>${t('profiles.condition.system_idle.when_active')}</option>
</select>
</div>
</div>`;
return;
}
if (type === 'display_state') {
const dState = data.state || 'on';
container.innerHTML = `
<div class="condition-fields">
<div class="condition-field">
<label>${t('profiles.condition.display_state.state')}</label>
<select class="condition-display-state">
<option value="on" ${dState === 'on' ? 'selected' : ''}>${t('profiles.condition.display_state.on')}</option>
<option value="off" ${dState === 'off' ? 'selected' : ''}>${t('profiles.condition.display_state.off')}</option>
</select>
</div>
</div>`;
return;
}
if (type === 'mqtt') {
const topic = data.topic || '';
const payload = data.payload || '';
const matchMode = data.match_mode || 'exact';
container.innerHTML = `
<div class="condition-fields">
<div class="condition-field">
<label>${t('profiles.condition.mqtt.topic')}</label>
<input type="text" class="condition-mqtt-topic" value="${escapeHtml(topic)}" placeholder="home/status/power">
</div>
<div class="condition-field">
<label>${t('profiles.condition.mqtt.payload')}</label>
<input type="text" class="condition-mqtt-payload" value="${escapeHtml(payload)}" placeholder="ON">
</div>
<div class="condition-field">
<label>${t('profiles.condition.mqtt.match_mode')}</label>
<select class="condition-mqtt-match-mode">
<option value="exact" ${matchMode === 'exact' ? 'selected' : ''}>${t('profiles.condition.mqtt.match_mode.exact')}</option>
<option value="contains" ${matchMode === 'contains' ? 'selected' : ''}>${t('profiles.condition.mqtt.match_mode.contains')}</option>
<option value="regex" ${matchMode === 'regex' ? 'selected' : ''}>${t('profiles.condition.mqtt.match_mode.regex')}</option>
</select>
</div>
</div>`;
return;
}
const appsValue = (data.apps || []).join('\n'); const appsValue = (data.apps || []).join('\n');
const matchType = data.match_type || 'running'; const matchType = data.match_type || 'running';
container.innerHTML = ` container.innerHTML = `
@@ -308,7 +401,7 @@ function addProfileConditionRow(condition) {
renderFields(condType, condition); renderFields(condType, condition);
typeSelect.addEventListener('change', () => { typeSelect.addEventListener('change', () => {
renderFields(typeSelect.value, { apps: [], match_type: 'running' }); renderFields(typeSelect.value, {});
}); });
list.appendChild(row); list.appendChild(row);
@@ -382,6 +475,30 @@ function getProfileEditorConditions() {
const condType = typeSelect ? typeSelect.value : 'application'; const condType = typeSelect ? typeSelect.value : 'application';
if (condType === 'always') { if (condType === 'always') {
conditions.push({ condition_type: 'always' }); conditions.push({ condition_type: 'always' });
} else if (condType === 'time_of_day') {
conditions.push({
condition_type: 'time_of_day',
start_time: row.querySelector('.condition-start-time').value || '00:00',
end_time: row.querySelector('.condition-end-time').value || '23:59',
});
} else if (condType === 'system_idle') {
conditions.push({
condition_type: 'system_idle',
idle_minutes: parseInt(row.querySelector('.condition-idle-minutes').value, 10) || 5,
when_idle: row.querySelector('.condition-when-idle').value === 'true',
});
} else if (condType === 'display_state') {
conditions.push({
condition_type: 'display_state',
state: row.querySelector('.condition-display-state').value || 'on',
});
} else if (condType === 'mqtt') {
conditions.push({
condition_type: 'mqtt',
topic: row.querySelector('.condition-mqtt-topic').value.trim(),
payload: row.querySelector('.condition-mqtt-payload').value,
match_mode: row.querySelector('.condition-mqtt-match-mode').value || 'exact',
});
} else { } else {
const matchType = row.querySelector('.condition-match-type').value; const matchType = row.querySelector('.condition-match-type').value;
const appsText = row.querySelector('.condition-apps').value.trim(); const appsText = row.querySelector('.condition-apps').value.trim();

View File

@@ -0,0 +1,336 @@
/**
* Scene Presets — capture, activate, edit, delete system state snapshots.
* Renders as a dedicated tab and also provides dashboard section rendering.
*/
import { apiKey } from '../core/state.js';
import { fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import { CardSection } from '../core/card-sections.js';
import { updateTabBadge } from './tabs.js';
import {
ICON_SCENE, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_SETTINGS,
} from '../core/icons.js';
let _presetsCache = [];
let _editingId = null;
let _scenesLoading = false;
class ScenePresetEditorModal extends Modal {
constructor() { super('scene-preset-editor-modal'); }
snapshotValues() {
return {
name: document.getElementById('scene-preset-editor-name').value,
description: document.getElementById('scene-preset-editor-description').value,
color: document.getElementById('scene-preset-editor-color').value,
};
}
}
const scenePresetModal = new ScenePresetEditorModal();
const csScenes = new CardSection('scenes', {
titleKey: 'scenes.title',
gridClass: 'devices-grid',
addCardOnclick: "openScenePresetCapture()",
keyAttr: 'data-scene-id',
});
// Re-render scenes when language changes (only if tab is active)
document.addEventListener('languageChanged', () => {
if (apiKey && (localStorage.getItem('activeTab') || 'dashboard') === 'scenes') loadScenes();
});
// ===== Tab rendering =====
export async function loadScenes() {
if (_scenesLoading) return;
_scenesLoading = true;
try {
const resp = await fetchWithAuth('/scene-presets');
if (!resp.ok) { _scenesLoading = false; return; }
const data = await resp.json();
_presetsCache = data.presets || [];
} catch {
_scenesLoading = false;
return;
}
const container = document.getElementById('scenes-content');
const items = csScenes.applySortOrder(_presetsCache.map(p => ({ key: p.id, html: _createSceneCard(p) })));
updateTabBadge('scenes', _presetsCache.length);
if (csScenes.isMounted()) {
csScenes.reconcile(items);
} else {
const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group"><button class="btn-expand-collapse" onclick="expandAllSceneSections()" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllSceneSections()" title="${t('section.collapse_all')}">⊟</button></span></div>`;
container.innerHTML = toolbar + csScenes.render(items);
csScenes.bind();
}
_scenesLoading = false;
}
export function expandAllSceneSections() {
CardSection.expandAll([csScenes]);
}
export function collapseAllSceneSections() {
CardSection.collapseAll([csScenes]);
}
function _createSceneCard(preset) {
const targetCount = (preset.targets || []).length;
const deviceCount = (preset.devices || []).length;
const profileCount = (preset.profiles || []).length;
const colorStyle = `border-left: 3px solid ${escapeHtml(preset.color || '#4fc3f7')}`;
const meta = [
targetCount > 0 ? `${ICON_TARGET} ${targetCount} ${t('scenes.targets_count')}` : null,
deviceCount > 0 ? `${ICON_SETTINGS} ${deviceCount} ${t('scenes.devices_count')}` : null,
profileCount > 0 ? `${profileCount} ${t('scenes.profiles_count')}` : null,
].filter(Boolean);
const updated = preset.updated_at ? new Date(preset.updated_at).toLocaleString() : '';
return `<div class="card" data-scene-id="${preset.id}" style="${colorStyle}">
<div class="card-top-actions">
<button class="card-remove-btn" onclick="deleteScenePreset('${preset.id}', '${escapeHtml(preset.name)}')" title="${t('scenes.delete')}">&#x2715;</button>
</div>
<div class="card-header">
<div class="card-title">${escapeHtml(preset.name)}</div>
</div>
${preset.description ? `<div class="card-subtitle"><span class="card-meta">${escapeHtml(preset.description)}</span></div>` : ''}
<div class="stream-card-props">
${meta.map(m => `<span class="stream-card-prop">${m}</span>`).join('')}
${updated ? `<span class="stream-card-prop">${updated}</span>` : ''}
</div>
<div class="card-actions">
<button class="btn btn-icon btn-secondary" onclick="editScenePreset('${preset.id}')" title="${t('scenes.edit')}">${ICON_EDIT}</button>
<button class="btn btn-icon btn-secondary" onclick="recaptureScenePreset('${preset.id}')" title="${t('scenes.recapture')}">${ICON_REFRESH}</button>
<button class="btn btn-icon btn-success" onclick="activateScenePreset('${preset.id}')" title="${t('scenes.activate')}">${ICON_START}</button>
</div>
</div>`;
}
// ===== Dashboard section (compact cards) =====
export async function loadScenePresets() {
try {
const resp = await fetchWithAuth('/scene-presets');
if (!resp.ok) return [];
const data = await resp.json();
_presetsCache = data.presets || [];
return _presetsCache;
} catch {
return [];
}
}
export function renderScenePresetsSection(presets) {
if (!presets || presets.length === 0) return '';
const captureBtn = `<button class="btn btn-sm btn-primary" onclick="event.stopPropagation(); openScenePresetCapture()" title="${t('scenes.capture')}">${ICON_CAPTURE} ${t('scenes.capture')}</button>`;
const cards = presets.map(p => _renderDashboardPresetCard(p)).join('');
return { headerExtra: captureBtn, content: `<div class="dashboard-autostart-grid">${cards}</div>` };
}
function _renderDashboardPresetCard(preset) {
const borderStyle = `border-left: 3px solid ${escapeHtml(preset.color)}`;
const targetCount = (preset.targets || []).length;
const deviceCount = (preset.devices || []).length;
const profileCount = (preset.profiles || []).length;
const subtitle = [
targetCount > 0 ? `${targetCount} ${t('scenes.targets_count')}` : null,
deviceCount > 0 ? `${deviceCount} ${t('scenes.devices_count')}` : null,
profileCount > 0 ? `${profileCount} ${t('scenes.profiles_count')}` : null,
].filter(Boolean).join(' \u00b7 ');
return `<div class="dashboard-target dashboard-scene-preset" data-scene-id="${preset.id}" style="${borderStyle}">
<div class="dashboard-target-info" onclick="activateScenePreset('${preset.id}')">
<span class="dashboard-target-icon">${ICON_SCENE}</span>
<div>
<div class="dashboard-target-name">${escapeHtml(preset.name)}</div>
${preset.description ? `<div class="dashboard-target-subtitle">${escapeHtml(preset.description)}</div>` : ''}
<div class="dashboard-target-subtitle">${subtitle}</div>
</div>
</div>
<div class="dashboard-target-actions">
<button class="dashboard-action-btn start" onclick="activateScenePreset('${preset.id}')" title="${t('scenes.activate')}">${ICON_START}</button>
</div>
</div>`;
}
// ===== Capture (create) =====
export function openScenePresetCapture() {
_editingId = null;
document.getElementById('scene-preset-editor-id').value = '';
document.getElementById('scene-preset-editor-name').value = '';
document.getElementById('scene-preset-editor-description').value = '';
document.getElementById('scene-preset-editor-color').value = '#4fc3f7';
document.getElementById('scene-preset-editor-error').style.display = 'none';
const titleEl = document.querySelector('#scene-preset-editor-title span[data-i18n]');
if (titleEl) { titleEl.setAttribute('data-i18n', 'scenes.add'); titleEl.textContent = t('scenes.add'); }
scenePresetModal.open();
scenePresetModal.snapshot();
}
// ===== Edit metadata =====
export async function editScenePreset(presetId) {
const preset = _presetsCache.find(p => p.id === presetId);
if (!preset) return;
_editingId = presetId;
document.getElementById('scene-preset-editor-id').value = presetId;
document.getElementById('scene-preset-editor-name').value = preset.name;
document.getElementById('scene-preset-editor-description').value = preset.description || '';
document.getElementById('scene-preset-editor-color').value = preset.color || '#4fc3f7';
document.getElementById('scene-preset-editor-error').style.display = 'none';
const titleEl = document.querySelector('#scene-preset-editor-title span[data-i18n]');
if (titleEl) { titleEl.setAttribute('data-i18n', 'scenes.edit'); titleEl.textContent = t('scenes.edit'); }
scenePresetModal.open();
scenePresetModal.snapshot();
}
// ===== Save (create or update) =====
export async function saveScenePreset() {
const name = document.getElementById('scene-preset-editor-name').value.trim();
const description = document.getElementById('scene-preset-editor-description').value.trim();
const color = document.getElementById('scene-preset-editor-color').value;
const errorEl = document.getElementById('scene-preset-editor-error');
if (!name) {
errorEl.textContent = t('scenes.error.name_required');
errorEl.style.display = 'block';
return;
}
try {
let resp;
if (_editingId) {
resp = await fetchWithAuth(`/scene-presets/${_editingId}`, {
method: 'PUT',
body: JSON.stringify({ name, description, color }),
});
} else {
resp = await fetchWithAuth('/scene-presets', {
method: 'POST',
body: JSON.stringify({ name, description, color }),
});
}
if (!resp.ok) {
const err = await resp.json();
errorEl.textContent = err.detail || t('scenes.error.save_failed');
errorEl.style.display = 'block';
return;
}
scenePresetModal.forceClose();
showToast(_editingId ? t('scenes.updated') : t('scenes.captured'), 'success');
_reloadScenesTab();
} catch (error) {
if (error.isAuth) return;
errorEl.textContent = t('scenes.error.save_failed');
errorEl.style.display = 'block';
}
}
export async function closeScenePresetEditor() {
await scenePresetModal.close();
}
// ===== Activate =====
export async function activateScenePreset(presetId) {
try {
const resp = await fetchWithAuth(`/scene-presets/${presetId}/activate`, {
method: 'POST',
});
if (!resp.ok) {
showToast(t('scenes.error.activate_failed'), 'error');
return;
}
const result = await resp.json();
if (result.status === 'activated') {
showToast(t('scenes.activated'), 'success');
} else {
showToast(`${t('scenes.activated_partial')}: ${result.errors.length} ${t('scenes.errors')}`, 'warning');
}
if (typeof window.loadDashboard === 'function') window.loadDashboard(true);
} catch (error) {
if (error.isAuth) return;
showToast(t('scenes.error.activate_failed'), 'error');
}
}
// ===== Recapture =====
export async function recaptureScenePreset(presetId) {
const preset = _presetsCache.find(p => p.id === presetId);
const name = preset ? preset.name : presetId;
const confirmed = await showConfirm(t('scenes.recapture_confirm', { name }));
if (!confirmed) return;
try {
const resp = await fetchWithAuth(`/scene-presets/${presetId}/recapture`, {
method: 'POST',
});
if (resp.ok) {
showToast(t('scenes.recaptured'), 'success');
_reloadScenesTab();
} else {
showToast(t('scenes.error.recapture_failed'), 'error');
}
} catch (error) {
if (error.isAuth) return;
showToast(t('scenes.error.recapture_failed'), 'error');
}
}
// ===== Delete =====
export async function deleteScenePreset(presetId) {
const preset = _presetsCache.find(p => p.id === presetId);
const name = preset ? preset.name : presetId;
const confirmed = await showConfirm(t('scenes.delete_confirm', { name }));
if (!confirmed) return;
try {
const resp = await fetchWithAuth(`/scene-presets/${presetId}`, {
method: 'DELETE',
});
if (resp.ok) {
showToast(t('scenes.deleted'), 'success');
_reloadScenesTab();
} else {
showToast(t('scenes.error.delete_failed'), 'error');
}
} catch (error) {
if (error.isAuth) return;
showToast(t('scenes.error.delete_failed'), 'error');
}
}
// ===== Helpers =====
function _reloadScenesTab() {
// Reload the scenes tab if it's active
if ((localStorage.getItem('activeTab') || 'dashboard') === 'scenes') {
loadScenes();
}
// Also refresh dashboard (scene presets section)
if (typeof window.loadDashboard === 'function') window.loadDashboard(true);
}

View File

@@ -19,8 +19,12 @@ function _setHash(tab, subTab) {
} }
let _suppressHashUpdate = false; let _suppressHashUpdate = false;
let _activeTab = null;
export function switchTab(name, { updateHash = true, skipLoad = false } = {}) { export function switchTab(name, { updateHash = true, skipLoad = false } = {}) {
if (_activeTab === name) return;
_activeTab = name;
document.querySelectorAll('.tab-btn').forEach(btn => { document.querySelectorAll('.tab-btn').forEach(btn => {
const isActive = btn.dataset.tab === name; const isActive = btn.dataset.tab === name;
btn.classList.toggle('active', isActive); btn.classList.toggle('active', isActive);
@@ -56,6 +60,8 @@ export function switchTab(name, { updateHash = true, skipLoad = false } = {}) {
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
} else if (name === 'profiles') { } else if (name === 'profiles') {
if (typeof window.loadProfiles === 'function') window.loadProfiles(); if (typeof window.loadProfiles === 'function') window.loadProfiles();
} else if (name === 'scenes') {
if (typeof window.loadScenes === 'function') window.loadScenes();
} }
} }
} }

View File

@@ -128,6 +128,9 @@
"device.led_type.hint": "RGB (3 channels) or RGBW (4 channels with dedicated white)", "device.led_type.hint": "RGB (3 channels) or RGBW (4 channels with dedicated white)",
"device.send_latency": "Send Latency (ms):", "device.send_latency": "Send Latency (ms):",
"device.send_latency.hint": "Simulated network/serial delay per frame in milliseconds", "device.send_latency.hint": "Simulated network/serial delay per frame in milliseconds",
"device.mqtt_topic": "MQTT Topic:",
"device.mqtt_topic.hint": "MQTT topic path for publishing pixel data (e.g. mqtt://ledgrab/device/name)",
"device.mqtt_topic.placeholder": "mqtt://ledgrab/device/living-room",
"device.url.hint": "IP address or hostname of the device (e.g. http://192.168.1.100)", "device.url.hint": "IP address or hostname of the device (e.g. http://192.168.1.100)",
"device.name": "Device Name:", "device.name": "Device Name:",
"device.name.placeholder": "Living Room TV", "device.name.placeholder": "Living Room TV",
@@ -529,6 +532,7 @@
"dashboard.stop_all": "Stop All", "dashboard.stop_all": "Stop All",
"dashboard.failed": "Failed to load dashboard", "dashboard.failed": "Failed to load dashboard",
"dashboard.section.profiles": "Profiles", "dashboard.section.profiles": "Profiles",
"dashboard.section.scenes": "Scene Presets",
"dashboard.targets": "Targets", "dashboard.targets": "Targets",
"dashboard.section.performance": "System Performance", "dashboard.section.performance": "System Performance",
"dashboard.perf.cpu": "CPU", "dashboard.perf.cpu": "CPU",
@@ -568,6 +572,27 @@
"profiles.condition.application.match_type.topmost": "Topmost (foreground)", "profiles.condition.application.match_type.topmost": "Topmost (foreground)",
"profiles.condition.application.match_type.topmost_fullscreen": "Topmost + Fullscreen", "profiles.condition.application.match_type.topmost_fullscreen": "Topmost + Fullscreen",
"profiles.condition.application.match_type.fullscreen": "Fullscreen", "profiles.condition.application.match_type.fullscreen": "Fullscreen",
"profiles.condition.time_of_day": "Time of Day",
"profiles.condition.time_of_day.start_time": "Start Time:",
"profiles.condition.time_of_day.end_time": "End Time:",
"profiles.condition.time_of_day.overnight_hint": "For overnight ranges (e.g. 22:0006:00), set start time after end time.",
"profiles.condition.system_idle": "System Idle",
"profiles.condition.system_idle.idle_minutes": "Idle Timeout (minutes):",
"profiles.condition.system_idle.mode": "Trigger Mode:",
"profiles.condition.system_idle.when_idle": "When idle",
"profiles.condition.system_idle.when_active": "When active",
"profiles.condition.display_state": "Display State",
"profiles.condition.display_state.state": "Monitor State:",
"profiles.condition.display_state.on": "On",
"profiles.condition.display_state.off": "Off (sleeping)",
"profiles.condition.mqtt": "MQTT",
"profiles.condition.mqtt.topic": "Topic:",
"profiles.condition.mqtt.payload": "Payload:",
"profiles.condition.mqtt.match_mode": "Match Mode:",
"profiles.condition.mqtt.match_mode.exact": "Exact",
"profiles.condition.mqtt.match_mode.contains": "Contains",
"profiles.condition.mqtt.match_mode.regex": "Regex",
"profiles.condition.mqtt.hint": "Activate when an MQTT topic receives a matching payload",
"profiles.targets": "Targets:", "profiles.targets": "Targets:",
"profiles.targets.hint": "Targets to start when this profile activates", "profiles.targets.hint": "Targets to start when this profile activates",
"profiles.targets.empty": "No targets available", "profiles.targets.empty": "No targets available",
@@ -586,6 +611,36 @@
"profiles.error.name_required": "Name is required", "profiles.error.name_required": "Name is required",
"profiles.toggle_all.start": "Start all targets", "profiles.toggle_all.start": "Start all targets",
"profiles.toggle_all.stop": "Stop all targets", "profiles.toggle_all.stop": "Stop all targets",
"scenes.title": "Scenes",
"scenes.add": "Capture Scene",
"scenes.edit": "Edit Scene",
"scenes.name": "Name:",
"scenes.name.hint": "A descriptive name for this scene preset",
"scenes.description": "Description:",
"scenes.description.hint": "Optional description of what this scene does",
"scenes.color": "Card Color:",
"scenes.color.hint": "Accent color for the scene card on the dashboard",
"scenes.capture": "Capture",
"scenes.activate": "Activate scene",
"scenes.recapture": "Recapture current state",
"scenes.delete": "Delete scene",
"scenes.targets_count": "targets",
"scenes.devices_count": "devices",
"scenes.profiles_count": "profiles",
"scenes.captured": "Scene captured",
"scenes.updated": "Scene updated",
"scenes.activated": "Scene activated",
"scenes.activated_partial": "Scene partially activated",
"scenes.errors": "errors",
"scenes.recaptured": "Scene recaptured",
"scenes.deleted": "Scene deleted",
"scenes.recapture_confirm": "Recapture current state into \"{name}\"?",
"scenes.delete_confirm": "Delete scene \"{name}\"?",
"scenes.error.name_required": "Name is required",
"scenes.error.save_failed": "Failed to save scene",
"scenes.error.activate_failed": "Failed to activate scene",
"scenes.error.recapture_failed": "Failed to recapture scene",
"scenes.error.delete_failed": "Failed to delete scene",
"autostart.title": "Auto-start Targets", "autostart.title": "Auto-start Targets",
"autostart.toggle.enabled": "Auto-start enabled", "autostart.toggle.enabled": "Auto-start enabled",
"autostart.toggle.disabled": "Auto-start disabled", "autostart.toggle.disabled": "Auto-start disabled",
@@ -961,6 +1016,7 @@
"search.group.pattern_templates": "Pattern Templates", "search.group.pattern_templates": "Pattern Templates",
"search.group.audio": "Audio Sources", "search.group.audio": "Audio Sources",
"search.group.value": "Value Sources", "search.group.value": "Value Sources",
"search.group.scenes": "Scene Presets",
"settings.backup.label": "Backup Configuration", "settings.backup.label": "Backup Configuration",
"settings.backup.hint": "Download all configuration (devices, targets, streams, templates, profiles) as a single JSON file.", "settings.backup.hint": "Download all configuration (devices, targets, streams, templates, profiles) as a single JSON file.",
"settings.backup.button": "Download Backup", "settings.backup.button": "Download Backup",

View File

@@ -128,6 +128,9 @@
"device.led_type.hint": "RGB (3 канала) или RGBW (4 канала с выделенным белым)", "device.led_type.hint": "RGB (3 канала) или RGBW (4 канала с выделенным белым)",
"device.send_latency": "Задержка отправки (мс):", "device.send_latency": "Задержка отправки (мс):",
"device.send_latency.hint": "Имитация сетевой/серийной задержки на кадр в миллисекундах", "device.send_latency.hint": "Имитация сетевой/серийной задержки на кадр в миллисекундах",
"device.mqtt_topic": "MQTT Топик:",
"device.mqtt_topic.hint": "MQTT топик для публикации пиксельных данных (напр. mqtt://ledgrab/device/name)",
"device.mqtt_topic.placeholder": "mqtt://ledgrab/device/гостиная",
"device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)", "device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)",
"device.name": "Имя Устройства:", "device.name": "Имя Устройства:",
"device.name.placeholder": "ТВ в Гостиной", "device.name.placeholder": "ТВ в Гостиной",
@@ -529,6 +532,7 @@
"dashboard.stop_all": "Остановить все", "dashboard.stop_all": "Остановить все",
"dashboard.failed": "Не удалось загрузить обзор", "dashboard.failed": "Не удалось загрузить обзор",
"dashboard.section.profiles": "Профили", "dashboard.section.profiles": "Профили",
"dashboard.section.scenes": "Пресеты сцен",
"dashboard.targets": "Цели", "dashboard.targets": "Цели",
"dashboard.section.performance": "Производительность системы", "dashboard.section.performance": "Производительность системы",
"dashboard.perf.cpu": "ЦП", "dashboard.perf.cpu": "ЦП",
@@ -568,6 +572,27 @@
"profiles.condition.application.match_type.topmost": "На переднем плане", "profiles.condition.application.match_type.topmost": "На переднем плане",
"profiles.condition.application.match_type.topmost_fullscreen": "На переднем плане + Полный экран", "profiles.condition.application.match_type.topmost_fullscreen": "На переднем плане + Полный экран",
"profiles.condition.application.match_type.fullscreen": "Полный экран", "profiles.condition.application.match_type.fullscreen": "Полный экран",
"profiles.condition.time_of_day": "Время суток",
"profiles.condition.time_of_day.start_time": "Время начала:",
"profiles.condition.time_of_day.end_time": "Время окончания:",
"profiles.condition.time_of_day.overnight_hint": "Для ночных диапазонов (например 22:0006:00) укажите время начала позже времени окончания.",
"profiles.condition.system_idle": "Бездействие системы",
"profiles.condition.system_idle.idle_minutes": "Тайм-аут бездействия (минуты):",
"profiles.condition.system_idle.mode": "Режим срабатывания:",
"profiles.condition.system_idle.when_idle": "При бездействии",
"profiles.condition.system_idle.when_active": "При активности",
"profiles.condition.display_state": "Состояние дисплея",
"profiles.condition.display_state.state": "Состояние монитора:",
"profiles.condition.display_state.on": "Включён",
"profiles.condition.display_state.off": "Выключен (спящий режим)",
"profiles.condition.mqtt": "MQTT",
"profiles.condition.mqtt.topic": "Топик:",
"profiles.condition.mqtt.payload": "Значение:",
"profiles.condition.mqtt.match_mode": "Режим сравнения:",
"profiles.condition.mqtt.match_mode.exact": "Точное совпадение",
"profiles.condition.mqtt.match_mode.contains": "Содержит",
"profiles.condition.mqtt.match_mode.regex": "Регулярное выражение",
"profiles.condition.mqtt.hint": "Активировать при получении совпадающего значения по MQTT топику",
"profiles.targets": "Цели:", "profiles.targets": "Цели:",
"profiles.targets.hint": "Цели для запуска при активации профиля", "profiles.targets.hint": "Цели для запуска при активации профиля",
"profiles.targets.empty": "Нет доступных целей", "profiles.targets.empty": "Нет доступных целей",
@@ -586,6 +611,36 @@
"profiles.error.name_required": "Введите название", "profiles.error.name_required": "Введите название",
"profiles.toggle_all.start": "Запустить все цели", "profiles.toggle_all.start": "Запустить все цели",
"profiles.toggle_all.stop": "Остановить все цели", "profiles.toggle_all.stop": "Остановить все цели",
"scenes.title": "Сцены",
"scenes.add": "Захватить сцену",
"scenes.edit": "Редактировать сцену",
"scenes.name": "Название:",
"scenes.name.hint": "Описательное имя для этого пресета сцены",
"scenes.description": "Описание:",
"scenes.description.hint": "Необязательное описание назначения этой сцены",
"scenes.color": "Цвет карточки:",
"scenes.color.hint": "Акцентный цвет для карточки сцены на панели управления",
"scenes.capture": "Захват",
"scenes.activate": "Активировать сцену",
"scenes.recapture": "Перезахватить текущее состояние",
"scenes.delete": "Удалить сцену",
"scenes.targets_count": "целей",
"scenes.devices_count": "устройств",
"scenes.profiles_count": "профилей",
"scenes.captured": "Сцена захвачена",
"scenes.updated": "Сцена обновлена",
"scenes.activated": "Сцена активирована",
"scenes.activated_partial": "Сцена активирована частично",
"scenes.errors": "ошибок",
"scenes.recaptured": "Сцена перезахвачена",
"scenes.deleted": "Сцена удалена",
"scenes.recapture_confirm": "Перезахватить текущее состояние в \"{name}\"?",
"scenes.delete_confirm": "Удалить сцену \"{name}\"?",
"scenes.error.name_required": "Необходимо указать название",
"scenes.error.save_failed": "Не удалось сохранить сцену",
"scenes.error.activate_failed": "Не удалось активировать сцену",
"scenes.error.recapture_failed": "Не удалось перезахватить сцену",
"scenes.error.delete_failed": "Не удалось удалить сцену",
"autostart.title": "Автозапуск целей", "autostart.title": "Автозапуск целей",
"autostart.toggle.enabled": "Автозапуск включён", "autostart.toggle.enabled": "Автозапуск включён",
"autostart.toggle.disabled": "Автозапуск отключён", "autostart.toggle.disabled": "Автозапуск отключён",
@@ -961,6 +1016,7 @@
"search.group.pattern_templates": "Шаблоны паттернов", "search.group.pattern_templates": "Шаблоны паттернов",
"search.group.audio": "Аудиоисточники", "search.group.audio": "Аудиоисточники",
"search.group.value": "Источники значений", "search.group.value": "Источники значений",
"search.group.scenes": "Пресеты сцен",
"settings.backup.label": "Резервное копирование", "settings.backup.label": "Резервное копирование",
"settings.backup.hint": "Скачать всю конфигурацию (устройства, цели, потоки, шаблоны, профили) в виде одного JSON-файла.", "settings.backup.hint": "Скачать всю конфигурацию (устройства, цели, потоки, шаблоны, профили) в виде одного JSON-файла.",
"settings.backup.button": "Скачать резервную копию", "settings.backup.button": "Скачать резервную копию",

View File

@@ -128,6 +128,9 @@
"device.led_type.hint": "RGB3通道或 RGBW4通道带独立白色", "device.led_type.hint": "RGB3通道或 RGBW4通道带独立白色",
"device.send_latency": "发送延迟(毫秒):", "device.send_latency": "发送延迟(毫秒):",
"device.send_latency.hint": "每帧模拟网络/串口延迟(毫秒)", "device.send_latency.hint": "每帧模拟网络/串口延迟(毫秒)",
"device.mqtt_topic": "MQTT 主题:",
"device.mqtt_topic.hint": "用于发布像素数据的 MQTT 主题路径(例如 mqtt://ledgrab/device/name",
"device.mqtt_topic.placeholder": "mqtt://ledgrab/device/客厅",
"device.url.hint": "设备的 IP 地址或主机名(例如 http://192.168.1.100", "device.url.hint": "设备的 IP 地址或主机名(例如 http://192.168.1.100",
"device.name": "设备名称:", "device.name": "设备名称:",
"device.name.placeholder": "客厅电视", "device.name.placeholder": "客厅电视",
@@ -529,6 +532,7 @@
"dashboard.stop_all": "全部停止", "dashboard.stop_all": "全部停止",
"dashboard.failed": "加载仪表盘失败", "dashboard.failed": "加载仪表盘失败",
"dashboard.section.profiles": "配置文件", "dashboard.section.profiles": "配置文件",
"dashboard.section.scenes": "场景预设",
"dashboard.targets": "目标", "dashboard.targets": "目标",
"dashboard.section.performance": "系统性能", "dashboard.section.performance": "系统性能",
"dashboard.perf.cpu": "CPU", "dashboard.perf.cpu": "CPU",
@@ -568,6 +572,27 @@
"profiles.condition.application.match_type.topmost": "最前(前台)", "profiles.condition.application.match_type.topmost": "最前(前台)",
"profiles.condition.application.match_type.topmost_fullscreen": "最前 + 全屏", "profiles.condition.application.match_type.topmost_fullscreen": "最前 + 全屏",
"profiles.condition.application.match_type.fullscreen": "全屏", "profiles.condition.application.match_type.fullscreen": "全屏",
"profiles.condition.time_of_day": "时段",
"profiles.condition.time_of_day.start_time": "开始时间:",
"profiles.condition.time_of_day.end_time": "结束时间:",
"profiles.condition.time_of_day.overnight_hint": "跨夜时段(如 22:0006:00请将开始时间设为晚于结束时间。",
"profiles.condition.system_idle": "系统空闲",
"profiles.condition.system_idle.idle_minutes": "空闲超时(分钟):",
"profiles.condition.system_idle.mode": "触发模式:",
"profiles.condition.system_idle.when_idle": "空闲时",
"profiles.condition.system_idle.when_active": "活跃时",
"profiles.condition.display_state": "显示器状态",
"profiles.condition.display_state.state": "显示器状态:",
"profiles.condition.display_state.on": "开启",
"profiles.condition.display_state.off": "关闭(休眠)",
"profiles.condition.mqtt": "MQTT",
"profiles.condition.mqtt.topic": "主题:",
"profiles.condition.mqtt.payload": "消息内容:",
"profiles.condition.mqtt.match_mode": "匹配模式:",
"profiles.condition.mqtt.match_mode.exact": "精确匹配",
"profiles.condition.mqtt.match_mode.contains": "包含",
"profiles.condition.mqtt.match_mode.regex": "正则表达式",
"profiles.condition.mqtt.hint": "当 MQTT 主题收到匹配的消息时激活",
"profiles.targets": "目标:", "profiles.targets": "目标:",
"profiles.targets.hint": "配置文件激活时要启动的目标", "profiles.targets.hint": "配置文件激活时要启动的目标",
"profiles.targets.empty": "没有可用的目标", "profiles.targets.empty": "没有可用的目标",
@@ -586,6 +611,36 @@
"profiles.error.name_required": "名称为必填项", "profiles.error.name_required": "名称为必填项",
"profiles.toggle_all.start": "启动所有目标", "profiles.toggle_all.start": "启动所有目标",
"profiles.toggle_all.stop": "停止所有目标", "profiles.toggle_all.stop": "停止所有目标",
"scenes.title": "场景",
"scenes.add": "捕获场景",
"scenes.edit": "编辑场景",
"scenes.name": "名称:",
"scenes.name.hint": "此场景预设的描述性名称",
"scenes.description": "描述:",
"scenes.description.hint": "此场景功能的可选描述",
"scenes.color": "卡片颜色:",
"scenes.color.hint": "仪表盘上场景卡片的强调色",
"scenes.capture": "捕获",
"scenes.activate": "激活场景",
"scenes.recapture": "重新捕获当前状态",
"scenes.delete": "删除场景",
"scenes.targets_count": "目标",
"scenes.devices_count": "设备",
"scenes.profiles_count": "配置",
"scenes.captured": "场景已捕获",
"scenes.updated": "场景已更新",
"scenes.activated": "场景已激活",
"scenes.activated_partial": "场景部分激活",
"scenes.errors": "错误",
"scenes.recaptured": "场景已重新捕获",
"scenes.deleted": "场景已删除",
"scenes.recapture_confirm": "将当前状态重新捕获到\"{name}\"中?",
"scenes.delete_confirm": "删除场景\"{name}\"",
"scenes.error.name_required": "名称为必填项",
"scenes.error.save_failed": "保存场景失败",
"scenes.error.activate_failed": "激活场景失败",
"scenes.error.recapture_failed": "重新捕获场景失败",
"scenes.error.delete_failed": "删除场景失败",
"autostart.title": "自动启动目标", "autostart.title": "自动启动目标",
"autostart.toggle.enabled": "自动启动已启用", "autostart.toggle.enabled": "自动启动已启用",
"autostart.toggle.disabled": "自动启动已禁用", "autostart.toggle.disabled": "自动启动已禁用",
@@ -961,6 +1016,7 @@
"search.group.pattern_templates": "图案模板", "search.group.pattern_templates": "图案模板",
"search.group.audio": "音频源", "search.group.audio": "音频源",
"search.group.value": "值源", "search.group.value": "值源",
"search.group.scenes": "场景预设",
"settings.backup.label": "备份配置", "settings.backup.label": "备份配置",
"settings.backup.hint": "将所有配置(设备、目标、流、模板、配置文件)下载为单个 JSON 文件。", "settings.backup.hint": "将所有配置(设备、目标、流、模板、配置文件)下载为单个 JSON 文件。",
"settings.backup.button": "下载备份", "settings.backup.button": "下载备份",

View File

@@ -22,6 +22,14 @@ class Condition:
return AlwaysCondition.from_dict(data) return AlwaysCondition.from_dict(data)
if ct == "application": if ct == "application":
return ApplicationCondition.from_dict(data) return ApplicationCondition.from_dict(data)
if ct == "time_of_day":
return TimeOfDayCondition.from_dict(data)
if ct == "system_idle":
return SystemIdleCondition.from_dict(data)
if ct == "display_state":
return DisplayStateCondition.from_dict(data)
if ct == "mqtt":
return MQTTCondition.from_dict(data)
raise ValueError(f"Unknown condition type: {ct}") raise ValueError(f"Unknown condition type: {ct}")
@@ -58,6 +66,98 @@ class ApplicationCondition(Condition):
) )
@dataclass
class TimeOfDayCondition(Condition):
"""Activate during a specific time range (server local time).
Supports overnight ranges: if start_time > end_time, the range wraps
around midnight (e.g. 22:00 → 06:00).
"""
condition_type: str = "time_of_day"
start_time: str = "00:00" # HH:MM
end_time: str = "23:59" # HH:MM
def to_dict(self) -> dict:
d = super().to_dict()
d["start_time"] = self.start_time
d["end_time"] = self.end_time
return d
@classmethod
def from_dict(cls, data: dict) -> "TimeOfDayCondition":
return cls(
start_time=data.get("start_time", "00:00"),
end_time=data.get("end_time", "23:59"),
)
@dataclass
class SystemIdleCondition(Condition):
"""Activate based on system idle time (keyboard/mouse inactivity)."""
condition_type: str = "system_idle"
idle_minutes: int = 5
when_idle: bool = True # True = active when idle; False = active when NOT idle
def to_dict(self) -> dict:
d = super().to_dict()
d["idle_minutes"] = self.idle_minutes
d["when_idle"] = self.when_idle
return d
@classmethod
def from_dict(cls, data: dict) -> "SystemIdleCondition":
return cls(
idle_minutes=data.get("idle_minutes", 5),
when_idle=data.get("when_idle", True),
)
@dataclass
class DisplayStateCondition(Condition):
"""Activate based on display/monitor power state."""
condition_type: str = "display_state"
state: str = "on" # "on" | "off"
def to_dict(self) -> dict:
d = super().to_dict()
d["state"] = self.state
return d
@classmethod
def from_dict(cls, data: dict) -> "DisplayStateCondition":
return cls(
state=data.get("state", "on"),
)
@dataclass
class MQTTCondition(Condition):
"""Activate based on an MQTT topic value."""
condition_type: str = "mqtt"
topic: str = ""
payload: str = ""
match_mode: str = "exact" # "exact" | "contains" | "regex"
def to_dict(self) -> dict:
d = super().to_dict()
d["topic"] = self.topic
d["payload"] = self.payload
d["match_mode"] = self.match_mode
return d
@classmethod
def from_dict(cls, data: dict) -> "MQTTCondition":
return cls(
topic=data.get("topic", ""),
payload=data.get("payload", ""),
match_mode=data.get("match_mode", "exact"),
)
@dataclass @dataclass
class Profile: class Profile:
"""Automation profile that activates targets based on conditions.""" """Automation profile that activates targets based on conditions."""

View File

@@ -0,0 +1,125 @@
"""Scene preset data models — snapshot of current system state."""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Dict, List, Optional
@dataclass
class TargetSnapshot:
"""Snapshot of a single target's mutable state."""
target_id: str
running: bool = False
color_strip_source_id: str = ""
brightness_value_source_id: str = ""
fps: int = 30
auto_start: bool = False
def to_dict(self) -> dict:
return {
"target_id": self.target_id,
"running": self.running,
"color_strip_source_id": self.color_strip_source_id,
"brightness_value_source_id": self.brightness_value_source_id,
"fps": self.fps,
"auto_start": self.auto_start,
}
@classmethod
def from_dict(cls, data: dict) -> "TargetSnapshot":
return cls(
target_id=data["target_id"],
running=data.get("running", False),
color_strip_source_id=data.get("color_strip_source_id", ""),
brightness_value_source_id=data.get("brightness_value_source_id", ""),
fps=data.get("fps", 30),
auto_start=data.get("auto_start", False),
)
@dataclass
class DeviceBrightnessSnapshot:
"""Snapshot of a device's software brightness."""
device_id: str
software_brightness: int = 255
def to_dict(self) -> dict:
return {
"device_id": self.device_id,
"software_brightness": self.software_brightness,
}
@classmethod
def from_dict(cls, data: dict) -> "DeviceBrightnessSnapshot":
return cls(
device_id=data["device_id"],
software_brightness=data.get("software_brightness", 255),
)
@dataclass
class ProfileSnapshot:
"""Snapshot of a profile's enabled state."""
profile_id: str
enabled: bool = True
def to_dict(self) -> dict:
return {
"profile_id": self.profile_id,
"enabled": self.enabled,
}
@classmethod
def from_dict(cls, data: dict) -> "ProfileSnapshot":
return cls(
profile_id=data["profile_id"],
enabled=data.get("enabled", True),
)
@dataclass
class ScenePreset:
"""A named snapshot of system state that can be restored."""
id: str
name: str
description: str = ""
color: str = "#4fc3f7" # accent color for the card
targets: List[TargetSnapshot] = field(default_factory=list)
devices: List[DeviceBrightnessSnapshot] = field(default_factory=list)
profiles: List[ProfileSnapshot] = field(default_factory=list)
order: int = 0
created_at: datetime = field(default_factory=datetime.utcnow)
updated_at: datetime = field(default_factory=datetime.utcnow)
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"description": self.description,
"color": self.color,
"targets": [t.to_dict() for t in self.targets],
"devices": [d.to_dict() for d in self.devices],
"profiles": [p.to_dict() for p in self.profiles],
"order": self.order,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
@classmethod
def from_dict(cls, data: dict) -> "ScenePreset":
return cls(
id=data["id"],
name=data["name"],
description=data.get("description", ""),
color=data.get("color", "#4fc3f7"),
targets=[TargetSnapshot.from_dict(t) for t in data.get("targets", [])],
devices=[DeviceBrightnessSnapshot.from_dict(d) for d in data.get("devices", [])],
profiles=[ProfileSnapshot.from_dict(p) for p in data.get("profiles", [])],
order=data.get("order", 0),
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
)

View File

@@ -0,0 +1,134 @@
"""Scene preset storage using JSON files."""
import json
import uuid
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
from wled_controller.storage.scene_preset import ScenePreset
from wled_controller.utils import atomic_write_json, get_logger
logger = get_logger(__name__)
class ScenePresetStore:
"""Persistent storage for scene presets."""
def __init__(self, file_path: str):
self.file_path = Path(file_path)
self._presets: Dict[str, ScenePreset] = {}
self._load()
def _load(self) -> None:
if not self.file_path.exists():
return
try:
with open(self.file_path, "r", encoding="utf-8") as f:
data = json.load(f)
presets_data = data.get("scene_presets", {})
loaded = 0
for preset_id, preset_dict in presets_data.items():
try:
preset = ScenePreset.from_dict(preset_dict)
self._presets[preset_id] = preset
loaded += 1
except Exception as e:
logger.error(f"Failed to load scene preset {preset_id}: {e}", exc_info=True)
if loaded > 0:
logger.info(f"Loaded {loaded} scene presets from storage")
except Exception as e:
logger.error(f"Failed to load scene presets from {self.file_path}: {e}")
raise
logger.info(f"Scene preset store initialized with {len(self._presets)} presets")
def _save(self) -> None:
try:
data = {
"version": "1.0.0",
"scene_presets": {
pid: p.to_dict() for pid, p in self._presets.items()
},
}
atomic_write_json(self.file_path, data)
except Exception as e:
logger.error(f"Failed to save scene presets to {self.file_path}: {e}")
raise
def get_all_presets(self) -> List[ScenePreset]:
return sorted(self._presets.values(), key=lambda p: p.order)
def get_preset(self, preset_id: str) -> ScenePreset:
if preset_id not in self._presets:
raise ValueError(f"Scene preset not found: {preset_id}")
return self._presets[preset_id]
def create_preset(self, preset: ScenePreset) -> ScenePreset:
for p in self._presets.values():
if p.name == preset.name:
raise ValueError(f"Scene preset with name '{preset.name}' already exists")
self._presets[preset.id] = preset
self._save()
logger.info(f"Created scene preset: {preset.name} ({preset.id})")
return preset
def update_preset(
self,
preset_id: str,
name: Optional[str] = None,
description: Optional[str] = None,
color: Optional[str] = None,
order: Optional[int] = None,
) -> ScenePreset:
if preset_id not in self._presets:
raise ValueError(f"Scene preset not found: {preset_id}")
preset = self._presets[preset_id]
if name is not None:
for pid, p in self._presets.items():
if pid != preset_id and p.name == name:
raise ValueError(f"Scene preset with name '{name}' already exists")
preset.name = name
if description is not None:
preset.description = description
if color is not None:
preset.color = color
if order is not None:
preset.order = order
preset.updated_at = datetime.utcnow()
self._save()
logger.info(f"Updated scene preset: {preset_id}")
return preset
def recapture_preset(self, preset_id: str, preset: ScenePreset) -> ScenePreset:
"""Replace snapshot data of an existing preset (recapture current state)."""
if preset_id not in self._presets:
raise ValueError(f"Scene preset not found: {preset_id}")
existing = self._presets[preset_id]
existing.targets = preset.targets
existing.devices = preset.devices
existing.profiles = preset.profiles
existing.updated_at = datetime.utcnow()
self._save()
logger.info(f"Recaptured scene preset: {preset_id}")
return existing
def delete_preset(self, preset_id: str) -> None:
if preset_id not in self._presets:
raise ValueError(f"Scene preset not found: {preset_id}")
del self._presets[preset_id]
self._save()
logger.info(f"Deleted scene preset: {preset_id}")
def count(self) -> int:
return len(self._presets)

View File

@@ -85,6 +85,7 @@
<button class="tab-btn" data-tab="profiles" onclick="switchTab('profiles')" role="tab" aria-selected="false" aria-controls="tab-profiles" id="tab-btn-profiles" title="Ctrl+2"><svg class="icon" viewBox="0 0 24 24"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/></svg> <span data-i18n="profiles.title">Profiles</span><span class="tab-badge" id="tab-badge-profiles" style="display:none"></span></button> <button class="tab-btn" data-tab="profiles" onclick="switchTab('profiles')" role="tab" aria-selected="false" aria-controls="tab-profiles" id="tab-btn-profiles" title="Ctrl+2"><svg class="icon" viewBox="0 0 24 24"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/></svg> <span data-i18n="profiles.title">Profiles</span><span class="tab-badge" id="tab-badge-profiles" style="display:none"></span></button>
<button class="tab-btn" data-tab="targets" onclick="switchTab('targets')" role="tab" aria-selected="false" aria-controls="tab-targets" id="tab-btn-targets" title="Ctrl+3"><svg class="icon" viewBox="0 0 24 24"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg> <span data-i18n="targets.title">Targets</span><span class="tab-badge" id="tab-badge-targets" style="display:none"></span></button> <button class="tab-btn" data-tab="targets" onclick="switchTab('targets')" role="tab" aria-selected="false" aria-controls="tab-targets" id="tab-btn-targets" title="Ctrl+3"><svg class="icon" viewBox="0 0 24 24"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg> <span data-i18n="targets.title">Targets</span><span class="tab-badge" id="tab-badge-targets" style="display:none"></span></button>
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')" role="tab" aria-selected="false" aria-controls="tab-streams" id="tab-btn-streams" title="Ctrl+4"><svg class="icon" viewBox="0 0 24 24"><path d="m17 2-5 5-5-5"/><rect width="20" height="15" x="2" y="7" rx="2"/></svg> <span data-i18n="streams.title">Sources</span></button> <button class="tab-btn" data-tab="streams" onclick="switchTab('streams')" role="tab" aria-selected="false" aria-controls="tab-streams" id="tab-btn-streams" title="Ctrl+4"><svg class="icon" viewBox="0 0 24 24"><path d="m17 2-5 5-5-5"/><rect width="20" height="15" x="2" y="7" rx="2"/></svg> <span data-i18n="streams.title">Sources</span></button>
<button class="tab-btn" data-tab="scenes" onclick="switchTab('scenes')" role="tab" aria-selected="false" aria-controls="tab-scenes" id="tab-btn-scenes" title="Ctrl+5"><svg class="icon" viewBox="0 0 24 24"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg> <span data-i18n="scenes.title">Scenes</span><span class="tab-badge" id="tab-badge-scenes" style="display:none"></span></button>
</div> </div>
<div class="tab-panel" id="tab-dashboard" role="tabpanel" aria-labelledby="tab-btn-dashboard"> <div class="tab-panel" id="tab-dashboard" role="tabpanel" aria-labelledby="tab-btn-dashboard">
@@ -111,6 +112,12 @@
</div> </div>
</div> </div>
<div class="tab-panel" id="tab-scenes" role="tabpanel" aria-labelledby="tab-btn-scenes">
<div id="scenes-content">
<div class="loading-spinner"></div>
</div>
</div>
<script> <script>
// Apply saved tab immediately during parse to prevent visible jump // Apply saved tab immediately during parse to prevent visible jump
(function() { (function() {
@@ -153,6 +160,7 @@
{% include 'modals/stream.html' %} {% include 'modals/stream.html' %}
{% include 'modals/pp-template.html' %} {% include 'modals/pp-template.html' %}
{% include 'modals/profile-editor.html' %} {% include 'modals/profile-editor.html' %}
{% include 'modals/scene-preset-editor.html' %}
{% include 'modals/audio-source-editor.html' %} {% include 'modals/audio-source-editor.html' %}
{% include 'modals/test-audio-source.html' %} {% include 'modals/test-audio-source.html' %}
{% include 'modals/audio-template.html' %} {% include 'modals/audio-template.html' %}

View File

@@ -30,6 +30,7 @@
<option value="wled">WLED</option> <option value="wled">WLED</option>
<option value="adalight">Adalight</option> <option value="adalight">Adalight</option>
<option value="ambiled">AmbiLED</option> <option value="ambiled">AmbiLED</option>
<option value="mqtt">MQTT</option>
<option value="mock">Mock</option> <option value="mock">Mock</option>
</select> </select>
</div> </div>

View File

@@ -0,0 +1,47 @@
<!-- Scene Preset Editor Modal -->
<div id="scene-preset-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="scene-preset-editor-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="scene-preset-editor-title"><svg class="icon" viewBox="0 0 24 24"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg> <span data-i18n="scenes.add">Capture Scene</span></h2>
<button class="modal-close-btn" onclick="closeScenePresetEditor()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<form id="scene-preset-editor-form">
<input type="hidden" id="scene-preset-editor-id">
<div class="form-group">
<div class="label-row">
<label for="scene-preset-editor-name" data-i18n="scenes.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="scenes.name.hint">A descriptive name for this scene preset</small>
<input type="text" id="scene-preset-editor-name" required>
</div>
<div class="form-group">
<div class="label-row">
<label for="scene-preset-editor-description" data-i18n="scenes.description">Description:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="scenes.description.hint">Optional description of what this scene does</small>
<input type="text" id="scene-preset-editor-description">
</div>
<div class="form-group">
<div class="label-row">
<label for="scene-preset-editor-color" data-i18n="scenes.color">Card Color:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="scenes.color.hint">Accent color for the scene card on the dashboard</small>
<input type="color" id="scene-preset-editor-color" value="#4fc3f7">
</div>
<div id="scene-preset-editor-error" class="error-message" style="display: none;"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeScenePresetEditor()" title="Cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveScenePreset()" title="Save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div>
</div>
</div>