/** * Spatial Automation Builder - Trigger volume editor and automation UI * Integrates with VolumeEditor for 3D volume creation and editing */ (function() { 'use strict'; // ── Module state ───────────────────────────────────────────────────────────── let _triggers = []; let _currentVolume = null; // Currently drawn volume shape let _editingId = null; let _volumeEditorReady = false; // Trigger condition labels const CONDITION_LABELS = { 'enter': 'When someone enters the volume', 'leave': 'When someone leaves the volume', 'dwell': 'When someone stays in the volume for a while', 'vacant': 'When the volume becomes empty', 'count': 'When occupant count crosses threshold' }; // ── Initialization ───────────────────────────────────────────────────────────── function init() { console.log('[AutomationBuilder] Initializing'); createPanelHTML(); setupEventListeners(); loadTriggers(); // Subscribe to state changes for triggers if (window.SpaxelState) { SpaxelState.subscribe('triggers', onTriggersChanged); } // Initialize volume editor callback if (window.VolumeEditor) { VolumeEditor.onVolumeCreated(onVolumeCreated); VolumeEditor.onVolumeChanged(onVolumeChanged); VolumeEditor.onVolumeDeleted(onVolumeDeleted); _volumeEditorReady = true; } else { console.warn('[AutomationBuilder] VolumeEditor not loaded yet'); } } function createPanelHTML() { const container = document.getElementById('automations-panel'); if (!container) { console.warn('[AutomationBuilder] #automations-panel not found'); return; } container.innerHTML = `

Spatial Triggers

Loading triggers...

Trigger Log

No trigger firings yet.

`; } function setupEventListeners() { // Volume drawing buttons document.getElementById('draw-box-btn')?.addEventListener('click', () => startDrawVolume('box')); document.getElementById('draw-cylinder-btn')?.addEventListener('click', () => startDrawVolume('cylinder')); // New trigger button document.getElementById('new-trigger-btn')?.addEventListener('click', openTriggerEditor); // Modal controls document.getElementById('modal-close')?.addEventListener('click', closeTriggerEditor); document.getElementById('modal-cancel')?.addEventListener('click', closeTriggerEditor); document.getElementById('modal-save')?.addEventListener('click', saveTrigger); document.getElementById('test-trigger-btn')?.addEventListener('click', () => test(_editingId)); // Condition change document.getElementById('trigger-condition')?.addEventListener('change', onConditionChange); // Action modal document.getElementById('add-action-btn')?.addEventListener('click', openActionModal); document.getElementById('action-cancel')?.addEventListener('click', closeActionModal); document.getElementById('action-save')?.addEventListener('click', saveAction); document.getElementById('action-type')?.addEventListener('change', onActionTypeChange); document.getElementById('action-modal-close')?.addEventListener('click', closeActionModal); } // ── Volume Drawing ──────────────────────────────────────────────────────────────── function startDrawVolume(type) { if (_volumeEditorReady && window.VolumeEditor) { VolumeEditor.startDrawMode(type); // Update UI to show drawing state document.querySelectorAll('.volume-tools button').forEach(btn => { if (btn.id === `draw-${type}-btn`) { btn.classList.add('active'); } else { btn.disabled = true; } }); // Update cursor in 3D scene document.getElementById('scene-container').style.cursor = 'crosshair'; // Close any open modal closeTriggerEditor(); } else { alert('3D scene not ready. Please wait for the dashboard to fully load.'); } } function stopDrawVolume() { if (_volumeEditorReady && window.VolumeEditor) { VolumeEditor.cancelDrawMode(); } // Reset UI document.querySelectorAll('.volume-tools button').forEach(btn => { btn.classList.remove('active'); btn.disabled = false; }); document.getElementById('scene-container').style.cursor = 'default'; } function onVolumeCreated(shape) { _currentVolume = shape; updateVolumeDisplay(); // Stop drawing mode stopDrawVolume(); // Open trigger editor with the new volume openTriggerEditor(); } function onVolumeChanged(volumeId, shape) { if (_editingId) { _currentVolume = shape; updateVolumeDisplay(); } } function onVolumeDeleted(volumeId) { if (_editingId && _currentVolume) { _currentVolume = null; updateVolumeDisplay(); } } function updateVolumeDisplay() { const display = document.getElementById('current-volume-shape'); const dims = document.getElementById('volume-dimensions'); if (!_currentVolume) { display.innerHTML = 'No volume selected. Click "Box" or "Cylinder" to draw one.'; dims.textContent = ''; return; } let typeText, dimsText; if (_currentVolume.type === 'box') { typeText = 'Box'; dimsText = `${_currentVolume.w?.toFixed(1)}m × ${_currentVolume.d?.toFixed(1)}m × ${_currentVolume.h?.toFixed(1)}m`; } else if (_currentVolume.type === 'cylinder') { typeText = 'Cylinder'; dimsText = `r: ${_currentVolume.r?.toFixed(1)}m, h: ${_currentVolume.h?.toFixed(1)}m`; } else { typeText = 'Unknown'; dimsText = ''; } display.innerHTML = `${typeText} volume created`; dims.textContent = dimsText; } // ── Trigger Editor ─────────────────────────────────────────────────────────────── function openTriggerEditor(trigger = null) { _editingId = trigger?.id || null; document.getElementById('modal-title').textContent = trigger ? 'Edit Trigger' : 'New Trigger'; document.getElementById('trigger-name').value = trigger?.name || ''; document.getElementById('trigger-condition').value = trigger?.condition || 'enter'; document.getElementById('trigger-cooldown')?.value = trigger?.cooldown || 60; // Load volume if editing if (trigger?.shape) { _currentVolume = trigger.shape; updateVolumeDisplay(); } // Load time constraint if (trigger?.time_constraint) { document.getElementById('time-from').value = trigger.time_constraint.from || ''; document.getElementById('time-to').value = trigger.time_constraint.to || ''; } // Render condition params onConditionChange(); // Render actions renderActions(trigger?.actions || []); document.getElementById('trigger-modal').style.display = 'flex'; } function closeTriggerEditor() { document.getElementById('trigger-modal').style.display = 'none'; _editingId = null; _currentVolume = null; updateVolumeDisplay(); } function onConditionChange() { const condition = document.getElementById('trigger-condition').value; const form = document.getElementById('condition-params-form'); let html = ''; switch (condition) { case 'dwell': html = ` Trigger after staying inside for this many seconds `; break; case 'count': html = ` Trigger when this many people are inside `; break; case 'vacant': html = ` Trigger after empty for this many seconds `; break; default: html = 'No parameters needed for this condition'; } form.innerHTML = html; } function getConditionParams() { const condition = document.getElementById('trigger-condition').value; const params = {}; switch (condition) { case 'dwell': const duration = document.getElementById('dwell-duration')?.value; if (duration) params.duration_s = parseInt(duration); break; case 'count': const threshold = document.getElementById('count-threshold')?.value; if (threshold) params.count_threshold = parseInt(threshold); break; case 'vacant': const vacantDuration = document.getElementById('vacant-duration')?.value; if (vacantDuration) params.duration_s = parseInt(vacantDuration); break; } return params; } // ── Actions ───────────────────────────────────────────────────────────────────── function renderActions(actions) { const list = document.getElementById('actions-list'); if (!actions || actions.length === 0) { list.innerHTML = '

No actions added

'; return; } list.innerHTML = actions.map((action, i) => `
${action.type} ${getActionSummary(action)}
`).join(''); } function getActionSummary(action) { switch (action.type) { case 'webhook': return action.params?.url || 'No URL'; case 'mqtt': return action.params?.topic || 'No topic'; case 'ntfy': return action.params?.server || 'Default server'; default: return 'Unknown action'; } } 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'); let html = ''; switch (type) { case 'webhook': html = ` Variables: trigger_name, condition, fired_at, blob_ids `; break; case 'mqtt': html = ` `; break; case 'ntfy': html = ` `; break; } form.innerHTML = html; } function saveAction() { const type = document.getElementById('action-type').value; const action = { type, params: {} }; switch (type) { case 'webhook': action.params.url = document.getElementById('action-url').value; const payload = document.getElementById('action-payload').value; if (payload) action.params.template = payload; break; case 'mqtt': action.params.topic = document.getElementById('action-topic').value; const mqttPayload = document.getElementById('action-payload').value; if (mqttPayload) action.params.template = mqttPayload; break; case 'ntfy': action.params.server = document.getElementById('action-ntfy-server').value; action.params.message = document.getElementById('action-message').value; break; } // Add to actions list const list = document.getElementById('actions-list'); const currentCount = list.querySelectorAll('.action-item').length; const actionItem = document.createElement('div'); actionItem.className = 'action-item'; actionItem.dataset.index = currentCount; actionItem.innerHTML = ` ${type} ${getActionSummary(action)} `; actionItem.dataset.action = JSON.stringify(action); list.appendChild(actionItem); closeActionModal(); } function removeAction(index) { const item = document.querySelector(`.action-item[data-index="${index}"]`); if (item) { item.remove(); } } function getActionsFromList() { const actions = []; document.querySelectorAll('.action-item').forEach(item => { if (item.dataset.action) { actions.push(JSON.parse(item.dataset.action)); } }); return actions; } // ── Trigger CRUD ──────────────────────────────────────────────────────────────── async function loadTriggers() { try { const res = await fetch('/api/triggers'); if (res.ok) { _triggers = await res.json(); renderTriggersList(); } } catch (err) { console.error('[AutomationBuilder] Failed to load triggers:', err); } } function renderTriggersList() { const list = document.getElementById('triggers-list'); if (!list) return; if (_triggers.length === 0) { list.innerHTML = `

No triggers configured yet.

Click "Box" or "Cylinder" to draw a volume, then click "New Trigger".

`; return; } list.innerHTML = _triggers.map(t => `

${escapeHtml(t.name)} ${t.error_message ? 'ERR' : ''} ${!t.enabled && t.error_message ? 'DISABLED' : ''} ${t.error_count > 0 && t.enabled ? 'WARN ' + t.error_count + '' : ''}

Condition: ${CONDITION_LABELS[t.condition] || t.condition}
Actions: ${t.actions?.length || 0} action(s)
${t.last_fired ? `
Last fired: ${formatTime(t.last_fired)}
` : ''} ${t.elapsed !== undefined ? `
Elapsed: ${t.elapsed}s ago
` : ''} ${t.error_message ? `
Error: ${escapeHtml(t.error_message)}
` : ''}
${!t.enabled && t.error_message ? ` ` : ''}
`).join(''); } async function saveTrigger() { const name = document.getElementById('trigger-name').value.trim(); if (!name) { alert('Please enter a name for the trigger'); return; } if (!_currentVolume) { alert('Please draw a volume first by clicking "Box" or "Cylinder"'); return; } const trigger = { name: name, shape: _currentVolume, condition: document.getElementById('trigger-condition').value, condition_params: getConditionParams(), time_constraint: getTimeConstraint(), actions: getActionsFromList(), enabled: true }; try { const url = _editingId ? `/api/triggers/${_editingId}` : '/api/triggers'; const method = _editingId ? 'PUT' : 'POST'; const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(trigger) }); if (!res.ok) { const error = await res.text(); throw new Error(error || 'Failed to save trigger'); } closeTriggerEditor(); await loadTriggers(); _currentVolume = null; console.log('[AutomationBuilder] Trigger saved successfully'); } catch (err) { console.error('[AutomationBuilder] Failed to save trigger:', err); alert('Failed to save trigger: ' + err.message); } } function getTimeConstraint() { const from = document.getElementById('time-from').value; const to = document.getElementById('time-to').value; if (!from && !to) return null; return { from: from || '00:00', to: to || '23:59' }; } async function toggleEnabled(id, enabled) { try { const trigger = _triggers.find(t => t.id === id); if (!trigger) return; const res = await fetch(`/api/triggers/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...trigger, enabled }) }); if (res.ok) { await loadTriggers(); } } catch (err) { console.error('[AutomationBuilder] Failed to toggle trigger:', err); } } async function test(id) { try { const res = await fetch(`/api/triggers/${id}/test`, { method: 'POST' }); const result = await res.json(); // Show detailed results let summary = ''; if (result.status === 'ok') { summary = `Test completed in ${result.response_ms}ms`; } else { summary = 'Test failed'; } let details = ''; if (result.actions && result.actions.length > 0) { details = result.actions.map(a => { const statusIcon = a.error ? 'FAIL' : 'OK'; const statusText = a.status ? ` (HTTP ${a.status}, ${a.response_ms}ms)` : ''; const errorText = a.error ? ` — ${escapeHtml(a.error)}` : ''; const urlText = a.url ? ` ${escapeHtml(a.url)}` : ''; return `
${statusIcon}${statusText}${errorText}${urlText}
`; }).join(''); } alert(`${summary}\n\n${details}`); } catch (err) { console.error('[AutomationBuilder] Test failed:', err); alert('Test failed: ' + err.message); } } async function reenable(id) { try { const res = await fetch(`/api/triggers/${id}/enable`, { method: 'POST' }); if (res.ok) { await loadTriggers(); } } catch (err) { console.error('[AutomationBuilder] Failed to re-enable trigger:', err); alert('Failed to re-enable trigger: ' + err.message); } } async function showWebhookLog(id) { try { const res = await fetch(`/api/triggers/${id}/webhook-log?limit=20`); if (!res.ok) return; const entries = await res.json(); if (!entries || entries.length === 0) { alert('No webhook firings recorded for this trigger.'); return; } const lines = entries.map(e => { const time = new Date(e.fired_at_ms).toLocaleString(); const status = e.status_code ? `HTTP ${e.status_code}` : 'ERR'; const latency = e.latency_ms + 'ms'; const error = e.error ? ` [${e.error}]` : ''; return `${time} | ${status} | ${latency}${error}`; }).join('\n'); alert(`Webhook Log (last ${entries.length}):\n\n${lines}`); } catch (err) { console.error('[AutomationBuilder] Failed to load webhook log:', err); alert('Failed to load webhook log: ' + err.message); } } async function deleteTrigger(id) { if (!confirm('Are you sure you want to delete this trigger?')) return; try { const res = await fetch(`/api/triggers/${id}`, { method: 'DELETE' }); if (res.ok) { await loadTriggers(); } } catch (err) { console.error('[AutomationBuilder] Failed to delete trigger:', err); alert('Failed to delete trigger: ' + err.message); } } function edit(id) { const trigger = _triggers.find(t => t.id === id); if (trigger) { openTriggerEditor(trigger); } } function onTriggersChanged(triggers) { _triggers = triggers; renderTriggersList(); } // ── Trigger Log ──────────────────────────────────────────────────────────────────── async function loadTriggerLog() { try { const res = await fetch('/api/triggers/log?limit=10'); if (res.ok) { const log = await res.json(); renderTriggerLog(log); } } catch (err) { console.error('[AutomationBuilder] Failed to load trigger log:', err); } } function renderTriggerLog(log) { const container = document.getElementById('trigger-log'); if (!container) return; if (!log || log.length === 0) { container.innerHTML = '

No trigger firings yet.

'; return; } container.innerHTML = log.map(entry => `
${formatTime(entry.fired_at)} ${escapeHtml(entry.trigger_name)} ${entry.condition}
`).join(''); // Auto-refresh every 30 seconds setTimeout(loadTriggerLog, 30000); } // ── Helpers ─────────────────────────────────────────────────────────────────────── function escapeHtml(str) { if (!str) return ''; return String(str).replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function formatTime(timestamp) { if (!timestamp) return 'Never'; const date = new Date(timestamp); return date.toLocaleTimeString(); } // ── Open Panel (SpaxelPanels integration) ────────────────────────────────────────── function openPanel() { console.log('[AutomationBuilder] Opening panel'); // Ensure panel HTML exists const container = document.getElementById('automations-panel'); if (!container) { console.warn('[AutomationBuilder] #automations-panel not found, creating it'); const panelContainer = document.createElement('div'); panelContainer.id = 'automations-panel'; panelContainer.style.display = 'none'; document.body.appendChild(panelContainer); createPanelHTML(); } // Open using SpaxelPanels framework if (window.SpaxelPanels) { SpaxelPanels.open('automations', { title: 'Spatial Triggers', content: document.getElementById('automations-panel').innerHTML, width: '420px', className: 'automations-sidebar', onOpen: function() { console.log('[AutomationBuilder] Panel opened, setting up events'); setupEventListeners(); loadTriggers(); // Initialize VolumeEditor integration if not already done if (!_volumeEditorReady && window.VolumeEditor) { VolumeEditor.onVolumeCreated(onVolumeCreated); VolumeEditor.onVolumeChanged(onVolumeChanged); VolumeEditor.onVolumeDeleted(onVolumeDeleted); _volumeEditorReady = true; } }, onClose: function() { console.log('[AutomationBuilder] Panel closed'); } }); } else { // Fallback: show the panel directly console.warn('[AutomationBuilder] SpaxelPanels not available, showing panel directly'); const panel = document.getElementById('automations-panel'); if (panel) { panel.style.display = 'block'; setupEventListeners(); loadTriggers(); } } } // ── Public API ───────────────────────────────────────────────────────────────────── window.AutomationBuilder = { init, open: openPanel, loadTriggers, renderTriggersList, openTriggerEditor, closeTriggerEditor, saveTrigger, toggleEnabled, test, reenable, showWebhookLog, delete: deleteTrigger, edit, removeAction }; // Register with SpaxelPanels if (window.SpaxelPanels) { SpaxelPanels.register('automations', openPanel); console.log('[AutomationBuilder] Registered with SpaxelPanels'); } // Auto-initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } console.log('[AutomationBuilder] Module loaded'); })();