Add webhook trigger condition for automations

Per-automation webhook URL with auto-generated 128-bit hex token.
External services (Home Assistant, IFTTT, curl) can POST to
/api/v1/webhooks/{token} with {"action": "activate"|"deactivate"}
to control automation state — no API key required (token is auth).

Backend: WebhookCondition model, engine state tracking with
immediate evaluation, webhook endpoint, schema/route updates.
Frontend: webhook option in condition editor, URL display with
copy button, card badge, i18n for en/ru/zh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 18:28:31 +03:00
parent 01104acad1
commit aafcf83896
12 changed files with 221 additions and 9 deletions

View File

@@ -1,6 +1,8 @@
"""Automation management API routes."""
from fastapi import APIRouter, Depends, HTTPException
import secrets
from fastapi import APIRouter, Depends, HTTPException, Request
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
@@ -24,6 +26,7 @@ from wled_controller.storage.automation import (
MQTTCondition,
SystemIdleCondition,
TimeOfDayCondition,
WebhookCondition,
)
from wled_controller.storage.automation_store import AutomationStore
from wled_controller.storage.scene_preset_store import ScenePresetStore
@@ -63,6 +66,10 @@ def _condition_from_schema(s: ConditionSchema) -> Condition:
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),
)
raise ValueError(f"Unknown condition type: {s.condition_type}")
@@ -71,8 +78,19 @@ def _condition_to_schema(c: Condition) -> ConditionSchema:
return ConditionSchema(**d)
def _automation_to_response(automation, engine: AutomationEngine) -> AutomationResponse:
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:
if 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,
@@ -82,6 +100,7 @@ def _automation_to_response(automation, engine: AutomationEngine) -> AutomationR
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"),
@@ -121,6 +140,7 @@ def _validate_scene_refs(
status_code=201,
)
async def create_automation(
request: Request,
data: AutomationCreate,
_auth: AuthRequired,
store: AutomationStore = Depends(get_automation_store),
@@ -149,7 +169,7 @@ async def create_automation(
if automation.enabled:
await engine.trigger_evaluate()
return _automation_to_response(automation, engine)
return _automation_to_response(automation, engine, request)
@router.get(
@@ -158,6 +178,7 @@ async def create_automation(
tags=["Automations"],
)
async def list_automations(
request: Request,
_auth: AuthRequired,
store: AutomationStore = Depends(get_automation_store),
engine: AutomationEngine = Depends(get_automation_engine),
@@ -165,7 +186,7 @@ async def list_automations(
"""List all automations."""
automations = store.get_all_automations()
return AutomationListResponse(
automations=[_automation_to_response(a, engine) for a in automations],
automations=[_automation_to_response(a, engine, request) for a in automations],
count=len(automations),
)
@@ -176,6 +197,7 @@ async def list_automations(
tags=["Automations"],
)
async def get_automation(
request: Request,
automation_id: str,
_auth: AuthRequired,
store: AutomationStore = Depends(get_automation_store),
@@ -187,7 +209,7 @@ async def get_automation(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return _automation_to_response(automation, engine)
return _automation_to_response(automation, engine, request)
@router.put(
@@ -196,6 +218,7 @@ async def get_automation(
tags=["Automations"],
)
async def update_automation(
request: Request,
automation_id: str,
data: AutomationUpdate,
_auth: AuthRequired,
@@ -244,7 +267,7 @@ async def update_automation(
if automation.enabled:
await engine.trigger_evaluate()
return _automation_to_response(automation, engine)
return _automation_to_response(automation, engine, request)
@router.delete(
@@ -276,6 +299,7 @@ async def delete_automation(
tags=["Automations"],
)
async def enable_automation(
request: Request,
automation_id: str,
_auth: AuthRequired,
store: AutomationStore = Depends(get_automation_store),
@@ -290,7 +314,7 @@ async def enable_automation(
# Evaluate immediately so scene activates without waiting for the next poll cycle
await engine.trigger_evaluate()
return _automation_to_response(automation, engine)
return _automation_to_response(automation, engine, request)
@router.post(
@@ -299,6 +323,7 @@ async def enable_automation(
tags=["Automations"],
)
async def disable_automation(
request: Request,
automation_id: str,
_auth: AuthRequired,
store: AutomationStore = Depends(get_automation_store),
@@ -312,4 +337,4 @@ async def disable_automation(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return _automation_to_response(automation, engine)
return _automation_to_response(automation, engine, request)