diff --git a/server/src/wled_controller/api/routes/profiles.py b/server/src/wled_controller/api/routes/profiles.py index 61721d8..9a6dcbb 100644 --- a/server/src/wled_controller/api/routes/profiles.py +++ b/server/src/wled_controller/api/routes/profiles.py @@ -4,9 +4,9 @@ from fastapi import APIRouter, Depends, HTTPException from wled_controller.api.auth import AuthRequired from wled_controller.api.dependencies import ( - get_picture_target_store, get_profile_engine, get_profile_store, + get_scene_preset_store, ) from wled_controller.api.schemas.profiles import ( ConditionSchema, @@ -16,7 +16,6 @@ from wled_controller.api.schemas.profiles import ( ProfileUpdate, ) from wled_controller.core.profiles.profile_engine import ProfileEngine -from wled_controller.storage.picture_target_store import PictureTargetStore from wled_controller.storage.profile import ( AlwaysCondition, ApplicationCondition, @@ -27,6 +26,7 @@ from wled_controller.storage.profile import ( TimeOfDayCondition, ) from wled_controller.storage.profile_store import ProfileStore +from wled_controller.storage.scene_preset_store import ScenePresetStore from wled_controller.utils import get_logger logger = get_logger(__name__) @@ -79,9 +79,10 @@ def _profile_to_response(profile, engine: ProfileEngine) -> ProfileResponse: enabled=profile.enabled, condition_logic=profile.condition_logic, conditions=[_condition_to_schema(c) for c in profile.conditions], - target_ids=profile.target_ids, + scene_preset_id=profile.scene_preset_id, + deactivation_mode=profile.deactivation_mode, + deactivation_scene_preset_id=profile.deactivation_scene_preset_id, is_active=state["is_active"], - active_target_ids=state["active_target_ids"], last_activated_at=state.get("last_activated_at"), last_deactivated_at=state.get("last_deactivated_at"), created_at=profile.created_at, @@ -94,12 +95,21 @@ def _validate_condition_logic(logic: str) -> None: raise HTTPException(status_code=400, detail=f"Invalid condition_logic: {logic}. Must be 'or' or 'and'.") -def _validate_target_ids(target_ids: list, target_store: PictureTargetStore) -> None: - for tid in target_ids: - try: - target_store.get_target(tid) - except ValueError: - raise HTTPException(status_code=400, detail=f"Target not found: {tid}") +def _validate_scene_refs( + scene_preset_id: str | None, + deactivation_scene_preset_id: str | None, + scene_store: ScenePresetStore, +) -> None: + """Validate that referenced scene preset IDs exist.""" + for sid, label in [ + (scene_preset_id, "scene_preset_id"), + (deactivation_scene_preset_id, "deactivation_scene_preset_id"), + ]: + if sid is not None: + try: + scene_store.get_preset(sid) + except ValueError: + raise HTTPException(status_code=400, detail=f"Scene preset not found: {sid} ({label})") # ===== CRUD Endpoints ===== @@ -115,11 +125,11 @@ async def create_profile( _auth: AuthRequired, store: ProfileStore = Depends(get_profile_store), engine: ProfileEngine = Depends(get_profile_engine), - target_store: PictureTargetStore = Depends(get_picture_target_store), + scene_store: ScenePresetStore = Depends(get_scene_preset_store), ): """Create a new profile.""" _validate_condition_logic(data.condition_logic) - _validate_target_ids(data.target_ids, target_store) + _validate_scene_refs(data.scene_preset_id, data.deactivation_scene_preset_id, scene_store) try: conditions = [_condition_from_schema(c) for c in data.conditions] @@ -131,7 +141,9 @@ async def create_profile( enabled=data.enabled, condition_logic=data.condition_logic, conditions=conditions, - target_ids=data.target_ids, + scene_preset_id=data.scene_preset_id, + deactivation_mode=data.deactivation_mode, + deactivation_scene_preset_id=data.deactivation_scene_preset_id, ) if profile.enabled: @@ -189,13 +201,14 @@ async def update_profile( _auth: AuthRequired, store: ProfileStore = Depends(get_profile_store), engine: ProfileEngine = Depends(get_profile_engine), - target_store: PictureTargetStore = Depends(get_picture_target_store), + scene_store: ScenePresetStore = Depends(get_scene_preset_store), ): """Update a profile.""" if data.condition_logic is not None: _validate_condition_logic(data.condition_logic) - if data.target_ids is not None: - _validate_target_ids(data.target_ids, target_store) + + # Validate scene refs (only the ones being updated) + _validate_scene_refs(data.scene_preset_id, data.deactivation_scene_preset_id, scene_store) conditions = None if data.conditions is not None: @@ -209,18 +222,25 @@ async def update_profile( if data.enabled is False: await engine.deactivate_if_active(profile_id) - profile = store.update_profile( + # Build update kwargs — use sentinel for Optional[str] fields + update_kwargs = dict( profile_id=profile_id, name=data.name, enabled=data.enabled, condition_logic=data.condition_logic, conditions=conditions, - target_ids=data.target_ids, + deactivation_mode=data.deactivation_mode, ) + if data.scene_preset_id is not None: + update_kwargs["scene_preset_id"] = data.scene_preset_id + if data.deactivation_scene_preset_id is not None: + update_kwargs["deactivation_scene_preset_id"] = data.deactivation_scene_preset_id + + profile = store.update_profile(**update_kwargs) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) - # Re-evaluate immediately if profile is enabled (may have new conditions/targets) + # Re-evaluate immediately if profile is enabled (may have new conditions/scene) if profile.enabled: await engine.trigger_evaluate() diff --git a/server/src/wled_controller/api/routes/scene_presets.py b/server/src/wled_controller/api/routes/scene_presets.py index ec4c07a..69234b9 100644 --- a/server/src/wled_controller/api/routes/scene_presets.py +++ b/server/src/wled_controller/api/routes/scene_presets.py @@ -22,15 +22,14 @@ from wled_controller.api.schemas.scene_presets import ( ScenePresetUpdate, ) from wled_controller.core.processing.processor_manager import ProcessorManager +from wled_controller.core.scenes.scene_activator import ( + apply_scene_state, + capture_current_snapshot, +) 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 import ScenePreset 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 @@ -39,45 +38,6 @@ 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, @@ -124,7 +84,7 @@ async def create_scene_preset( manager: ProcessorManager = Depends(get_processor_manager), ): """Capture current state as a new scene preset.""" - targets, devices, profiles = _capture_snapshot( + targets, devices, profiles = capture_current_snapshot( target_store, device_store, profile_store, manager, ) @@ -244,7 +204,7 @@ async def recapture_scene_preset( manager: ProcessorManager = Depends(get_processor_manager), ): """Re-capture current state into an existing preset (updates snapshot).""" - targets, devices, profiles = _capture_snapshot( + targets, devices, profiles = capture_current_snapshot( target_store, device_store, profile_store, manager, ) @@ -287,101 +247,11 @@ async def activate_scene_preset( except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) - errors = [] + status, errors = await apply_scene_state( + preset, target_store, device_store, profile_store, engine, manager, + ) - # 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: + if not errors: logger.info(f"Scene preset '{preset.name}' activated successfully") return ActivateResponse(status=status, errors=errors) diff --git a/server/src/wled_controller/api/schemas/profiles.py b/server/src/wled_controller/api/schemas/profiles.py index 2af910b..fd8193c 100644 --- a/server/src/wled_controller/api/schemas/profiles.py +++ b/server/src/wled_controller/api/schemas/profiles.py @@ -34,7 +34,9 @@ class ProfileCreate(BaseModel): enabled: bool = Field(default=True, description="Whether the profile is enabled") condition_logic: str = Field(default="or", description="How conditions combine: 'or' or 'and'") conditions: List[ConditionSchema] = Field(default_factory=list, description="List of conditions") - target_ids: List[str] = Field(default_factory=list, description="Target IDs to activate") + scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate") + deactivation_mode: str = Field(default="none", description="'none', 'revert', or 'fallback_scene'") + deactivation_scene_preset_id: Optional[str] = Field(None, description="Scene preset for fallback deactivation") class ProfileUpdate(BaseModel): @@ -44,7 +46,9 @@ class ProfileUpdate(BaseModel): enabled: Optional[bool] = Field(None, description="Whether the profile is enabled") condition_logic: Optional[str] = Field(None, description="How conditions combine: 'or' or 'and'") conditions: Optional[List[ConditionSchema]] = Field(None, description="List of conditions") - target_ids: Optional[List[str]] = Field(None, description="Target IDs to activate") + scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate") + deactivation_mode: Optional[str] = Field(None, description="'none', 'revert', or 'fallback_scene'") + deactivation_scene_preset_id: Optional[str] = Field(None, description="Scene preset for fallback deactivation") class ProfileResponse(BaseModel): @@ -55,9 +59,10 @@ class ProfileResponse(BaseModel): enabled: bool = Field(description="Whether the profile is enabled") condition_logic: str = Field(description="Condition combination logic") conditions: List[ConditionSchema] = Field(description="List of conditions") - target_ids: List[str] = Field(description="Target IDs to activate") + scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate") + deactivation_mode: str = Field(default="none", description="Deactivation behavior") + deactivation_scene_preset_id: Optional[str] = Field(None, description="Fallback scene preset") is_active: bool = Field(default=False, description="Whether the profile is currently active") - active_target_ids: List[str] = Field(default_factory=list, description="Targets currently owned by this profile") last_activated_at: Optional[datetime] = Field(None, description="Last time this profile was activated") last_deactivated_at: Optional[datetime] = Field(None, description="Last time this profile was deactivated") created_at: datetime = Field(description="Creation timestamp") diff --git a/server/src/wled_controller/core/profiles/profile_engine.py b/server/src/wled_controller/core/profiles/profile_engine.py index 70b2318..58ca0bc 100644 --- a/server/src/wled_controller/core/profiles/profile_engine.py +++ b/server/src/wled_controller/core/profiles/profile_engine.py @@ -1,9 +1,9 @@ -"""Profile engine — background loop that evaluates conditions and manages targets.""" +"""Profile engine — background loop that evaluates conditions and activates scenes.""" import asyncio import re from datetime import datetime, timezone -from typing import Dict, Optional, Set +from typing import Dict, List, Optional, Set from wled_controller.core.profiles.platform_detector import PlatformDetector from wled_controller.storage.profile import ( @@ -17,27 +17,41 @@ from wled_controller.storage.profile import ( TimeOfDayCondition, ) from wled_controller.storage.profile_store import ProfileStore +from wled_controller.storage.scene_preset import ScenePreset from wled_controller.utils import get_logger logger = get_logger(__name__) class ProfileEngine: - """Evaluates profile conditions and starts/stops targets accordingly.""" + """Evaluates profile conditions and activates/deactivates scene presets.""" - def __init__(self, profile_store: ProfileStore, processor_manager, poll_interval: float = 1.0, - mqtt_service=None): + def __init__( + self, + profile_store: ProfileStore, + processor_manager, + poll_interval: float = 1.0, + mqtt_service=None, + scene_preset_store=None, + target_store=None, + device_store=None, + ): self._store = profile_store self._manager = processor_manager self._poll_interval = poll_interval self._detector = PlatformDetector() self._mqtt_service = mqtt_service + self._scene_preset_store = scene_preset_store + self._target_store = target_store + self._device_store = device_store self._task: Optional[asyncio.Task] = None self._eval_lock = asyncio.Lock() # Runtime state (not persisted) - # profile_id → set of target_ids that THIS profile started - self._active_profiles: Dict[str, Set[str]] = {} + # profile_id → True when profile is currently active + self._active_profiles: Dict[str, bool] = {} + # profile_id → snapshot captured before activation (for "revert" mode) + self._pre_activation_snapshots: Dict[str, ScenePreset] = {} # profile_id → datetime of last activation / deactivation self._last_activated: Dict[str, datetime] = {} self._last_deactivated: Dict[str, datetime] = {} @@ -274,57 +288,116 @@ class ProfileEngine: return any(app in running_procs for app in apps_lower) async def _activate_profile(self, profile: Profile) -> None: - started: Set[str] = set() - failed = False - for target_id in profile.target_ids: - try: - # Skip targets that are already running (manual or other profile) - proc = self._manager._processors.get(target_id) - if proc and proc.is_running: - continue - - await self._manager.start_processing(target_id) - started.add(target_id) - logger.info(f"Profile '{profile.name}' started target {target_id}") - except Exception as e: - failed = True - logger.warning(f"Profile '{profile.name}' failed to start target {target_id}: {e}") - - if started or not failed: - # Active: either we started targets, or all were already running - self._active_profiles[profile.id] = started + if not profile.scene_preset_id: + # No scene configured — just mark active (conditions matched but nothing to do) + self._active_profiles[profile.id] = True self._last_activated[profile.id] = datetime.now(timezone.utc) - self._fire_event(profile.id, "activated", list(started)) - logger.info(f"Profile '{profile.name}' activated ({len(started)} targets started)") + self._fire_event(profile.id, "activated") + logger.info(f"Profile '{profile.name}' activated (no scene configured)") + return + + if not self._scene_preset_store or not self._target_store or not self._device_store: + logger.warning(f"Profile '{profile.name}' matched but scene stores not available") + return + + # Load the scene preset + try: + preset = self._scene_preset_store.get_preset(profile.scene_preset_id) + except ValueError: + logger.warning(f"Profile '{profile.name}': scene preset {profile.scene_preset_id} not found") + return + + # For "revert" mode, capture current state before activating + if profile.deactivation_mode == "revert": + from wled_controller.core.scenes.scene_activator import capture_current_snapshot + targets, devices, profiles = capture_current_snapshot( + self._target_store, self._device_store, self._store, self._manager, + ) + self._pre_activation_snapshots[profile.id] = ScenePreset( + id=f"_revert_{profile.id}", + name=f"Pre-activation snapshot for {profile.name}", + targets=targets, + devices=devices, + profiles=profiles, + ) + + # Apply the scene + from wled_controller.core.scenes.scene_activator import apply_scene_state + status, errors = await apply_scene_state( + preset, self._target_store, self._device_store, self._store, + self, self._manager, skip_profiles=True, + ) + + self._active_profiles[profile.id] = True + self._last_activated[profile.id] = datetime.now(timezone.utc) + self._fire_event(profile.id, "activated") + + if errors: + logger.warning(f"Profile '{profile.name}' activated with errors: {errors}") else: - logger.debug(f"Profile '{profile.name}' matched but targets failed to start — will retry") + logger.info(f"Profile '{profile.name}' activated (scene '{preset.name}' applied)") async def _deactivate_profile(self, profile_id: str) -> None: - owned = self._active_profiles.pop(profile_id, set()) - stopped = [] + was_active = self._active_profiles.pop(profile_id, False) + if not was_active: + return - for target_id in owned: - try: - proc = self._manager._processors.get(target_id) - if proc and proc.is_running: - await self._manager.stop_processing(target_id) - stopped.append(target_id) - logger.info(f"Profile {profile_id} stopped target {target_id}") - except Exception as e: - logger.warning(f"Profile {profile_id} failed to stop target {target_id}: {e}") + # Look up the profile for deactivation settings + try: + profile = self._store.get_profile(profile_id) + except ValueError: + profile = None - if stopped: - self._last_deactivated[profile_id] = datetime.now(timezone.utc) - self._fire_event(profile_id, "deactivated", stopped) - logger.info(f"Profile {profile_id} deactivated ({len(stopped)} targets stopped)") + deactivation_mode = profile.deactivation_mode if profile else "none" - def _fire_event(self, profile_id: str, action: str, target_ids: list) -> None: + if deactivation_mode == "revert": + snapshot = self._pre_activation_snapshots.pop(profile_id, None) + if snapshot and self._target_store and self._device_store: + from wled_controller.core.scenes.scene_activator import apply_scene_state + status, errors = await apply_scene_state( + snapshot, self._target_store, self._device_store, self._store, + self, self._manager, skip_profiles=True, + ) + if errors: + logger.warning(f"Profile {profile_id} revert errors: {errors}") + else: + logger.info(f"Profile {profile_id} deactivated (reverted to previous state)") + else: + logger.warning(f"Profile {profile_id}: no snapshot available for revert") + + elif deactivation_mode == "fallback_scene": + fallback_id = profile.deactivation_scene_preset_id if profile else None + if fallback_id and self._scene_preset_store and self._target_store and self._device_store: + try: + fallback = self._scene_preset_store.get_preset(fallback_id) + from wled_controller.core.scenes.scene_activator import apply_scene_state + status, errors = await apply_scene_state( + fallback, self._target_store, self._device_store, self._store, + self, self._manager, skip_profiles=True, + ) + if errors: + logger.warning(f"Profile {profile_id} fallback errors: {errors}") + else: + logger.info(f"Profile {profile_id} deactivated (fallback scene '{fallback.name}' applied)") + except ValueError: + logger.warning(f"Profile {profile_id}: fallback scene {fallback_id} not found") + else: + logger.info(f"Profile {profile_id} deactivated (no fallback scene configured)") + else: + # "none" mode — just clear active state + logger.info(f"Profile {profile_id} deactivated") + + self._last_deactivated[profile_id] = datetime.now(timezone.utc) + self._fire_event(profile_id, "deactivated") + # Clean up any leftover snapshot + self._pre_activation_snapshots.pop(profile_id, None) + + def _fire_event(self, profile_id: str, action: str) -> None: try: self._manager._fire_event({ "type": "profile_state_changed", "profile_id": profile_id, "action": action, - "target_ids": target_ids, }) except Exception: pass @@ -334,10 +407,8 @@ class ProfileEngine: def get_profile_state(self, profile_id: str) -> dict: """Get runtime state of a single profile.""" is_active = profile_id in self._active_profiles - owned = list(self._active_profiles.get(profile_id, set())) return { "is_active": is_active, - "active_target_ids": owned, "last_activated_at": self._last_activated.get(profile_id), "last_deactivated_at": self._last_deactivated.get(profile_id), } diff --git a/server/src/wled_controller/core/scenes/__init__.py b/server/src/wled_controller/core/scenes/__init__.py new file mode 100644 index 0000000..9467c7d --- /dev/null +++ b/server/src/wled_controller/core/scenes/__init__.py @@ -0,0 +1,8 @@ +"""Scene activation and snapshot utilities.""" + +from wled_controller.core.scenes.scene_activator import ( + apply_scene_state, + capture_current_snapshot, +) + +__all__ = ["apply_scene_state", "capture_current_snapshot"] diff --git a/server/src/wled_controller/core/scenes/scene_activator.py b/server/src/wled_controller/core/scenes/scene_activator.py new file mode 100644 index 0000000..df79dbb --- /dev/null +++ b/server/src/wled_controller/core/scenes/scene_activator.py @@ -0,0 +1,186 @@ +"""Reusable scene activation and snapshot capture logic. + +These functions are used by both the scene-presets API route and the profile engine. +""" + +from typing import List, Optional, Tuple + +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.utils import get_logger + +logger = get_logger(__name__) + + +def capture_current_snapshot( + target_store: PictureTargetStore, + device_store: DeviceStore, + profile_store: ProfileStore, + processor_manager: ProcessorManager, +) -> Tuple[List[TargetSnapshot], List[DeviceBrightnessSnapshot], List[ProfileSnapshot]]: + """Capture current system state as snapshot lists. + + Returns (targets, devices, profiles) snapshot tuples. + """ + 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 + + +async def apply_scene_state( + preset: ScenePreset, + target_store: PictureTargetStore, + device_store: DeviceStore, + profile_store: ProfileStore, + profile_engine, + processor_manager: ProcessorManager, + *, + skip_profiles: bool = False, +) -> Tuple[str, List[str]]: + """Apply a scene preset's state to the system. + + Args: + preset: The scene preset to activate. + target_store: Target store for reading/updating targets. + device_store: Device store for reading/updating devices. + profile_store: Profile store for reading/updating profiles. + profile_engine: Profile engine for deactivation and re-evaluation. + processor_manager: Processor manager for starting/stopping targets. + skip_profiles: If True, skip toggling profile enable states (used when + called from the profile engine itself to avoid recursion). + + Returns: + (status, errors) where status is "activated" or "partial" and + errors is a list of error messages. + """ + errors: List[str] = [] + + # 1. Toggle profile enable states + if not skip_profiles: + for ps in preset.profiles: + try: + p = profile_store.get_profile(ps.profile_id) + if p.enabled != ps.enabled: + if not ps.enabled: + await profile_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 = processor_manager._processors.get(ts.target_id) + if proc and proc.is_running: + await processor_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 = processor_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(processor_manager, settings_changed=False, css_changed=True) + if bvs_changed: + target.sync_with_manager(processor_manager, settings_changed=False, brightness_vs_changed=True) + if settings_changed: + target.sync_with_manager(processor_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 = processor_manager._processors.get(ts.target_id) + if not proc or not proc.is_running: + await processor_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 = processor_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 + if not skip_profiles: + try: + await profile_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 activation errors: {errors}") + + return status, errors diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index 807379f..346ef8b 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -108,8 +108,14 @@ async def lifespan(app: FastAPI): 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 profile engine (needs processor_manager + mqtt_service + stores for scene activation) + profile_engine = ProfileEngine( + profile_store, processor_manager, + mqtt_service=mqtt_service, + scene_preset_store=scene_preset_store, + target_store=picture_target_store, + device_store=device_store, + ) # Create auto-backup engine auto_backup_engine = AutoBackupEngine( diff --git a/server/src/wled_controller/static/css/profiles.css b/server/src/wled_controller/static/css/profiles.css index bcdcbc7..9147739 100644 --- a/server/src/wled_controller/static/css/profiles.css +++ b/server/src/wled_controller/static/css/profiles.css @@ -174,28 +174,99 @@ text-align: center; } -/* Profile target checklist */ -.profile-targets-checklist { +/* Scene selector (searchable combobox) */ +.scene-selector { + position: relative; +} + +.scene-selector-input-wrap { + display: flex; + align-items: center; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-color); + overflow: hidden; +} + +.scene-selector-input { + flex: 1; + padding: 6px 8px; + border: none; + background: transparent; + color: var(--text-color); + font-size: 0.9rem; + font-family: inherit; + outline: none; +} + +.scene-selector-clear { + background: none; + border: none; + color: var(--text-muted); + font-size: 1.1rem; + cursor: pointer; + padding: 4px 8px; + line-height: 1; + display: none; +} + +.scene-selector-clear.visible { + display: block; +} + +.scene-selector-clear:hover { + color: var(--danger-color, #dc3545); +} + +.scene-selector-dropdown { + display: none; + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 100; max-height: 200px; overflow-y: auto; border: 1px solid var(--border-color); - border-radius: 4px; - padding: 6px; + border-top: none; + border-radius: 0 0 4px 4px; + background: var(--bg-color); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); } -.profile-target-item { +.scene-selector-dropdown.open { + display: block; +} + +.scene-selector-item { + padding: 6px 10px; + font-size: 0.85rem; + cursor: pointer; display: flex; align-items: center; - gap: 8px; - padding: 4px 6px; - cursor: pointer; - border-radius: 3px; + gap: 6px; + transition: background 0.1s; } -.profile-target-item:hover { - background: var(--bg-secondary, var(--bg-color)); +.scene-selector-item:hover { + background: rgba(33, 150, 243, 0.15); } -.profile-target-item input[type="checkbox"] { - margin: 0; +.scene-selector-item.selected { + background: rgba(33, 150, 243, 0.2); + font-weight: 600; +} + +.scene-selector-item .scene-color-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.scene-selector-empty { + padding: 8px 10px; + font-size: 0.85rem; + color: var(--text-muted); + text-align: center; } diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 387222d..725d583 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -79,7 +79,7 @@ import { import { loadProfiles, openProfileEditor, closeProfileEditorModal, saveProfileEditor, addProfileCondition, - toggleProfileEnabled, toggleProfileTargets, deleteProfile, + toggleProfileEnabled, deleteProfile, expandAllProfileSections, collapseAllProfileSections, } from './features/profiles.js'; import { @@ -307,7 +307,6 @@ Object.assign(window, { saveProfileEditor, addProfileCondition, toggleProfileEnabled, - toggleProfileTargets, deleteProfile, expandAllProfileSections, collapseAllProfileSections, diff --git a/server/src/wled_controller/static/js/features/dashboard.js b/server/src/wled_controller/static/js/features/dashboard.js index dc6a22d..2696fe8 100644 --- a/server/src/wled_controller/static/js/features/dashboard.js +++ b/server/src/wled_controller/static/js/features/dashboard.js @@ -11,7 +11,7 @@ import { startAutoRefresh, updateTabBadge } from './tabs.js'; import { getTargetTypeIcon, ICON_TARGET, ICON_PROFILE, ICON_CLOCK, ICON_WARNING, ICON_OK, - ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_AUTOSTART, ICON_HELP, + ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_AUTOSTART, ICON_HELP, ICON_SCENE, } from '../core/icons.js'; import { loadScenePresets, renderScenePresetsSection } from './scene-presets.js'; @@ -269,12 +269,6 @@ function _updateProfilesInPlace(profiles) { badge.textContent = t('profiles.status.inactive'); } } - const metricVal = card.querySelector('.dashboard-metric-value'); - if (metricVal) { - const cnt = p.target_ids.length; - const active = (p.active_target_ids || []).length; - metricVal.textContent = p.is_active ? `${active}/${cnt}` : `${cnt}`; - } const btn = card.querySelector('.dashboard-target-actions .dashboard-action-btn'); if (btn) { btn.className = `dashboard-action-btn ${p.enabled ? 'stop' : 'start'}`; @@ -490,7 +484,8 @@ export async function loadDashboard(forceFullRender = false) { const activeProfiles = profiles.filter(p => p.is_active); const inactiveProfiles = profiles.filter(p => !p.is_active); updateTabBadge('profiles', activeProfiles.length); - const profileItems = [...activeProfiles, ...inactiveProfiles].map(p => renderDashboardProfile(p)).join(''); + const sceneMap = new Map(scenePresets.map(s => [s.id, s])); + const profileItems = [...activeProfiles, ...inactiveProfiles].map(p => renderDashboardProfile(p, sceneMap)).join(''); dynamicHtml += `