// AutomationsPanel - Spatial automation builder UI window.AutomationsPanel = (function() { 'use strict'; let automations = []; let triggerVolumes = []; let zones = []; let people = []; let editingId = null; let testMode = false; // Trigger type labels const triggerLabels = { 'zone_enter': 'When someone enters a zone', 'zone_leave': 'When someone leaves a zone', 'zone_dwell': 'When someone stays in a zone for a while', 'zone_vacant': 'When a zone becomes empty', 'person_count_change': 'When occupant count changes', 'fall_detected': 'When a fall is detected', 'anomaly': 'When an anomaly is detected', 'ble_device_present': 'When a BLE device arrives', 'ble_device_absent': 'When a BLE device leaves', 'volume_enter': 'When someone enters a trigger volume', 'volume_leave': 'When someone leaves a trigger volume' }; // Action type labels const actionLabels = { 'webhook': 'HTTP Webhook', 'mqtt_publish': 'MQTT Publish', 'ntfy': 'Ntfy Notification', 'pushover': 'Pushover Notification' }; // Initialize panel function init() { createPanelHTML(); loadInitialData(); setupEventListeners(); } function createPanelHTML() { const container = document.getElementById('automations-panel'); if (!container) return; container.innerHTML = `

Automations

`; } function setupEventListeners() { // New automation button document.getElementById('new-automation-btn')?.addEventListener('click', () => openEditor()); // Modal controls document.getElementById('modal-close')?.addEventListener('click', closeEditor); document.getElementById('modal-cancel')?.addEventListener('click', closeEditor); document.getElementById('modal-save')?.addEventListener('click', saveAutomation); document.getElementById('test-fire-btn')?.addEventListener('click', testFire); // Trigger type change document.getElementById('trigger-type')?.addEventListener('change', onTriggerTypeChange); // Condition modal document.getElementById('add-condition-btn')?.addEventListener('click', openConditionModal); document.getElementById('condition-modal-close')?.addEventListener('click', closeConditionModal); document.getElementById('condition-cancel')?.addEventListener('click', closeConditionModal); document.getElementById('condition-save')?.addEventListener('click', addCondition); document.getElementById('condition-type')?.addEventListener('change', onConditionTypeChange); // Action modal document.getElementById('add-action-btn')?.addEventListener('click', openActionModal); document.getElementById('action-modal-close')?.addEventListener('click', closeActionModal); document.getElementById('action-cancel')?.addEventListener('click', closeActionModal); document.getElementById('action-save')?.addEventListener('click', addAction); document.getElementById('action-type')?.addEventListener('change', onActionTypeChange); } async function loadInitialData() { try { // Load automations const autoRes = await fetch('/api/automations'); if (autoRes.ok) { automations = await autoRes.json(); } // Load zones const zonesRes = await fetch('/api/zones'); if (zonesRes.ok) { zones = await zonesRes.json(); } // Load people const peopleRes = await fetch('/api/people'); if (peopleRes.ok) { people = await peopleRes.json(); } // Load trigger volumes const volumesRes = await fetch('/api/automations/volumes'); if (volumesRes.ok) { triggerVolumes = await volumesRes.json(); } renderAutomationsList(); } catch (err) { console.error('Failed to load data:', err); } } function renderAutomationsList() { const list = document.getElementById('automations-list'); if (!list) return; if (automations.length === 0) { list.innerHTML = `

No automations configured yet.

Click "New Automation" to create your first automation.

`; return; } list.innerHTML = automations.map(a => `

${escapeHtml(a.name)}

Trigger: ${triggerLabels[a.trigger_type] || a.trigger_type}
Actions: ${a.actions.length} action(s)
Fired: ${a.fire_count || 0} time(s)
${a.last_fired ? `
Last fired: ${formatTime(a.last_fired)}
` : ''}
`).join(''); } function openEditor(automation = null) { editingId = automation?.id || null; document.getElementById('modal-title').textContent = editingId ? 'Edit Automation' : 'New Automation'; document.getElementById('trigger-type').value = automation?.trigger_type || ''; document.getElementById('automation-name').value = automation?.name || ''; document.getElementById('automation-cooldown').value = automation?.cooldown || 60; // Show/hide steps document.getElementById('step-config').style.display = automation ? 'block' : 'none'; document.getElementById('step-conditions').style.display = automation ? 'block' : 'none'; document.getElementById('step-actions').style.display = automation ? 'block' : 'none'; document.getElementById('step-summary').style.display = automation ? 'block' : 'none'; if (automation) { renderTriggerConfig(automation.trigger_type, automation.trigger_config); renderConditions(automation.conditions || []); renderActions(automation.actions); updateSummary(); } else { document.getElementById('conditions-list').innerHTML = ''; document.getElementById('actions-list').innerHTML = ''; } document.getElementById('automation-modal').style.display = 'flex'; } function closeEditor() { document.getElementById('automation-modal').style.display = 'none'; editingId = null; } function onTriggerTypeChange() { const triggerType = document.getElementById('trigger-type').value; if (triggerType) { document.getElementById('step-config').style.display = 'block'; renderTriggerConfig(triggerType); } else { document.getElementById('step-config').style.display = 'none'; } } function renderTriggerConfig(triggerType, config = {}) { const form = document.getElementById('trigger-config-form'); if (!form || !triggerType) return; let html = ''; switch (triggerType) { case 'zone_enter': case 'zone_leave': case 'zone_dwell': case 'zone_vacant': case 'person_count_change': html = `
`; if (triggerType === 'zone_dwell') { html += `
`; } if (triggerType === 'person_count_change') { html += `
`; } break; case 'fall_detected': html = `
`; break; case 'ble_device_present': case 'ble_device_absent': html = `
`; if (triggerType === 'ble_device_absent') { html += `
`; } break; } form.innerHTML = html; // Show next steps document.getElementById('step-conditions').style.display = 'block'; document.getElementById('step-actions').style.display = 'block'; document.getElementById('step-summary').style.display = 'block'; } function renderConditions(conditions) { const list = document.getElementById('conditions-list'); if (!list) return; if (conditions.length === 0) { list.innerHTML = '

No conditions added.

'; return; } const conditionLabels = { 'person_filter': 'Person', 'time_window': 'Time', 'day_of_week': 'Days', 'system_mode': 'Mode', 'zone_occupancy': 'Zone Occupancy' }; list.innerHTML = conditions.map((c, i) => `
${conditionLabels[c.type] || c.type}: ${escapeHtml(c.value)}
`).join(''); } function openConditionModal() { document.getElementById('condition-type').value = 'person_filter'; onConditionTypeChange(); document.getElementById('condition-modal').style.display = 'flex'; } function closeConditionModal() { document.getElementById('condition-modal').style.display = 'none'; } function onConditionTypeChange() { const type = document.getElementById('condition-type').value; const form = document.getElementById('condition-value-form'); if (!form) return; switch (type) { case 'person_filter': form.innerHTML = `
`; break; case 'time_window': form.innerHTML = `

Supports overnight ranges (e.g., 22:00-07:00)

`; break; case 'day_of_week': form.innerHTML = `
`; break; case 'system_mode': form.innerHTML = `
`; break; case 'zone_occupancy': form.innerHTML = `
`; break; } } function addCondition() { const type = document.getElementById('condition-type').value; let value = ''; switch (type) { case 'person_filter': value = document.getElementById('condition-person').value; break; case 'time_window': const start = document.getElementById('condition-time-start').value; const end = document.getElementById('condition-time-end').value; value = `${start}-${end}`; break; case 'day_of_week': const days = []; document.querySelectorAll('#condition-value-form input:checked').forEach(cb => { days.push(cb.value); }); value = days.join(','); break; case 'system_mode': value = document.getElementById('condition-mode').value; break; case 'zone_occupancy': const zone = document.getElementById('condition-occ-zone').value; const op = document.getElementById('condition-occ-op').value; const count = document.getElementById('condition-occ-count').value; value = `${zone}:${op}:${count}`; break; } // Add to current automation being edited const conditionsList = document.getElementById('conditions-list'); const conditionItem = document.createElement('div'); conditionItem.className = 'condition-item'; conditionItem.innerHTML = ` ${type}: ${escapeHtml(value)} `; conditionsList.appendChild(conditionItem); closeConditionModal(); updateSummary(); } function renderActions(actions) { const list = document.getElementById('actions-list'); if (!list) return; if (actions.length === 0) { list.innerHTML = '

No actions added.

'; return; } list.innerHTML = actions.map((a, i) => `
${actionLabels[a.type] || a.type} ${escapeHtml(a.url || a.topic || a.server || '')}
`).join(''); } function openActionModal() { document.getElementById('action-type').value = 'webhook'; onActionTypeChange(); document.getElementById('action-modal').style.display = 'flex'; } function closeActionModal() { document.getElementById('action-modal').style.display = 'none'; } function onActionTypeChange() { const type = document.getElementById('action-type').value; const form = document.getElementById('action-config-form'); if (!form) return; switch (type) { case 'webhook': form.innerHTML = `

Variables: {{person_name}}, {{zone_name}}, {{from_zone}}, {{to_zone}}, {{timestamp}}, {{occupant_count}}, {{event_type}}

`; break; case 'mqtt_publish': form.innerHTML = `
`; break; case 'ntfy': form.innerHTML = `
`; break; case 'pushover': form.innerHTML = `
`; break; } } function addAction() { const type = document.getElementById('action-type').value; const action = { type }; switch (type) { case 'webhook': action.url = document.getElementById('action-url').value; action.template = document.getElementById('action-template').value; break; case 'mqtt_publish': action.topic = document.getElementById('action-topic').value; action.template = document.getElementById('action-template').value; break; case 'ntfy': action.server = document.getElementById('action-server').value; action.template = document.getElementById('action-template').value; break; case 'pushover': action.token = document.getElementById('action-token').value; action.user_key = document.getElementById('action-user-key').value; action.template = document.getElementById('action-template').value; break; } const actionsList = document.getElementById('actions-list'); const actionItem = document.createElement('div'); actionItem.className = 'action-item'; actionItem.innerHTML = ` ${actionLabels[type]} ${escapeHtml(action.url || action.topic || action.server || '')} `; actionItem.dataset.action = JSON.stringify(action); actionsList.appendChild(actionItem); closeActionModal(); updateSummary(); } function updateSummary() { const triggerType = document.getElementById('trigger-type').value; const name = document.getElementById('automation-name').value || 'Untitled'; const conditions = getConditionsFromList(); const actions = getActionsFromList(); const summaryText = document.getElementById('summary-text'); if (summaryText) { summaryText.innerHTML = `

"${escapeHtml(name)}"

When: ${triggerLabels[triggerType] || triggerType}

${conditions.length > 0 ? `

Conditions: ${conditions.length} filter(s)

` : ''}

Then: ${actions.length} action(s)

`; } } function getConditionsFromList() { const conditions = []; document.querySelectorAll('#conditions-list .condition-item').forEach(item => { const text = item.querySelector('span').textContent; const parts = text.split(': '); if (parts.length >= 2) { conditions.push({ type: parts[0].trim(), value: parts.slice(1).join(': ').trim() }); } }); return conditions; } function getActionsFromList() { const actions = []; document.querySelectorAll('#actions-list .action-item').forEach(item => { if (item.dataset.action) { actions.push(JSON.parse(item.dataset.action)); } }); return actions; } function getTriggerConfig() { const triggerType = document.getElementById('trigger-type').value; const config = {}; const zoneEl = document.getElementById('config-zone-id'); const personEl = document.getElementById('config-person-id'); const deviceEl = document.getElementById('config-device-mac'); if (zoneEl) config.zone_id = zoneEl.value; if (personEl) config.person_id = personEl.value; if (deviceEl) config.device_mac = deviceEl.value; const dwellEl = document.getElementById('config-dwell-minutes'); const absentEl = document.getElementById('config-absent-minutes'); const countThresholdEl = document.getElementById('config-count-threshold'); const countDirEl = document.getElementById('config-count-direction'); if (dwellEl) config.dwell_minutes = parseInt(dwellEl.value) || 5; if (absentEl) config.absent_minutes = parseInt(absentEl.value) || 15; if (countThresholdEl) config.count_threshold = parseInt(countThresholdEl.value) || 1; if (countDirEl) config.count_direction = countDirEl.value; return config; } async function saveAutomation() { const automation = { id: editingId || `auto_${Date.now()}`, name: document.getElementById('automation-name').value || 'Untitled', enabled: true, trigger_type: document.getElementById('trigger-type').value, trigger_config: getTriggerConfig(), conditions: getConditionsFromList(), actions: getActionsFromList(), cooldown: parseInt(document.getElementById('automation-cooldown').value) || 60 }; try { const url = '/api/automations' + (editingId ? `/${editingId}` : ''); const method = editingId ? 'PUT' : 'POST'; const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(automation) }); if (!res.ok) throw new Error('Failed to save'); closeEditor(); await loadInitialData(); } catch (err) { console.error('Failed to save automation:', err); alert('Failed to save automation: ' + err.message); } } async function testFire(id) { try { const res = await fetch(`/api/automations/${id}/test`, { method: 'POST' }); if (!res.ok) throw new Error('Test fire failed'); alert('Test fire sent!'); } catch (err) { console.error('Test fire failed:', err); alert('Test fire failed: ' + err.message); } } async function toggleEnabled(id, enabled) { try { const automation = automations.find(a => a.id === id); if (!automation) return; automation.enabled = enabled; const res = await fetch(`/api/automations/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(automation) }); if (!res.ok) throw new Error('Failed to update'); renderAutomationsList(); } catch (err) { console.error('Failed to toggle automation:', err); } } function edit(id) { const automation = automations.find(a => a.id === id); if (automation) { openEditor(automation); } } async function deleteAutomation(id) { if (!confirm('Are you sure you want to delete this automation?')) return; try { const res = await fetch(`/api/automations/${id}`, { method: 'DELETE' }); if (!res.ok) throw new Error('Failed to delete'); await loadInitialData(); } catch (err) { console.error('Failed to delete automation:', err); alert('Failed to delete automation: ' + err.message); } } function removeCondition(index) { const conditions = getConditionsFromList(); conditions.splice(index, 1); renderConditions(conditions); updateSummary(); } function removeAction(index) { const actions = getActionsFromList(); actions.splice(index, 1); renderActions(actions); updateSummary(); } // Helper functions function escapeHtml(str) { if (!str) return ''; return str.replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function formatTime(timestamp) { if (!timestamp) return 'Never'; const date = new Date(timestamp); return date.toLocaleString(); } // Public API return { init, edit, delete: deleteAutomation, toggleEnabled, testFire, removeCondition, removeAction, updateSummary }; })();