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:
387
server/src/wled_controller/api/routes/scene_presets.py
Normal file
387
server/src/wled_controller/api/routes/scene_presets.py
Normal 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)
|
||||
Reference in New Issue
Block a user