Rename profiles to automations across backend and frontend

Rename the "profiles" entity to "automations" throughout the entire
codebase for clarity. Updates Python models, storage, API routes/schemas,
engine, frontend JS modules, HTML templates, CSS classes, i18n keys
(en/ru/zh), dashboard, tutorials, and command palette.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 18:01:39 +03:00
parent da3e53e1f1
commit 21248e2dc9
39 changed files with 1180 additions and 1179 deletions

View File

@@ -0,0 +1 @@
"""Automation engine — condition evaluation and scene activation."""

View File

@@ -1,34 +1,34 @@
"""Profile engine — background loop that evaluates conditions and activates scenes."""
"""Automation engine — background loop that evaluates conditions and activates scenes."""
import asyncio
import re
from datetime import datetime, timezone
from typing import Dict, List, Optional, Set
from wled_controller.core.profiles.platform_detector import PlatformDetector
from wled_controller.storage.profile import (
from wled_controller.core.automations.platform_detector import PlatformDetector
from wled_controller.storage.automation import (
AlwaysCondition,
ApplicationCondition,
Automation,
Condition,
DisplayStateCondition,
MQTTCondition,
Profile,
SystemIdleCondition,
TimeOfDayCondition,
)
from wled_controller.storage.profile_store import ProfileStore
from wled_controller.storage.automation_store import AutomationStore
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 activates/deactivates scene presets."""
class AutomationEngine:
"""Evaluates automation conditions and activates/deactivates scene presets."""
def __init__(
self,
profile_store: ProfileStore,
automation_store: AutomationStore,
processor_manager,
poll_interval: float = 1.0,
mqtt_service=None,
@@ -36,7 +36,7 @@ class ProfileEngine:
target_store=None,
device_store=None,
):
self._store = profile_store
self._store = automation_store
self._manager = processor_manager
self._poll_interval = poll_interval
self._detector = PlatformDetector()
@@ -48,11 +48,11 @@ class ProfileEngine:
self._eval_lock = asyncio.Lock()
# Runtime state (not persisted)
# profile_id → True when profile is currently active
self._active_profiles: Dict[str, bool] = {}
# profile_id → snapshot captured before activation (for "revert" mode)
# automation_id → True when automation is currently active
self._active_automations: Dict[str, bool] = {}
# automation_id → snapshot captured before activation (for "revert" mode)
self._pre_activation_snapshots: Dict[str, ScenePreset] = {}
# profile_id → datetime of last activation / deactivation
# automation_id → datetime of last activation / deactivation
self._last_activated: Dict[str, datetime] = {}
self._last_deactivated: Dict[str, datetime] = {}
@@ -60,7 +60,7 @@ class ProfileEngine:
if self._task is not None:
return
self._task = asyncio.create_task(self._poll_loop())
logger.info("Profile engine started")
logger.info("Automation engine started")
async def stop(self) -> None:
if self._task is None:
@@ -73,11 +73,11 @@ class ProfileEngine:
pass
self._task = None
# Deactivate all profiles (stop owned targets)
for profile_id in list(self._active_profiles.keys()):
await self._deactivate_profile(profile_id)
# Deactivate all automations
for automation_id in list(self._active_automations.keys()):
await self._deactivate_automation(automation_id)
logger.info("Profile engine stopped")
logger.info("Automation engine stopped")
async def _poll_loop(self) -> None:
try:
@@ -85,7 +85,7 @@ class ProfileEngine:
try:
await self._evaluate_all()
except Exception as e:
logger.error(f"Profile evaluation error: {e}", exc_info=True)
logger.error(f"Automation evaluation error: {e}", exc_info=True)
await asyncio.sleep(self._poll_interval)
except asyncio.CancelledError:
pass
@@ -115,20 +115,20 @@ class ProfileEngine:
return running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs, idle_seconds, display_state
async def _evaluate_all_locked(self) -> None:
profiles = self._store.get_all_profiles()
if not profiles:
# No profiles — deactivate any stale state
for pid in list(self._active_profiles.keys()):
await self._deactivate_profile(pid)
automations = self._store.get_all_automations()
if not automations:
# No automations — deactivate any stale state
for aid in list(self._active_automations.keys()):
await self._deactivate_automation(aid)
return
# Determine which detection methods are actually needed
match_types_used: set = set()
needs_idle = False
needs_display_state = False
for p in profiles:
if p.enabled:
for c in p.conditions:
for a in automations:
if a.enabled:
for c in a.conditions:
if isinstance(c, ApplicationCondition):
match_types_used.add(c.match_type)
elif isinstance(c, SystemIdleCondition):
@@ -151,34 +151,34 @@ class ProfileEngine:
)
)
active_profile_ids = set()
active_automation_ids = set()
for profile in profiles:
for automation in automations:
should_be_active = (
profile.enabled
and (len(profile.conditions) == 0
automation.enabled
and (len(automation.conditions) == 0
or self._evaluate_conditions(
profile, running_procs, topmost_proc, topmost_fullscreen,
automation, running_procs, topmost_proc, topmost_fullscreen,
fullscreen_procs, idle_seconds, display_state))
)
is_active = profile.id in self._active_profiles
is_active = automation.id in self._active_automations
if should_be_active and not is_active:
await self._activate_profile(profile)
active_profile_ids.add(profile.id)
await self._activate_automation(automation)
active_automation_ids.add(automation.id)
elif should_be_active and is_active:
active_profile_ids.add(profile.id)
active_automation_ids.add(automation.id)
elif not should_be_active and is_active:
await self._deactivate_profile(profile.id)
await self._deactivate_automation(automation.id)
# Deactivate profiles that were removed from store while active
for pid in list(self._active_profiles.keys()):
if pid not in active_profile_ids:
await self._deactivate_profile(pid)
# Deactivate automations that were removed from store while active
for aid in list(self._active_automations.keys()):
if aid not in active_automation_ids:
await self._deactivate_automation(aid)
def _evaluate_conditions(
self, profile: Profile, running_procs: Set[str],
self, automation: Automation, running_procs: Set[str],
topmost_proc: Optional[str], topmost_fullscreen: bool,
fullscreen_procs: Set[str],
idle_seconds: Optional[float], display_state: Optional[str],
@@ -188,10 +188,10 @@ class ProfileEngine:
c, running_procs, topmost_proc, topmost_fullscreen,
fullscreen_procs, idle_seconds, display_state,
)
for c in profile.conditions
for c in automation.conditions
]
if profile.condition_logic == "and":
if automation.condition_logic == "and":
return all(results)
return any(results) # "or" is default
@@ -287,116 +287,116 @@ class ProfileEngine:
# Default: "running"
return any(app in running_procs for app in apps_lower)
async def _activate_profile(self, profile: Profile) -> None:
if not profile.scene_preset_id:
async def _activate_automation(self, automation: Automation) -> None:
if not automation.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")
logger.info(f"Profile '{profile.name}' activated (no scene configured)")
self._active_automations[automation.id] = True
self._last_activated[automation.id] = datetime.now(timezone.utc)
self._fire_event(automation.id, "activated")
logger.info(f"Automation '{automation.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")
logger.warning(f"Automation '{automation.name}' matched but scene stores not available")
return
# Load the scene preset
try:
preset = self._scene_preset_store.get_preset(profile.scene_preset_id)
preset = self._scene_preset_store.get_preset(automation.scene_preset_id)
except ValueError:
logger.warning(f"Profile '{profile.name}': scene preset {profile.scene_preset_id} not found")
logger.warning(f"Automation '{automation.name}': scene preset {automation.scene_preset_id} not found")
return
# For "revert" mode, capture current state before activating
if profile.deactivation_mode == "revert":
if automation.deactivation_mode == "revert":
from wled_controller.core.scenes.scene_activator import capture_current_snapshot
targets, devices, profiles = capture_current_snapshot(
targets, devices, automations = 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}",
self._pre_activation_snapshots[automation.id] = ScenePreset(
id=f"_revert_{automation.id}",
name=f"Pre-activation snapshot for {automation.name}",
targets=targets,
devices=devices,
profiles=profiles,
profiles=automations,
)
# 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, self._manager, skip_automations=True,
)
self._active_profiles[profile.id] = True
self._last_activated[profile.id] = datetime.now(timezone.utc)
self._fire_event(profile.id, "activated")
self._active_automations[automation.id] = True
self._last_activated[automation.id] = datetime.now(timezone.utc)
self._fire_event(automation.id, "activated")
if errors:
logger.warning(f"Profile '{profile.name}' activated with errors: {errors}")
logger.warning(f"Automation '{automation.name}' activated with errors: {errors}")
else:
logger.info(f"Profile '{profile.name}' activated (scene '{preset.name}' applied)")
logger.info(f"Automation '{automation.name}' activated (scene '{preset.name}' applied)")
async def _deactivate_profile(self, profile_id: str) -> None:
was_active = self._active_profiles.pop(profile_id, False)
async def _deactivate_automation(self, automation_id: str) -> None:
was_active = self._active_automations.pop(automation_id, False)
if not was_active:
return
# Look up the profile for deactivation settings
# Look up the automation for deactivation settings
try:
profile = self._store.get_profile(profile_id)
automation = self._store.get_automation(automation_id)
except ValueError:
profile = None
automation = None
deactivation_mode = profile.deactivation_mode if profile else "none"
deactivation_mode = automation.deactivation_mode if automation else "none"
if deactivation_mode == "revert":
snapshot = self._pre_activation_snapshots.pop(profile_id, None)
snapshot = self._pre_activation_snapshots.pop(automation_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,
self, self._manager, skip_automations=True,
)
if errors:
logger.warning(f"Profile {profile_id} revert errors: {errors}")
logger.warning(f"Automation {automation_id} revert errors: {errors}")
else:
logger.info(f"Profile {profile_id} deactivated (reverted to previous state)")
logger.info(f"Automation {automation_id} deactivated (reverted to previous state)")
else:
logger.warning(f"Profile {profile_id}: no snapshot available for revert")
logger.warning(f"Automation {automation_id}: no snapshot available for revert")
elif deactivation_mode == "fallback_scene":
fallback_id = profile.deactivation_scene_preset_id if profile else None
fallback_id = automation.deactivation_scene_preset_id if automation 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,
self, self._manager, skip_automations=True,
)
if errors:
logger.warning(f"Profile {profile_id} fallback errors: {errors}")
logger.warning(f"Automation {automation_id} fallback errors: {errors}")
else:
logger.info(f"Profile {profile_id} deactivated (fallback scene '{fallback.name}' applied)")
logger.info(f"Automation {automation_id} deactivated (fallback scene '{fallback.name}' applied)")
except ValueError:
logger.warning(f"Profile {profile_id}: fallback scene {fallback_id} not found")
logger.warning(f"Automation {automation_id}: fallback scene {fallback_id} not found")
else:
logger.info(f"Profile {profile_id} deactivated (no fallback scene configured)")
logger.info(f"Automation {automation_id} deactivated (no fallback scene configured)")
else:
# "none" mode — just clear active state
logger.info(f"Profile {profile_id} deactivated")
logger.info(f"Automation {automation_id} deactivated")
self._last_deactivated[profile_id] = datetime.now(timezone.utc)
self._fire_event(profile_id, "deactivated")
self._last_deactivated[automation_id] = datetime.now(timezone.utc)
self._fire_event(automation_id, "deactivated")
# Clean up any leftover snapshot
self._pre_activation_snapshots.pop(profile_id, None)
self._pre_activation_snapshots.pop(automation_id, None)
def _fire_event(self, profile_id: str, action: str) -> None:
def _fire_event(self, automation_id: str, action: str) -> None:
try:
self._manager._fire_event({
"type": "profile_state_changed",
"profile_id": profile_id,
"type": "automation_state_changed",
"automation_id": automation_id,
"action": action,
})
except Exception:
@@ -404,30 +404,30 @@ class ProfileEngine:
# ===== Public query methods (used by API) =====
def get_profile_state(self, profile_id: str) -> dict:
"""Get runtime state of a single profile."""
is_active = profile_id in self._active_profiles
def get_automation_state(self, automation_id: str) -> dict:
"""Get runtime state of a single automation."""
is_active = automation_id in self._active_automations
return {
"is_active": is_active,
"last_activated_at": self._last_activated.get(profile_id),
"last_deactivated_at": self._last_deactivated.get(profile_id),
"last_activated_at": self._last_activated.get(automation_id),
"last_deactivated_at": self._last_deactivated.get(automation_id),
}
def get_all_profile_states(self) -> Dict[str, dict]:
"""Get runtime states of all profiles."""
def get_all_automation_states(self) -> Dict[str, dict]:
"""Get runtime states of all automations."""
result = {}
for profile in self._store.get_all_profiles():
result[profile.id] = self.get_profile_state(profile.id)
for automation in self._store.get_all_automations():
result[automation.id] = self.get_automation_state(automation.id)
return result
async def trigger_evaluate(self) -> None:
"""Run a single evaluation cycle immediately (used after enabling a profile)."""
"""Run a single evaluation cycle immediately (used after enabling an automation)."""
try:
await self._evaluate_all()
except Exception as e:
logger.error(f"Immediate profile evaluation error: {e}", exc_info=True)
logger.error(f"Immediate automation evaluation error: {e}", exc_info=True)
async def deactivate_if_active(self, profile_id: str) -> None:
"""Deactivate a profile immediately (used when disabling/deleting)."""
if profile_id in self._active_profiles:
await self._deactivate_profile(profile_id)
async def deactivate_if_active(self, automation_id: str) -> None:
"""Deactivate an automation immediately (used when disabling/deleting)."""
if automation_id in self._active_automations:
await self._deactivate_automation(automation_id)

View File

@@ -18,7 +18,7 @@ class MQTTService:
Features:
- Publish messages (retained or transient)
- Subscribe to topics with callback dispatch
- Topic value cache for synchronous reads (profile condition evaluation)
- Topic value cache for synchronous reads (automation condition evaluation)
- Auto-reconnect loop
- Birth / will messages for online status
"""
@@ -95,7 +95,7 @@ class MQTTService:
logger.warning(f"MQTT subscribe failed ({topic}): {e}")
def get_last_value(self, topic: str) -> Optional[str]:
"""Get cached last value for a topic (synchronous — for profile evaluation)."""
"""Get cached last value for a topic (synchronous — for automation evaluation)."""
return self._topic_cache.get(topic)
async def _connection_loop(self) -> None:
@@ -170,7 +170,7 @@ class MQTTService:
topic = f"{self._config.base_topic}/target/{target_id}/state"
await self.publish(topic, json.dumps(state), retain=True)
async def publish_profile_state(self, profile_id: str, action: str) -> None:
"""Publish profile state change to MQTT."""
topic = f"{self._config.base_topic}/profile/{profile_id}/state"
async def publish_automation_state(self, automation_id: str, action: str) -> None:
"""Publish automation state change to MQTT."""
topic = f"{self._config.base_topic}/automation/{automation_id}/state"
await self.publish(topic, json.dumps({"action": action}), retain=True)

View File

@@ -1 +0,0 @@
"""Profile automation — condition evaluation and target management."""

View File

@@ -1,6 +1,6 @@
"""Reusable scene activation and snapshot capture logic.
These functions are used by both the scene-presets API route and the profile engine.
These functions are used by both the scene-presets API route and the automation engine.
"""
from typing import List, Optional, Tuple
@@ -8,10 +8,10 @@ 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.automation_store import AutomationStore
from wled_controller.storage.scene_preset import (
AutomationSnapshot,
DeviceBrightnessSnapshot,
ProfileSnapshot,
ScenePreset,
TargetSnapshot,
)
@@ -23,12 +23,12 @@ logger = get_logger(__name__)
def capture_current_snapshot(
target_store: PictureTargetStore,
device_store: DeviceStore,
profile_store: ProfileStore,
automation_store: AutomationStore,
processor_manager: ProcessorManager,
) -> Tuple[List[TargetSnapshot], List[DeviceBrightnessSnapshot], List[ProfileSnapshot]]:
) -> Tuple[List[TargetSnapshot], List[DeviceBrightnessSnapshot], List[AutomationSnapshot]]:
"""Capture current system state as snapshot lists.
Returns (targets, devices, profiles) snapshot tuples.
Returns (targets, devices, automations) snapshot tuples.
"""
targets = []
for t in target_store.get_all_targets():
@@ -50,25 +50,25 @@ def capture_current_snapshot(
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,
automations = []
for a in automation_store.get_all_automations():
automations.append(AutomationSnapshot(
automation_id=a.id,
enabled=a.enabled,
))
return targets, devices, profiles
return targets, devices, automations
async def apply_scene_state(
preset: ScenePreset,
target_store: PictureTargetStore,
device_store: DeviceStore,
profile_store: ProfileStore,
profile_engine,
automation_store: AutomationStore,
automation_engine,
processor_manager: ProcessorManager,
*,
skip_profiles: bool = False,
skip_automations: bool = False,
) -> Tuple[str, List[str]]:
"""Apply a scene preset's state to the system.
@@ -76,11 +76,11 @@ async def apply_scene_state(
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.
automation_store: Automation store for reading/updating automations.
automation_engine: Automation 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).
skip_automations: If True, skip toggling automation enable states (used when
called from the automation engine itself to avoid recursion).
Returns:
(status, errors) where status is "activated" or "partial" and
@@ -88,19 +88,19 @@ async def apply_scene_state(
"""
errors: List[str] = []
# 1. Toggle profile enable states
if not skip_profiles:
for ps in preset.profiles:
# 1. Toggle automation enable states
if not skip_automations:
for auto_snap in preset.automations:
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)
a = automation_store.get_automation(auto_snap.automation_id)
if a.enabled != auto_snap.enabled:
if not auto_snap.enabled:
await automation_engine.deactivate_if_active(auto_snap.automation_id)
automation_store.update_automation(auto_snap.automation_id, enabled=auto_snap.enabled)
except ValueError:
errors.append(f"Profile {ps.profile_id} not found (skipped)")
errors.append(f"Automation {auto_snap.automation_id} not found (skipped)")
except Exception as e:
errors.append(f"Profile {ps.profile_id}: {e}")
errors.append(f"Automation {auto_snap.automation_id}: {e}")
# 2. Stop targets that should be stopped
for ts in preset.targets:
@@ -172,12 +172,12 @@ async def apply_scene_state(
except Exception as e:
errors.append(f"Device {ds.device_id} brightness: {e}")
# Trigger profile re-evaluation after all changes
if not skip_profiles:
# Trigger automation re-evaluation after all changes
if not skip_automations:
try:
await profile_engine.trigger_evaluate()
await automation_engine.trigger_evaluate()
except Exception as e:
errors.append(f"Profile re-evaluation: {e}")
errors.append(f"Automation re-evaluation: {e}")
status = "activated" if not errors else "partial"
if errors: