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
1010 lines
49 KiB
TypeScript
1010 lines
49 KiB
TypeScript
/**
|
||
* 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}">●</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}">●</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 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');
|
||
}
|
||
}
|