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

@@ -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;
}

View File

@@ -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,

View File

@@ -132,6 +132,9 @@ function createAutomationCard(automation, sceneMap = new Map()) {
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>`;
}
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>`;
});
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="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="webhook" ${condType === 'webhook' ? 'selected' : ''}>${t('automations.condition.webhook')}</option>
</select>
<button type="button" class="btn-remove-condition" onclick="this.closest('.automation-condition-row').remove()" title="Remove">&#x2715;</button>
</div>
@@ -475,6 +479,30 @@ function addAutomationConditionRow(condition) {
</div>`;
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 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);

View File

@@ -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...",

View File

@@ -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": "Поиск сцен...",

View File

@@ -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": "搜索场景...",