/**
* 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.
'; // 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 += '
' +
' ';
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');
})();