/** * Spaxel Dashboard - BLE Device Panel (Phase 6) * * People & Devices panel for discovering, registering, and labeling BLE devices. * Uses the SpaxelPanels framework for integration. */ (function() { 'use strict'; // ============================================ // State // ============================================ const state = { devices: [], registeredDevices: [], discoveredDevices: [], people: [], selectedDevice: null, loading: false, error: null, filters: { showRegistered: true, showDiscovered: true }, // Auto-type detection hints typeHints: { 'apple_phone': { icon: '📱', label: 'iPhone' }, 'apple_watch': { icon: '⌚', label: 'Apple Watch' }, 'apple_earbuds': { icon: '🎧', label: 'AirPods' }, 'fitbit': { icon: '⌚', label: 'Fitbit' }, 'garmin': { icon: '⌚', label: 'Garmin' }, 'tile': { icon: '📍', label: 'Tile Tracker' }, 'microsoft': { icon: '💻', label: 'Microsoft' }, 'samsung': { icon: '📱', label: 'Samsung' }, 'google': { icon: '📱', label: 'Google' }, 'ruuvi': { icon: '🌡️', label: 'Ruuvi Sensor' } } }; // ============================================ // API Functions // ============================================ function fetchDevices(filter) { let url = '/api/ble/devices'; const params = []; // Default to last 24 hours params.push('hours=24'); if (filter === 'registered') { params.push('registered=true'); } else if (filter === 'discovered') { params.push('discovered=true'); } if (params.length > 0) { url += '?' + params.join('&'); } return fetch(url) .then(function(res) { if (!res.ok) { throw new Error('Failed to fetch devices: ' + res.status); } return res.json(); }) .then(function(data) { return data.devices || []; }); } function fetchPeople() { return fetch('/api/people') .then(function(res) { if (!res.ok) { return []; // People API might not exist yet } return res.json(); }) .catch(function() { return []; // Graceful degradation }); } function loadAllDevices() { state.loading = true; // Fetch devices and people in parallel return Promise.all([ fetchDevices().then(function(devices) { // Split into registered and discovered based on person_id state.registeredDevices = devices.filter(function(d) { return d.person_id && d.person_id !== ''; }); state.discoveredDevices = devices.filter(function(d) { return !d.person_id || d.person_id === ''; }); return devices; }), fetchPeople().then(function(people) { state.people = people || []; return people; }) ]).then(function() { state.loading = false; updateUnregisteredCount(); }).catch(function(err) { state.loading = false; state.error = err.message; console.error('[BLEPanel] Error loading devices:', err); }); } function updateDevice(mac, data) { return fetch('/api/ble/devices/' + encodeURIComponent(mac), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }) .then(function(res) { if (!res.ok) { return res.json().then(function(err) { throw new Error(err.error || 'Failed to update device'); }); } return res.json(); }) .then(function() { return loadAllDevices(); }); } function getDeviceHistory(mac) { return fetch('/api/ble/devices/' + encodeURIComponent(mac) + '/history?limit=50') .then(function(res) { if (!res.ok) { throw new Error('Failed to fetch device history'); } return res.json(); }); } // ============================================ // UI Rendering // ============================================ function renderLoading() { return '
Loading devices...
'; } function renderError(error) { return '
Error: ' + escapeHtml(error) + '
'; } function renderDeviceList(devices, title, showType) { if (!devices || devices.length === 0) { return '
No ' + title.toLowerCase() + ' devices
'; } // Sort by sighting frequency (rssi_count), then by last_seen var sortedDevices = devices.slice().sort(function(a, b) { var countDiff = (b.rssi_count || 0) - (a.rssi_count || 0); if (countDiff !== 0) return countDiff; return (b.last_seen_at || 0) - (a.last_seen_at || 0); }); var html = '
' + title + ' (' + sortedDevices.length + ')
'; html += '
'; sortedDevices.forEach(function(device) { var name = device.name || device.label || device.device_name || formatMAC(device.mac); var typeIcon = getDeviceTypeIcon(device.device_type); var typeLabel = device.device_type ? getTypeLabel(device.device_type) : ''; var rssiText = device.rssi_avg !== 0 ? device.rssi_avg + ' dBm' : ''; var lastSeenText = formatTime(device.last_seen_at); var personName = device.person_name || ''; var color = device.color || '#888'; var cssClass = device.person_id ? 'ble-device-person' : 'ble-device-unregistered'; html += '
' + '' + typeIcon + '' + '' + '' + escapeHtml(name) + ''; if (typeLabel) { html += '' + typeLabel + ''; } if (personName) { html += '(' + escapeHtml(personName) + ')'; } html += '' + // Close ble-device-info '' + (rssiText ? '' + rssiText + '' : '') + '' + lastSeenText + '' + ''; // Add action buttons if (device.person_id) { // Registered device - show expand for details html += ''; } else { // Unregistered device - show add button html += ''; } html += '
'; }); html += '
'; return html; } function renderPanelContent() { if (state.loading) { return renderLoading(); } if (state.error) { return renderError(state.error); } var html = ''; // Add privacy notice html += '
' + '📱 Phones may appear multiple times due to address rotation. ' + 'Wearables and tracker tags have stable addresses.
'; // Add manual pre-registration button html += '
' + '' + '
'; // Add person section if (state.filters.showRegistered) { html += renderDeviceList(state.registeredDevices, 'People', 'person'); } // Add discovered devices section if (state.filters.showDiscovered) { html += renderDeviceList(state.discoveredDevices, 'Discovered', 'unregistered'); } if (html === '') { html = '
No BLE devices discovered yet. ' + 'Devices will appear here automatically when nodes detect them.
'; } return html; } function renderEditModal(device) { var isNew = !device; var title = isNew ? 'Register Device' : 'Edit Device'; var mac = device ? device.mac : ''; var deviceName = device ? (device.name || device.device_name || '') : ''; var label = device ? (device.label || '') : ''; var deviceType = device ? (device.device_type || 'unknown') : 'unknown'; var color = device ? (device.color || '#4fc3f7') : '#4fc3f7'; return '
' + '

' + title + '

' + (isNew ? '

Register this BLE device to track a person, pet, or object.

' : '') + (device && device.device_name ? '

Detected: ' + escapeHtml(device.device_name) + (device.manufacturer ? ' (' + escapeHtml(device.manufacturer) + ')' : '') + '

' : '') + '
' + '' + '' + '
' + '
' + '' + '' + '

Or create a new person below

' + '
' + '
' + '' + '' + '
' + '
' + '' + '' + '
' + '
' + '' + '' + '
' + '
' + '' + '' + '
' + '
'; } function getPeopleOptions(selectedPersonId) { var html = ''; state.people.forEach(function(p) { var selected = p.id === selectedPersonId ? ' selected' : ''; html += ''; }); return html; } // ============================================ // Panel Opening // ============================================ function openBLEPanel() { // Refresh data when opening loadAllDevices(); // Open the panel using SpaxelPanels return SpaxelPanels.openSidebar({ title: 'People & Devices', content: function(contentEl) { // Render initial content contentEl.innerHTML = renderPanelContent(); // Set up event listeners setupEventListeners(contentEl); // Store reference for updates contentEl._blePanelRefresh = refreshContent; }, width: '380px', className: 'ble-panel', onOpen: function(contentEl) { // Panel opened - refresh data loadAllDevices(); }, onClose: function() { // Panel closed - clean up } }); } function refreshContent() { return loadAllDevices().then(function() { // After loading, re-render the panel content var panel = document.querySelector('.ble-panel .panel-content'); if (panel) { panel.innerHTML = renderPanelContent(); setupEventListeners(panel); } }); } function setupEventListeners(contentEl) { // Pre-register button var preregBtn = contentEl.querySelector('#ble-preregister-btn'); if (preregBtn) { preregBtn.addEventListener('click', showPreregisterModal); } // Device add buttons var addBtns = contentEl.querySelectorAll('.ble-device-add'); addBtns.forEach(function(btn) { btn.addEventListener('click', function(e) { e.stopPropagation(); var mac = this.getAttribute('data-mac'); showRegisterModal(mac); }); }); // Device expand buttons var expandBtns = contentEl.querySelectorAll('.ble-device-expand'); expandBtns.forEach(function(btn) { btn.addEventListener('click', function(e) { e.stopPropagation(); var mac = this.getAttribute('data-mac'); var device = findDevice(mac); if (device) { showDeviceDetails(device); } }); }); // Device items (for clicking through to details) var devices = contentEl.querySelectorAll('.ble-device-person'); devices.forEach(function(item) { item.addEventListener('click', function() { var mac = this.getAttribute('data-mac'); var device = findDevice(mac); if (device) { showDeviceDetails(device); } }); }); // Setup modal action button handlers setupModalActionHandlers(contentEl); } function showPreregisterModal() { SpaxelPanels.openModal({ title: 'Pre-register Device', content: renderPreregisterForm(), width: '400px', showCancel: true, showConfirm: false, onOpen: function(modalEl) { var registerBtn = modalEl.querySelector('#ble-preregister-submit'); var cancelBtn = modalEl.querySelector('#ble-preregister-cancel'); cancelBtn.addEventListener('click', function() { SpaxelPanels.closeModal(); }); registerBtn.addEventListener('click', function() { var mac = modalEl.querySelector('#ble-preregister-mac').value.trim(); var label = modalEl.querySelector('#ble-preregister-label').value.trim(); if (!mac) { SpaxelPanels.showError('Please enter a MAC address'); return; } // Validate MAC format (basic validation) var macPattern = /^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/; if (!macPattern.test(mac)) { SpaxelPanels.showError('Invalid MAC address format. Use format: AA:BB:CC:DD:EE:FF'); return; } var data = { mac: mac }; if (label) { data.label = label; } // Pre-register the device preregisterDevice(data).then(function() { SpaxelPanels.showSuccess('Device pre-registered. When your tracker tag is detected, it will be automatically associated with this entry.'); SpaxelPanels.closeModal(); loadAllDevices(); }).catch(function(err) { SpaxelPanels.showError('Failed to pre-register device: ' + err.message); }); }); } }); } function renderPreregisterForm() { return '
' + '

Pre-register Device

' + '

Manually register a tracker tag by its MAC address. Useful for pre-registering Tile, AirTag, or other trackers before they are detected.

' + '
' + '' + '' + '
' + '
' + '' + '' + '
' + '
' + '' + '' + '
' + '
'; } function preregisterDevice(data) { return fetch('/api/ble/devices/preregister', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }) .then(function(res) { if (!res.ok) { return res.json().then(function(err) { throw new Error(err.error || 'Failed to pre-register device'); }); } return res.json(); }); } function setupModalActionHandlers(modalEl) { // Edit button var editBtn = modalEl.querySelector('#ble-edit-btn'); if (editBtn) { editBtn.addEventListener('click', function() { var mac = this.getAttribute('data-mac') || modalEl.getAttribute('data-device-mac'); if (mac) { var device = findDevice(mac); SpaxelPanels.closeModal(); if (device) { showRegisterModal(mac); } } }); } // Unregister button var unregisterBtn = modalEl.querySelector('#ble-unregister-btn'); if (unregisterBtn) { unregisterBtn.addEventListener('click', function() { var mac = this.getAttribute('data-mac') || modalEl.getAttribute('data-device-mac'); if (mac && confirm('Unregister this device? This will remove the person association.')) { unregisterDevice(mac).then(function() { SpaxelPanels.showSuccess('Device unregistered'); SpaxelPanels.closeModal(); loadAllDevices(); }).catch(function(err) { SpaxelPanels.showError('Failed to unregister device: ' + err.message); }); } }); } } function unregisterDevice(mac) { return fetch('/api/ble/devices/' + encodeURIComponent(mac), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ person_id: null }) }) .then(function(res) { if (!res.ok) { return res.json().then(function(err) { throw new Error(err.error || 'Failed to unregister device'); }); } return res.json(); }); } // ============================================ // Modals // ============================================ function showRegisterModal(mac) { var device = findDevice(mac); var isNew = !device; SpaxelPanels.openModal({ title: isNew ? 'Register Device' : 'Edit Device', content: renderEditModal(device), width: '400px', showCancel: true, showConfirm: false, onOpen: function(modalEl) { var saveBtn = modalEl.querySelector('#ble-edit-save'); var cancelBtn = modalEl.querySelector('#ble-edit-cancel'); cancelBtn.addEventListener('click', function() { SpaxelPanels.closeModal(); }); saveBtn.addEventListener('click', function() { var label = modalEl.querySelector('#ble-edit-label').value; var personId = modalEl.querySelector('#ble-edit-person').value; var newPersonName = modalEl.querySelector('#ble-edit-new-person').value; var color = modalEl.querySelector('#ble-edit-color').value; var deviceType = modalEl.querySelector('#ble-edit-type').value; // Create new person if specified var registrationPromise = Promise.resolve(); if (newPersonName && !personId) { // Need to create a new person first registrationPromise = createPerson(newPersonName, color).then(function(person) { return person.id; }); } else if (newPersonName && personId) { // User selected a person AND entered a new name - prefer new person registrationPromise = createPerson(newPersonName, color).then(function(person) { return person.id; }); } else { registrationPromise = Promise.resolve(personId); } registrationPromise.then(function(finalPersonId) { var data = { label: label || newPersonName || deviceName, device_type: deviceType }; // Set color for the person (not the device) if (finalPersonId) { data.person_id = finalPersonId; } updateDevice(mac, data).then(function() { SpaxelPanels.showSuccess('Device registered successfully'); SpaxelPanels.closeModal(); // Refresh the panel and reload people loadAllDevices(); }).catch(function(err) { SpaxelPanels.showError('Failed to register device: ' + err.message); }); }).catch(function(err) { SpaxelPanels.showError('Failed to create person: ' + err.message); }); }); } }); } function createPerson(name, color) { return fetch('/api/people', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name, color: color }) }) .then(function(res) { if (!res.ok) { return res.json().then(function(err) { throw new Error(err.error || 'Failed to create person'); }); } return res.json(); }); } function showDeviceDetails(device) { var deviceName = device.name || device.label || device.device_name || formatMAC(device.mac); // Fetch device history getDeviceHistory(device.mac).then(function(historyData) { SpaxelPanels.openModal({ title: deviceName, content: renderDeviceDetails(device, historyData), width: '400px', showCancel: true, showConfirm: false, className: 'ble-details-modal' }); }).catch(function(err) { // Show details without history if fetch fails console.error('[BLEPanel] Failed to fetch device history:', err); SpaxelPanels.openModal({ title: deviceName, content: renderDeviceDetails(device, null), width: '400px', showCancel: true, showConfirm: false, className: 'ble-details-modal' }); }); } function renderDeviceDetails(device, historyData) { var html = '
' + '
' + 'MAC Address' + '' + escapeHtml(device.mac) + '' + '
'; if (device.manufacturer) { html += '
' + 'Manufacturer' + '' + escapeHtml(device.manufacturer) + '' + '
'; } if (device.device_type && device.device_type !== 'unknown') { html += '
' + 'Device Type' + '' + getTypeLabel(device.device_type) + '' + '
'; } if (device.person_name) { html += '
' + 'Assigned To' + '' + escapeHtml(device.person_name) + '' + '
'; } html += '
' + 'First Seen' + '' + formatTime(device.first_seen_at) + '' + '
' + '
' + 'Last Seen' + '' + formatTime(device.last_seen_at) + '' + '
'; if (device.last_seen_node) { html += '
' + 'Last Seen By' + '' + escapeHtml(device.last_seen_node) + '' + '
'; } if (device.rssi_count > 0) { html += '
' + 'Sighting Count' + '' + device.rssi_count + ' times' + '
'; } if (device.rssi_avg !== 0) { html += '
' + 'Average RSSI' + '' + device.rssi_avg + ' dBm' + '
'; } html += '
'; // Close ble-device-details // Add recent history if available if (historyData && historyData.history && historyData.history.length > 0) { html += '
Recent Sightings
'; html += '
'; historyData.history.slice(0, 10).forEach(function(entry) { html += '
' + '' + formatTime(entry.timestamp) + '' + '' + entry.rssi_dbm + ' dBm' + 'from ' + escapeHtml(entry.node_mac) + '' + '
'; }); html += '
'; } // Add action buttons html += '
' + ' '; if (device.person_id) { html += ' '; } html += '
'; return html; } // Store reference to current device for modal handlers state.currentModalDevice = device; } // ============================================ // Utility Functions // ============================================ function findDevice(mac) { return state.registeredDevices.concat(state.discoveredDevices).find(function(d) { return d.mac === mac; }); } function formatMAC(mac) { if (!mac) return ''; // Show truncated MAC (last 4 segments) for privacy var parts = mac.split(':'); if (parts.length === 6) { return 'XX:XX:' + parts.slice(2).join(':'); } return mac; } function getTypeLabel(type) { switch (type) { case 'apple_phone': return 'iPhone'; case 'apple_watch': return 'Apple Watch'; case 'apple_earbuds': return 'AirPods'; case 'fitbit': return 'Fitbit'; case 'garmin': return 'Garmin'; case 'tile': return 'Tile'; case 'microsoft': return 'Microsoft'; case 'samsung': return 'Samsung'; case 'google': return 'Google'; case 'ruuvi': return 'Ruuvi'; case 'person': return 'Phone'; case 'pet': return 'Pet Tracker'; case 'object': return 'Object'; case 'wearable': return 'Wearable'; case 'headphones': return 'Headphones'; case 'tracker': return 'Tracker Tag'; default: return ''; } } function getDeviceTypeIcon(type) { if (!type) return '📡'; // Check if we have a type hint for this device type if (state.typeHints[type]) { return state.typeHints[type].icon; } // Fallback icons based on type category switch (type) { case 'apple_phone': case 'person': return '📱'; case 'apple_watch': case 'fitbit': case 'garmin': case 'wearable': return '⌚'; case 'apple_earbuds': case 'headphones': return '🎧'; case 'tile': case 'tracker': return '📍'; case 'microsoft': return '💻'; case 'ruuvi': return '🌡️'; default: return '📡'; } } function getColorForPerson(personName) { // Check if we have a person with this name in our people list var person = state.people.find(function(p) { return p.name === personName; }); if (person && person.color) { return person.color; } // Generate consistent color based on person name var hash = 0; for (var i = 0; i < personName.length; i++) { hash = personName.charCodeAt(i) + ((hash << 5) - hash); } var hue = Math.abs(hash) % 360; return 'hsl(' + hue + ', 70%, 60%)'; } function formatTime(timestamp) { if (!timestamp) return 'Unknown'; var date; // Handle both Unix timestamps (in nanoseconds from Go) and JS dates if (typeof timestamp === 'number') { // If it's in nanoseconds (Go time), convert to milliseconds if (timestamp > 10000000000) { date = new Date(timestamp / 1000000); } else { date = new Date(timestamp); } } else { date = new Date(timestamp); } var now = new Date(); var diff = now - date; if (diff < 60000) return 'just now'; if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago'; if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago'; if (diff < 604800000) return Math.floor(diff / 86400000) + 'd ago'; return date.toLocaleDateString(); } function escapeHtml(text) { if (!text) return ''; return String(text) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function updateUnregisteredCount() { var count = state.discoveredDevices.length; var badge = document.getElementById('ble-unregistered-badge'); if (badge) { badge.textContent = count > 0 ? count : ''; badge.style.display = count > 0 ? 'inline' : 'none'; } } // ============================================ // Public API // ============================================ window.BLEPanel = { // Open the BLE panel open: openBLEPanel, // Refresh device list refresh: loadAllDevices, // Update devices (called from WebSocket) updateDevices: function(devices) { state.registeredDevices = devices.filter(function(d) { return d.person_id; }); state.discoveredDevices = devices.filter(function(d) { return !d.person_id; }); updateUnregisteredCount(); // If panel is open, refresh content var panelContent = document.querySelector('.ble-panel .panel-content'); if (panelContent && panelContent._blePanelRefresh) { panelContent.innerHTML = renderPanelContent(); setupEventListeners(panelContent); } } }; // ============================================ // Registration & Initialization // ============================================ // Register the BLE panel if (window.SpaxelPanels) { SpaxelPanels.register('ble', openBLEPanel); } // Also register as a global function for direct access window.openBLEPanel = openBLEPanel; // Initial data load loadAllDevices(); // Update unregistered count badge periodically setInterval(loadAllDevices, 30000); // Every 30 seconds console.log('[BLEPanel] BLE device panel module loaded'); })();