/** * Automations — automation cards, editor, condition builder, process picker, scene selector. */ import { apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, scenePresetsCache } from '../core/state.js'; import { fetchWithAuth, escapeHtml } from '../core/api.js'; import { t } from '../core/i18n.js'; import { showToast, showConfirm, setTabRefreshing } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { CardSection } from '../core/card-sections.js'; import { updateTabBadge } from './tabs.js'; 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 } from '../core/icons.js'; import * as P from '../core/icon-paths.js'; import { wrapCard } from '../core/card-colors.js'; import { TagInput, renderTagChips } from '../core/tag-input.js'; import { IconSelect } from '../core/icon-select.js'; import { EntitySelect } from '../core/entity-palette.js'; import { attachProcessPicker } from '../core/process-picker.js'; import { csScenes, createSceneCard } from './scene-presets.js'; let _automationTagsInput = null; class AutomationEditorModal extends Modal { constructor() { super('automation-editor-modal'); } onForceClose() { if (_automationTagsInput) { _automationTagsInput.destroy(); _automationTagsInput = null; } } snapshotValues() { return { name: document.getElementById('automation-editor-name').value, enabled: document.getElementById('automation-editor-enabled').checked.toString(), logic: document.getElementById('automation-editor-logic').value, conditions: JSON.stringify(getAutomationEditorConditions()), scenePresetId: document.getElementById('automation-scene-id').value, deactivationMode: document.getElementById('automation-deactivation-mode').value, deactivationScenePresetId: document.getElementById('automation-fallback-scene-id').value, tags: JSON.stringify(_automationTagsInput ? _automationTagsInput.getValue() : []), }; } } const automationModal = new AutomationEditorModal(); const csAutomations = new CardSection('automations', { titleKey: 'automations.title', gridClass: 'devices-grid', addCardOnclick: "openAutomationEditor()", keyAttr: 'data-automation-id' }); /* ── Condition logic IconSelect ───────────────────────────────── */ const _icon = (d) => `${d}`; let _conditionLogicIconSelect = 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 }); } // 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) { if (error.isAuth) return; console.error('Failed to load automations:', error); container.innerHTML = `

${error.message}

`; } finally { set_automationsLoading(false); setTabRefreshing('automations-content', false); } } export function expandAllAutomationSections() { CardSection.expandAll([csAutomations, csScenes]); } export function collapseAllAutomationSections() { CardSection.collapseAll([csAutomations, csScenes]); } function renderAutomations(automations, sceneMap) { 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) }))); if (csAutomations.isMounted()) { csAutomations.reconcile(autoItems); csScenes.reconcile(sceneItems); } else { const toolbar = `
`; container.innerHTML = toolbar + csAutomations.render(autoItems) + csScenes.render(sceneItems); csAutomations.bind(); csScenes.bind(); // Localize data-i18n elements within the container container.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.getAttribute('data-i18n')); }); } } function createAutomationCard(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 = `${t('automations.conditions.empty')}`; } else { const parts = automation.conditions.map(c => { if (c.condition_type === 'always') { return `${ICON_OK} ${t('automations.condition.always')}`; } if (c.condition_type === 'startup') { return `${ICON_START} ${t('automations.condition.startup')}`; } if (c.condition_type === 'application') { const apps = (c.apps || []).join(', '); const matchLabel = t('automations.condition.application.match_type.' + (c.match_type || 'running')); return `${t('automations.condition.application')}: ${apps} (${matchLabel})`; } if (c.condition_type === 'time_of_day') { return `${ICON_CLOCK} ${c.start_time || '00:00'} – ${c.end_time || '23:59'}`; } if (c.condition_type === 'system_idle') { const mode = c.when_idle !== false ? t('automations.condition.system_idle.when_idle') : t('automations.condition.system_idle.when_active'); return `${ICON_TIMER} ${c.idle_minutes || 5}m (${mode})`; } if (c.condition_type === 'display_state') { const stateLabel = t('automations.condition.display_state.' + (c.state || 'on')); return `${ICON_MONITOR} ${t('automations.condition.display_state')}: ${stateLabel}`; } if (c.condition_type === 'mqtt') { return `${ICON_RADIO} ${t('automations.condition.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}`; } if (c.condition_type === 'webhook') { return `🔗 ${t('automations.condition.webhook')}`; } return `${c.condition_type}`; }); const logicLabel = automation.condition_logic === 'and' ? t('automations.logic.and') : t('automations.logic.or'); condPills = parts.join(`${logicLabel}`); } // 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 deactivationLabel = ''; if (automation.deactivation_mode === 'revert') { deactivationLabel = t('automations.deactivation_mode.revert'); } else if (automation.deactivation_mode === 'fallback_scene') { const fallback = automation.deactivation_scene_preset_id ? sceneMap.get(automation.deactivation_scene_preset_id) : null; deactivationLabel = fallback ? `${t('automations.deactivation_mode.fallback_scene')}: ${escapeHtml(fallback.name)}` : t('automations.deactivation_mode.fallback_scene'); } let lastActivityMeta = ''; if (automation.last_activated_at) { const ts = new Date(automation.last_activated_at); lastActivityMeta = `${ICON_CLOCK} ${ts.toLocaleString()}`; } 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: `
${escapeHtml(automation.name)} ${statusText}
${automation.condition_logic === 'and' ? t('automations.logic.all') : t('automations.logic.any')} ${ICON_SCENE} ${sceneName} ${deactivationLabel ? `${deactivationLabel}` : ''} ${lastActivityMeta}
${condPills}
${renderTagChips(automation.tags)}`, actions: ` `, }); } export async function openAutomationEditor(automationId, cloneData) { const modal = document.getElementById('automation-editor-modal'); const titleEl = document.getElementById('automation-editor-title'); const idInput = document.getElementById('automation-editor-id'); const nameInput = document.getElementById('automation-editor-name'); const enabledInput = document.getElementById('automation-editor-enabled'); const logicSelect = document.getElementById('automation-editor-logic'); const condList = document.getElementById('automation-conditions-list'); const errorEl = document.getElementById('automation-editor-error'); 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').value = 'none'; if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue('none'); document.getElementById('automation-fallback-scene-group').style.display = 'none'; let _editorTags = []; 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').value = deactMode; if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(deactMode); _onDeactivationModeChange(); _initSceneSelector('automation-fallback-scene-id', automation.deactivation_scene_preset_id); _editorTags = automation.tags || []; } catch (e) { 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').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').onchange = _onDeactivationModeChange; automationModal.open(); modal.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.getAttribute('data-i18n')); }); modal.querySelectorAll('[data-i18n-placeholder]').forEach(el => { el.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').value; document.getElementById('automation-fallback-scene-group').style.display = mode === 'fallback_scene' ? '' : 'none'; } export async function closeAutomationEditorModal() { await automationModal.close(); } // ===== Scene selector (EntitySelect) ===== let _sceneEntitySelect = null; let _fallbackSceneEntitySelect = null; function _getSceneItems() { return (scenePresetsCache.data || []).map(s => ({ value: s.id, label: s.name, icon: ``, })); } function _initSceneSelector(selectId, selectedId) { const sel = document.getElementById(selectId); // Populate ${CONDITION_TYPE_KEYS.map(k => ``).join('')}
`; const typeSelect = row.querySelector('.condition-type-select'); const container = row.querySelector('.condition-fields-container'); // Attach IconSelect to the condition type dropdown const condIconSelect = new IconSelect({ target: typeSelect, items: _buildConditionTypeItems(), columns: 4, }); function renderFields(type, data) { if (type === 'always') { container.innerHTML = `${t('automations.condition.always.hint')}`; return; } if (type === 'startup') { container.innerHTML = `${t('automations.condition.startup.hint')}`; return; } if (type === 'time_of_day') { const startTime = data.start_time || '00:00'; const endTime = data.end_time || '23:59'; container.innerHTML = `
${t('automations.condition.time_of_day.overnight_hint')}
`; return; } if (type === 'system_idle') { const idleMinutes = data.idle_minutes ?? 5; const whenIdle = data.when_idle ?? true; container.innerHTML = `
`; return; } if (type === 'display_state') { const dState = data.state || 'on'; container.innerHTML = `
`; return; } if (type === 'mqtt') { const topic = data.topic || ''; const payload = data.payload || ''; const matchMode = data.match_mode || 'exact'; container.innerHTML = `
`; return; } if (type === 'webhook') { if (data.token) { const webhookUrl = window.location.origin + '/api/v1/webhooks/' + data.token; container.innerHTML = `
${t('automations.condition.webhook.hint')}
`; } else { container.innerHTML = `
${t('automations.condition.webhook.hint')}

${t('automations.condition.webhook.save_first')}

`; } return; } const appsValue = (data.apps || []).join('\n'); const matchType = data.match_type || 'running'; container.innerHTML = `
`; const textarea = container.querySelector('.condition-apps'); 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, }); } } 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 = []; rows.forEach(row => { const typeSelect = row.querySelector('.condition-type-select'); 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').value || '00:00', end_time: row.querySelector('.condition-end-time').value || '23:59', }); } else if (condType === 'system_idle') { conditions.push({ condition_type: 'system_idle', idle_minutes: parseInt(row.querySelector('.condition-idle-minutes').value, 10) || 5, when_idle: row.querySelector('.condition-when-idle').value === 'true', }); } else if (condType === 'display_state') { conditions.push({ condition_type: 'display_state', state: row.querySelector('.condition-display-state').value || 'on', }); } else if (condType === 'mqtt') { conditions.push({ condition_type: 'mqtt', topic: row.querySelector('.condition-mqtt-topic').value.trim(), 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(); 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'); const nameInput = document.getElementById('automation-editor-name'); const enabledInput = document.getElementById('automation-editor-enabled'); const logicSelect = document.getElementById('automation-editor-logic'); 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').value || null, deactivation_mode: document.getElementById('automation-deactivation-mode').value, deactivation_scene_preset_id: document.getElementById('automation-fallback-scene-id').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'); loadAutomations(); } catch (e) { if (e.isAuth) return; automationModal.showError(e.message); } } export async function toggleAutomationEnabled(automationId, enable) { try { const action = enable ? 'enable' : 'disable'; const resp = await fetchWithAuth(`/automations/${automationId}/${action}`, { method: 'POST', }); if (!resp.ok) throw new Error(`Failed to ${action} automation`); loadAutomations(); } catch (e) { if (e.isAuth) return; showToast(e.message, 'error'); } } 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 cloneAutomation(automationId) { 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) { if (e.isAuth) return; showToast(t('automations.error.clone_failed'), 'error'); } } export async function deleteAutomation(automationId, automationName) { 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) throw new Error('Failed to delete automation'); showToast(t('automations.deleted'), 'success'); loadAutomations(); } catch (e) { if (e.isAuth) return; showToast(e.message, 'error'); } }