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

@@ -9,6 +9,7 @@ from .routes.postprocessing import router as postprocessing_router
from .routes.picture_sources import router as picture_sources_router
from .routes.pattern_templates import router as pattern_templates_router
from .routes.picture_targets import router as picture_targets_router
from .routes.profiles import router as profiles_router
router = APIRouter()
router.include_router(system_router)
@@ -18,5 +19,6 @@ router.include_router(postprocessing_router)
router.include_router(pattern_templates_router)
router.include_router(picture_sources_router)
router.include_router(picture_targets_router)
router.include_router(profiles_router)
__all__ = ["router"]

View File

@@ -7,6 +7,8 @@ from wled_controller.storage.postprocessing_template_store import Postprocessing
from wled_controller.storage.pattern_template_store import PatternTemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_target_store import PictureTargetStore
from wled_controller.storage.profile_store import ProfileStore
from wled_controller.core.profiles.profile_engine import ProfileEngine
# Global instances (initialized in main.py)
_device_store: DeviceStore | None = None
@@ -16,6 +18,8 @@ _pattern_template_store: PatternTemplateStore | None = None
_picture_source_store: PictureSourceStore | None = None
_picture_target_store: PictureTargetStore | None = None
_processor_manager: ProcessorManager | None = None
_profile_store: ProfileStore | None = None
_profile_engine: ProfileEngine | None = None
def get_device_store() -> DeviceStore:
@@ -67,6 +71,20 @@ def get_processor_manager() -> ProcessorManager:
return _processor_manager
def get_profile_store() -> ProfileStore:
"""Get profile store dependency."""
if _profile_store is None:
raise RuntimeError("Profile store not initialized")
return _profile_store
def get_profile_engine() -> ProfileEngine:
"""Get profile engine dependency."""
if _profile_engine is None:
raise RuntimeError("Profile engine not initialized")
return _profile_engine
def init_dependencies(
device_store: DeviceStore,
template_store: TemplateStore,
@@ -75,10 +93,13 @@ def init_dependencies(
pattern_template_store: PatternTemplateStore | None = None,
picture_source_store: PictureSourceStore | None = None,
picture_target_store: PictureTargetStore | None = None,
profile_store: ProfileStore | None = None,
profile_engine: ProfileEngine | None = None,
):
"""Initialize global dependencies."""
global _device_store, _template_store, _processor_manager
global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store
global _profile_store, _profile_engine
_device_store = device_store
_template_store = template_store
_processor_manager = processor_manager
@@ -86,3 +107,5 @@ def init_dependencies(
_pattern_template_store = pattern_template_store
_picture_source_store = picture_source_store
_picture_target_store = picture_target_store
_profile_store = profile_store
_profile_engine = profile_engine

View File

@@ -0,0 +1,255 @@
"""Profile management API routes."""
from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
get_picture_target_store,
get_profile_engine,
get_profile_store,
)
from wled_controller.api.schemas.profiles import (
ConditionSchema,
ProfileCreate,
ProfileListResponse,
ProfileResponse,
ProfileUpdate,
)
from wled_controller.core.profiles.profile_engine import ProfileEngine
from wled_controller.storage.picture_target_store import PictureTargetStore
from wled_controller.storage.profile import ApplicationCondition, Condition
from wled_controller.storage.profile_store import ProfileStore
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
# ===== Helpers =====
def _condition_from_schema(s: ConditionSchema) -> Condition:
if s.condition_type == "application":
return ApplicationCondition(
apps=s.apps or [],
match_type=s.match_type or "running",
)
raise ValueError(f"Unknown condition type: {s.condition_type}")
def _condition_to_schema(c: Condition) -> ConditionSchema:
d = c.to_dict()
return ConditionSchema(**d)
def _profile_to_response(profile, engine: ProfileEngine) -> ProfileResponse:
state = engine.get_profile_state(profile.id)
return ProfileResponse(
id=profile.id,
name=profile.name,
enabled=profile.enabled,
condition_logic=profile.condition_logic,
conditions=[_condition_to_schema(c) for c in profile.conditions],
target_ids=profile.target_ids,
is_active=state["is_active"],
active_target_ids=state["active_target_ids"],
last_activated_at=state.get("last_activated_at"),
last_deactivated_at=state.get("last_deactivated_at"),
created_at=profile.created_at,
updated_at=profile.updated_at,
)
def _validate_condition_logic(logic: str) -> None:
if logic not in ("or", "and"):
raise HTTPException(status_code=400, detail=f"Invalid condition_logic: {logic}. Must be 'or' or 'and'.")
def _validate_target_ids(target_ids: list, target_store: PictureTargetStore) -> None:
for tid in target_ids:
try:
target_store.get_target(tid)
except ValueError:
raise HTTPException(status_code=400, detail=f"Target not found: {tid}")
# ===== CRUD Endpoints =====
@router.post(
"/api/v1/profiles",
response_model=ProfileResponse,
tags=["Profiles"],
status_code=201,
)
async def create_profile(
data: ProfileCreate,
_auth: AuthRequired,
store: ProfileStore = Depends(get_profile_store),
engine: ProfileEngine = Depends(get_profile_engine),
target_store: PictureTargetStore = Depends(get_picture_target_store),
):
"""Create a new profile."""
_validate_condition_logic(data.condition_logic)
_validate_target_ids(data.target_ids, target_store)
try:
conditions = [_condition_from_schema(c) for c in data.conditions]
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
profile = store.create_profile(
name=data.name,
enabled=data.enabled,
condition_logic=data.condition_logic,
conditions=conditions,
target_ids=data.target_ids,
)
return _profile_to_response(profile, engine)
@router.get(
"/api/v1/profiles",
response_model=ProfileListResponse,
tags=["Profiles"],
)
async def list_profiles(
_auth: AuthRequired,
store: ProfileStore = Depends(get_profile_store),
engine: ProfileEngine = Depends(get_profile_engine),
):
"""List all profiles."""
profiles = store.get_all_profiles()
return ProfileListResponse(
profiles=[_profile_to_response(p, engine) for p in profiles],
count=len(profiles),
)
@router.get(
"/api/v1/profiles/{profile_id}",
response_model=ProfileResponse,
tags=["Profiles"],
)
async def get_profile(
profile_id: str,
_auth: AuthRequired,
store: ProfileStore = Depends(get_profile_store),
engine: ProfileEngine = Depends(get_profile_engine),
):
"""Get a single profile."""
try:
profile = store.get_profile(profile_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return _profile_to_response(profile, engine)
@router.put(
"/api/v1/profiles/{profile_id}",
response_model=ProfileResponse,
tags=["Profiles"],
)
async def update_profile(
profile_id: str,
data: ProfileUpdate,
_auth: AuthRequired,
store: ProfileStore = Depends(get_profile_store),
engine: ProfileEngine = Depends(get_profile_engine),
target_store: PictureTargetStore = Depends(get_picture_target_store),
):
"""Update a profile."""
if data.condition_logic is not None:
_validate_condition_logic(data.condition_logic)
if data.target_ids is not None:
_validate_target_ids(data.target_ids, target_store)
conditions = None
if data.conditions is not None:
try:
conditions = [_condition_from_schema(c) for c in data.conditions]
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
try:
# If disabling, deactivate first
if data.enabled is False:
await engine.deactivate_if_active(profile_id)
profile = store.update_profile(
profile_id=profile_id,
name=data.name,
enabled=data.enabled,
condition_logic=data.condition_logic,
conditions=conditions,
target_ids=data.target_ids,
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return _profile_to_response(profile, engine)
@router.delete(
"/api/v1/profiles/{profile_id}",
status_code=204,
tags=["Profiles"],
)
async def delete_profile(
profile_id: str,
_auth: AuthRequired,
store: ProfileStore = Depends(get_profile_store),
engine: ProfileEngine = Depends(get_profile_engine),
):
"""Delete a profile."""
# Deactivate first (stop owned targets)
await engine.deactivate_if_active(profile_id)
try:
store.delete_profile(profile_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
# ===== Enable/Disable =====
@router.post(
"/api/v1/profiles/{profile_id}/enable",
response_model=ProfileResponse,
tags=["Profiles"],
)
async def enable_profile(
profile_id: str,
_auth: AuthRequired,
store: ProfileStore = Depends(get_profile_store),
engine: ProfileEngine = Depends(get_profile_engine),
):
"""Enable a profile."""
try:
profile = store.update_profile(profile_id=profile_id, enabled=True)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return _profile_to_response(profile, engine)
@router.post(
"/api/v1/profiles/{profile_id}/disable",
response_model=ProfileResponse,
tags=["Profiles"],
)
async def disable_profile(
profile_id: str,
_auth: AuthRequired,
store: ProfileStore = Depends(get_profile_store),
engine: ProfileEngine = Depends(get_profile_engine),
):
"""Disable a profile and stop any targets it owns."""
await engine.deactivate_if_active(profile_id)
try:
profile = store.update_profile(profile_id=profile_id, enabled=False)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return _profile_to_response(profile, engine)

View File

@@ -11,6 +11,7 @@ from wled_controller.api.schemas.system import (
DisplayInfo,
DisplayListResponse,
HealthResponse,
ProcessListResponse,
VersionResponse,
)
from wled_controller.core.capture.screen_capture import get_available_displays
@@ -91,3 +92,24 @@ async def get_displays(_: AuthRequired):
status_code=500,
detail=f"Failed to retrieve display information: {str(e)}"
)
@router.get("/api/v1/system/processes", response_model=ProcessListResponse, tags=["Config"])
async def get_running_processes(_: AuthRequired):
"""Get list of currently running process names.
Returns a sorted list of unique process names for use in profile conditions.
"""
from wled_controller.core.profiles.platform_detector import PlatformDetector
try:
detector = PlatformDetector()
processes = await detector.get_running_processes()
sorted_procs = sorted(processes)
return ProcessListResponse(processes=sorted_procs, count=len(sorted_procs))
except Exception as e:
logger.error(f"Failed to get processes: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to retrieve process list: {str(e)}"
)

View File

@@ -0,0 +1,58 @@
"""Profile-related schemas."""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class ConditionSchema(BaseModel):
"""A single condition within a profile."""
condition_type: str = Field(description="Condition type discriminator (e.g. 'application')")
apps: Optional[List[str]] = Field(None, description="Process names (for application condition)")
match_type: Optional[str] = Field(None, description="'running' or 'topmost' (for application condition)")
class ProfileCreate(BaseModel):
"""Request to create a profile."""
name: str = Field(description="Profile name", min_length=1, max_length=100)
enabled: bool = Field(default=True, description="Whether the profile is enabled")
condition_logic: str = Field(default="or", description="How conditions combine: 'or' or 'and'")
conditions: List[ConditionSchema] = Field(default_factory=list, description="List of conditions")
target_ids: List[str] = Field(default_factory=list, description="Target IDs to activate")
class ProfileUpdate(BaseModel):
"""Request to update a profile."""
name: Optional[str] = Field(None, description="Profile name", min_length=1, max_length=100)
enabled: Optional[bool] = Field(None, description="Whether the profile is enabled")
condition_logic: Optional[str] = Field(None, description="How conditions combine: 'or' or 'and'")
conditions: Optional[List[ConditionSchema]] = Field(None, description="List of conditions")
target_ids: Optional[List[str]] = Field(None, description="Target IDs to activate")
class ProfileResponse(BaseModel):
"""Profile information response."""
id: str = Field(description="Profile ID")
name: str = Field(description="Profile name")
enabled: bool = Field(description="Whether the profile is enabled")
condition_logic: str = Field(description="Condition combination logic")
conditions: List[ConditionSchema] = Field(description="List of conditions")
target_ids: List[str] = Field(description="Target IDs to activate")
is_active: bool = Field(default=False, description="Whether the profile is currently active")
active_target_ids: List[str] = Field(default_factory=list, description="Targets currently owned by this profile")
last_activated_at: Optional[datetime] = Field(None, description="Last time this profile was activated")
last_deactivated_at: Optional[datetime] = Field(None, description="Last time this profile was deactivated")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
class ProfileListResponse(BaseModel):
"""List of profiles response."""
profiles: List[ProfileResponse] = Field(description="List of profiles")
count: int = Field(description="Number of profiles")

View File

@@ -40,3 +40,10 @@ class DisplayListResponse(BaseModel):
displays: List[DisplayInfo] = Field(description="Available displays")
count: int = Field(description="Number of displays")
class ProcessListResponse(BaseModel):
"""List of running processes."""
processes: List[str] = Field(description="Sorted list of unique process names")
count: int = Field(description="Number of unique processes")