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:
@@ -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),
|
||||
}
|
||||
|
||||
8
server/src/wled_controller/core/scenes/__init__.py
Normal file
8
server/src/wled_controller/core/scenes/__init__.py
Normal 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"]
|
||||
186
server/src/wled_controller/core/scenes/scene_activator.py
Normal file
186
server/src/wled_controller/core/scenes/scene_activator.py
Normal 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
|
||||
Reference in New Issue
Block a user