|
|
|
@@ -5,6 +5,7 @@
|
|
|
|
|
import {
|
|
|
|
|
apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj,
|
|
|
|
|
scenePresetsCache, _cachedHASources, haSourcesCache,
|
|
|
|
|
_cachedValueSources, valueSourcesCache,
|
|
|
|
|
getHAEntityFriendlyName, setHAEntityNames,
|
|
|
|
|
} from '../core/state.ts';
|
|
|
|
|
import { prefetchHAEntities } from './home-assistant-sources.ts';
|
|
|
|
@@ -26,6 +27,7 @@ import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts'
|
|
|
|
|
import { getBaseOrigin } from './settings.ts';
|
|
|
|
|
import { IconSelect } from '../core/icon-select.ts';
|
|
|
|
|
import { EntitySelect } from '../core/entity-palette.ts';
|
|
|
|
|
import { enhanceMiniSelects } from '../core/mini-select.ts';
|
|
|
|
|
import { attachProcessPicker } from '../core/process-picker.ts';
|
|
|
|
|
import { TreeNav } from '../core/tree-nav.ts';
|
|
|
|
|
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
|
|
|
|
@@ -106,6 +108,12 @@ class AutomationEditorModal extends Modal {
|
|
|
|
|
|
|
|
|
|
onForceClose() {
|
|
|
|
|
if (_automationTagsInput) { _automationTagsInput.destroy(); _automationTagsInput = null; }
|
|
|
|
|
// Tear down any per-rule portal widgets (http_poll EntitySelect /
|
|
|
|
|
// IconSelect) the rule rows attached. Walks every rule row in the
|
|
|
|
|
// modal, no-op for rows that didn't stash widgets.
|
|
|
|
|
document
|
|
|
|
|
.querySelectorAll<HTMLElement>('#automation-rules-list .rule-fields-container')
|
|
|
|
|
.forEach(c => _disposeHTTPPollWidgets(c));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
snapshotValues() {
|
|
|
|
@@ -226,6 +234,7 @@ export async function loadAutomations() {
|
|
|
|
|
automationsCacheObj.fetch(),
|
|
|
|
|
scenePresetsCache.fetch(),
|
|
|
|
|
haSourcesCache.fetch(),
|
|
|
|
|
valueSourcesCache.fetch(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const sceneMap = new Map(scenes.map(s => [s.id, s]));
|
|
|
|
@@ -345,8 +354,67 @@ const RULE_CHIP_RENDERERS: Record<string, RuleChipBuilder> = {
|
|
|
|
|
title: tooltip,
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
http_poll: (c) => {
|
|
|
|
|
const vsId = c.value_source_id || '';
|
|
|
|
|
const vs = (_cachedValueSources || []).find(v => v.id === vsId);
|
|
|
|
|
const vsLabel = vs?.name || (vsId ? vsId : t('automations.rule.http_poll.no_source'));
|
|
|
|
|
const op = c.operator || 'equals';
|
|
|
|
|
const opGlyph = _httpOpGlyph(op);
|
|
|
|
|
const rhs = op === 'exists' ? '' : ` ${c.value ?? ''}`;
|
|
|
|
|
return {
|
|
|
|
|
icon: _icon(P.globe),
|
|
|
|
|
text: `${vsLabel} ${opGlyph}${rhs}`,
|
|
|
|
|
title: t('automations.rule.http_poll'),
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type HTTPPollOp = 'equals' | 'not_equals' | 'contains' | 'regex' | 'gt' | 'lt' | 'exists';
|
|
|
|
|
|
|
|
|
|
const HTTP_OP_KEYS: HTTPPollOp[] = [
|
|
|
|
|
'equals', 'not_equals', 'contains', 'regex', 'gt', 'lt', 'exists',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const _HTTP_OP_GLYPHS: Record<HTTPPollOp, string> = {
|
|
|
|
|
equals: '=',
|
|
|
|
|
not_equals: '≠',
|
|
|
|
|
contains: '∈',
|
|
|
|
|
regex: '/.../',
|
|
|
|
|
gt: '>',
|
|
|
|
|
lt: '<',
|
|
|
|
|
exists: '?',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const _HTTP_OP_ICON_PATHS: Record<HTTPPollOp, string> = {
|
|
|
|
|
equals: P.check,
|
|
|
|
|
not_equals: P.circleOff,
|
|
|
|
|
contains: P.search,
|
|
|
|
|
regex: P.code,
|
|
|
|
|
gt: P.chevronUp,
|
|
|
|
|
lt: P.chevronDown,
|
|
|
|
|
exists: P.zap,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function _httpOpGlyph(op: string): string {
|
|
|
|
|
return _HTTP_OP_GLYPHS[op as HTTPPollOp] ?? '=';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _httpOpIconPath(op: string): string {
|
|
|
|
|
return _HTTP_OP_ICON_PATHS[op as HTTPPollOp] ?? P.check;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Destroy any EntitySelect / IconSelect widgets that the http_poll rule
|
|
|
|
|
* branch attached to *container*. Safe to call when there are none —
|
|
|
|
|
* it just clears the stash. Called both before re-rendering the rule's
|
|
|
|
|
* fields (rule-type change) and before removing the rule row entirely. */
|
|
|
|
|
function _disposeHTTPPollWidgets(container: HTMLElement): void {
|
|
|
|
|
const stash = (container as any)._httpPollWidgets;
|
|
|
|
|
if (!stash) return;
|
|
|
|
|
try { stash.vsEntitySelect?.destroy?.(); } catch { /* widget already gone */ }
|
|
|
|
|
try { stash.opIconSelect?.destroy?.(); } catch { /* widget already gone */ }
|
|
|
|
|
delete (container as any)._httpPollWidgets;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Render a chain-arrow separator span. `+` between AND-rules,
|
|
|
|
|
* the localised OR label between OR-rules, and `→` for the
|
|
|
|
|
* rule-chain → scene-activation transition. */
|
|
|
|
@@ -681,11 +749,12 @@ export function addAutomationRule() {
|
|
|
|
|
_autoGenerateAutomationName();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const RULE_TYPE_KEYS = ['startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook', 'home_assistant'];
|
|
|
|
|
const RULE_TYPE_KEYS = ['startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook', 'home_assistant', 'http_poll'];
|
|
|
|
|
const RULE_TYPE_ICONS = {
|
|
|
|
|
startup: P.power, application: P.smartphone,
|
|
|
|
|
time_of_day: P.clock, system_idle: P.moon, display_state: P.monitor,
|
|
|
|
|
mqtt: P.radio, webhook: P.globe, home_assistant: P.home,
|
|
|
|
|
http_poll: P.globe,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const MATCH_TYPE_KEYS = ['running', 'topmost', 'topmost_fullscreen', 'fullscreen'];
|
|
|
|
@@ -777,7 +846,7 @@ function addAutomationRuleRow(rule: any) {
|
|
|
|
|
<select class="rule-type-select">
|
|
|
|
|
${RULE_TYPE_KEYS.map(k => `<option value="${k}" ${ruleType === k ? 'selected' : ''}>${t('automations.rule.' + k)}</option>`).join('')}
|
|
|
|
|
</select>
|
|
|
|
|
<button type="button" class="btn-remove-rule" onclick="this.closest('.automation-rule-row').remove(); if(window._autoGenerateAutomationName) window._autoGenerateAutomationName();" title="Remove">${ICON_TRASH}</button>
|
|
|
|
|
<button type="button" class="btn-remove-rule" title="Remove">${ICON_TRASH}</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="rule-fields-container" style="display:none"></div>
|
|
|
|
|
`;
|
|
|
|
@@ -794,14 +863,29 @@ function addAutomationRuleRow(rule: any) {
|
|
|
|
|
const typeSelect = row.querySelector('.rule-type-select') as HTMLSelectElement;
|
|
|
|
|
const container = row.querySelector('.rule-fields-container') as HTMLElement;
|
|
|
|
|
|
|
|
|
|
// Remove button — dispose any widgets the rule body stashed (portal
|
|
|
|
|
// overlays would otherwise leak) before pulling the row from the DOM.
|
|
|
|
|
const removeBtn = row.querySelector('.btn-remove-rule') as HTMLButtonElement;
|
|
|
|
|
removeBtn.addEventListener('click', () => {
|
|
|
|
|
_disposeHTTPPollWidgets(container);
|
|
|
|
|
row.remove();
|
|
|
|
|
const autoGen = (window as any)._autoGenerateAutomationName;
|
|
|
|
|
if (typeof autoGen === 'function') autoGen();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Attach IconSelect to the rule type dropdown
|
|
|
|
|
const ruleIconSelect = new IconSelect({
|
|
|
|
|
target: typeSelect,
|
|
|
|
|
items: _buildRuleTypeItems(),
|
|
|
|
|
columns: 4,
|
|
|
|
|
columns: 3,
|
|
|
|
|
} as any);
|
|
|
|
|
|
|
|
|
|
function renderFields(type: any, data: any) {
|
|
|
|
|
// Tear down any widgets the previous renderFields call attached
|
|
|
|
|
// (EntitySelect/IconSelect portal overlays to document.body, so
|
|
|
|
|
// a bare ``container.innerHTML = …`` leaves them in the registry).
|
|
|
|
|
_disposeHTTPPollWidgets(container);
|
|
|
|
|
|
|
|
|
|
if (type === 'startup') {
|
|
|
|
|
container.innerHTML = `<small class="rule-hint-desc">${t('automations.rule.startup.hint')}</small>`;
|
|
|
|
|
return;
|
|
|
|
@@ -880,6 +964,7 @@ function addAutomationRuleRow(rule: any) {
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`;
|
|
|
|
|
enhanceMiniSelects(container, 'select.rule-display-state');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (type === 'mqtt') {
|
|
|
|
@@ -905,6 +990,7 @@ function addAutomationRuleRow(rule: any) {
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`;
|
|
|
|
|
enhanceMiniSelects(container, 'select.rule-mqtt-match-mode');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (type === 'home_assistant') {
|
|
|
|
@@ -987,6 +1073,87 @@ function addAutomationRuleRow(rule: any) {
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (type === 'http_poll') {
|
|
|
|
|
const vsId = data.value_source_id || '';
|
|
|
|
|
const operator = data.operator || 'equals';
|
|
|
|
|
const valueStr = data.value || '';
|
|
|
|
|
|
|
|
|
|
container.innerHTML = `
|
|
|
|
|
<div class="rule-fields">
|
|
|
|
|
<small class="rule-hint-desc">${t('automations.rule.http_poll.hint')}</small>
|
|
|
|
|
<div class="rule-field">
|
|
|
|
|
<label>${t('automations.rule.http_poll.value_source')}</label>
|
|
|
|
|
<select class="rule-http-value-source">
|
|
|
|
|
<option value="">—</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="rule-field">
|
|
|
|
|
<label>${t('automations.rule.http_poll.operator')}</label>
|
|
|
|
|
<select class="rule-http-operator">
|
|
|
|
|
${HTTP_OP_KEYS.map(k => `<option value="${k}" ${operator === k ? 'selected' : ''}>${t('automations.rule.http_poll.operator.' + k)}</option>`).join('')}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="rule-field rule-http-value-field">
|
|
|
|
|
<label>${t('automations.rule.http_poll.value')}</label>
|
|
|
|
|
<input type="text" class="rule-http-value" value="${escapeHtml(valueStr)}" placeholder="${escapeHtml(t('automations.rule.http_poll.value.placeholder'))}">
|
|
|
|
|
</div>
|
|
|
|
|
</div>`;
|
|
|
|
|
|
|
|
|
|
// Pull only HTTP value sources (source_type === 'http')
|
|
|
|
|
const httpVs = (_cachedValueSources || []).filter((v: any) => v.source_type === 'http');
|
|
|
|
|
|
|
|
|
|
// Wire EntitySelect for the value source picker
|
|
|
|
|
const vsSelect = container.querySelector('.rule-http-value-source') as HTMLSelectElement;
|
|
|
|
|
// Pre-populate the option so EntitySelect can sync display text.
|
|
|
|
|
vsSelect.innerHTML = `<option value="">—</option>` +
|
|
|
|
|
httpVs.map((v: any) => `<option value="${v.id}" ${v.id === vsId ? 'selected' : ''}>${escapeHtml(v.name)}</option>`).join('');
|
|
|
|
|
vsSelect.value = vsId || '';
|
|
|
|
|
const vsEntitySelect = new EntitySelect({
|
|
|
|
|
target: vsSelect,
|
|
|
|
|
getItems: () => (_cachedValueSources || [])
|
|
|
|
|
.filter((v: any) => v.source_type === 'http')
|
|
|
|
|
.map((v: any) => ({
|
|
|
|
|
value: v.id,
|
|
|
|
|
label: v.name,
|
|
|
|
|
icon: _icon(P.globe),
|
|
|
|
|
desc: v.json_path || t('automations.rule.http_poll.raw_body'),
|
|
|
|
|
})),
|
|
|
|
|
placeholder: t('palette.search'),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Wire IconSelect for operator
|
|
|
|
|
const opSelect = container.querySelector('.rule-http-operator') as HTMLSelectElement;
|
|
|
|
|
const opItems = HTTP_OP_KEYS.map(k => ({
|
|
|
|
|
value: k,
|
|
|
|
|
icon: _icon(_httpOpIconPath(k)),
|
|
|
|
|
label: t('automations.rule.http_poll.operator.' + k),
|
|
|
|
|
desc: t('automations.rule.http_poll.operator.' + k + '.desc'),
|
|
|
|
|
}));
|
|
|
|
|
const opIconSelect = new IconSelect({
|
|
|
|
|
target: opSelect,
|
|
|
|
|
items: opItems,
|
|
|
|
|
columns: 3,
|
|
|
|
|
onChange: (newOp: string) => {
|
|
|
|
|
// Hide the value field when operator is 'exists'
|
|
|
|
|
const valField = container.querySelector('.rule-http-value-field') as HTMLElement;
|
|
|
|
|
if (valField) valField.style.display = newOp === 'exists' ? 'none' : '';
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
// Sync initial visibility based on the operator we just loaded.
|
|
|
|
|
const valField = container.querySelector('.rule-http-value-field') as HTMLElement;
|
|
|
|
|
if (valField) valField.style.display = operator === 'exists' ? 'none' : '';
|
|
|
|
|
|
|
|
|
|
// Stash both widgets so they can be destroyed when the row's
|
|
|
|
|
// rule type changes (renderFields re-entry) or the row is
|
|
|
|
|
// removed (button.btn-remove-rule onclick — calls
|
|
|
|
|
// _disposeHTTPPollWidgets via the row's data hook).
|
|
|
|
|
(container as any)._httpPollWidgets = {
|
|
|
|
|
vsEntitySelect,
|
|
|
|
|
opIconSelect,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (type === 'webhook') {
|
|
|
|
|
if (data.token) {
|
|
|
|
|
const webhookUrl = getBaseOrigin() + '/api/v1/webhooks/' + data.token;
|
|
|
|
@@ -1102,6 +1269,18 @@ function getAutomationEditorRules() {
|
|
|
|
|
state: (row.querySelector('.rule-ha-state') as HTMLInputElement).value,
|
|
|
|
|
match_mode: (row.querySelector('.rule-ha-match-mode') as HTMLSelectElement).value || 'exact',
|
|
|
|
|
});
|
|
|
|
|
} else if (ruleType === 'http_poll') {
|
|
|
|
|
const op = (row.querySelector('.rule-http-operator') as HTMLSelectElement).value || 'equals';
|
|
|
|
|
const r: any = {
|
|
|
|
|
rule_type: 'http_poll',
|
|
|
|
|
value_source_id: (row.querySelector('.rule-http-value-source') as HTMLSelectElement).value,
|
|
|
|
|
operator: op,
|
|
|
|
|
};
|
|
|
|
|
// The 'exists' operator has no comparison value.
|
|
|
|
|
if (op !== 'exists') {
|
|
|
|
|
r.value = (row.querySelector('.rule-http-value') as HTMLInputElement).value;
|
|
|
|
|
}
|
|
|
|
|
rules.push(r);
|
|
|
|
|
} else {
|
|
|
|
|
const matchType = (row.querySelector('.rule-match-type') as HTMLSelectElement).value;
|
|
|
|
|
const appsText = (row.querySelector('.rule-apps') as HTMLTextAreaElement).value.trim();
|
|
|
|
@@ -1114,6 +1293,8 @@ function getAutomationEditorRules() {
|
|
|
|
|
|
|
|
|
|
export async function saveAutomationEditor() {
|
|
|
|
|
const idInput = document.getElementById('automation-editor-id') as HTMLInputElement;
|
|
|
|
|
if (automationModal.closeIfPristine(idInput.value)) return;
|
|
|
|
|
|
|
|
|
|
const nameInput = document.getElementById('automation-editor-name') as HTMLInputElement;
|
|
|
|
|
const enabledInput = document.getElementById('automation-editor-enabled') as HTMLInputElement;
|
|
|
|
|
const logicSelect = document.getElementById('automation-editor-logic') as HTMLSelectElement;
|
|
|
|
|