/**
* 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) => ``;
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 = `
${escapeHtml(error.message)}
`;
} 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 => `${p.html}
`).join('');
container!.innerHTML = panels;
CardSection.bindAll([csAutomations, csScenes]);
// Event delegation for scene preset card actions
initScenePresetDelegation(container!);
_automationsTree.setExtraHtml(``);
_automationsTree.update(treeItems, activeTab);
_automationsTree.observeSections('automations-content', {
'automations': 'automations',
'scenes': 'scenes',
});
}
}
type ConditionPillRenderer = (c: any) => string;
const CONDITION_PILL_RENDERERS: Record = {
always: (c) => `${ICON_OK} ${t('automations.condition.always')}`,
startup: (c) => `${ICON_START} ${t('automations.condition.startup')}`,
application: (c) => {
const apps = (c.apps || []).join(', ');
const matchLabel = t('automations.condition.application.match_type.' + (c.match_type || 'running'));
return `${t('automations.condition.application')}: ${apps} (${matchLabel})`;
},
time_of_day: (c) => `${ICON_CLOCK} ${t('automations.condition.time_of_day')}: ${c.start_time || '00:00'} – ${c.end_time || '23:59'}`,
system_idle: (c) => {
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})`;
},
display_state: (c) => {
const stateLabel = t('automations.condition.display_state.' + (c.state || 'on'));
return `${ICON_MONITOR} ${t('automations.condition.display_state')}: ${stateLabel}`;
},
mqtt: (c) => `${ICON_RADIO} ${t('automations.condition.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}`,
webhook: (c) => `${ICON_WEB} ${t('automations.condition.webhook')}`,
home_assistant: (c) => `${_icon(P.home)} ${t('automations.condition.home_assistant')}: ${escapeHtml(c.entity_id || '')} = ${escapeHtml(c.state || '*')}`,
};
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 = `${t('automations.conditions.empty')}`;
} else {
const parts = automation.conditions.map(c => {
const renderer = CONDITION_PILL_RENDERERS[c.condition_type];
return renderer ? renderer(c) : `${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 deactivationMeta = '';
if (automation.deactivation_mode === 'revert') {
deactivationMeta = `${ICON_UNDO} ${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;
if (fallback) {
const fbColor = fallback.color || '#4fc3f7';
deactivationMeta = `${ICON_UNDO} ● ${escapeHtml(fallback.name)}`;
} else {
deactivationMeta = `${ICON_UNDO} ${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: `
${condPills}
${ICON_SCENE} ● ${sceneName}
${deactivationMeta}
${renderTagChips(automation.tags)}`,
actions: `
`,
});
}
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: ``,
}));
}
function _initSceneSelector(selectId: any, selectedId: any) {
const sel = document.getElementById(selectId) as HTMLSelectElement;
// Populate