Files
wled-screen-controller-mixed/server/src/wled_controller/static/js/features/automations.ts
alexei.dolgolyov 2153dde4b7 feat: Home Assistant integration — WebSocket connection, automation conditions, UI
Add full Home Assistant integration via WebSocket API:
- HARuntime: persistent WebSocket client with auth, auto-reconnect, entity state cache
- HAManager: ref-counted runtime pool (like WeatherManager)
- HomeAssistantCondition: new automation trigger type matching entity states
- REST API: CRUD for HA sources + /test, /entities, /status endpoints
- /api/v1/system/integrations-status: combined MQTT + HA dashboard indicators
- Frontend: HA Sources tab in Streams, condition type in automation editor
- Modal editor with host, token, SSL, entity filters
- websockets>=13.0 dependency added
2026-03-27 22:42:48 +03:00

1010 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Automations — automation cards, editor, condition builder, process picker, scene selector.
*/
import { apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, scenePresetsCache, _cachedHASources } from '../core/state.ts';
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { showToast, showConfirm, setTabRefreshing } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
import { CardSection } from '../core/card-sections.ts';
import { updateTabBadge, updateSubTabHash } from './tabs.ts';
import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_CLONE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB, ICON_SEARCH } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { getBaseOrigin } from './settings.ts';
import { IconSelect } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { attachProcessPicker } from '../core/process-picker.ts';
import { TreeNav } from '../core/tree-nav.ts';
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
import type { Automation } from '../types.ts';
let _automationTagsInput: any = null;
// ── Auto-name ──
let _autoNameManuallyEdited = false;
function _autoGenerateAutomationName() {
if (_autoNameManuallyEdited) return;
if ((document.getElementById('automation-editor-id') as HTMLInputElement).value) return;
const sceneSel = document.getElementById('automation-scene-id') as HTMLSelectElement | null;
const sceneName = sceneSel?.selectedOptions[0]?.textContent?.trim() || '';
const logic = (document.getElementById('automation-editor-logic') as HTMLSelectElement).value;
const condCount = document.querySelectorAll('#automation-conditions-list .condition-row').length;
let name = '';
if (sceneName) name = sceneName;
if (condCount > 0) {
const logicLabel = logic === 'and' ? 'AND' : 'OR';
const suffix = `${condCount} ${logicLabel}`;
name = name ? `${name} · ${suffix}` : suffix;
}
(document.getElementById('automation-editor-name') as HTMLInputElement).value = name || t('automations.add');
}
class AutomationEditorModal extends Modal {
constructor() { super('automation-editor-modal'); }
onForceClose() {
if (_automationTagsInput) { _automationTagsInput.destroy(); _automationTagsInput = null; }
}
snapshotValues() {
return {
name: (document.getElementById('automation-editor-name') as HTMLInputElement).value,
enabled: (document.getElementById('automation-editor-enabled') as HTMLInputElement).checked.toString(),
logic: (document.getElementById('automation-editor-logic') as HTMLSelectElement).value,
conditions: JSON.stringify(getAutomationEditorConditions()),
scenePresetId: (document.getElementById('automation-scene-id') as HTMLSelectElement).value,
deactivationMode: (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value,
deactivationScenePresetId: (document.getElementById('automation-fallback-scene-id') as HTMLSelectElement).value,
tags: JSON.stringify(_automationTagsInput ? _automationTagsInput.getValue() : []),
};
}
}
const automationModal = new AutomationEditorModal();
// ── Bulk action handlers ──
async function _bulkEnableAutomations(ids: any) {
const results = await Promise.allSettled(ids.map(id =>
fetchWithAuth(`/automations/${id}/enable`, { method: 'POST' })
));
const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length;
if (failed) showToast(`${ids.length - failed}/${ids.length} enabled`, 'warning');
else showToast(t('automations.updated'), 'success');
automationsCacheObj.invalidate();
loadAutomations();
}
async function _bulkDisableAutomations(ids: any) {
const results = await Promise.allSettled(ids.map(id =>
fetchWithAuth(`/automations/${id}/disable`, { method: 'POST' })
));
const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length;
if (failed) showToast(`${ids.length - failed}/${ids.length} disabled`, 'warning');
else showToast(t('automations.updated'), 'success');
automationsCacheObj.invalidate();
loadAutomations();
}
async function _bulkDeleteAutomations(ids: any) {
const results = await Promise.allSettled(ids.map(id =>
fetchWithAuth(`/automations/${id}`, { method: 'DELETE' })
));
const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length;
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
else showToast(t('automations.deleted'), 'success');
automationsCacheObj.invalidate();
loadAutomations();
}
const csAutomations = new CardSection('automations', { titleKey: 'automations.title', gridClass: 'devices-grid', addCardOnclick: "openAutomationEditor()", keyAttr: 'data-automation-id', emptyKey: 'section.empty.automations', bulkActions: [
{ key: 'enable', labelKey: 'bulk.enable', icon: ICON_OK, handler: _bulkEnableAutomations },
{ key: 'disable', labelKey: 'bulk.disable', icon: ICON_CIRCLE_OFF, handler: _bulkDisableAutomations },
{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteAutomations },
] } as any);
// ── Tree navigation ──
let _automationsTreeTriggered = false;
const _automationsTree = new TreeNav('automations-tree-nav', {
onSelect: (key: string) => {
_automationsTreeTriggered = true;
switchAutomationTab(key);
_automationsTreeTriggered = false;
}
});
export function switchAutomationTab(tabKey: string) {
document.querySelectorAll('.automation-sub-tab-panel').forEach(panel =>
(panel as HTMLElement).classList.toggle('active', panel.id === `automation-tab-${tabKey}`)
);
localStorage.setItem('activeAutomationTab', tabKey);
updateSubTabHash('automations', tabKey);
if (!_automationsTreeTriggered) {
_automationsTree.setActive(tabKey);
}
}
/* ── Condition logic IconSelect ───────────────────────────────── */
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
let _conditionLogicIconSelect: any = null;
function _ensureConditionLogicIconSelect() {
const sel = document.getElementById('automation-editor-logic');
if (!sel) return;
const items = [
{ value: 'or', icon: _icon(P.zap), label: t('automations.condition_logic.or'), desc: t('automations.condition_logic.or.desc') },
{ value: 'and', icon: _icon(P.link), label: t('automations.condition_logic.and'), desc: t('automations.condition_logic.and.desc') },
];
if (_conditionLogicIconSelect) { _conditionLogicIconSelect.updateItems(items); return; }
_conditionLogicIconSelect = new IconSelect({ target: sel, items, columns: 2 } as any);
}
// Re-render automations when language changes (only if tab is active)
document.addEventListener('languageChanged', () => {
if (apiKey && (localStorage.getItem('activeTab') || 'dashboard') === 'automations') loadAutomations();
});
// React to real-time automation state changes from global events WS
document.addEventListener('server:automation_state_changed', () => {
if (apiKey && (localStorage.getItem('activeTab') || 'dashboard') === 'automations') {
loadAutomations();
}
});
export async function loadAutomations() {
if (_automationsLoading) return;
set_automationsLoading(true);
const container = document.getElementById('automations-content');
if (!container) { set_automationsLoading(false); return; }
if (!csAutomations.isMounted()) setTabRefreshing('automations-content', true);
try {
const [automations, scenes] = await Promise.all([
automationsCacheObj.fetch(),
scenePresetsCache.fetch(),
]);
const sceneMap = new Map(scenes.map(s => [s.id, s]));
const activeCount = automations.filter(a => a.is_active).length;
updateTabBadge('automations', activeCount);
renderAutomations(automations, sceneMap);
} catch (error: any) {
if (error.isAuth) return;
console.error('Failed to load automations:', error);
container.innerHTML = `<p class="error-message">${escapeHtml(error.message)}</p>`;
} finally {
set_automationsLoading(false);
setTabRefreshing('automations-content', false);
}
}
function renderAutomations(automations: any, sceneMap: any) {
const container = document.getElementById('automations-content');
const autoItems = csAutomations.applySortOrder(automations.map(a => ({ key: a.id, html: createAutomationCard(a, sceneMap) })));
const sceneItems = csScenes.applySortOrder(scenePresetsCache.data.map(s => ({ key: s.id, html: createSceneCard(s) })));
const activeTab = localStorage.getItem('activeAutomationTab') || 'automations';
const treeItems = [
{ key: 'automations', icon: ICON_AUTOMATION, titleKey: 'automations.title', count: automations.length },
{ key: 'scenes', icon: ICON_SCENE, titleKey: 'scenes.title', count: scenePresetsCache.data.length },
];
if (csAutomations.isMounted()) {
_automationsTree.updateCounts({
automations: automations.length,
scenes: scenePresetsCache.data.length,
});
csAutomations.reconcile(autoItems);
csScenes.reconcile(sceneItems);
} else {
const panels = [
{ key: 'automations', html: csAutomations.render(autoItems) },
{ key: 'scenes', html: csScenes.render(sceneItems) },
].map(p => `<div class="automation-sub-tab-panel stream-tab-panel${p.key === activeTab ? ' active' : ''}" id="automation-tab-${p.key}">${p.html}</div>`).join('');
container!.innerHTML = panels;
CardSection.bindAll([csAutomations, csScenes]);
// Event delegation for scene preset card actions
initScenePresetDelegation(container!);
_automationsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startAutomationsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
_automationsTree.update(treeItems, activeTab);
_automationsTree.observeSections('automations-content', {
'automations': 'automations',
'scenes': 'scenes',
});
}
}
type ConditionPillRenderer = (c: any) => string;
const CONDITION_PILL_RENDERERS: Record<string, ConditionPillRenderer> = {
always: (c) => `<span class="stream-card-prop">${ICON_OK} ${t('automations.condition.always')}</span>`,
startup: (c) => `<span class="stream-card-prop">${ICON_START} ${t('automations.condition.startup')}</span>`,
application: (c) => {
const apps = (c.apps || []).join(', ');
const matchLabel = t('automations.condition.application.match_type.' + (c.match_type || 'running'));
return `<span class="stream-card-prop stream-card-prop-full">${t('automations.condition.application')}: ${apps} (${matchLabel})</span>`;
},
time_of_day: (c) => `<span class="stream-card-prop">${ICON_CLOCK} ${t('automations.condition.time_of_day')}: ${c.start_time || '00:00'} ${c.end_time || '23:59'}</span>`,
system_idle: (c) => {
const mode = c.when_idle !== false ? t('automations.condition.system_idle.when_idle') : t('automations.condition.system_idle.when_active');
return `<span class="stream-card-prop">${ICON_TIMER} ${c.idle_minutes || 5}m (${mode})</span>`;
},
display_state: (c) => {
const stateLabel = t('automations.condition.display_state.' + (c.state || 'on'));
return `<span class="stream-card-prop">${ICON_MONITOR} ${t('automations.condition.display_state')}: ${stateLabel}</span>`;
},
mqtt: (c) => `<span class="stream-card-prop stream-card-prop-full">${ICON_RADIO} ${t('automations.condition.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}</span>`,
webhook: (c) => `<span class="stream-card-prop">${ICON_WEB} ${t('automations.condition.webhook')}</span>`,
home_assistant: (c) => `<span class="stream-card-prop stream-card-prop-full">${_icon(P.home)} ${t('automations.condition.home_assistant')}: ${escapeHtml(c.entity_id || '')} = ${escapeHtml(c.state || '*')}</span>`,
};
function createAutomationCard(automation: Automation, sceneMap = new Map()) {
const statusClass = !automation.enabled ? 'disabled' : automation.is_active ? 'active' : 'inactive';
const statusText = !automation.enabled ? t('automations.status.disabled') : automation.is_active ? t('automations.status.active') : t('automations.status.inactive');
let condPills = '';
if (automation.conditions.length === 0) {
condPills = `<span class="stream-card-prop">${t('automations.conditions.empty')}</span>`;
} else {
const parts = automation.conditions.map(c => {
const renderer = CONDITION_PILL_RENDERERS[c.condition_type];
return renderer ? renderer(c) : `<span class="stream-card-prop">${c.condition_type}</span>`;
});
const logicLabel = automation.condition_logic === 'and' ? t('automations.logic.and') : t('automations.logic.or');
condPills = parts.join(`<span class="automation-logic-label">${logicLabel}</span>`);
}
// Scene info
const scene = automation.scene_preset_id ? sceneMap.get(automation.scene_preset_id) : null;
const sceneName = scene ? escapeHtml(scene.name) : t('automations.scene.none_selected');
const sceneColor = scene ? scene.color || '#4fc3f7' : '#888';
// Deactivation mode label
let deactivationMeta = '';
if (automation.deactivation_mode === 'revert') {
deactivationMeta = `<span class="card-meta">${ICON_UNDO} ${t('automations.deactivation_mode.revert')}</span>`;
} else if (automation.deactivation_mode === 'fallback_scene') {
const fallback = automation.deactivation_scene_preset_id ? sceneMap.get(automation.deactivation_scene_preset_id) : null;
if (fallback) {
const fbColor = fallback.color || '#4fc3f7';
deactivationMeta = `<span class="card-meta stream-card-link" onclick="event.stopPropagation(); navigateToCard('automations',null,'scenes','data-scene-id','${automation.deactivation_scene_preset_id}')">${ICON_UNDO} <span style="color:${fbColor}">&#x25CF;</span> ${escapeHtml(fallback.name)}</span>`;
} else {
deactivationMeta = `<span class="card-meta">${ICON_UNDO} ${t('automations.deactivation_mode.fallback_scene')}</span>`;
}
}
let lastActivityMeta = '';
if (automation.last_activated_at) {
const ts = new Date(automation.last_activated_at);
lastActivityMeta = `<span class="card-meta" title="${t('automations.last_activated')}">${ICON_CLOCK} ${ts.toLocaleString()}</span>`;
}
return wrapCard({
dataAttr: 'data-automation-id',
id: automation.id,
classes: !automation.enabled ? 'automation-status-disabled' : '',
removeOnclick: `deleteAutomation('${automation.id}', '${escapeHtml(automation.name)}')`,
removeTitle: t('common.delete'),
content: `
<div class="card-header">
<div class="card-title" title="${escapeHtml(automation.name)}">
<span class="card-title-text">${escapeHtml(automation.name)}</span>
<span class="badge badge-automation-${statusClass}">${statusText}</span>
</div>
</div>
<div class="card-subtitle">
<span class="card-meta">${condPills}</span>
<span class="card-meta${scene ? ' stream-card-link' : ''}"${scene ? ` onclick="event.stopPropagation(); navigateToCard('automations',null,'scenes','data-scene-id','${automation.scene_preset_id}')"` : ''}>${ICON_SCENE} <span style="color:${sceneColor}">&#x25CF;</span> ${sceneName}</span>
${deactivationMeta}
</div>
${renderTagChips(automation.tags)}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="cloneAutomation('${automation.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="openAutomationEditor('${automation.id}')" title="${t('automations.edit')}">${ICON_SETTINGS}</button>
<button class="btn btn-icon ${automation.enabled ? 'btn-warning' : 'btn-success'}" onclick="toggleAutomationEnabled('${automation.id}', ${!automation.enabled})" title="${automation.enabled ? t('automations.action.disable') : t('automations.status.active')}">
${automation.enabled ? ICON_PAUSE : ICON_START}
</button>`,
});
}
export async function openAutomationEditor(automationId?: any, cloneData?: any) {
const modal = document.getElementById('automation-editor-modal');
const titleEl = document.getElementById('automation-editor-title');
const idInput = document.getElementById('automation-editor-id') as HTMLInputElement;
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;
const condList = document.getElementById('automation-conditions-list');
const errorEl = document.getElementById('automation-editor-error') as HTMLElement;
errorEl.style.display = 'none';
condList!.innerHTML = '';
_ensureConditionLogicIconSelect();
_ensureDeactivationModeIconSelect();
// Fetch scenes for selector
try {
await scenePresetsCache.fetch();
} catch { /* use cached */ }
// Reset deactivation mode
(document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value = 'none';
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue('none');
(document.getElementById('automation-fallback-scene-group') as HTMLElement).style.display = 'none';
let _editorTags: any[] = [];
if (automationId) {
titleEl!.innerHTML = `${ICON_AUTOMATION} ${t('automations.edit')}`;
try {
const resp = await fetchWithAuth(`/automations/${automationId}`);
if (!resp.ok) throw new Error('Failed to load automation');
const automation = await resp.json();
idInput.value = automation.id;
nameInput.value = automation.name;
enabledInput.checked = automation.enabled;
logicSelect.value = automation.condition_logic;
if (_conditionLogicIconSelect) _conditionLogicIconSelect.setValue(automation.condition_logic);
for (const c of automation.conditions) {
addAutomationConditionRow(c);
}
// Scene selector
_initSceneSelector('automation-scene-id', automation.scene_preset_id);
// Deactivation mode
const deactMode = automation.deactivation_mode || 'none';
(document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value = deactMode;
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(deactMode);
_onDeactivationModeChange();
_initSceneSelector('automation-fallback-scene-id', automation.deactivation_scene_preset_id);
_editorTags = automation.tags || [];
} catch (e: any) {
showToast(e.message, 'error');
return;
}
} else if (cloneData) {
// Clone mode — create with prefilled data
titleEl!.innerHTML = `${ICON_AUTOMATION} ${t('automations.add')}`;
idInput.value = '';
nameInput.value = (cloneData.name || '') + ' (Copy)';
enabledInput.checked = cloneData.enabled !== false;
logicSelect.value = cloneData.condition_logic || 'or';
if (_conditionLogicIconSelect) _conditionLogicIconSelect.setValue(cloneData.condition_logic || 'or');
// Clone conditions (strip webhook tokens — they must be unique)
for (const c of (cloneData.conditions || [])) {
const clonedCond = { ...c };
if (clonedCond.condition_type === 'webhook') delete clonedCond.token;
addAutomationConditionRow(clonedCond);
}
_initSceneSelector('automation-scene-id', cloneData.scene_preset_id);
const cloneDeactMode = cloneData.deactivation_mode || 'none';
(document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value = cloneDeactMode;
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(cloneDeactMode);
_onDeactivationModeChange();
_initSceneSelector('automation-fallback-scene-id', cloneData.deactivation_scene_preset_id);
_editorTags = cloneData.tags || [];
} else {
titleEl!.innerHTML = `${ICON_AUTOMATION} ${t('automations.add')}`;
idInput.value = '';
nameInput.value = '';
enabledInput.checked = true;
logicSelect.value = 'or';
if (_conditionLogicIconSelect) _conditionLogicIconSelect.setValue('or');
_initSceneSelector('automation-scene-id', null);
_initSceneSelector('automation-fallback-scene-id', null);
}
// Wire up deactivation mode change
(document.getElementById('automation-deactivation-mode') as HTMLSelectElement).onchange = _onDeactivationModeChange;
// Auto-name wiring
_autoNameManuallyEdited = !!(automationId || cloneData);
nameInput.oninput = () => { _autoNameManuallyEdited = true; };
(window as any)._autoGenerateAutomationName = _autoGenerateAutomationName;
if (!automationId && !cloneData) _autoGenerateAutomationName();
automationModal.open();
modal!.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.getAttribute('data-i18n')!);
});
modal!.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
(el as HTMLInputElement).placeholder = t(el.getAttribute('data-i18n-placeholder')!);
});
// Tags
if (_automationTagsInput) { _automationTagsInput.destroy(); _automationTagsInput = null; }
_automationTagsInput = new TagInput(document.getElementById('automation-tags-container'), { placeholder: t('tags.placeholder') });
_automationTagsInput.setValue(_editorTags);
automationModal.snapshot();
}
function _onDeactivationModeChange() {
const mode = (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value;
(document.getElementById('automation-fallback-scene-group') as HTMLElement).style.display = mode === 'fallback_scene' ? '' : 'none';
}
export async function closeAutomationEditorModal() {
await automationModal.close();
}
// ===== Scene selector (EntitySelect) =====
let _sceneEntitySelect: any = null;
let _fallbackSceneEntitySelect: any = null;
function _getSceneItems() {
return (scenePresetsCache.data || []).map(s => ({
value: s.id,
label: s.name,
icon: `<span class="scene-color-dot" style="background:${s.color || '#4fc3f7'}"></span>`,
}));
}
function _initSceneSelector(selectId: any, selectedId: any) {
const sel = document.getElementById(selectId) as HTMLSelectElement;
// Populate <select> with scene options
sel.innerHTML = (scenePresetsCache.data || []).map(s =>
`<option value="${s.id}">${escapeHtml(s.name)}</option>`
).join('');
sel.value = selectedId || '';
// Determine which EntitySelect slot to use
const isMain = selectId === 'automation-scene-id';
const existing = isMain ? _sceneEntitySelect : _fallbackSceneEntitySelect;
if (existing) existing.destroy();
const es = new EntitySelect({
target: sel,
getItems: _getSceneItems,
placeholder: t('automations.scene.search_placeholder'),
allowNone: true,
noneLabel: t('automations.scene.none_selected'),
} as any);
if (isMain) {
_sceneEntitySelect = es;
sel.onchange = () => _autoGenerateAutomationName();
} else {
_fallbackSceneEntitySelect = es;
}
}
// ===== Deactivation mode IconSelect =====
const DEACT_MODE_KEYS = ['none', 'revert', 'fallback_scene'];
const DEACT_MODE_ICONS = {
none: P.pause, revert: P.undo2, fallback_scene: P.sparkles,
};
let _deactivationModeIconSelect: any = null;
function _ensureDeactivationModeIconSelect() {
const sel = document.getElementById('automation-deactivation-mode');
if (!sel || _deactivationModeIconSelect) return;
const items = DEACT_MODE_KEYS.map(k => ({
value: k,
icon: _icon(DEACT_MODE_ICONS[k]),
label: t(`automations.deactivation_mode.${k}`),
desc: t(`automations.deactivation_mode.${k}.desc`),
}));
_deactivationModeIconSelect = new IconSelect({ target: sel, items, columns: 3 } as any);
}
// ===== Condition editor =====
export function addAutomationCondition() {
addAutomationConditionRow({ condition_type: 'application', apps: [], match_type: 'running' });
_autoGenerateAutomationName();
}
const CONDITION_TYPE_KEYS = ['always', 'startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook', 'home_assistant'];
const CONDITION_TYPE_ICONS = {
always: P.refreshCw, 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,
};
const MATCH_TYPE_KEYS = ['running', 'topmost', 'topmost_fullscreen', 'fullscreen'];
const MATCH_TYPE_ICONS = {
running: P.play, topmost: P.monitor, topmost_fullscreen: P.tv, fullscreen: P.square,
};
function _buildMatchTypeItems() {
return MATCH_TYPE_KEYS.map(k => ({
value: k,
icon: _icon(MATCH_TYPE_ICONS[k]),
label: t(`automations.condition.application.match_type.${k}`),
desc: t(`automations.condition.application.match_type.${k}.desc`),
}));
}
function _buildConditionTypeItems() {
return CONDITION_TYPE_KEYS.map(k => ({
value: k,
icon: _icon(CONDITION_TYPE_ICONS[k]),
label: t(`automations.condition.${k}`),
desc: t(`automations.condition.${k}.desc`),
}));
}
/** Wire up the custom time-range picker inputs → sync to hidden fields. */
function _wireTimeRangePicker(container: HTMLElement) {
const startH = container.querySelector('.tr-start-h') as HTMLInputElement;
const startM = container.querySelector('.tr-start-m') as HTMLInputElement;
const endH = container.querySelector('.tr-end-h') as HTMLInputElement;
const endM = container.querySelector('.tr-end-m') as HTMLInputElement;
const hiddenStart = container.querySelector('.condition-start-time') as HTMLInputElement;
const hiddenEnd = container.querySelector('.condition-end-time') as HTMLInputElement;
if (!startH || !startM || !endH || !endM) return;
const pad = (n: number) => String(n).padStart(2, '0');
function clamp(input: HTMLInputElement, min: number, max: number) {
let v = parseInt(input.value, 10);
if (isNaN(v)) v = min;
if (v < min) v = min;
if (v > max) v = max;
input.value = pad(v);
return v;
}
function sync() {
const sh = clamp(startH, 0, 23);
const sm = clamp(startM, 0, 59);
const eh = clamp(endH, 0, 23);
const em = clamp(endM, 0, 59);
hiddenStart.value = `${pad(sh)}:${pad(sm)}`;
hiddenEnd.value = `${pad(eh)}:${pad(em)}`;
}
[startH, startM, endH, endM].forEach(inp => {
inp.addEventListener('focus', () => inp.select());
inp.addEventListener('input', sync);
inp.addEventListener('blur', sync);
inp.addEventListener('keydown', (e) => {
const isHour = inp.dataset.role === 'hour';
const max = isHour ? 23 : 59;
if (e.key === 'ArrowUp') {
e.preventDefault();
let v = parseInt(inp.value, 10) || 0;
inp.value = pad(v >= max ? 0 : v + 1);
sync();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
let v = parseInt(inp.value, 10) || 0;
inp.value = pad(v <= 0 ? max : v - 1);
sync();
}
});
});
sync();
}
function addAutomationConditionRow(condition: any) {
const list = document.getElementById('automation-conditions-list');
const row = document.createElement('div');
row.className = 'automation-condition-row';
const condType = condition.condition_type || 'application';
row.innerHTML = `
<div class="condition-header">
<select class="condition-type-select">
${CONDITION_TYPE_KEYS.map(k => `<option value="${k}" ${condType === k ? 'selected' : ''}>${t('automations.condition.' + k)}</option>`).join('')}
</select>
<button type="button" class="btn-remove-condition" onclick="this.closest('.automation-condition-row').remove(); if(window._autoGenerateAutomationName) window._autoGenerateAutomationName();" title="Remove">${ICON_TRASH}</button>
</div>
<div class="condition-fields-container"></div>
`;
const typeSelect = row.querySelector('.condition-type-select') as HTMLSelectElement;
const container = row.querySelector('.condition-fields-container') as HTMLElement;
// Attach IconSelect to the condition type dropdown
const condIconSelect = new IconSelect({
target: typeSelect,
items: _buildConditionTypeItems(),
columns: 4,
} as any);
function renderFields(type: any, data: any) {
if (type === 'always') {
container.innerHTML = `<small class="condition-always-desc">${t('automations.condition.always.hint')}</small>`;
return;
}
if (type === 'startup') {
container.innerHTML = `<small class="condition-always-desc">${t('automations.condition.startup.hint')}</small>`;
return;
}
if (type === 'time_of_day') {
const startTime = data.start_time || '00:00';
const endTime = data.end_time || '23:59';
const [sh, sm] = startTime.split(':').map(Number);
const [eh, em] = endTime.split(':').map(Number);
const pad = (n: number) => String(n).padStart(2, '0');
container.innerHTML = `
<div class="condition-fields">
<input type="hidden" class="condition-start-time" value="${startTime}">
<input type="hidden" class="condition-end-time" value="${endTime}">
<div class="time-range-picker">
<div class="time-range-slot">
<span class="time-range-label">${t('automations.condition.time_of_day.start_time')}</span>
<div class="time-range-input-wrap">
<input type="number" class="tr-start-h" min="0" max="23" value="${sh}" data-role="hour">
<span class="time-range-colon">:</span>
<input type="number" class="tr-start-m" min="0" max="59" value="${pad(sm)}" data-role="minute">
</div>
</div>
<div class="time-range-arrow">→</div>
<div class="time-range-slot">
<span class="time-range-label">${t('automations.condition.time_of_day.end_time')}</span>
<div class="time-range-input-wrap">
<input type="number" class="tr-end-h" min="0" max="23" value="${eh}" data-role="hour">
<span class="time-range-colon">:</span>
<input type="number" class="tr-end-m" min="0" max="59" value="${pad(em)}" data-role="minute">
</div>
</div>
</div>
<small class="condition-always-desc">${t('automations.condition.time_of_day.overnight_hint')}</small>
</div>`;
_wireTimeRangePicker(container);
return;
}
if (type === 'system_idle') {
const idleMinutes = data.idle_minutes ?? 5;
const whenIdle = data.when_idle ?? true;
container.innerHTML = `
<div class="condition-fields">
<div class="condition-field">
<label>${t('automations.condition.system_idle.idle_minutes')}</label>
<input type="number" class="condition-idle-minutes" min="1" max="999" value="${idleMinutes}">
</div>
<div class="condition-field">
<label>${t('automations.condition.system_idle.mode')}</label>
<select class="condition-when-idle">
<option value="true" ${whenIdle ? 'selected' : ''}>${t('automations.condition.system_idle.when_idle')}</option>
<option value="false" ${!whenIdle ? 'selected' : ''}>${t('automations.condition.system_idle.when_active')}</option>
</select>
</div>
</div>`;
return;
}
if (type === 'display_state') {
const dState = data.state || 'on';
container.innerHTML = `
<div class="condition-fields">
<div class="condition-field">
<label>${t('automations.condition.display_state.state')}</label>
<select class="condition-display-state">
<option value="on" ${dState === 'on' ? 'selected' : ''}>${t('automations.condition.display_state.on')}</option>
<option value="off" ${dState === 'off' ? 'selected' : ''}>${t('automations.condition.display_state.off')}</option>
</select>
</div>
</div>`;
return;
}
if (type === 'mqtt') {
const topic = data.topic || '';
const payload = data.payload || '';
const matchMode = data.match_mode || 'exact';
container.innerHTML = `
<div class="condition-fields">
<div class="condition-field">
<label>${t('automations.condition.mqtt.topic')}</label>
<input type="text" class="condition-mqtt-topic" value="${escapeHtml(topic)}" placeholder="home/status/power">
</div>
<div class="condition-field">
<label>${t('automations.condition.mqtt.payload')}</label>
<input type="text" class="condition-mqtt-payload" value="${escapeHtml(payload)}" placeholder="ON">
</div>
<div class="condition-field">
<label>${t('automations.condition.mqtt.match_mode')}</label>
<select class="condition-mqtt-match-mode">
<option value="exact" ${matchMode === 'exact' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.exact')}</option>
<option value="contains" ${matchMode === 'contains' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.contains')}</option>
<option value="regex" ${matchMode === 'regex' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.regex')}</option>
</select>
</div>
</div>`;
return;
}
if (type === 'home_assistant') {
const haSourceId = data.ha_source_id || '';
const entityId = data.entity_id || '';
const haState = data.state || '';
const matchMode = data.match_mode || 'exact';
// Build HA source options from cached data
const haOptions = _cachedHASources.map((s: any) =>
`<option value="${s.id}" ${s.id === haSourceId ? 'selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
container.innerHTML = `
<div class="condition-fields">
<small class="condition-always-desc">${t('automations.condition.home_assistant.hint')}</small>
<div class="condition-field">
<label>${t('automations.condition.home_assistant.ha_source')}</label>
<select class="condition-ha-source-id">
<option value="">—</option>
${haOptions}
</select>
</div>
<div class="condition-field">
<label>${t('automations.condition.home_assistant.entity_id')}</label>
<input type="text" class="condition-ha-entity-id" value="${escapeHtml(entityId)}" placeholder="binary_sensor.front_door">
</div>
<div class="condition-field">
<label>${t('automations.condition.home_assistant.state')}</label>
<input type="text" class="condition-ha-state" value="${escapeHtml(haState)}" placeholder="on">
</div>
<div class="condition-field">
<label>${t('automations.condition.home_assistant.match_mode')}</label>
<select class="condition-ha-match-mode">
<option value="exact" ${matchMode === 'exact' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.exact')}</option>
<option value="contains" ${matchMode === 'contains' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.contains')}</option>
<option value="regex" ${matchMode === 'regex' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.regex')}</option>
</select>
</div>
</div>`;
return;
}
if (type === 'webhook') {
if (data.token) {
const webhookUrl = getBaseOrigin() + '/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 = `
<div class="condition-fields">
<div class="condition-field">
<label>${t('automations.condition.application.match_type')}</label>
<select class="condition-match-type">
<option value="running" ${matchType === 'running' ? 'selected' : ''}>${t('automations.condition.application.match_type.running')}</option>
<option value="topmost" ${matchType === 'topmost' ? 'selected' : ''}>${t('automations.condition.application.match_type.topmost')}</option>
<option value="topmost_fullscreen" ${matchType === 'topmost_fullscreen' ? 'selected' : ''}>${t('automations.condition.application.match_type.topmost_fullscreen')}</option>
<option value="fullscreen" ${matchType === 'fullscreen' ? 'selected' : ''}>${t('automations.condition.application.match_type.fullscreen')}</option>
</select>
</div>
<div class="condition-field">
<div class="condition-apps-header">
<label>${t('automations.condition.application.apps')}</label>
<button type="button" class="btn btn-icon btn-secondary btn-browse-apps" title="${t('automations.condition.application.browse')}">${ICON_SEARCH}</button>
</div>
<textarea class="condition-apps" rows="3" placeholder="firefox.exe&#10;chrome.exe">${escapeHtml(appsValue)}</textarea>
</div>
</div>
`;
const textarea = container.querySelector('.condition-apps') as HTMLTextAreaElement;
attachProcessPicker(container, textarea);
// Attach IconSelect to match type
const matchSel = container.querySelector('.condition-match-type');
if (matchSel) {
new IconSelect({
target: matchSel,
items: _buildMatchTypeItems(),
columns: 2,
} as any);
}
}
renderFields(condType, condition);
typeSelect.addEventListener('change', () => {
renderFields(typeSelect.value, {});
});
list!.appendChild(row);
}
function getAutomationEditorConditions() {
const rows = document.querySelectorAll('#automation-conditions-list .automation-condition-row');
const conditions: any[] = [];
rows.forEach(row => {
const typeSelect = row.querySelector('.condition-type-select') as HTMLSelectElement;
const condType = typeSelect ? typeSelect.value : 'application';
if (condType === 'always') {
conditions.push({ condition_type: 'always' });
} else if (condType === 'startup') {
conditions.push({ condition_type: 'startup' });
} else if (condType === 'time_of_day') {
conditions.push({
condition_type: 'time_of_day',
start_time: (row.querySelector('.condition-start-time') as HTMLInputElement).value || '00:00',
end_time: (row.querySelector('.condition-end-time') as HTMLInputElement).value || '23:59',
});
} else if (condType === 'system_idle') {
conditions.push({
condition_type: 'system_idle',
idle_minutes: parseInt((row.querySelector('.condition-idle-minutes') as HTMLInputElement).value, 10) || 5,
when_idle: (row.querySelector('.condition-when-idle') as HTMLSelectElement).value === 'true',
});
} else if (condType === 'display_state') {
conditions.push({
condition_type: 'display_state',
state: (row.querySelector('.condition-display-state') as HTMLSelectElement).value || 'on',
});
} else if (condType === 'mqtt') {
conditions.push({
condition_type: 'mqtt',
topic: (row.querySelector('.condition-mqtt-topic') as HTMLInputElement).value.trim(),
payload: (row.querySelector('.condition-mqtt-payload') as HTMLInputElement).value,
match_mode: (row.querySelector('.condition-mqtt-match-mode') as HTMLSelectElement).value || 'exact',
});
} else if (condType === 'webhook') {
const tokenInput = row.querySelector('.condition-webhook-token') as HTMLInputElement;
const cond: any = { condition_type: 'webhook' };
if (tokenInput && tokenInput.value) cond.token = tokenInput.value;
conditions.push(cond);
} else if (condType === 'home_assistant') {
conditions.push({
condition_type: 'home_assistant',
ha_source_id: (row.querySelector('.condition-ha-source-id') as HTMLSelectElement).value,
entity_id: (row.querySelector('.condition-ha-entity-id') as HTMLInputElement).value.trim(),
state: (row.querySelector('.condition-ha-state') as HTMLInputElement).value,
match_mode: (row.querySelector('.condition-ha-match-mode') as HTMLSelectElement).value || 'exact',
});
} else {
const matchType = (row.querySelector('.condition-match-type') as HTMLSelectElement).value;
const appsText = (row.querySelector('.condition-apps') as HTMLTextAreaElement).value.trim();
const apps = appsText ? appsText.split('\n').map(a => a.trim()).filter(Boolean) : [];
conditions.push({ condition_type: 'application', apps, match_type: matchType });
}
});
return conditions;
}
export async function saveAutomationEditor() {
const idInput = document.getElementById('automation-editor-id') as HTMLInputElement;
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;
const name = nameInput.value.trim();
if (!name) {
automationModal.showError(t('automations.error.name_required'));
return;
}
const body = {
name,
enabled: enabledInput.checked,
condition_logic: logicSelect.value,
conditions: getAutomationEditorConditions(),
scene_preset_id: (document.getElementById('automation-scene-id') as HTMLSelectElement).value || null,
deactivation_mode: (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value,
deactivation_scene_preset_id: (document.getElementById('automation-fallback-scene-id') as HTMLSelectElement).value || null,
tags: _automationTagsInput ? _automationTagsInput.getValue() : [],
};
const automationId = idInput.value;
const isEdit = !!automationId;
try {
const url = isEdit ? `/automations/${automationId}` : '/automations';
const resp = await fetchWithAuth(url, {
method: isEdit ? 'PUT' : 'POST',
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to save automation');
}
automationModal.forceClose();
showToast(isEdit ? t('automations.updated') : t('automations.created'), 'success');
automationsCacheObj.invalidate();
loadAutomations();
} catch (e: any) {
if (e.isAuth) return;
automationModal.showError(e.message);
}
}
export async function toggleAutomationEnabled(automationId: any, enable: any) {
try {
const action = enable ? 'enable' : 'disable';
const resp = await fetchWithAuth(`/automations/${automationId}/${action}`, {
method: 'POST',
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `Failed to ${action} automation`);
}
automationsCacheObj.invalidate();
loadAutomations();
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
export function copyWebhookUrl(btn: any) {
const input = btn.closest('.webhook-url-row').querySelector('.condition-webhook-url') as HTMLInputElement;
if (!input || !input.value) return;
const onCopied = () => {
const orig = btn.textContent;
btn.textContent = t('automations.condition.webhook.copied');
setTimeout(() => { btn.textContent = orig; }, 1500);
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(input.value).then(onCopied);
} else {
input.select();
document.execCommand('copy');
onCopied();
}
}
export async function cloneAutomation(automationId: any) {
try {
const resp = await fetchWithAuth(`/automations/${automationId}`);
if (!resp.ok) throw new Error('Failed to load automation');
const automation = await resp.json();
openAutomationEditor(null, automation);
} catch (e: any) {
if (e.isAuth) return;
showToast(t('automations.error.clone_failed'), 'error');
}
}
export async function deleteAutomation(automationId: any, automationName: any) {
const msg = t('automations.delete.confirm').replace('{name}', automationName);
const confirmed = await showConfirm(msg);
if (!confirmed) return;
try {
const resp = await fetchWithAuth(`/automations/${automationId}`, {
method: 'DELETE',
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to delete automation');
}
showToast(t('automations.deleted'), 'success');
automationsCacheObj.invalidate();
loadAutomations();
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}