Rename profiles to automations across backend and frontend
Rename the "profiles" entity to "automations" throughout the entire codebase for clarity. Updates Python models, storage, API routes/schemas, engine, frontend JS modules, HTML templates, CSS classes, i18n keys (en/ru/zh), dashboard, tutorials, and command palette. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
315
server/src/wled_controller/api/routes/automations.py
Normal file
315
server/src/wled_controller/api/routes/automations.py
Normal file
@@ -0,0 +1,315 @@
|
||||
"""Automation management API routes."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
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,
|
||||
SystemIdleCondition,
|
||||
TimeOfDayCondition,
|
||||
)
|
||||
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",
|
||||
)
|
||||
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) -> AutomationResponse:
|
||||
state = engine.get_automation_state(automation.id)
|
||||
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,
|
||||
is_active=state["is_active"],
|
||||
last_activated_at=state.get("last_activated_at"),
|
||||
last_deactivated_at=state.get("last_deactivated_at"),
|
||||
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(
|
||||
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,
|
||||
)
|
||||
|
||||
if automation.enabled:
|
||||
await engine.trigger_evaluate()
|
||||
|
||||
return _automation_to_response(automation, engine)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/automations",
|
||||
response_model=AutomationListResponse,
|
||||
tags=["Automations"],
|
||||
)
|
||||
async def list_automations(
|
||||
_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) for a in automations],
|
||||
count=len(automations),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/automations/{automation_id}",
|
||||
response_model=AutomationResponse,
|
||||
tags=["Automations"],
|
||||
)
|
||||
async def get_automation(
|
||||
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)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/automations/{automation_id}",
|
||||
response_model=AutomationResponse,
|
||||
tags=["Automations"],
|
||||
)
|
||||
async def update_automation(
|
||||
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,
|
||||
)
|
||||
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()
|
||||
|
||||
return _automation_to_response(automation, engine)
|
||||
|
||||
|
||||
@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))
|
||||
|
||||
|
||||
# ===== Enable/Disable =====
|
||||
|
||||
@router.post(
|
||||
"/api/v1/automations/{automation_id}/enable",
|
||||
response_model=AutomationResponse,
|
||||
tags=["Automations"],
|
||||
)
|
||||
async def enable_automation(
|
||||
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)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/automations/{automation_id}/disable",
|
||||
response_model=AutomationResponse,
|
||||
tags=["Automations"],
|
||||
)
|
||||
async def disable_automation(
|
||||
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)
|
||||
Reference in New Issue
Block a user