Files
wled-screen-controller-mixed/server/src/wled_controller/api/routes/automations.py
alexei.dolgolyov 05152a0f51 Settings tabs, log overlay, external URL, Sources tree restructure, audio fixes
- Settings modal split into 3 tabs: General, Backup, MQTT
- Log viewer moved to full-screen overlay with compact toolbar
- External URL setting: API endpoints + UI for configuring server domain
  used in webhook/WS URLs instead of auto-detected local IP
- Sources tab tree restructured: Picture Source (Screen Capture/Static/
  Processed sub-groups), Color Strip, Audio, Utility
- TreeNav extended to support nested groups (3-level tree)
- Audio tab split into Sources and Templates sub-tabs
- Fix audio template test: device picker now filters by engine type
  (was showing WASAPI indices for sounddevice templates)
- Audio template test device picker disabled during active test
- Rename "Input Source" to "Source" in CSS test preview (en/ru/zh)
- Fix i18n: log filter/level items deferred to avoid stale t() calls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:16:57 +03:00

357 lines
12 KiB
Python

"""Automation management API routes."""
import secrets
from fastapi import APIRouter, Depends, HTTPException, Request
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_automation_engine,
get_automation_store,
get_scene_preset_store,
)
from wled_controller.api.schemas.automations import (
AutomationCreate,
AutomationListResponse,
AutomationResponse,
AutomationUpdate,
ConditionSchema,
)
from wled_controller.core.automations.automation_engine import AutomationEngine
from wled_controller.storage.automation import (
AlwaysCondition,
ApplicationCondition,
Condition,
DisplayStateCondition,
MQTTCondition,
StartupCondition,
SystemIdleCondition,
TimeOfDayCondition,
WebhookCondition,
)
from wled_controller.storage.automation_store import AutomationStore
from wled_controller.storage.scene_preset_store import ScenePresetStore
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 == "always":
return AlwaysCondition()
if s.condition_type == "application":
return ApplicationCondition(
apps=s.apps or [],
match_type=s.match_type or "running",
)
if s.condition_type == "time_of_day":
return TimeOfDayCondition(
start_time=s.start_time or "00:00",
end_time=s.end_time or "23:59",
)
if s.condition_type == "system_idle":
return SystemIdleCondition(
idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5,
when_idle=s.when_idle if s.when_idle is not None else True,
)
if s.condition_type == "display_state":
return DisplayStateCondition(
state=s.state or "on",
)
if s.condition_type == "mqtt":
return MQTTCondition(
topic=s.topic or "",
payload=s.payload or "",
match_mode=s.match_mode or "exact",
)
if s.condition_type == "webhook":
return WebhookCondition(
token=s.token or secrets.token_hex(16),
)
if s.condition_type == "startup":
return StartupCondition()
raise ValueError(f"Unknown condition type: {s.condition_type}")
def _condition_to_schema(c: Condition) -> ConditionSchema:
d = c.to_dict()
return ConditionSchema(**d)
def _automation_to_response(automation, engine: AutomationEngine, request: Request = None) -> AutomationResponse:
state = engine.get_automation_state(automation.id)
# Build webhook URL from the first webhook condition (if any)
webhook_url = None
for c in automation.conditions:
if isinstance(c, WebhookCondition) and c.token:
# Prefer configured external URL, fall back to request base URL
from wled_controller.api.routes.system import load_external_url
ext = load_external_url()
if ext:
webhook_url = ext + f"/api/v1/webhooks/{c.token}"
elif request:
webhook_url = str(request.base_url).rstrip("/") + f"/api/v1/webhooks/{c.token}"
else:
webhook_url = f"/api/v1/webhooks/{c.token}"
break
return AutomationResponse(
id=automation.id,
name=automation.name,
enabled=automation.enabled,
condition_logic=automation.condition_logic,
conditions=[_condition_to_schema(c) for c in automation.conditions],
scene_preset_id=automation.scene_preset_id,
deactivation_mode=automation.deactivation_mode,
deactivation_scene_preset_id=automation.deactivation_scene_preset_id,
webhook_url=webhook_url,
is_active=state["is_active"],
last_activated_at=state.get("last_activated_at"),
last_deactivated_at=state.get("last_deactivated_at"),
tags=getattr(automation, 'tags', []),
created_at=automation.created_at,
updated_at=automation.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_scene_refs(
scene_preset_id: str | None,
deactivation_scene_preset_id: str | None,
scene_store: ScenePresetStore,
) -> None:
"""Validate that referenced scene preset IDs exist."""
for sid, label in [
(scene_preset_id, "scene_preset_id"),
(deactivation_scene_preset_id, "deactivation_scene_preset_id"),
]:
if sid is not None:
try:
scene_store.get_preset(sid)
except ValueError:
raise HTTPException(status_code=400, detail=f"Scene preset not found: {sid} ({label})")
# ===== CRUD Endpoints =====
@router.post(
"/api/v1/automations",
response_model=AutomationResponse,
tags=["Automations"],
status_code=201,
)
async def create_automation(
request: Request,
data: AutomationCreate,
_auth: AuthRequired,
store: AutomationStore = Depends(get_automation_store),
engine: AutomationEngine = Depends(get_automation_engine),
scene_store: ScenePresetStore = Depends(get_scene_preset_store),
):
"""Create a new automation."""
_validate_condition_logic(data.condition_logic)
_validate_scene_refs(data.scene_preset_id, data.deactivation_scene_preset_id, scene_store)
try:
conditions = [_condition_from_schema(c) for c in data.conditions]
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
automation = store.create_automation(
name=data.name,
enabled=data.enabled,
condition_logic=data.condition_logic,
conditions=conditions,
scene_preset_id=data.scene_preset_id,
deactivation_mode=data.deactivation_mode,
deactivation_scene_preset_id=data.deactivation_scene_preset_id,
tags=data.tags,
)
if automation.enabled:
await engine.trigger_evaluate()
fire_entity_event("automation", "created", automation.id)
return _automation_to_response(automation, engine, request)
@router.get(
"/api/v1/automations",
response_model=AutomationListResponse,
tags=["Automations"],
)
async def list_automations(
request: Request,
_auth: AuthRequired,
store: AutomationStore = Depends(get_automation_store),
engine: AutomationEngine = Depends(get_automation_engine),
):
"""List all automations."""
automations = store.get_all_automations()
return AutomationListResponse(
automations=[_automation_to_response(a, engine, request) for a in automations],
count=len(automations),
)
@router.get(
"/api/v1/automations/{automation_id}",
response_model=AutomationResponse,
tags=["Automations"],
)
async def get_automation(
request: Request,
automation_id: str,
_auth: AuthRequired,
store: AutomationStore = Depends(get_automation_store),
engine: AutomationEngine = Depends(get_automation_engine),
):
"""Get a single automation."""
try:
automation = store.get_automation(automation_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return _automation_to_response(automation, engine, request)
@router.put(
"/api/v1/automations/{automation_id}",
response_model=AutomationResponse,
tags=["Automations"],
)
async def update_automation(
request: Request,
automation_id: str,
data: AutomationUpdate,
_auth: AuthRequired,
store: AutomationStore = Depends(get_automation_store),
engine: AutomationEngine = Depends(get_automation_engine),
scene_store: ScenePresetStore = Depends(get_scene_preset_store),
):
"""Update an automation."""
if data.condition_logic is not None:
_validate_condition_logic(data.condition_logic)
# Validate scene refs (only the ones being updated)
_validate_scene_refs(data.scene_preset_id, data.deactivation_scene_preset_id, scene_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(automation_id)
# Build update kwargs — use sentinel for Optional[str] fields
update_kwargs = dict(
automation_id=automation_id,
name=data.name,
enabled=data.enabled,
condition_logic=data.condition_logic,
conditions=conditions,
deactivation_mode=data.deactivation_mode,
tags=data.tags,
)
if data.scene_preset_id is not None:
update_kwargs["scene_preset_id"] = data.scene_preset_id
if data.deactivation_scene_preset_id is not None:
update_kwargs["deactivation_scene_preset_id"] = data.deactivation_scene_preset_id
automation = store.update_automation(**update_kwargs)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
# Re-evaluate immediately if automation is enabled (may have new conditions/scene)
if automation.enabled:
await engine.trigger_evaluate()
fire_entity_event("automation", "updated", automation_id)
return _automation_to_response(automation, engine, request)
@router.delete(
"/api/v1/automations/{automation_id}",
status_code=204,
tags=["Automations"],
)
async def delete_automation(
automation_id: str,
_auth: AuthRequired,
store: AutomationStore = Depends(get_automation_store),
engine: AutomationEngine = Depends(get_automation_engine),
):
"""Delete an automation."""
# Deactivate first
await engine.deactivate_if_active(automation_id)
try:
store.delete_automation(automation_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
fire_entity_event("automation", "deleted", automation_id)
# ===== Enable/Disable =====
@router.post(
"/api/v1/automations/{automation_id}/enable",
response_model=AutomationResponse,
tags=["Automations"],
)
async def enable_automation(
request: Request,
automation_id: str,
_auth: AuthRequired,
store: AutomationStore = Depends(get_automation_store),
engine: AutomationEngine = Depends(get_automation_engine),
):
"""Enable an automation."""
try:
automation = store.update_automation(automation_id=automation_id, enabled=True)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
# Evaluate immediately so scene activates without waiting for the next poll cycle
await engine.trigger_evaluate()
return _automation_to_response(automation, engine, request)
@router.post(
"/api/v1/automations/{automation_id}/disable",
response_model=AutomationResponse,
tags=["Automations"],
)
async def disable_automation(
request: Request,
automation_id: str,
_auth: AuthRequired,
store: AutomationStore = Depends(get_automation_store),
engine: AutomationEngine = Depends(get_automation_engine),
):
"""Disable an automation and deactivate it."""
await engine.deactivate_if_active(automation_id)
try:
automation = store.update_automation(automation_id=automation_id, enabled=False)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return _automation_to_response(automation, engine, request)