/** * 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 } from '../core/icons.js'; import { wrapCard } from '../core/card-colors.js'; import { csScenes, createSceneCard } from './scene-presets.js'; class AutomationEditorModal extends Modal { constructor() { super('automation-editor-modal'); } 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, }; } } const automationModal = new AutomationEditorModal(); const csAutomations = new CardSection('automations', { titleKey: 'automations.title', gridClass: 'devices-grid', addCardOnclick: "openAutomationEditor()", keyAttr: 'data-automation-id' }); // 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}
`, 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 = ''; // Fetch scenes for selector try { await scenePresetsCache.fetch(); } catch { /* use cached */ } // Reset deactivation mode document.getElementById('automation-deactivation-mode').value = 'none'; document.getElementById('automation-fallback-scene-group').style.display = 'none'; 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; for (const c of automation.conditions) { addAutomationConditionRow(c); } // Scene selector _initSceneSelector('automation-scene', automation.scene_preset_id); // Deactivation mode document.getElementById('automation-deactivation-mode').value = automation.deactivation_mode || 'none'; _onDeactivationModeChange(); _initSceneSelector('automation-fallback-scene', automation.deactivation_scene_preset_id); } 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'; // 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', cloneData.scene_preset_id); document.getElementById('automation-deactivation-mode').value = cloneData.deactivation_mode || 'none'; _onDeactivationModeChange(); _initSceneSelector('automation-fallback-scene', cloneData.deactivation_scene_preset_id); } else { titleEl.innerHTML = `${ICON_AUTOMATION} ${t('automations.add')}`; idInput.value = ''; nameInput.value = ''; enabledInput.checked = true; logicSelect.value = 'or'; _initSceneSelector('automation-scene', null); _initSceneSelector('automation-fallback-scene', 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')); }); 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 logic ===== function _initSceneSelector(prefix, selectedId) { const hiddenInput = document.getElementById(`${prefix}-id`); const searchInput = document.getElementById(`${prefix}-search`); const clearBtn = document.getElementById(`${prefix}-clear`); const dropdown = document.getElementById(`${prefix}-dropdown`); hiddenInput.value = selectedId || ''; // Set initial display text if (selectedId) { const scene = scenePresetsCache.data.find(s => s.id === selectedId); searchInput.value = scene ? scene.name : ''; clearBtn.classList.toggle('visible', true); } else { searchInput.value = ''; clearBtn.classList.toggle('visible', false); } // Render dropdown items function renderDropdown(filter) { const query = (filter || '').toLowerCase(); const filtered = query ? scenePresetsCache.data.filter(s => s.name.toLowerCase().includes(query)) : scenePresetsCache.data; if (filtered.length === 0) { dropdown.innerHTML = `
${t('automations.scene.none_available')}
`; } else { dropdown.innerHTML = filtered.map(s => { const selected = s.id === hiddenInput.value ? ' selected' : ''; return `
${escapeHtml(s.name)}
`; }).join(''); } // Attach click handlers dropdown.querySelectorAll('.scene-selector-item').forEach(item => { item.addEventListener('click', () => { const id = item.dataset.sceneId; const scene = scenePresetsCache.data.find(s => s.id === id); hiddenInput.value = id; searchInput.value = scene ? scene.name : ''; clearBtn.classList.toggle('visible', true); dropdown.classList.remove('open'); }); }); } // Show dropdown on focus/click searchInput.onfocus = () => { renderDropdown(searchInput.value); dropdown.classList.add('open'); }; searchInput.oninput = () => { renderDropdown(searchInput.value); dropdown.classList.add('open'); // If text doesn't match any scene, clear the hidden input const exactMatch = scenePresetsCache.data.find(s => s.name.toLowerCase() === searchInput.value.toLowerCase()); if (!exactMatch) { hiddenInput.value = ''; clearBtn.classList.toggle('visible', !!searchInput.value); } }; searchInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); // Select first visible item const first = dropdown.querySelector('.scene-selector-item'); if (first) first.click(); } else if (e.key === 'Escape') { dropdown.classList.remove('open'); searchInput.blur(); } }; // Clear button clearBtn.onclick = () => { hiddenInput.value = ''; searchInput.value = ''; clearBtn.classList.remove('visible'); dropdown.classList.remove('open'); }; // Close dropdown when clicking outside const selectorEl = searchInput.closest('.scene-selector'); // Remove old listener if any (re-init) if (selectorEl._outsideClickHandler) { document.removeEventListener('click', selectorEl._outsideClickHandler); } selectorEl._outsideClickHandler = (e) => { if (!selectorEl.contains(e.target)) { dropdown.classList.remove('open'); } }; document.addEventListener('click', selectorEl._outsideClickHandler); } // ===== Condition editor ===== export function addAutomationCondition() { addAutomationConditionRow({ condition_type: 'application', apps: [], match_type: 'running' }); } function addAutomationConditionRow(condition) { 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 = `
`; const typeSelect = row.querySelector('.condition-type-select'); const container = row.querySelector('.condition-fields-container'); 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 browseBtn = container.querySelector('.btn-browse-apps'); const picker = container.querySelector('.process-picker'); browseBtn.addEventListener('click', () => toggleProcessPicker(picker, row)); const searchInput = container.querySelector('.process-picker-search'); searchInput.addEventListener('input', () => filterProcessPicker(picker)); } renderFields(condType, condition); typeSelect.addEventListener('change', () => { renderFields(typeSelect.value, {}); }); list.appendChild(row); } async function toggleProcessPicker(picker, row) { if (picker.style.display !== 'none') { picker.style.display = 'none'; return; } const listEl = picker.querySelector('.process-picker-list'); const searchEl = picker.querySelector('.process-picker-search'); searchEl.value = ''; listEl.innerHTML = `
${t('common.loading')}
`; picker.style.display = ''; try { const resp = await fetchWithAuth('/system/processes'); if (!resp.ok) throw new Error('Failed to fetch processes'); const data = await resp.json(); const textarea = row.querySelector('.condition-apps'); const existing = new Set(textarea.value.split('\n').map(a => a.trim().toLowerCase()).filter(Boolean)); picker._processes = data.processes; picker._existing = existing; renderProcessPicker(picker, data.processes, existing); searchEl.focus(); } catch (e) { listEl.innerHTML = `
${e.message}
`; } } function renderProcessPicker(picker, processes, existing) { const listEl = picker.querySelector('.process-picker-list'); if (processes.length === 0) { listEl.innerHTML = `
${t('automations.condition.application.no_processes')}
`; return; } listEl.innerHTML = processes.map(p => { const added = existing.has(p.toLowerCase()); return `
${escapeHtml(p)}${added ? ' \u2713' : ''}
`; }).join(''); listEl.querySelectorAll('.process-picker-item:not(.added)').forEach(item => { item.addEventListener('click', () => { const proc = item.dataset.process; const row = picker.closest('.automation-condition-row'); const textarea = row.querySelector('.condition-apps'); const current = textarea.value.trim(); textarea.value = current ? current + '\n' + proc : proc; item.classList.add('added'); item.textContent = proc + ' \u2713'; picker._existing.add(proc.toLowerCase()); }); }); } function filterProcessPicker(picker) { const query = picker.querySelector('.process-picker-search').value.toLowerCase(); const filtered = (picker._processes || []).filter(p => p.includes(query)); renderProcessPicker(picker, filtered, picker._existing || new Set()); } 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, }; 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'); } }