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.save_first')}
+