/**
* 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: `
${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 = `
`;
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');
}
}