- Add static_color capability to WLED and serial providers with native set_color() dispatch (WLED uses JSON API, serial uses idle client) - Encapsulate device-specific logic in providers instead of device_type checks in ProcessorManager and API routes - Add HAOS light entity for devices with brightness_control + static_color (Adalight/AmbiLED get light entity, WLED keeps number entity) - Fix serial device brightness and turn-off: pass software_brightness through provider chain, clear device on color=null, re-send static color after brightness change - Add global events WebSocket (events-ws.js) replacing per-tab WS, enabling real-time profile state updates on both dashboard and profiles tabs - Fix profile activation: mark active when all targets already running, add asyncio.Lock to prevent concurrent evaluation races, skip process enumeration when no profile has conditions, trigger immediate evaluation on enable/create/update for instant target startup - Add reliable server restart script (restart.ps1) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
266 lines
7.8 KiB
Python
266 lines
7.8 KiB
Python
"""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,
|
|
)
|
|
|
|
if profile.enabled:
|
|
await engine.trigger_evaluate()
|
|
|
|
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))
|
|
|
|
# Re-evaluate immediately if profile is enabled (may have new conditions/targets)
|
|
if profile.enabled:
|
|
await engine.trigger_evaluate()
|
|
|
|
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))
|
|
|
|
# Evaluate immediately so targets start without waiting for the next poll cycle
|
|
await engine.trigger_evaluate()
|
|
|
|
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)
|