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

@@ -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.value_sources import router as value_sources_router
from .routes.profiles import router as profiles_router
from .routes.scene_presets import router as scene_presets_router
router = APIRouter()
router.include_router(system_router)
@@ -30,5 +31,6 @@ router.include_router(audio_templates_router)
router.include_router(value_sources_router)
router.include_router(picture_targets_router)
router.include_router(profiles_router)
router.include_router(scene_presets_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.value_source_store import ValueSourceStore
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.backup.auto_backup import AutoBackupEngine
@@ -29,6 +30,7 @@ _audio_template_store: AudioTemplateStore | None = None
_value_source_store: ValueSourceStore | None = None
_processor_manager: ProcessorManager | None = None
_profile_store: ProfileStore | None = None
_scene_preset_store: ScenePresetStore | None = None
_profile_engine: ProfileEngine | None = None
@@ -116,6 +118,13 @@ def get_profile_store() -> ProfileStore:
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:
"""Get profile engine dependency."""
if _profile_engine is None:
@@ -143,6 +152,7 @@ def init_dependencies(
audio_template_store: AudioTemplateStore | None = None,
value_source_store: ValueSourceStore | None = None,
profile_store: ProfileStore | None = None,
scene_preset_store: ScenePresetStore | None = None,
profile_engine: ProfileEngine | None = None,
auto_backup_engine: AutoBackupEngine | None = None,
):
@@ -150,7 +160,7 @@ def init_dependencies(
global _device_store, _template_store, _processor_manager
global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_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
_template_store = template_store
_processor_manager = processor_manager
@@ -163,5 +173,6 @@ def init_dependencies(
_audio_template_store = audio_template_store
_value_source_store = value_source_store
_profile_store = profile_store
_scene_preset_store = scene_preset_store
_profile_engine = profile_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.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.utils import get_logger
@@ -28,11 +36,33 @@ router = APIRouter()
# ===== Helpers =====
def _condition_from_schema(s: ConditionSchema) -> Condition:
if s.condition_type == "always":
return AlwaysCondition()
if s.condition_type == "application":
return ApplicationCondition(
apps=s.apps or [],
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}")

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",
"value_sources": "value_sources_file",
"profiles": "profiles_file",
"scene_presets": "scene_presets_file",
}
_SERVER_DIR = Path(__file__).resolve().parents[4]

View File

@@ -10,8 +10,21 @@ class ConditionSchema(BaseModel):
"""A single condition within a profile."""
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)")
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):

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"
value_sources_file: str = "data/value_sources.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):
@@ -61,6 +74,7 @@ class Config(BaseSettings):
server: ServerConfig = Field(default_factory=ServerConfig)
auth: AuthConfig = Field(default_factory=AuthConfig)
storage: StorageConfig = Field(default_factory=StorageConfig)
mqtt: MQTTConfig = Field(default_factory=MQTTConfig)
logging: LoggingConfig = Field(default_factory=LoggingConfig)
@classmethod

View File

@@ -282,5 +282,8 @@ def _register_builtin_providers():
from wled_controller.core.devices.mock_provider import MockDeviceProvider
register_provider(MockDeviceProvider())
from wled_controller.core.devices.mqtt_provider import MQTTDeviceProvider
register_provider(MQTTDeviceProvider())
_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 os
import sys
import threading
from typing import Optional, Set
from wled_controller.utils import get_logger
@@ -21,6 +22,148 @@ _IS_WINDOWS = sys.platform == "win32"
class PlatformDetector:
"""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]:
"""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."""
import asyncio
import re
from datetime import datetime, timezone
from typing import Dict, Optional, Set
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.utils import get_logger
@@ -15,11 +25,13 @@ logger = get_logger(__name__)
class ProfileEngine:
"""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._manager = processor_manager
self._poll_interval = poll_interval
self._detector = PlatformDetector()
self._mqtt_service = mqtt_service
self._task: Optional[asyncio.Task] = None
self._eval_lock = asyncio.Lock()
@@ -70,11 +82,12 @@ class ProfileEngine:
def _detect_all_sync(
self, needs_running: bool, needs_topmost: bool, needs_fullscreen: bool,
needs_idle: bool, needs_display_state: bool,
) -> tuple:
"""Run all platform detection in a single thread call.
Batching the three detection calls into one executor submission reduces
event-loop wake-ups from 3 to 1, minimising asyncio.sleep() jitter in
Batching detection calls into one executor submission reduces
event-loop wake-ups, minimising asyncio.sleep() jitter in
latency-sensitive processing loops.
"""
running_procs = self._detector._get_running_processes_sync() if needs_running else set()
@@ -83,7 +96,9 @@ class ProfileEngine:
else:
topmost_proc, topmost_fullscreen = None, False
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:
profiles = self._store.get_all_profiles()
@@ -95,23 +110,30 @@ class ProfileEngine:
# Determine which detection methods are actually needed
match_types_used: set = set()
needs_idle = False
needs_display_state = False
for p in profiles:
if p.enabled:
for c in p.conditions:
mt = getattr(c, "match_type", "running")
match_types_used.add(mt)
if isinstance(c, ApplicationCondition):
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_topmost = bool(match_types_used & {"topmost", "topmost_fullscreen"})
needs_fullscreen = "fullscreen" in match_types_used
# Single executor call for all platform detection (avoids 3 separate
# event-loop roundtrips that can jitter processing-loop timing)
# Single executor call for all platform detection
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(
None, self._detect_all_sync,
needs_running, needs_topmost, needs_fullscreen,
needs_idle, needs_display_state,
)
)
@@ -121,7 +143,9 @@ class ProfileEngine:
should_be_active = (
profile.enabled
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
@@ -143,9 +167,13 @@ class ProfileEngine:
self, profile: Profile, running_procs: Set[str],
topmost_proc: Optional[str], topmost_fullscreen: bool,
fullscreen_procs: Set[str],
idle_seconds: Optional[float], display_state: Optional[str],
) -> bool:
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
]
@@ -157,11 +185,63 @@ class ProfileEngine:
self, condition: Condition, running_procs: Set[str],
topmost_proc: Optional[str], topmost_fullscreen: bool,
fullscreen_procs: Set[str],
idle_seconds: Optional[float], display_state: Optional[str],
) -> bool:
if isinstance(condition, AlwaysCondition):
return True
if isinstance(condition, ApplicationCondition):
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
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
from wled_controller.storage.value_source_store import ValueSourceStore
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.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.api.routes.system import STORE_MAP
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)
value_source_store = ValueSourceStore(config.storage.value_sources_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
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("All API requests require valid Bearer token authentication")
# Create profile engine (needs processor_manager)
profile_engine = ProfileEngine(profile_store, processor_manager)
# Create MQTT service (shared broker connection)
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
auto_backup_engine = AutoBackupEngine(
@@ -123,6 +131,7 @@ async def lifespan(app: FastAPI):
audio_template_store=audio_template_store,
value_source_store=value_source_store,
profile_store=profile_store,
scene_preset_store=scene_preset_store,
profile_engine=profile_engine,
auto_backup_engine=auto_backup_engine,
)
@@ -162,6 +171,9 @@ async def lifespan(app: FastAPI):
# Start background health monitoring for all devices
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)
await profile_engine.start()
@@ -206,6 +218,12 @@ async def lifespan(app: FastAPI):
except Exception as 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
app = FastAPI(
title="LED Grab",

View File

@@ -82,6 +82,11 @@ import {
toggleProfileEnabled, toggleProfileTargets, deleteProfile,
expandAllProfileSections, collapseAllProfileSections,
} 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
import {
@@ -307,6 +312,18 @@ Object.assign(window, {
expandAllProfileSections,
collapseAllProfileSections,
// scene presets
loadScenes,
expandAllSceneSections,
collapseAllSceneSections,
openScenePresetCapture,
editScenePreset,
saveScenePreset,
closeScenePresetEditor,
activateScenePreset,
recaptureScenePreset,
deleteScenePreset,
// device-discovery
onDeviceTypeChanged,
updateBaudFpsHint,
@@ -422,9 +439,9 @@ document.addEventListener('keydown', (e) => {
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) {
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];
if (tab) {
e.preventDefault();

View File

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

View File

@@ -7,7 +7,7 @@ import { t } from './i18n.js';
import { navigateToCard } from './navigation.js';
import {
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,
} from './icons.js';
@@ -31,7 +31,7 @@ function _mapEntities(data, mapFn) {
}
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 = [];
_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;
}
@@ -114,6 +119,7 @@ const _responseKeys = [
['/audio-sources', 'sources'],
['/value-sources', 'sources'],
['/picture-sources', 'streams'],
['/scene-presets', 'presets'],
];
async function _fetchAllEntities() {
@@ -132,7 +138,7 @@ async function _fetchAllEntities() {
const _groupOrder = [
'devices', 'targets', 'kc_targets', 'css', 'profiles',
'streams', 'capture_templates', 'pp_templates', 'pattern_templates',
'audio', 'value',
'audio', 'value', 'scenes',
];
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_DOWNLOAD = _svg(P.download);
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 === 'streams' && typeof window.loadPictureSources === 'function') window.loadPictureSources();
else if (tab === 'targets' && typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
else if (tab === 'scenes' && typeof window.loadScenes === 'function') window.loadScenes();
}
function _showDimOverlay(duration) {

View File

@@ -13,6 +13,7 @@ import {
ICON_TARGET, ICON_PROFILE, ICON_CLOCK, ICON_WARNING, ICON_OK,
ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_AUTOSTART, ICON_HELP,
} from '../core/icons.js';
import { loadScenePresets, renderScenePresetsSection } from './scene-presets.js';
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
const MAX_FPS_SAMPLES = 120;
@@ -373,13 +374,14 @@ export async function loadDashboard(forceFullRender = false) {
try {
// 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('/profiles').catch(() => null),
fetchWithAuth('/devices').catch(() => null),
fetchWithAuth('/color-strip-sources').catch(() => null),
fetchWithAuth('/picture-targets/batch/states').catch(() => null),
fetchWithAuth('/picture-targets/batch/metrics').catch(() => null),
loadScenePresets(),
]);
const targetsData = await targetsResp.json();
@@ -401,7 +403,7 @@ export async function loadDashboard(forceFullRender = false) {
let runningIds = [];
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>`;
} else {
const enriched = targets.map(target => ({
@@ -496,6 +498,17 @@ export async function loadDashboard(forceFullRender = false) {
</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) {
let targetsInner = '';

View File

@@ -6,7 +6,7 @@ import {
_discoveryScanRunning, set_discoveryScanRunning,
_discoveryCache, set_discoveryCache,
} 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 { showToast } from '../core/ui.js';
import { Modal } from '../core/modal.js';
@@ -43,7 +43,29 @@ export function onDeviceTypeChanged() {
const ledTypeGroup = document.getElementById('device-led-type-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';
urlInput.removeAttribute('required');
serialGroup.style.display = 'none';
@@ -53,6 +75,7 @@ export function onDeviceTypeChanged() {
if (ledTypeGroup) ledTypeGroup.style.display = '';
if (sendLatencyGroup) sendLatencyGroup.style.display = '';
if (discoverySection) discoverySection.style.display = 'none';
if (scanBtn) scanBtn.style.display = 'none';
} else if (isSerialDevice(deviceType)) {
urlGroup.style.display = 'none';
urlInput.removeAttribute('required');
@@ -62,6 +85,7 @@ export function onDeviceTypeChanged() {
baudRateGroup.style.display = '';
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
if (scanBtn) scanBtn.style.display = 'none';
// Hide discovery list — serial port dropdown replaces it
if (discoverySection) discoverySection.style.display = 'none';
// Populate from cache or show placeholder (lazy-load on focus)
@@ -85,6 +109,11 @@ export function onDeviceTypeChanged() {
baudRateGroup.style.display = 'none';
if (ledTypeGroup) ledTypeGroup.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
if (deviceType in _discoveryCache) {
_renderDiscoveryList();
@@ -316,6 +345,11 @@ export async function handleAddDevice(event) {
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)) {
error.textContent = t('device_discovery.error.fill_all_fields');
error.style.display = 'block';

View File

@@ -5,7 +5,7 @@
import {
_deviceBrightnessCache, updateDeviceBrightness,
} 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 { showToast, showConfirm } from '../core/ui.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;
const isMock = isMockDevice(device.device_type);
const isMqtt = isMqttDevice(device.device_type);
const urlGroup = document.getElementById('settings-url-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) {
urlGroup.style.display = 'none';
document.getElementById('settings-device-url').removeAttribute('required');
urlInput.removeAttribute('required');
serialGroup.style.display = 'none';
} else if (isAdalight) {
urlGroup.style.display = 'none';
document.getElementById('settings-device-url').removeAttribute('required');
urlInput.removeAttribute('required');
serialGroup.style.display = '';
_populateSettingsSerialPorts(device.url);
} else {
urlGroup.style.display = '';
document.getElementById('settings-device-url').setAttribute('required', '');
document.getElementById('settings-device-url').value = device.url;
urlInput.setAttribute('required', '');
urlInput.value = device.url;
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');

View File

@@ -9,7 +9,7 @@ import { showToast, showConfirm, setTabRefreshing } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import { CardSection } from '../core/card-sections.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 {
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'));
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>`;
});
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">
<option value="always" ${condType === 'always' ? 'selected' : ''}>${t('profiles.condition.always')}</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>
<button type="button" class="btn-remove-condition" onclick="this.closest('.profile-condition-row').remove()" title="Remove">&#x2715;</button>
</div>
@@ -273,6 +291,81 @@ function addProfileConditionRow(condition) {
container.innerHTML = `<small class="condition-always-desc">${t('profiles.condition.always.hint')}</small>`;
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 matchType = data.match_type || 'running';
container.innerHTML = `
@@ -308,7 +401,7 @@ function addProfileConditionRow(condition) {
renderFields(condType, condition);
typeSelect.addEventListener('change', () => {
renderFields(typeSelect.value, { apps: [], match_type: 'running' });
renderFields(typeSelect.value, {});
});
list.appendChild(row);
@@ -382,6 +475,30 @@ function getProfileEditorConditions() {
const condType = typeSelect ? typeSelect.value : 'application';
if (condType === '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 {
const matchType = row.querySelector('.condition-match-type').value;
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 _activeTab = null;
export function switchTab(name, { updateHash = true, skipLoad = false } = {}) {
if (_activeTab === name) return;
_activeTab = name;
document.querySelectorAll('.tab-btn').forEach(btn => {
const isActive = btn.dataset.tab === name;
btn.classList.toggle('active', isActive);
@@ -56,6 +60,8 @@ export function switchTab(name, { updateHash = true, skipLoad = false } = {}) {
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
} else if (name === 'profiles') {
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.send_latency": "Send Latency (ms):",
"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.name": "Device Name:",
"device.name.placeholder": "Living Room TV",
@@ -529,6 +532,7 @@
"dashboard.stop_all": "Stop All",
"dashboard.failed": "Failed to load dashboard",
"dashboard.section.profiles": "Profiles",
"dashboard.section.scenes": "Scene Presets",
"dashboard.targets": "Targets",
"dashboard.section.performance": "System Performance",
"dashboard.perf.cpu": "CPU",
@@ -568,6 +572,27 @@
"profiles.condition.application.match_type.topmost": "Topmost (foreground)",
"profiles.condition.application.match_type.topmost_fullscreen": "Topmost + 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.hint": "Targets to start when this profile activates",
"profiles.targets.empty": "No targets available",
@@ -586,6 +611,36 @@
"profiles.error.name_required": "Name is required",
"profiles.toggle_all.start": "Start 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.toggle.enabled": "Auto-start enabled",
"autostart.toggle.disabled": "Auto-start disabled",
@@ -961,6 +1016,7 @@
"search.group.pattern_templates": "Pattern Templates",
"search.group.audio": "Audio Sources",
"search.group.value": "Value Sources",
"search.group.scenes": "Scene Presets",
"settings.backup.label": "Backup Configuration",
"settings.backup.hint": "Download all configuration (devices, targets, streams, templates, profiles) as a single JSON file.",
"settings.backup.button": "Download Backup",

View File

@@ -128,6 +128,9 @@
"device.led_type.hint": "RGB (3 канала) или RGBW (4 канала с выделенным белым)",
"device.send_latency": "Задержка отправки (мс):",
"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.name": "Имя Устройства:",
"device.name.placeholder": "ТВ в Гостиной",
@@ -529,6 +532,7 @@
"dashboard.stop_all": "Остановить все",
"dashboard.failed": "Не удалось загрузить обзор",
"dashboard.section.profiles": "Профили",
"dashboard.section.scenes": "Пресеты сцен",
"dashboard.targets": "Цели",
"dashboard.section.performance": "Производительность системы",
"dashboard.perf.cpu": "ЦП",
@@ -568,6 +572,27 @@
"profiles.condition.application.match_type.topmost": "На переднем плане",
"profiles.condition.application.match_type.topmost_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.hint": "Цели для запуска при активации профиля",
"profiles.targets.empty": "Нет доступных целей",
@@ -586,6 +611,36 @@
"profiles.error.name_required": "Введите название",
"profiles.toggle_all.start": "Запустить все цели",
"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.toggle.enabled": "Автозапуск включён",
"autostart.toggle.disabled": "Автозапуск отключён",
@@ -961,6 +1016,7 @@
"search.group.pattern_templates": "Шаблоны паттернов",
"search.group.audio": "Аудиоисточники",
"search.group.value": "Источники значений",
"search.group.scenes": "Пресеты сцен",
"settings.backup.label": "Резервное копирование",
"settings.backup.hint": "Скачать всю конфигурацию (устройства, цели, потоки, шаблоны, профили) в виде одного JSON-файла.",
"settings.backup.button": "Скачать резервную копию",

View File

@@ -128,6 +128,9 @@
"device.led_type.hint": "RGB3通道或 RGBW4通道带独立白色",
"device.send_latency": "发送延迟(毫秒):",
"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.name": "设备名称:",
"device.name.placeholder": "客厅电视",
@@ -529,6 +532,7 @@
"dashboard.stop_all": "全部停止",
"dashboard.failed": "加载仪表盘失败",
"dashboard.section.profiles": "配置文件",
"dashboard.section.scenes": "场景预设",
"dashboard.targets": "目标",
"dashboard.section.performance": "系统性能",
"dashboard.perf.cpu": "CPU",
@@ -568,6 +572,27 @@
"profiles.condition.application.match_type.topmost": "最前(前台)",
"profiles.condition.application.match_type.topmost_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.hint": "配置文件激活时要启动的目标",
"profiles.targets.empty": "没有可用的目标",
@@ -586,6 +611,36 @@
"profiles.error.name_required": "名称为必填项",
"profiles.toggle_all.start": "启动所有目标",
"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.toggle.enabled": "自动启动已启用",
"autostart.toggle.disabled": "自动启动已禁用",
@@ -961,6 +1016,7 @@
"search.group.pattern_templates": "图案模板",
"search.group.audio": "音频源",
"search.group.value": "值源",
"search.group.scenes": "场景预设",
"settings.backup.label": "备份配置",
"settings.backup.hint": "将所有配置(设备、目标、流、模板、配置文件)下载为单个 JSON 文件。",
"settings.backup.button": "下载备份",

View File

@@ -22,6 +22,14 @@ class Condition:
return AlwaysCondition.from_dict(data)
if ct == "application":
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}")
@@ -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
class Profile:
"""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="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="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 class="tab-panel" id="tab-dashboard" role="tabpanel" aria-labelledby="tab-btn-dashboard">
@@ -111,6 +112,12 @@
</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>
// Apply saved tab immediately during parse to prevent visible jump
(function() {
@@ -153,6 +160,7 @@
{% include 'modals/stream.html' %}
{% include 'modals/pp-template.html' %}
{% include 'modals/profile-editor.html' %}
{% include 'modals/scene-preset-editor.html' %}
{% include 'modals/audio-source-editor.html' %}
{% include 'modals/test-audio-source.html' %}
{% include 'modals/audio-template.html' %}

View File

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