From da3e53e1f1734e264b5f38c0be999a53c0bd8168 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 28 Feb 2026 17:29:02 +0300 Subject: [PATCH] Replace profile targets with scene activation and searchable scene selector Profiles now activate scene presets instead of individual targets, with configurable deactivation behavior (none/revert/fallback scene). The target checklist UI is replaced by a searchable combobox for scene selection that scales well with many scenes. Co-Authored-By: Claude Opus 4.6 --- .../wled_controller/api/routes/profiles.py | 58 +++-- .../api/routes/scene_presets.py | 152 +---------- .../wled_controller/api/schemas/profiles.py | 13 +- .../core/profiles/profile_engine.py | 167 ++++++++---- .../wled_controller/core/scenes/__init__.py | 8 + .../core/scenes/scene_activator.py | 186 +++++++++++++ server/src/wled_controller/main.py | 10 +- .../wled_controller/static/css/profiles.css | 97 ++++++- server/src/wled_controller/static/js/app.js | 3 +- .../static/js/features/dashboard.js | 26 +- .../static/js/features/profiles.js | 245 ++++++++++++------ .../wled_controller/static/locales/en.json | 17 +- .../wled_controller/static/locales/ru.json | 17 +- .../wled_controller/static/locales/zh.json | 17 +- server/src/wled_controller/storage/profile.py | 14 +- .../wled_controller/storage/profile_store.py | 20 +- .../templates/modals/profile-editor.html | 42 ++- 17 files changed, 739 insertions(+), 353 deletions(-) create mode 100644 server/src/wled_controller/core/scenes/__init__.py create mode 100644 server/src/wled_controller/core/scenes/scene_activator.py 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 += `
${_sectionHeader('profiles', t('dashboard.section.profiles'), profiles.length)} @@ -669,7 +664,7 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap } } -function renderDashboardProfile(profile) { +function renderDashboardProfile(profile, sceneMap = new Map()) { const isActive = profile.is_active; const isDisabled = !profile.enabled; @@ -693,9 +688,9 @@ function renderDashboardProfile(profile) { ? `${t('profiles.status.active')}` : `${t('profiles.status.inactive')}`; - const targetCount = profile.target_ids.length; - const activeCount = (profile.active_target_ids || []).length; - const targetsInfo = isActive ? `${activeCount}/${targetCount}` : `${targetCount}`; + // Scene info + const scene = profile.scene_preset_id ? sceneMap.get(profile.scene_preset_id) : null; + const sceneName = scene ? escapeHtml(scene.name) : t('profiles.scene.none_selected'); return `
${profile.condition_logic === 'and' ? t('profiles.logic.all') : t('profiles.logic.any')} - ${ICON_TARGET} ${targetCountText} + ${ICON_SCENE} ${sceneName} + ${deactivationLabel ? `${deactivationLabel}` : ''} ${lastActivityMeta}
${condPills}
- ${profile.target_ids.length > 0 ? (() => { - const anyRunning = profile.target_ids.some(id => runningTargetIds.has(id)); - return ``; - })() : ''} @@ -191,7 +198,18 @@ export async function openProfileEditor(profileId) { errorEl.style.display = 'none'; condList.innerHTML = ''; - await loadProfileTargetChecklist([]); + // Fetch scenes for selector + try { + const resp = await fetchWithAuth('/scene-presets'); + if (resp.ok) { + const data = await resp.json(); + _scenesCache = data.presets || []; + } + } catch { /* use cached */ } + + // Reset deactivation mode + document.getElementById('profile-deactivation-mode').value = 'none'; + document.getElementById('profile-fallback-scene-group').style.display = 'none'; if (profileId) { titleEl.innerHTML = `${ICON_PROFILE} ${t('profiles.edit')}`; @@ -209,7 +227,13 @@ export async function openProfileEditor(profileId) { addProfileConditionRow(c); } - await loadProfileTargetChecklist(profile.target_ids); + // Scene selector + _initSceneSelector('profile-scene', profile.scene_preset_id); + + // Deactivation mode + document.getElementById('profile-deactivation-mode').value = profile.deactivation_mode || 'none'; + _onDeactivationModeChange(); + _initSceneSelector('profile-fallback-scene', profile.deactivation_scene_preset_id); } catch (e) { showToast(e.message, 'error'); return; @@ -220,44 +244,132 @@ export async function openProfileEditor(profileId) { nameInput.value = ''; enabledInput.checked = true; logicSelect.value = 'or'; + _initSceneSelector('profile-scene', null); + _initSceneSelector('profile-fallback-scene', null); } + // Wire up deactivation mode change + document.getElementById('profile-deactivation-mode').onchange = _onDeactivationModeChange; + profileModal.open(); modal.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.getAttribute('data-i18n')); }); + modal.querySelectorAll('[data-i18n-placeholder]').forEach(el => { + el.placeholder = t(el.getAttribute('data-i18n-placeholder')); + }); profileModal.snapshot(); } +function _onDeactivationModeChange() { + const mode = document.getElementById('profile-deactivation-mode').value; + document.getElementById('profile-fallback-scene-group').style.display = mode === 'fallback_scene' ? '' : 'none'; +} + export async function closeProfileEditorModal() { await profileModal.close(); } -async function loadProfileTargetChecklist(selectedIds) { - const container = document.getElementById('profile-targets-list'); - try { - const resp = await fetchWithAuth('/picture-targets'); - if (!resp.ok) throw new Error('Failed to load targets'); - const data = await resp.json(); - const targets = data.targets || []; +// ===== Scene selector logic ===== - if (targets.length === 0) { - container.innerHTML = `${t('profiles.targets.empty')}`; - return; +function _initSceneSelector(prefix, selectedId) { + const hiddenInput = document.getElementById(`${prefix}-id`); + const searchInput = document.getElementById(`${prefix}-search`); + const clearBtn = document.getElementById(`${prefix}-clear`); + const dropdown = document.getElementById(`${prefix}-dropdown`); + + hiddenInput.value = selectedId || ''; + + // Set initial display text + if (selectedId) { + const scene = _scenesCache.find(s => s.id === selectedId); + searchInput.value = scene ? scene.name : ''; + clearBtn.classList.toggle('visible', true); + } else { + searchInput.value = ''; + clearBtn.classList.toggle('visible', false); + } + + // Render dropdown items + function renderDropdown(filter) { + const query = (filter || '').toLowerCase(); + const filtered = query ? _scenesCache.filter(s => s.name.toLowerCase().includes(query)) : _scenesCache; + + if (filtered.length === 0) { + dropdown.innerHTML = `
${t('profiles.scene.none_available')}
`; + } else { + dropdown.innerHTML = filtered.map(s => { + const selected = s.id === hiddenInput.value ? ' selected' : ''; + return `
${escapeHtml(s.name)}
`; + }).join(''); } - container.innerHTML = targets.map(tgt => { - const checked = selectedIds.includes(tgt.id) ? 'checked' : ''; - return ``; - }).join(''); - } catch (e) { - container.innerHTML = `${e.message}`; + // Attach click handlers + dropdown.querySelectorAll('.scene-selector-item').forEach(item => { + item.addEventListener('click', () => { + const id = item.dataset.sceneId; + const scene = _scenesCache.find(s => s.id === id); + hiddenInput.value = id; + searchInput.value = scene ? scene.name : ''; + clearBtn.classList.toggle('visible', true); + dropdown.classList.remove('open'); + }); + }); } + + // Show dropdown on focus/click + searchInput.onfocus = () => { + renderDropdown(searchInput.value); + dropdown.classList.add('open'); + }; + + searchInput.oninput = () => { + renderDropdown(searchInput.value); + dropdown.classList.add('open'); + // If text doesn't match any scene, clear the hidden input + const exactMatch = _scenesCache.find(s => s.name.toLowerCase() === searchInput.value.toLowerCase()); + if (!exactMatch) { + hiddenInput.value = ''; + clearBtn.classList.toggle('visible', !!searchInput.value); + } + }; + + searchInput.onkeydown = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + // Select first visible item + const first = dropdown.querySelector('.scene-selector-item'); + if (first) first.click(); + } else if (e.key === 'Escape') { + dropdown.classList.remove('open'); + searchInput.blur(); + } + }; + + // Clear button + clearBtn.onclick = () => { + hiddenInput.value = ''; + searchInput.value = ''; + clearBtn.classList.remove('visible'); + dropdown.classList.remove('open'); + }; + + // Close dropdown when clicking outside + const selectorEl = searchInput.closest('.scene-selector'); + // Remove old listener if any (re-init) + if (selectorEl._outsideClickHandler) { + document.removeEventListener('click', selectorEl._outsideClickHandler); + } + selectorEl._outsideClickHandler = (e) => { + if (!selectorEl.contains(e.target)) { + dropdown.classList.remove('open'); + } + }; + document.addEventListener('click', selectorEl._outsideClickHandler); } +// ===== Condition editor ===== + export function addProfileCondition() { addProfileConditionRow({ condition_type: 'application', apps: [], match_type: 'running' }); } @@ -444,7 +556,7 @@ function renderProcessPicker(picker, processes, existing) { } listEl.innerHTML = processes.map(p => { const added = existing.has(p.toLowerCase()); - return `
${escapeHtml(p)}${added ? ' ✓' : ''}
`; + return `
${escapeHtml(p)}${added ? ' \u2713' : ''}
`; }).join(''); listEl.querySelectorAll('.process-picker-item:not(.added)').forEach(item => { @@ -455,7 +567,7 @@ function renderProcessPicker(picker, processes, existing) { const current = textarea.value.trim(); textarea.value = current ? current + '\n' + proc : proc; item.classList.add('added'); - item.textContent = proc + ' ✓'; + item.textContent = proc + ' \u2713'; picker._existing.add(proc.toLowerCase()); }); }); @@ -509,11 +621,6 @@ function getProfileEditorConditions() { return conditions; } -function getProfileEditorTargetIds() { - const checkboxes = document.querySelectorAll('#profile-targets-list input[type="checkbox"]:checked'); - return Array.from(checkboxes).map(cb => cb.value); -} - export async function saveProfileEditor() { const idInput = document.getElementById('profile-editor-id'); const nameInput = document.getElementById('profile-editor-name'); @@ -531,7 +638,9 @@ export async function saveProfileEditor() { enabled: enabledInput.checked, condition_logic: logicSelect.value, conditions: getProfileEditorConditions(), - target_ids: getProfileEditorTargetIds(), + scene_preset_id: document.getElementById('profile-scene-id').value || null, + deactivation_mode: document.getElementById('profile-deactivation-mode').value, + deactivation_scene_preset_id: document.getElementById('profile-fallback-scene-id').value || null, }; const profileId = idInput.value; @@ -557,28 +666,6 @@ export async function saveProfileEditor() { } } -export async function toggleProfileTargets(profileId) { - try { - const profileResp = await fetchWithAuth(`/profiles/${profileId}`); - if (!profileResp.ok) throw new Error('Failed to load profile'); - const profile = await profileResp.json(); - // Batch fetch all target states to determine which are running - const batchResp = await fetchWithAuth('/picture-targets/batch/states'); - const allStates = batchResp.ok ? (await batchResp.json()).states : {}; - const runningSet = new Set( - profile.target_ids.filter(id => allStates[id]?.processing) - ); - const shouldStop = profile.target_ids.some(id => runningSet.has(id)); - await Promise.all(profile.target_ids.map(id => - fetchWithAuth(`/picture-targets/${id}/${shouldStop ? 'stop' : 'start'}`, { method: 'POST' }).catch(() => {}) - )); - loadProfiles(); - } catch (e) { - if (e.isAuth) return; - showToast(e.message, 'error'); - } -} - export async function toggleProfileEnabled(profileId, enable) { try { const action = enable ? 'enable' : 'disable'; diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index c1e6e08..a4a2b0e 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -593,9 +593,18 @@ "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", + "profiles.scene": "Scene:", + "profiles.scene.hint": "Scene preset to activate when conditions are met", + "profiles.scene.search_placeholder": "Search scenes...", + "profiles.scene.none_selected": "No scene", + "profiles.scene.none_available": "No scenes available", + "profiles.deactivation_mode": "Deactivation:", + "profiles.deactivation_mode.hint": "What happens when conditions stop matching", + "profiles.deactivation_mode.none": "None — keep current state", + "profiles.deactivation_mode.revert": "Revert to previous state", + "profiles.deactivation_mode.fallback_scene": "Activate fallback scene", + "profiles.deactivation_scene": "Fallback Scene:", + "profiles.deactivation_scene.hint": "Scene to activate when this profile deactivates", "profiles.status.active": "Active", "profiles.status.inactive": "Inactive", "profiles.status.disabled": "Disabled", @@ -609,8 +618,6 @@ "profiles.created": "Profile created", "profiles.deleted": "Profile deleted", "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", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 66de89b..9c7aaab 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -593,9 +593,18 @@ "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": "Нет доступных целей", + "profiles.scene": "Сцена:", + "profiles.scene.hint": "Пресет сцены для активации при выполнении условий", + "profiles.scene.search_placeholder": "Поиск сцен...", + "profiles.scene.none_selected": "Нет сцены", + "profiles.scene.none_available": "Нет доступных сцен", + "profiles.deactivation_mode": "Деактивация:", + "profiles.deactivation_mode.hint": "Что происходит, когда условия перестают выполняться", + "profiles.deactivation_mode.none": "Ничего — оставить текущее состояние", + "profiles.deactivation_mode.revert": "Вернуть предыдущее состояние", + "profiles.deactivation_mode.fallback_scene": "Активировать резервную сцену", + "profiles.deactivation_scene": "Резервная сцена:", + "profiles.deactivation_scene.hint": "Сцена для активации при деактивации профиля", "profiles.status.active": "Активен", "profiles.status.inactive": "Неактивен", "profiles.status.disabled": "Отключён", @@ -609,8 +618,6 @@ "profiles.created": "Профиль создан", "profiles.deleted": "Профиль удалён", "profiles.error.name_required": "Введите название", - "profiles.toggle_all.start": "Запустить все цели", - "profiles.toggle_all.stop": "Остановить все цели", "scenes.title": "Сцены", "scenes.add": "Захватить сцену", "scenes.edit": "Редактировать сцену", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index ac5b537..85bc568 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -593,9 +593,18 @@ "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": "没有可用的目标", + "profiles.scene": "场景:", + "profiles.scene.hint": "条件满足时激活的场景预设", + "profiles.scene.search_placeholder": "搜索场景...", + "profiles.scene.none_selected": "无场景", + "profiles.scene.none_available": "没有可用的场景", + "profiles.deactivation_mode": "停用方式:", + "profiles.deactivation_mode.hint": "条件不再满足时的行为", + "profiles.deactivation_mode.none": "无 — 保持当前状态", + "profiles.deactivation_mode.revert": "恢复到之前的状态", + "profiles.deactivation_mode.fallback_scene": "激活备用场景", + "profiles.deactivation_scene": "备用场景:", + "profiles.deactivation_scene.hint": "配置文件停用时激活的场景", "profiles.status.active": "活动", "profiles.status.inactive": "非活动", "profiles.status.disabled": "已禁用", @@ -609,8 +618,6 @@ "profiles.created": "配置文件已创建", "profiles.deleted": "配置文件已删除", "profiles.error.name_required": "名称为必填项", - "profiles.toggle_all.start": "启动所有目标", - "profiles.toggle_all.stop": "停止所有目标", "scenes.title": "场景", "scenes.add": "捕获场景", "scenes.edit": "编辑场景", diff --git a/server/src/wled_controller/storage/profile.py b/server/src/wled_controller/storage/profile.py index 539590c..0f9998a 100644 --- a/server/src/wled_controller/storage/profile.py +++ b/server/src/wled_controller/storage/profile.py @@ -160,14 +160,16 @@ class MQTTCondition(Condition): @dataclass class Profile: - """Automation profile that activates targets based on conditions.""" + """Automation profile that activates a scene preset based on conditions.""" id: str name: str enabled: bool condition_logic: str # "or" | "and" conditions: List[Condition] - target_ids: List[str] + scene_preset_id: Optional[str] # scene to activate when conditions are met + deactivation_mode: str # "none" | "revert" | "fallback_scene" + deactivation_scene_preset_id: Optional[str] # scene for fallback_scene mode created_at: datetime updated_at: datetime @@ -178,7 +180,9 @@ class Profile: "enabled": self.enabled, "condition_logic": self.condition_logic, "conditions": [c.to_dict() for c in self.conditions], - "target_ids": list(self.target_ids), + "scene_preset_id": self.scene_preset_id, + "deactivation_mode": self.deactivation_mode, + "deactivation_scene_preset_id": self.deactivation_scene_preset_id, "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), } @@ -198,7 +202,9 @@ class Profile: enabled=data.get("enabled", True), condition_logic=data.get("condition_logic", "or"), conditions=conditions, - target_ids=data.get("target_ids", []), + scene_preset_id=data.get("scene_preset_id"), + deactivation_mode=data.get("deactivation_mode", "none"), + deactivation_scene_preset_id=data.get("deactivation_scene_preset_id"), created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())), updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())), ) diff --git a/server/src/wled_controller/storage/profile_store.py b/server/src/wled_controller/storage/profile_store.py index 5596924..f3edd3e 100644 --- a/server/src/wled_controller/storage/profile_store.py +++ b/server/src/wled_controller/storage/profile_store.py @@ -74,7 +74,9 @@ class ProfileStore: enabled: bool = True, condition_logic: str = "or", conditions: Optional[List[Condition]] = None, - target_ids: Optional[List[str]] = None, + scene_preset_id: Optional[str] = None, + deactivation_mode: str = "none", + deactivation_scene_preset_id: Optional[str] = None, ) -> Profile: for p in self._profiles.values(): if p.name == name: @@ -89,7 +91,9 @@ class ProfileStore: enabled=enabled, condition_logic=condition_logic, conditions=conditions or [], - target_ids=target_ids or [], + scene_preset_id=scene_preset_id, + deactivation_mode=deactivation_mode, + deactivation_scene_preset_id=deactivation_scene_preset_id, created_at=now, updated_at=now, ) @@ -107,7 +111,9 @@ class ProfileStore: enabled: Optional[bool] = None, condition_logic: Optional[str] = None, conditions: Optional[List[Condition]] = None, - target_ids: Optional[List[str]] = None, + scene_preset_id: str = "__unset__", + deactivation_mode: Optional[str] = None, + deactivation_scene_preset_id: str = "__unset__", ) -> Profile: if profile_id not in self._profiles: raise ValueError(f"Profile not found: {profile_id}") @@ -125,8 +131,12 @@ class ProfileStore: profile.condition_logic = condition_logic if conditions is not None: profile.conditions = conditions - if target_ids is not None: - profile.target_ids = target_ids + if scene_preset_id != "__unset__": + profile.scene_preset_id = scene_preset_id + if deactivation_mode is not None: + profile.deactivation_mode = deactivation_mode + if deactivation_scene_preset_id != "__unset__": + profile.deactivation_scene_preset_id = deactivation_scene_preset_id profile.updated_at = datetime.utcnow() self._save() diff --git a/server/src/wled_controller/templates/modals/profile-editor.html b/server/src/wled_controller/templates/modals/profile-editor.html index 0662395..3ac1b25 100644 --- a/server/src/wled_controller/templates/modals/profile-editor.html +++ b/server/src/wled_controller/templates/modals/profile-editor.html @@ -56,11 +56,47 @@
- +
- -
+ +
+ +
+ + +
+
+
+
+ +
+
+ + +
+ + +
+ +