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:
@@ -16,6 +16,7 @@ from .routes.audio_templates import router as audio_templates_router
|
||||
from .routes.value_sources import router as value_sources_router
|
||||
from .routes.automations import router as automations_router
|
||||
from .routes.scene_presets import router as scene_presets_router
|
||||
from .routes.webhooks import router as webhooks_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(system_router)
|
||||
@@ -32,5 +33,6 @@ router.include_router(value_sources_router)
|
||||
router.include_router(picture_targets_router)
|
||||
router.include_router(automations_router)
|
||||
router.include_router(scene_presets_router)
|
||||
router.include_router(webhooks_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
57
server/src/wled_controller/api/routes/webhooks.py
Normal file
57
server/src/wled_controller/api/routes/webhooks.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Webhook endpoint for automation triggers.
|
||||
|
||||
External services call POST /api/v1/webhooks/{token} with a JSON body
|
||||
containing {"action": "activate"} or {"action": "deactivate"} to control
|
||||
automations that have a webhook condition. No API-key auth is required —
|
||||
the secret token itself authenticates the caller.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from wled_controller.api.dependencies import get_automation_engine, get_automation_store
|
||||
from wled_controller.core.automations.automation_engine import AutomationEngine
|
||||
from wled_controller.storage.automation import WebhookCondition
|
||||
from wled_controller.storage.automation_store import AutomationStore
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class WebhookPayload(BaseModel):
|
||||
action: str = Field(description="'activate' or 'deactivate'")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/webhooks/{token}",
|
||||
tags=["Webhooks"],
|
||||
)
|
||||
async def handle_webhook(
|
||||
token: str,
|
||||
body: WebhookPayload,
|
||||
store: AutomationStore = Depends(get_automation_store),
|
||||
engine: AutomationEngine = Depends(get_automation_engine),
|
||||
):
|
||||
"""Receive a webhook call and set the corresponding condition state."""
|
||||
if body.action not in ("activate", "deactivate"):
|
||||
raise HTTPException(status_code=400, detail="action must be 'activate' or 'deactivate'")
|
||||
|
||||
# Find the automation that owns this token
|
||||
for automation in store.get_all_automations():
|
||||
for condition in automation.conditions:
|
||||
if isinstance(condition, WebhookCondition) and condition.token == token:
|
||||
active = body.action == "activate"
|
||||
await engine.set_webhook_state(token, active)
|
||||
logger.info(
|
||||
"Webhook %s: automation '%s' (%s) → %s",
|
||||
token[:8], automation.name, automation.id, body.action,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"automation_id": automation.id,
|
||||
"automation_name": automation.name,
|
||||
"action": body.action,
|
||||
}
|
||||
|
||||
raise HTTPException(status_code=404, detail="Webhook token not found")
|
||||
@@ -25,6 +25,8 @@ class ConditionSchema(BaseModel):
|
||||
topic: Optional[str] = Field(None, description="MQTT topic to watch (for mqtt condition)")
|
||||
payload: Optional[str] = Field(None, description="Expected payload value (for mqtt condition)")
|
||||
match_mode: Optional[str] = Field(None, description="'exact', 'contains', or 'regex' (for mqtt condition)")
|
||||
# Webhook condition fields
|
||||
token: Optional[str] = Field(None, description="Secret token for webhook URL (for webhook condition)")
|
||||
|
||||
|
||||
class AutomationCreate(BaseModel):
|
||||
@@ -62,6 +64,7 @@ class AutomationResponse(BaseModel):
|
||||
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
|
||||
deactivation_mode: str = Field(default="none", description="Deactivation behavior")
|
||||
deactivation_scene_preset_id: Optional[str] = Field(None, description="Fallback scene preset")
|
||||
webhook_url: Optional[str] = Field(None, description="Webhook URL for the first webhook condition (if any)")
|
||||
is_active: bool = Field(default=False, description="Whether the automation is currently active")
|
||||
last_activated_at: Optional[datetime] = Field(None, description="Last time this automation was activated")
|
||||
last_deactivated_at: Optional[datetime] = Field(None, description="Last time this automation was deactivated")
|
||||
|
||||
Reference in New Issue
Block a user