Add profile system for automatic target activation

Profiles monitor running processes and foreground windows to
automatically start/stop targets when conditions are met.
Includes profile engine, platform detector (WMI), REST API,
process browser endpoint, and calibration persistence fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 15:12:34 +03:00
parent d6cf45c873
commit 29d9b95885
15 changed files with 933 additions and 10 deletions

View File

@@ -691,22 +691,16 @@ class ProcessorManager:
state.device_type, state.device_url, client, state.health,
)
# Auto-sync LED count
# Auto-sync LED count (preserve existing calibration)
reported = state.health.device_led_count
if reported and reported != state.led_count and self._device_store:
old_count = state.led_count
logger.info(
f"Device {device_id} LED count changed: {old_count}{reported}, "
f"updating calibration"
f"Device {device_id} LED count changed: {old_count}{reported}"
)
try:
device = self._device_store.update_device(device_id, led_count=reported)
self._device_store.update_device(device_id, led_count=reported)
state.led_count = reported
state.calibration = device.calibration
# Propagate to WLED processors using this device
for proc in self._processors.values():
if isinstance(proc, WledTargetProcessor) and proc.device_id == device_id:
proc.update_calibration(device.calibration)
except Exception as e:
logger.error(f"Failed to sync LED count for {device_id}: {e}")

View File

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

View File

@@ -0,0 +1,91 @@
"""Platform-specific process and window detection.
Windows: uses wmi for process listing, ctypes for foreground window detection.
Non-Windows: graceful degradation (returns empty results).
"""
import asyncio
import ctypes
import ctypes.wintypes
import os
import sys
from typing import Optional, Set
from wled_controller.utils import get_logger
logger = get_logger(__name__)
_IS_WINDOWS = sys.platform == "win32"
class PlatformDetector:
"""Detect running processes and the foreground window's process."""
def _get_running_processes_sync(self) -> Set[str]:
"""Get set of lowercase process names (blocking, call via executor)."""
if not _IS_WINDOWS:
return set()
try:
import pythoncom
pythoncom.CoInitialize()
try:
import wmi
w = wmi.WMI()
return {p.Name.lower() for p in w.Win32_Process() if p.Name}
finally:
pythoncom.CoUninitialize()
except Exception as e:
logger.error(f"Failed to enumerate processes: {e}")
return set()
def _get_topmost_process_sync(self) -> Optional[str]:
"""Get lowercase process name of the foreground window (blocking, call via executor)."""
if not _IS_WINDOWS:
return None
try:
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
psapi = ctypes.windll.psapi
hwnd = user32.GetForegroundWindow()
if not hwnd:
return None
pid = ctypes.wintypes.DWORD()
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
if not pid.value:
return None
PROCESS_QUERY_INFORMATION = 0x0400
PROCESS_VM_READ = 0x0010
handle = kernel32.OpenProcess(
PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, False, pid.value
)
if not handle:
return None
try:
buf = ctypes.create_unicode_buffer(512)
psapi.GetModuleFileNameExW(handle, None, buf, 512)
full_path = buf.value
if full_path:
return os.path.basename(full_path).lower()
finally:
kernel32.CloseHandle(handle)
return None
except Exception as e:
logger.error(f"Failed to get foreground process: {e}")
return None
async def get_running_processes(self) -> Set[str]:
"""Get set of lowercase process names (async-safe)."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._get_running_processes_sync)
async def get_topmost_process(self) -> Optional[str]:
"""Get lowercase process name of the foreground window (async-safe)."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._get_topmost_process_sync)

View File

@@ -0,0 +1,216 @@
"""Profile engine — background loop that evaluates conditions and manages targets."""
import asyncio
from datetime import datetime, timezone
from typing import Dict, Optional, Set
from wled_controller.core.profiles.platform_detector import PlatformDetector
from wled_controller.storage.profile import ApplicationCondition, Condition, Profile
from wled_controller.storage.profile_store import ProfileStore
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class ProfileEngine:
"""Evaluates profile conditions and starts/stops targets accordingly."""
def __init__(self, profile_store: ProfileStore, processor_manager, poll_interval: float = 3.0):
self._store = profile_store
self._manager = processor_manager
self._poll_interval = poll_interval
self._detector = PlatformDetector()
self._task: Optional[asyncio.Task] = None
# Runtime state (not persisted)
# profile_id → set of target_ids that THIS profile started
self._active_profiles: Dict[str, Set[str]] = {}
# profile_id → datetime of last activation / deactivation
self._last_activated: Dict[str, datetime] = {}
self._last_deactivated: Dict[str, datetime] = {}
async def start(self) -> None:
if self._task is not None:
return
self._task = asyncio.create_task(self._poll_loop())
logger.info("Profile engine started")
async def stop(self) -> None:
if self._task is None:
return
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
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)
logger.info("Profile engine stopped")
async def _poll_loop(self) -> None:
try:
while True:
try:
await self._evaluate_all()
except Exception as e:
logger.error(f"Profile evaluation error: {e}", exc_info=True)
await asyncio.sleep(self._poll_interval)
except asyncio.CancelledError:
pass
async def _evaluate_all(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)
return
# Gather platform state once per cycle
running_procs = await self._detector.get_running_processes()
topmost_proc = await self._detector.get_topmost_process()
active_profile_ids = set()
for profile in profiles:
should_be_active = (
profile.enabled
and len(profile.conditions) > 0
and self._evaluate_conditions(profile, running_procs, topmost_proc)
)
is_active = profile.id in self._active_profiles
if should_be_active and not is_active:
await self._activate_profile(profile)
active_profile_ids.add(profile.id)
elif should_be_active and is_active:
active_profile_ids.add(profile.id)
elif not should_be_active and is_active:
await self._deactivate_profile(profile.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)
def _evaluate_conditions(
self, profile: Profile, running_procs: Set[str], topmost_proc: Optional[str]
) -> bool:
results = [
self._evaluate_condition(c, running_procs, topmost_proc)
for c in profile.conditions
]
if profile.condition_logic == "and":
return all(results)
return any(results) # "or" is default
def _evaluate_condition(
self, condition: Condition, running_procs: Set[str], topmost_proc: Optional[str]
) -> bool:
if isinstance(condition, ApplicationCondition):
return self._evaluate_app_condition(condition, running_procs, topmost_proc)
return False
def _evaluate_app_condition(
self,
condition: ApplicationCondition,
running_procs: Set[str],
topmost_proc: Optional[str],
) -> bool:
if not condition.apps:
return False
apps_lower = [a.lower() for a in condition.apps]
if condition.match_type == "topmost":
if topmost_proc is None:
return False
return any(app == topmost_proc for app in apps_lower)
# Default: "running"
return any(app in running_procs for app in apps_lower)
async def _activate_profile(self, profile: Profile) -> None:
started: Set[str] = set()
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:
logger.warning(f"Profile '{profile.name}' failed to start target {target_id}: {e}")
if started:
self._active_profiles[profile.id] = started
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)")
else:
logger.debug(f"Profile '{profile.name}' matched but no targets started — will retry")
async def _deactivate_profile(self, profile_id: str) -> None:
owned = self._active_profiles.pop(profile_id, set())
stopped = []
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}")
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)")
def _fire_event(self, profile_id: str, action: str, target_ids: list) -> None:
try:
self._manager._fire_event({
"type": "profile_state_changed",
"profile_id": profile_id,
"action": action,
"target_ids": target_ids,
})
except Exception:
pass
# ===== 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
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),
}
def get_all_profile_states(self) -> Dict[str, dict]:
"""Get runtime states of all profiles."""
result = {}
for profile in self._store.get_all_profiles():
result[profile.id] = self.get_profile_state(profile.id)
return result
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)