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

@@ -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.value_sources import router as value_sources_router
from .routes.automations import router as automations_router from .routes.automations import router as automations_router
from .routes.scene_presets import router as scene_presets_router from .routes.scene_presets import router as scene_presets_router
from .routes.webhooks import router as webhooks_router
router = APIRouter() router = APIRouter()
router.include_router(system_router) router.include_router(system_router)
@@ -32,5 +33,6 @@ router.include_router(value_sources_router)
router.include_router(picture_targets_router) router.include_router(picture_targets_router)
router.include_router(automations_router) router.include_router(automations_router)
router.include_router(scene_presets_router) router.include_router(scene_presets_router)
router.include_router(webhooks_router)
__all__ = ["router"] __all__ = ["router"]

View File

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

View 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")

View File

@@ -25,6 +25,8 @@ class ConditionSchema(BaseModel):
topic: Optional[str] = Field(None, description="MQTT topic to watch (for mqtt condition)") 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)") 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)") 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): class AutomationCreate(BaseModel):
@@ -62,6 +64,7 @@ class AutomationResponse(BaseModel):
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate") scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
deactivation_mode: str = Field(default="none", description="Deactivation behavior") deactivation_mode: str = Field(default="none", description="Deactivation behavior")
deactivation_scene_preset_id: Optional[str] = Field(None, description="Fallback scene preset") 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") 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_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") last_deactivated_at: Optional[datetime] = Field(None, description="Last time this automation was deactivated")

View File

@@ -15,6 +15,7 @@ from wled_controller.storage.automation import (
MQTTCondition, MQTTCondition,
SystemIdleCondition, SystemIdleCondition,
TimeOfDayCondition, TimeOfDayCondition,
WebhookCondition,
) )
from wled_controller.storage.automation_store import AutomationStore from wled_controller.storage.automation_store import AutomationStore
from wled_controller.storage.scene_preset import ScenePreset from wled_controller.storage.scene_preset import ScenePreset
@@ -55,6 +56,8 @@ class AutomationEngine:
# automation_id → datetime of last activation / deactivation # automation_id → datetime of last activation / deactivation
self._last_activated: Dict[str, datetime] = {} self._last_activated: Dict[str, datetime] = {}
self._last_deactivated: 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: async def start(self) -> None:
if self._task is not None: if self._task is not None:
@@ -213,6 +216,8 @@ class AutomationEngine:
return self._evaluate_display_state(condition, display_state) return self._evaluate_display_state(condition, display_state)
if isinstance(condition, MQTTCondition): if isinstance(condition, MQTTCondition):
return self._evaluate_mqtt(condition) return self._evaluate_mqtt(condition)
if isinstance(condition, WebhookCondition):
return self._webhook_states.get(condition.token, False)
return False return False
@staticmethod @staticmethod
@@ -420,6 +425,15 @@ class AutomationEngine:
except Exception as e: except Exception as e:
logger.error(f"Immediate automation evaluation error: {e}", exc_info=True) 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: async def deactivate_if_active(self, automation_id: str) -> None:
"""Deactivate an automation immediately (used when disabling/deleting).""" """Deactivate an automation immediately (used when disabling/deleting)."""
if automation_id in self._active_automations: if automation_id in self._active_automations:

View File

@@ -270,3 +270,34 @@
color: var(--text-muted); color: var(--text-muted);
text-align: center; 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;
}

View File

@@ -79,7 +79,7 @@ import {
import { import {
loadAutomations, openAutomationEditor, closeAutomationEditorModal, loadAutomations, openAutomationEditor, closeAutomationEditorModal,
saveAutomationEditor, addAutomationCondition, saveAutomationEditor, addAutomationCondition,
toggleAutomationEnabled, deleteAutomation, toggleAutomationEnabled, deleteAutomation, copyWebhookUrl,
expandAllAutomationSections, collapseAllAutomationSections, expandAllAutomationSections, collapseAllAutomationSections,
} from './features/automations.js'; } from './features/automations.js';
import { import {
@@ -311,6 +311,7 @@ Object.assign(window, {
addAutomationCondition, addAutomationCondition,
toggleAutomationEnabled, toggleAutomationEnabled,
deleteAutomation, deleteAutomation,
copyWebhookUrl,
expandAllAutomationSections, expandAllAutomationSections,
collapseAllAutomationSections, collapseAllAutomationSections,

View File

@@ -132,6 +132,9 @@ function createAutomationCard(automation, sceneMap = new Map()) {
if (c.condition_type === 'mqtt') { if (c.condition_type === 'mqtt') {
return `<span class="stream-card-prop stream-card-prop-full">${ICON_RADIO} ${t('automations.condition.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}</span>`; return `<span class="stream-card-prop stream-card-prop-full">${ICON_RADIO} ${t('automations.condition.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}</span>`;
} }
if (c.condition_type === 'webhook') {
return `<span class="stream-card-prop">&#x1F517; ${t('automations.condition.webhook')}</span>`;
}
return `<span class="stream-card-prop">${c.condition_type}</span>`; return `<span class="stream-card-prop">${c.condition_type}</span>`;
}); });
const logicLabel = automation.condition_logic === 'and' ? t('automations.logic.and') : t('automations.logic.or'); const logicLabel = automation.condition_logic === 'and' ? t('automations.logic.and') : t('automations.logic.or');
@@ -386,6 +389,7 @@ function addAutomationConditionRow(condition) {
<option value="system_idle" ${condType === 'system_idle' ? 'selected' : ''}>${t('automations.condition.system_idle')}</option> <option value="system_idle" ${condType === 'system_idle' ? 'selected' : ''}>${t('automations.condition.system_idle')}</option>
<option value="display_state" ${condType === 'display_state' ? 'selected' : ''}>${t('automations.condition.display_state')}</option> <option value="display_state" ${condType === 'display_state' ? 'selected' : ''}>${t('automations.condition.display_state')}</option>
<option value="mqtt" ${condType === 'mqtt' ? 'selected' : ''}>${t('automations.condition.mqtt')}</option> <option value="mqtt" ${condType === 'mqtt' ? 'selected' : ''}>${t('automations.condition.mqtt')}</option>
<option value="webhook" ${condType === 'webhook' ? 'selected' : ''}>${t('automations.condition.webhook')}</option>
</select> </select>
<button type="button" class="btn-remove-condition" onclick="this.closest('.automation-condition-row').remove()" title="Remove">&#x2715;</button> <button type="button" class="btn-remove-condition" onclick="this.closest('.automation-condition-row').remove()" title="Remove">&#x2715;</button>
</div> </div>
@@ -475,6 +479,30 @@ function addAutomationConditionRow(condition) {
</div>`; </div>`;
return; return;
} }
if (type === 'webhook') {
if (data.token) {
const webhookUrl = window.location.origin + '/api/v1/webhooks/' + data.token;
container.innerHTML = `
<div class="condition-fields">
<small class="condition-always-desc">${t('automations.condition.webhook.hint')}</small>
<div class="condition-field">
<label>${t('automations.condition.webhook.url')}</label>
<div class="webhook-url-row">
<input type="text" class="condition-webhook-url" value="${escapeHtml(webhookUrl)}" readonly>
<button type="button" class="btn btn-secondary btn-webhook-copy" onclick="copyWebhookUrl(this)">${t('automations.condition.webhook.copy')}</button>
</div>
</div>
<input type="hidden" class="condition-webhook-token" value="${escapeHtml(data.token)}">
</div>`;
} else {
container.innerHTML = `
<div class="condition-fields">
<small class="condition-always-desc">${t('automations.condition.webhook.hint')}</small>
<p class="webhook-save-hint">${t('automations.condition.webhook.save_first')}</p>
</div>`;
}
return;
}
const appsValue = (data.apps || []).join('\n'); const appsValue = (data.apps || []).join('\n');
const matchType = data.match_type || 'running'; const matchType = data.match_type || 'running';
container.innerHTML = ` container.innerHTML = `
@@ -608,6 +636,11 @@ function getAutomationEditorConditions() {
payload: row.querySelector('.condition-mqtt-payload').value, payload: row.querySelector('.condition-mqtt-payload').value,
match_mode: row.querySelector('.condition-mqtt-match-mode').value || 'exact', 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 { } else {
const matchType = row.querySelector('.condition-match-type').value; const matchType = row.querySelector('.condition-match-type').value;
const appsText = row.querySelector('.condition-apps').value.trim(); 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) { export async function deleteAutomation(automationId, automationName) {
const msg = t('automations.delete.confirm').replace('{name}', automationName); const msg = t('automations.delete.confirm').replace('{name}', automationName);
const confirmed = await showConfirm(msg); const confirmed = await showConfirm(msg);

View File

@@ -607,6 +607,12 @@
"automations.condition.mqtt.match_mode.contains": "Contains", "automations.condition.mqtt.match_mode.contains": "Contains",
"automations.condition.mqtt.match_mode.regex": "Regex", "automations.condition.mqtt.match_mode.regex": "Regex",
"automations.condition.mqtt.hint": "Activate when an MQTT topic receives a matching payload", "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": "Scene:",
"automations.scene.hint": "Scene preset to activate when conditions are met", "automations.scene.hint": "Scene preset to activate when conditions are met",
"automations.scene.search_placeholder": "Search scenes...", "automations.scene.search_placeholder": "Search scenes...",

View File

@@ -607,6 +607,12 @@
"automations.condition.mqtt.match_mode.contains": "Содержит", "automations.condition.mqtt.match_mode.contains": "Содержит",
"automations.condition.mqtt.match_mode.regex": "Регулярное выражение", "automations.condition.mqtt.match_mode.regex": "Регулярное выражение",
"automations.condition.mqtt.hint": "Активировать при получении совпадающего значения по MQTT топику", "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": "Сцена:",
"automations.scene.hint": "Пресет сцены для активации при выполнении условий", "automations.scene.hint": "Пресет сцены для активации при выполнении условий",
"automations.scene.search_placeholder": "Поиск сцен...", "automations.scene.search_placeholder": "Поиск сцен...",

View File

@@ -607,6 +607,12 @@
"automations.condition.mqtt.match_mode.contains": "包含", "automations.condition.mqtt.match_mode.contains": "包含",
"automations.condition.mqtt.match_mode.regex": "正则表达式", "automations.condition.mqtt.match_mode.regex": "正则表达式",
"automations.condition.mqtt.hint": "当 MQTT 主题收到匹配的消息时激活", "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": "场景:",
"automations.scene.hint": "条件满足时激活的场景预设", "automations.scene.hint": "条件满足时激活的场景预设",
"automations.scene.search_placeholder": "搜索场景...", "automations.scene.search_placeholder": "搜索场景...",

View File

@@ -30,6 +30,8 @@ class Condition:
return DisplayStateCondition.from_dict(data) return DisplayStateCondition.from_dict(data)
if ct == "mqtt": if ct == "mqtt":
return MQTTCondition.from_dict(data) return MQTTCondition.from_dict(data)
if ct == "webhook":
return WebhookCondition.from_dict(data)
raise ValueError(f"Unknown condition type: {ct}") 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 @dataclass
class Automation: class Automation:
"""Automation that activates a scene preset based on conditions.""" """Automation that activates a scene preset based on conditions."""