From aafcf83896bcdb5f7966bc4c63331c7bb9753249 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 1 Mar 2026 18:28:31 +0300 Subject: [PATCH] Add webhook trigger condition for automations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/src/wled_controller/api/__init__.py | 2 + .../wled_controller/api/routes/automations.py | 41 ++++++++++--- .../wled_controller/api/routes/webhooks.py | 57 +++++++++++++++++++ .../api/schemas/automations.py | 3 + .../core/automations/automation_engine.py | 14 +++++ .../static/css/automations.css | 31 ++++++++++ server/src/wled_controller/static/js/app.js | 3 +- .../static/js/features/automations.js | 42 ++++++++++++++ .../wled_controller/static/locales/en.json | 6 ++ .../wled_controller/static/locales/ru.json | 6 ++ .../wled_controller/static/locales/zh.json | 6 ++ .../src/wled_controller/storage/automation.py | 19 +++++++ 12 files changed, 221 insertions(+), 9 deletions(-) create mode 100644 server/src/wled_controller/api/routes/webhooks.py diff --git a/server/src/wled_controller/api/__init__.py b/server/src/wled_controller/api/__init__.py index f7a45c1..97d01e8 100644 --- a/server/src/wled_controller/api/__init__.py +++ b/server/src/wled_controller/api/__init__.py @@ -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"] diff --git a/server/src/wled_controller/api/routes/automations.py b/server/src/wled_controller/api/routes/automations.py index e1f4c06..44370c9 100644 --- a/server/src/wled_controller/api/routes/automations.py +++ b/server/src/wled_controller/api/routes/automations.py @@ -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) diff --git a/server/src/wled_controller/api/routes/webhooks.py b/server/src/wled_controller/api/routes/webhooks.py new file mode 100644 index 0000000..c26a851 --- /dev/null +++ b/server/src/wled_controller/api/routes/webhooks.py @@ -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") diff --git a/server/src/wled_controller/api/schemas/automations.py b/server/src/wled_controller/api/schemas/automations.py index 38fdd8c..edd53d5 100644 --- a/server/src/wled_controller/api/schemas/automations.py +++ b/server/src/wled_controller/api/schemas/automations.py @@ -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") diff --git a/server/src/wled_controller/core/automations/automation_engine.py b/server/src/wled_controller/core/automations/automation_engine.py index 636aa8f..05686a3 100644 --- a/server/src/wled_controller/core/automations/automation_engine.py +++ b/server/src/wled_controller/core/automations/automation_engine.py @@ -15,6 +15,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 import ScenePreset @@ -55,6 +56,8 @@ class AutomationEngine: # automation_id → datetime of last activation / deactivation self._last_activated: Dict[str, datetime] = {} self._last_deactivated: Dict[str, datetime] = {} + # webhook_token → bool (volatile state set by webhook calls) + self._webhook_states: Dict[str, bool] = {} async def start(self) -> None: if self._task is not None: @@ -213,6 +216,8 @@ class AutomationEngine: return self._evaluate_display_state(condition, display_state) if isinstance(condition, MQTTCondition): return self._evaluate_mqtt(condition) + if isinstance(condition, WebhookCondition): + return self._webhook_states.get(condition.token, False) return False @staticmethod @@ -420,6 +425,15 @@ class AutomationEngine: except Exception as e: logger.error(f"Immediate automation evaluation error: {e}", exc_info=True) + async def set_webhook_state(self, token: str, active: bool) -> None: + """Set webhook condition state and trigger immediate evaluation.""" + self._webhook_states[token] = active + await self.trigger_evaluate() + + def get_webhook_state(self, token: str) -> bool: + """Read current webhook state (False if never called).""" + return self._webhook_states.get(token, False) + async def deactivate_if_active(self, automation_id: str) -> None: """Deactivate an automation immediately (used when disabling/deleting).""" if automation_id in self._active_automations: diff --git a/server/src/wled_controller/static/css/automations.css b/server/src/wled_controller/static/css/automations.css index b2395b8..a198584 100644 --- a/server/src/wled_controller/static/css/automations.css +++ b/server/src/wled_controller/static/css/automations.css @@ -270,3 +270,34 @@ color: var(--text-muted); text-align: center; } + +/* Webhook URL row */ +.webhook-url-row { + display: flex; + gap: 6px; + align-items: center; +} + +.webhook-url-row input { + flex: 1; + padding: 6px 8px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-color); + color: var(--text-color); + font-size: 0.8rem; + font-family: monospace; +} + +.btn-webhook-copy { + white-space: nowrap; + padding: 6px 12px !important; + font-size: 0.8rem !important; +} + +.webhook-save-hint { + color: var(--text-muted); + font-size: 0.85rem; + font-style: italic; + margin: 4px 0 0; +} diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 1f894a2..9afbd80 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -79,7 +79,7 @@ import { import { loadAutomations, openAutomationEditor, closeAutomationEditorModal, saveAutomationEditor, addAutomationCondition, - toggleAutomationEnabled, deleteAutomation, + toggleAutomationEnabled, deleteAutomation, copyWebhookUrl, expandAllAutomationSections, collapseAllAutomationSections, } from './features/automations.js'; import { @@ -311,6 +311,7 @@ Object.assign(window, { addAutomationCondition, toggleAutomationEnabled, deleteAutomation, + copyWebhookUrl, expandAllAutomationSections, collapseAllAutomationSections, diff --git a/server/src/wled_controller/static/js/features/automations.js b/server/src/wled_controller/static/js/features/automations.js index 0ed7b90..0d163d9 100644 --- a/server/src/wled_controller/static/js/features/automations.js +++ b/server/src/wled_controller/static/js/features/automations.js @@ -132,6 +132,9 @@ function createAutomationCard(automation, sceneMap = new Map()) { if (c.condition_type === 'mqtt') { return `${ICON_RADIO} ${t('automations.condition.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}`; } + if (c.condition_type === 'webhook') { + return `🔗 ${t('automations.condition.webhook')}`; + } return `${c.condition_type}`; }); const logicLabel = automation.condition_logic === 'and' ? t('automations.logic.and') : t('automations.logic.or'); @@ -386,6 +389,7 @@ function addAutomationConditionRow(condition) { + @@ -475,6 +479,30 @@ function addAutomationConditionRow(condition) { `; return; } + if (type === 'webhook') { + if (data.token) { + const webhookUrl = window.location.origin + '/api/v1/webhooks/' + data.token; + container.innerHTML = ` +
+ ${t('automations.condition.webhook.hint')} +
+ +
+ + +
+
+ +
`; + } else { + container.innerHTML = ` +
+ ${t('automations.condition.webhook.hint')} +

${t('automations.condition.webhook.save_first')}

+
`; + } + return; + } const appsValue = (data.apps || []).join('\n'); const matchType = data.match_type || 'running'; container.innerHTML = ` @@ -608,6 +636,11 @@ function getAutomationEditorConditions() { payload: row.querySelector('.condition-mqtt-payload').value, match_mode: row.querySelector('.condition-mqtt-match-mode').value || 'exact', }); + } else if (condType === 'webhook') { + const tokenInput = row.querySelector('.condition-webhook-token'); + const cond = { condition_type: 'webhook' }; + if (tokenInput && tokenInput.value) cond.token = tokenInput.value; + conditions.push(cond); } else { const matchType = row.querySelector('.condition-match-type').value; const appsText = row.querySelector('.condition-apps').value.trim(); @@ -677,6 +710,15 @@ export async function toggleAutomationEnabled(automationId, enable) { } } +export function copyWebhookUrl(btn) { + const input = btn.closest('.webhook-url-row').querySelector('.condition-webhook-url'); + navigator.clipboard.writeText(input.value).then(() => { + const orig = btn.textContent; + btn.textContent = t('automations.condition.webhook.copied'); + setTimeout(() => { btn.textContent = orig; }, 1500); + }); +} + export async function deleteAutomation(automationId, automationName) { const msg = t('automations.delete.confirm').replace('{name}', automationName); const confirmed = await showConfirm(msg); diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 0fdca58..5b57776 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -607,6 +607,12 @@ "automations.condition.mqtt.match_mode.contains": "Contains", "automations.condition.mqtt.match_mode.regex": "Regex", "automations.condition.mqtt.hint": "Activate when an MQTT topic receives a matching payload", + "automations.condition.webhook": "Webhook", + "automations.condition.webhook.hint": "Activate via an HTTP call from external services (Home Assistant, IFTTT, curl, etc.)", + "automations.condition.webhook.url": "Webhook URL:", + "automations.condition.webhook.copy": "Copy", + "automations.condition.webhook.copied": "Copied!", + "automations.condition.webhook.save_first": "Save the automation first to generate a webhook URL", "automations.scene": "Scene:", "automations.scene.hint": "Scene preset to activate when conditions are met", "automations.scene.search_placeholder": "Search scenes...", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index cdd949f..d1007f5 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -607,6 +607,12 @@ "automations.condition.mqtt.match_mode.contains": "Содержит", "automations.condition.mqtt.match_mode.regex": "Регулярное выражение", "automations.condition.mqtt.hint": "Активировать при получении совпадающего значения по MQTT топику", + "automations.condition.webhook": "Вебхук", + "automations.condition.webhook.hint": "Активировать через HTTP-запрос от внешних сервисов (Home Assistant, IFTTT, curl и т.д.)", + "automations.condition.webhook.url": "URL вебхука:", + "automations.condition.webhook.copy": "Скопировать", + "automations.condition.webhook.copied": "Скопировано!", + "automations.condition.webhook.save_first": "Сначала сохраните автоматизацию для генерации URL вебхука", "automations.scene": "Сцена:", "automations.scene.hint": "Пресет сцены для активации при выполнении условий", "automations.scene.search_placeholder": "Поиск сцен...", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 2e57951..9aaafa9 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -607,6 +607,12 @@ "automations.condition.mqtt.match_mode.contains": "包含", "automations.condition.mqtt.match_mode.regex": "正则表达式", "automations.condition.mqtt.hint": "当 MQTT 主题收到匹配的消息时激活", + "automations.condition.webhook": "Webhook", + "automations.condition.webhook.hint": "通过外部服务的 HTTP 请求激活(Home Assistant、IFTTT、curl 等)", + "automations.condition.webhook.url": "Webhook URL:", + "automations.condition.webhook.copy": "复制", + "automations.condition.webhook.copied": "已复制!", + "automations.condition.webhook.save_first": "请先保存自动化以生成 Webhook URL", "automations.scene": "场景:", "automations.scene.hint": "条件满足时激活的场景预设", "automations.scene.search_placeholder": "搜索场景...", diff --git a/server/src/wled_controller/storage/automation.py b/server/src/wled_controller/storage/automation.py index 3f5b3ca..a7e1847 100644 --- a/server/src/wled_controller/storage/automation.py +++ b/server/src/wled_controller/storage/automation.py @@ -30,6 +30,8 @@ class Condition: return DisplayStateCondition.from_dict(data) if ct == "mqtt": return MQTTCondition.from_dict(data) + if ct == "webhook": + return WebhookCondition.from_dict(data) raise ValueError(f"Unknown condition type: {ct}") @@ -158,6 +160,23 @@ class MQTTCondition(Condition): ) +@dataclass +class WebhookCondition(Condition): + """Activate via an HTTP webhook call with a secret token.""" + + condition_type: str = "webhook" + token: str = "" # auto-generated 128-bit hex secret + + def to_dict(self) -> dict: + d = super().to_dict() + d["token"] = self.token + return d + + @classmethod + def from_dict(cls, data: dict) -> "WebhookCondition": + return cls(token=data.get("token", "")) + + @dataclass class Automation: """Automation that activates a scene preset based on conditions."""