929 lines
39 KiB
JavaScript
929 lines
39 KiB
JavaScript
// 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 = `
|
|
<div class="automations-header">
|
|
<h2>Automations</h2>
|
|
<div class="automations-actions">
|
|
<button class="btn btn-primary" id="new-automation-btn">
|
|
<span class="icon">+</span> New Automation
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="automations-list" id="automations-list">
|
|
<!-- Automations will be loaded here -->
|
|
</div>
|
|
|
|
<!-- Automation Editor Modal -->
|
|
<div class="modal" id="automation-modal" style="display: none;">
|
|
<div class="modal-content automation-editor">
|
|
<div class="modal-header">
|
|
<h3 id="modal-title">New Automation</h3>
|
|
<button class="modal-close" id="modal-close">×</button>
|
|
</div>
|
|
|
|
<div class="modal-body">
|
|
<!-- Step 1: Choose Trigger -->
|
|
<div class="editor-step" id="step-trigger">
|
|
<h4>1. Choose Trigger</h4>
|
|
<select id="trigger-type" class="form-control">
|
|
<option value="">Select a trigger...</option>
|
|
${Object.entries(triggerLabels).map(([k, v]) =>
|
|
`<option value="${k}">${v}</option>`
|
|
).join('')}
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Step 2: Configure Trigger -->
|
|
<div class="editor-step" id="step-config" style="display: none;">
|
|
<h4>2. Configure Trigger</h4>
|
|
<div id="trigger-config-form"></div>
|
|
</div>
|
|
|
|
<!-- Step 3: Add Conditions -->
|
|
<div class="editor-step" id="step-conditions" style="display: none;">
|
|
<h4>3. Add Conditions (Optional)</h4>
|
|
<div id="conditions-list"></div>
|
|
<button class="btn btn-secondary btn-sm" id="add-condition-btn">+ Add Condition</button>
|
|
</div>
|
|
|
|
<!-- Step 4: Add Actions -->
|
|
<div class="editor-step" id="step-actions" style="display: none;">
|
|
<h4>4. Add Actions</h4>
|
|
<div id="actions-list"></div>
|
|
<button class="btn btn-secondary btn-sm" id="add-action-btn">+ Add Action</button>
|
|
</div>
|
|
|
|
<!-- Step 5: Summary -->
|
|
<div class="editor-step" id="step-summary" style="display: none;">
|
|
<h4>5. Summary</h4>
|
|
<div class="form-group">
|
|
<label>Name</label>
|
|
<input type="text" id="automation-name" class="form-control" placeholder="e.g., Kitchen Light On">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Cooldown (seconds)</label>
|
|
<input type="number" id="automation-cooldown" class="form-control" value="60" min="0">
|
|
</div>
|
|
<div id="summary-text" class="summary-box"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-footer">
|
|
<button class="btn btn-secondary" id="test-fire-btn">Test Fire</button>
|
|
<button class="btn btn-secondary" id="modal-cancel">Cancel</button>
|
|
<button class="btn btn-primary" id="modal-save">Save Automation</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Condition Modal -->
|
|
<div class="modal" id="condition-modal" style="display: none;">
|
|
<div class="modal-content modal-sm">
|
|
<div class="modal-header">
|
|
<h3>Add Condition</h3>
|
|
<button class="modal-close" id="condition-modal-close">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="form-group">
|
|
<label>Type</label>
|
|
<select id="condition-type" class="form-control">
|
|
<option value="person_filter">Person Filter</option>
|
|
<option value="time_window">Time Window</option>
|
|
<option value="day_of_week">Day of Week</option>
|
|
<option value="system_mode">System Mode</option>
|
|
<option value="zone_occupancy">Zone Occupancy</option>
|
|
</select>
|
|
</div>
|
|
<div id="condition-value-form"></div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-secondary" id="condition-cancel">Cancel</button>
|
|
<button class="btn btn-primary" id="condition-save">Add</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Action Modal -->
|
|
<div class="modal" id="action-modal" style="display: none;">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3>Add Action</h3>
|
|
<button class="modal-close" id="action-modal-close">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="form-group">
|
|
<label>Type</label>
|
|
<select id="action-type" class="form-control">
|
|
${Object.entries(actionLabels).map(([k, v]) =>
|
|
`<option value="${k}">${v}</option>`
|
|
).join('')}
|
|
</select>
|
|
</div>
|
|
<div id="action-config-form"></div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-secondary" id="action-cancel">Cancel</button>
|
|
<button class="btn btn-primary" id="action-save">Add</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 = `
|
|
<div class="empty-state">
|
|
<p>No automations configured yet.</p>
|
|
<p>Click "New Automation" to create your first automation.</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = automations.map(a => `
|
|
<div class="automation-card ${a.enabled ? '' : 'disabled'}" data-id="${a.id}">
|
|
<div class="automation-header">
|
|
<h3>${escapeHtml(a.name)}</h3>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" ${a.enabled ? 'checked' : ''}
|
|
onchange="AutomationsPanel.toggleEnabled('${a.id}', this.checked)">
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
<div class="automation-details">
|
|
<div class="detail-row">
|
|
<span class="detail-label">Trigger:</span>
|
|
<span class="detail-value">${triggerLabels[a.trigger_type] || a.trigger_type}</span>
|
|
</div>
|
|
<div class="detail-row">
|
|
<span class="detail-label">Actions:</span>
|
|
<span class="detail-value">${a.actions.length} action(s)</span>
|
|
</div>
|
|
<div class="detail-row">
|
|
<span class="detail-label">Fired:</span>
|
|
<span class="detail-value">${a.fire_count || 0} time(s)</span>
|
|
</div>
|
|
${a.last_fired ? `
|
|
<div class="detail-row">
|
|
<span class="detail-label">Last fired:</span>
|
|
<span class="detail-value">${formatTime(a.last_fired)}</span>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
<div class="automation-actions">
|
|
<button class="btn btn-sm btn-secondary" onclick="AutomationsPanel.edit('${a.id}')">Edit</button>
|
|
<button class="btn btn-sm btn-secondary" onclick="AutomationsPanel.testFire('${a.id}')">Test</button>
|
|
<button class="btn btn-sm btn-danger" onclick="AutomationsPanel.delete('${a.id}')">Delete</button>
|
|
</div>
|
|
</div>
|
|
`).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 = `
|
|
<div class="form-group">
|
|
<label>Zone</label>
|
|
<select id="config-zone-id" class="form-control">
|
|
<option value="">Any Zone</option>
|
|
${zones.map(z => `<option value="${z.id}" ${config.zone_id === z.id ? 'selected' : ''}>${escapeHtml(z.name)}</option>`).join('')}
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Person</label>
|
|
<select id="config-person-id" class="form-control">
|
|
<option value="anyone">Anyone</option>
|
|
${people.map(p => `<option value="${p.id}" ${config.person_id === p.id ? 'selected' : ''}>${escapeHtml(p.name)}</option>`).join('')}
|
|
</select>
|
|
</div>
|
|
`;
|
|
if (triggerType === 'zone_dwell') {
|
|
html += `
|
|
<div class="form-group">
|
|
<label>Dwell Time (minutes)</label>
|
|
<input type="number" id="config-dwell-minutes" class="form-control"
|
|
value="${config.dwell_minutes || 5}" min="1">
|
|
</div>
|
|
`;
|
|
}
|
|
if (triggerType === 'person_count_change') {
|
|
html += `
|
|
<div class="form-group">
|
|
<label>Count Threshold</label>
|
|
<input type="number" id="config-count-threshold" class="form-control"
|
|
value="${config.count_threshold || 1}" min="0">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Direction</label>
|
|
<select id="config-count-direction" class="form-control">
|
|
<option value="up" ${config.count_direction === 'up' ? 'selected' : ''}>Increasing</option>
|
|
<option value="down" ${config.count_direction === 'down' ? 'selected' : ''}>Decreasing</option>
|
|
</select>
|
|
</div>
|
|
`;
|
|
}
|
|
break;
|
|
|
|
case 'fall_detected':
|
|
html = `
|
|
<div class="form-group">
|
|
<label>Zone</label>
|
|
<select id="config-zone-id" class="form-control">
|
|
<option value="">Any Zone</option>
|
|
${zones.map(z => `<option value="${z.id}" ${config.zone_id === z.id ? 'selected' : ''}>${escapeHtml(z.name)}</option>`).join('')}
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Person</label>
|
|
<select id="config-person-id" class="form-control">
|
|
<option value="anyone">Anyone</option>
|
|
${people.map(p => `<option value="${p.id}" ${config.person_id === p.id ? 'selected' : ''}>${escapeHtml(p.name)}</option>`).join('')}
|
|
</select>
|
|
</div>
|
|
`;
|
|
break;
|
|
|
|
case 'ble_device_present':
|
|
case 'ble_device_absent':
|
|
html = `
|
|
<div class="form-group">
|
|
<label>Device</label>
|
|
<select id="config-device-mac" class="form-control">
|
|
<option value="">Any Device</option>
|
|
${people.flatMap(p => (p.devices || []).map(d =>
|
|
`<option value="${d.mac}" ${config.device_mac === d.mac ? 'selected' : ''}>${escapeHtml(d.name || d.mac)} (${escapeHtml(p.name)})</option>`
|
|
)).join('')}
|
|
</select>
|
|
</div>
|
|
`;
|
|
if (triggerType === 'ble_device_absent') {
|
|
html += `
|
|
<div class="form-group">
|
|
<label>Absent Time (minutes)</label>
|
|
<input type="number" id="config-absent-minutes" class="form-control"
|
|
value="${config.absent_minutes || 15}" min="1">
|
|
</div>
|
|
`;
|
|
}
|
|
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 = '<p class="text-muted">No conditions added.</p>';
|
|
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) => `
|
|
<div class="condition-item">
|
|
<span>${conditionLabels[c.type] || c.type}: ${escapeHtml(c.value)}</span>
|
|
<button class="btn btn-sm btn-icon" onclick="AutomationsPanel.removeCondition(${i})">×</button>
|
|
</div>
|
|
`).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 = `
|
|
<div class="form-group">
|
|
<label>Person</label>
|
|
<select id="condition-person" class="form-control">
|
|
<option value="anyone">Anyone</option>
|
|
${people.map(p => `<option value="${p.id}">${escapeHtml(p.name)}</option>`).join('')}
|
|
</select>
|
|
</div>
|
|
`;
|
|
break;
|
|
case 'time_window':
|
|
form.innerHTML = `
|
|
<div class="form-group">
|
|
<label>Start Time</label>
|
|
<input type="time" id="condition-time-start" class="form-control" value="22:00">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>End Time</label>
|
|
<input type="time" id="condition-time-end" class="form-control" value="07:00">
|
|
</div>
|
|
<p class="hint">Supports overnight ranges (e.g., 22:00-07:00)</p>
|
|
`;
|
|
break;
|
|
case 'day_of_week':
|
|
form.innerHTML = `
|
|
<div class="form-group">
|
|
<label>Days</label>
|
|
<div class="checkbox-group">
|
|
<label><input type="checkbox" value="0"> Sun</label>
|
|
<label><input type="checkbox" value="1"> Mon</label>
|
|
<label><input type="checkbox" value="2"> Tue</label>
|
|
<label><input type="checkbox" value="3"> Wed</label>
|
|
<label><input type="checkbox" value="4"> Thu</label>
|
|
<label><input type="checkbox" value="5"> Fri</label>
|
|
<label><input type="checkbox" value="6"> Sat</label>
|
|
</div>
|
|
</div>
|
|
`;
|
|
break;
|
|
case 'system_mode':
|
|
form.innerHTML = `
|
|
<div class="form-group">
|
|
<label>Mode</label>
|
|
<select id="condition-mode" class="form-control">
|
|
<option value="home">Home</option>
|
|
<option value="away">Away</option>
|
|
<option value="sleep">Sleep</option>
|
|
</select>
|
|
</div>
|
|
`;
|
|
break;
|
|
case 'zone_occupancy':
|
|
form.innerHTML = `
|
|
<div class="form-group">
|
|
<label>Zone</label>
|
|
<select id="condition-occ-zone" class="form-control">
|
|
${zones.map(z => `<option value="${z.id}">${escapeHtml(z.name)}</option>`).join('')}
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Condition</label>
|
|
<select id="condition-occ-op" class="form-control">
|
|
<option value="lt">Less than</option>
|
|
<option value="lte">Less than or equal</option>
|
|
<option value="eq">Equal to</option>
|
|
<option value="gte">Greater than or equal</option>
|
|
<option value="gt">Greater than</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Count</label>
|
|
<input type="number" id="condition-occ-count" class="form-control" value="1" min="0">
|
|
</div>
|
|
`;
|
|
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 = `
|
|
<span>${type}: ${escapeHtml(value)}</span>
|
|
<button class="btn btn-sm btn-icon" onclick="this.parentElement.remove(); AutomationsPanel.updateSummary();">×</button>
|
|
`;
|
|
conditionsList.appendChild(conditionItem);
|
|
|
|
closeConditionModal();
|
|
updateSummary();
|
|
}
|
|
|
|
function renderActions(actions) {
|
|
const list = document.getElementById('actions-list');
|
|
if (!list) return;
|
|
|
|
if (actions.length === 0) {
|
|
list.innerHTML = '<p class="text-muted">No actions added.</p>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = actions.map((a, i) => `
|
|
<div class="action-item">
|
|
<span>${actionLabels[a.type] || a.type}</span>
|
|
<small>${escapeHtml(a.url || a.topic || a.server || '')}</small>
|
|
<button class="btn btn-sm btn-icon" onclick="AutomationsPanel.removeAction(${i})">×</button>
|
|
</div>
|
|
`).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 = `
|
|
<div class="form-group">
|
|
<label>URL</label>
|
|
<input type="url" id="action-url" class="form-control" placeholder="https://example.com/webhook">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Payload Template (optional)</label>
|
|
<textarea id="action-template" class="form-control" rows="3"
|
|
placeholder='{"text": "{{person_name}} entered {{zone_name}}"}'></textarea>
|
|
<p class="hint">Variables: {{person_name}}, {{zone_name}}, {{from_zone}}, {{to_zone}}, {{timestamp}}, {{occupant_count}}, {{event_type}}</p>
|
|
</div>
|
|
`;
|
|
break;
|
|
case 'mqtt_publish':
|
|
form.innerHTML = `
|
|
<div class="form-group">
|
|
<label>Topic</label>
|
|
<input type="text" id="action-topic" class="form-control" placeholder="home/living_room/light">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Payload Template</label>
|
|
<textarea id="action-template" class="form-control" rows="3"
|
|
placeholder='{"state": "ON"}'></textarea>
|
|
</div>
|
|
`;
|
|
break;
|
|
case 'ntfy':
|
|
form.innerHTML = `
|
|
<div class="form-group">
|
|
<label>Ntfy Server URL</label>
|
|
<input type="url" id="action-server" class="form-control" placeholder="https://ntfy.sh/mytopic">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Message Template (optional)</label>
|
|
<textarea id="action-template" class="form-control" rows="2"
|
|
placeholder="{{person_name}} triggered {{event_type}}"></textarea>
|
|
</div>
|
|
`;
|
|
break;
|
|
case 'pushover':
|
|
form.innerHTML = `
|
|
<div class="form-group">
|
|
<label>Application Token</label>
|
|
<input type="text" id="action-token" class="form-control">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>User Key</label>
|
|
<input type="text" id="action-user-key" class="form-control">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Message Template (optional)</label>
|
|
<textarea id="action-template" class="form-control" rows="2"></textarea>
|
|
</div>
|
|
`;
|
|
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 = `
|
|
<span>${actionLabels[type]}</span>
|
|
<small>${escapeHtml(action.url || action.topic || action.server || '')}</small>
|
|
<button class="btn btn-sm btn-icon" onclick="this.parentElement.remove(); AutomationsPanel.updateSummary();">×</button>
|
|
`;
|
|
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 = `
|
|
<p><strong>"${escapeHtml(name)}"</strong></p>
|
|
<p>When: <em>${triggerLabels[triggerType] || triggerType}</em></p>
|
|
${conditions.length > 0 ? `<p>Conditions: ${conditions.length} filter(s)</p>` : ''}
|
|
<p>Then: ${actions.length} action(s)</p>
|
|
`;
|
|
}
|
|
}
|
|
|
|
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, '>')
|
|
.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
|
|
};
|
|
})();
|