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 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 17:29:02 +03:00
parent 2e747b5ece
commit da3e53e1f1
17 changed files with 739 additions and 353 deletions

View File

@@ -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),
}

View File

@@ -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"]

View File

@@ -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