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:
@@ -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}")
|
||||
|
||||
|
||||
1
server/src/wled_controller/core/profiles/__init__.py
Normal file
1
server/src/wled_controller/core/profiles/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Profile automation — condition evaluation and target management."""
|
||||
@@ -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)
|
||||
216
server/src/wled_controller/core/profiles/profile_engine.py
Normal file
216
server/src/wled_controller/core/profiles/profile_engine.py
Normal 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)
|
||||
Reference in New Issue
Block a user