feat: implement BLE device discovery & registration dashboard panel

Implements a comprehensive 'People & Devices' panel for BLE device management:

Frontend (dashboard/js/ble-panel.js, dashboard/css/ble-panel.css):
- Device list sorted by sighting frequency (rssi_count)
- Registration modal with label, person assignment, color picker, device type
- Auto-type hints with icons (iPhone, Apple Watch, Fitbit, Tile, AirTag)
- Pre-registration form for manual MAC address entry
- Unregistered count badge on panel toggle
- Device details modal with sighting history

Backend (mothership/internal/ble/handler.go):
- GET /api/ble/devices with registered/discovered filters and hours parameter
- PUT /api/ble/devices/{mac} for updates (label, device_type, person_id)
- GET /api/ble/devices/{mac}/history for sighting timeline
- POST /api/ble/devices/preregister for manual device entry
- GET /api/people and POST /api/people for person management

Database (mothership/internal/ble/registry.go):
- Enhanced ble_devices table with person_id, device_type, manufacturer fields
- ble_device_sightings table for history timeline
- Auto-detection of device types from manufacturer data (Apple, Fitbit, Garmin, Tile)
- RSSI tracking and averaging

Integration (dashboard/index.html, mothership/cmd/mothership/main.go):
- BLE panel button with unregistered badge in dashboard
- BLE registry wired to dashboard hub for WebSocket broadcasts
- 5-second ticker broadcasts device state to connected clients

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-06 15:20:36 -04:00
parent d2e0ea2111
commit 3781ab7f86
6 changed files with 1761 additions and 1 deletions

423
dashboard/css/ble-panel.css Normal file
View file

@ -0,0 +1,423 @@
/**
* Spaxel Dashboard - BLE Panel Styles
*
* People & Devices panel specific styles.
*/
/* ============================================
BLE Panel Layout
============================================ */
.ble-panel {
background: #1e1e3a;
}
/* Loading State */
.ble-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
color: #888;
font-size: 14px;
}
/* Error State */
.ble-error {
background: rgba(239, 83, 80, 0.1);
border: 1px solid rgba(239, 83, 80, 0.3);
border-radius: 8px;
padding: 16px;
color: #ef5350;
font-size: 13px;
text-align: center;
}
.ble-error button {
margin-top: 12px;
padding: 6px 12px;
background: rgba(239, 83, 80, 0.2);
border: 1px solid rgba(239, 83, 80, 0.4);
border-radius: 4px;
color: #ef5350;
font-size: 12px;
cursor: pointer;
transition: background 0.2s;
}
.ble-error button:hover {
background: rgba(239, 83, 80, 0.3);
}
/* Empty State */
.ble-empty {
text-align: center;
padding: 40px 20px;
color: #666;
font-size: 13px;
line-height: 1.5;
}
/* Section Header */
.ble-section-header {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: #888;
margin: 20px 0 12px 0;
padding-bottom: 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.ble-section-header:first-child {
margin-top: 0;
}
/* ============================================
Device List
============================================ */
.ble-device-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.ble-device {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.03);
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.ble-device:hover {
background: rgba(255, 255, 255, 0.08);
}
.ble-device-person {
border-left: 3px solid #ab47bc;
}
.ble-device-unregistered {
border-left: 3px solid #666;
}
/* Device Icon */
.ble-device-icon {
font-size: 18px;
flex-shrink: 0;
width: 24px;
text-align: center;
}
/* Device Name */
.ble-device-name {
flex: 1;
font-size: 13px;
color: #eee;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Device RSSI */
.ble-device-rssi {
font-size: 11px;
color: #888;
font-family: monospace;
flex-shrink: 0;
}
/* Device Buttons */
.ble-device-add,
.ble-device-expand {
flex-shrink: 0;
width: 24px;
height: 24px;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s, transform 0.1s;
display: flex;
align-items: center;
justify-content: center;
}
.ble-device-add {
background: rgba(76, 175, 80, 0.2);
color: #66bb6a;
}
.ble-device-add:hover {
background: rgba(76, 175, 80, 0.3);
transform: scale(1.1);
}
.ble-device-expand {
background: rgba(255, 255, 255, 0.1);
color: #888;
}
.ble-device-expand:hover {
background: rgba(255, 255, 255, 0.15);
}
/* ============================================
Edit/Register Modal
============================================ */
.ble-edit-form {
padding: 8px 0;
}
.ble-edit-form h3 {
margin: 0 0 8px;
font-size: 16px;
color: #eee;
}
.ble-hint {
margin: 0 0 16px;
font-size: 13px;
color: #aaa;
line-height: 1.4;
}
.ble-form-group {
margin-bottom: 12px;
}
.ble-form-group label {
display: block;
font-size: 12px;
color: #aaa;
margin-bottom: 4px;
}
.ble-form-group input[type="text"],
.ble-form-group input[type="color"],
.ble-form-group select {
width: 100%;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
color: #eee;
font-size: 13px;
box-sizing: border-box;
}
.ble-form-group input[type="color"] {
height: 36px;
padding: 2px;
cursor: pointer;
}
.ble-form-group input:focus,
.ble-form-group select:focus {
outline: none;
border-color: #ab47bc;
box-shadow: 0 0 0 2px rgba(171, 71, 188, 0.15);
}
.ble-form-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 16px;
}
.btn-cancel {
padding: 8px 16px;
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 4px;
color: #ccc;
font-size: 13px;
cursor: pointer;
transition: background 0.2s;
}
.btn-cancel:hover {
background: rgba(255, 255, 255, 0.15);
}
.btn-primary {
padding: 8px 16px;
background: #ab47bc;
border: none;
border-radius: 4px;
color: #fff;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover {
background: #9c4cb3;
}
.btn-secondary {
padding: 8px 16px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
color: #ccc;
font-size: 13px;
cursor: pointer;
transition: background 0.2s;
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.15);
color: #eee;
}
.btn-danger {
padding: 8px 16px;
background: rgba(244, 67, 54, 0.2);
border: 1px solid rgba(244, 67, 54, 0.3);
border-radius: 4px;
color: #ef5350;
font-size: 13px;
cursor: pointer;
transition: background 0.2s;
}
.btn-danger:hover {
background: rgba(244, 67, 54, 0.3);
}
/* ============================================
Device Details Modal
============================================ */
.ble-device-details {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.ble-detail-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.ble-detail-label {
font-size: 12px;
color: #888;
}
.ble-detail-value {
font-size: 13px;
color: #eee;
font-family: monospace;
}
.ble-details-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.ble-details-actions button {
flex: 1;
min-width: 80px;
}
/* ============================================
History View (Future Enhancement)
============================================ */
.ble-history-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 200px;
overflow-y: auto;
}
.ble-history-entry {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: rgba(255, 255, 255, 0.03);
border-radius: 4px;
font-size: 11px;
}
.ble-history-time {
color: #888;
font-family: monospace;
}
.ble-history-rssi {
color: #4fc3f7;
font-family: monospace;
}
.ble-history-node {
color: #aaa;
font-size: 10px;
}
/* ============================================
Privacy Notice
============================================ */
.ble-privacy-notice {
background: rgba(171, 71, 188, 0.1);
border: 1px solid rgba(171, 71, 188, 0.2);
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 16px;
font-size: 11px;
color: #ba68c8;
line-height: 1.4;
}
/* ============================================
Animations
============================================ */
@keyframes ble-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.ble-device.unseen .ble-device-icon {
animation: ble-pulse 2s ease-in-out infinite;
}
/* ============================================
Responsive
============================================ */
@media (max-width: 480px) {
.ble-device {
padding: 8px 10px;
}
.ble-device-name {
font-size: 12px;
}
.ble-details-actions {
flex-direction: column;
}
.ble-details-actions button {
width: 100%;
}
}

View file

@ -8,6 +8,7 @@
<link rel="stylesheet" href="css/panels.css">
<link rel="stylesheet" href="css/timeline.css">
<link rel="stylesheet" href="css/apdetection.css">
<link rel="stylesheet" href="css/ble-panel.css">
<style>
* {
margin: 0;
@ -1362,6 +1363,43 @@
color: #ccc;
}
/* BLE button in status bar */
#ble-btn {
background: rgba(171, 71, 188, 0.15);
border: 1px solid rgba(171, 71, 188, 0.4);
color: #ba68c8;
font-size: 12px;
padding: 3px 10px;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
position: relative;
}
#ble-btn:hover {
background: rgba(171, 71, 188, 0.25);
color: #ce93d8;
}
#ble-btn .badge {
position: absolute;
top: -4px;
right: -4px;
background: #ef5350;
color: white;
font-size: 9px;
font-weight: 600;
padding: 1px 4px;
border-radius: 8px;
min-width: 14px;
text-align: center;
display: none;
}
#ble-btn.has-unregistered .badge {
display: block;
}
/* Room editor button */
#room-editor-btn {
background: rgba(255,255,255,0.06);
@ -2009,6 +2047,7 @@
</div>
</div>
<div class="status-item" style="margin-left:auto; gap:6px;">
<button id="ble-btn" onclick="BLEPanel && BLEPanel.open()">People & Devices<span id="ble-unregistered-badge" class="badge"></span></button>
<button id="settings-btn" onclick="openSettingsPanel && openSettingsPanel()">Settings</button>
<button id="add-node-btn" onclick="SpaxelOnboard && SpaxelOnboard.start()">+ Add Node</button>
<button id="add-virtual-btn" onclick="Placement && Placement.addVirtualNode()">+ Virtual</button>
@ -2111,6 +2150,8 @@
<script src="js/router.js"></script>
<!-- Settings Panel -->
<script src="js/settings-panel.js"></script>
<!-- BLE People & Devices Panel -->
<script src="js/ble-panel.js"></script>
<!-- Troubleshooting manager (must load before app.js) -->
<script src="js/troubleshoot.js"></script>
<!-- First-time feature tooltips (must load before app.js) -->

931
dashboard/js/ble-panel.js Normal file
View file

@ -0,0 +1,931 @@
/**
* 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 '<div class="ble-loading">Loading devices...</div>';
}
function renderError(error) {
return '<div class="ble-error">Error: ' + escapeHtml(error) +
'<button onclick="window.BLEPanel && window.BLEPanel.refresh()">Retry</button></div>';
}
function renderDeviceList(devices, title, showType) {
if (!devices || devices.length === 0) {
return '<div class="ble-empty">No ' + title.toLowerCase() + ' devices</div>';
}
// 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 = '<div class="ble-section-header">' + title + ' (' + sortedDevices.length + ')</div>';
html += '<div class="ble-device-list">';
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 += '<div class="ble-device ' + cssClass + '" data-mac="' + device.mac + '">' +
'<span class="ble-device-icon" style="color:' + color + '">' + typeIcon + '</span>' +
'<span class="ble-device-info">' +
'<span class="ble-device-name">' + escapeHtml(name) + '</span>';
if (typeLabel) {
html += '<span class="ble-device-type">' + typeLabel + '</span>';
}
if (personName) {
html += '<span class="ble-device-person-label">(' + escapeHtml(personName) + ')</span>';
}
html += '</span>' + // Close ble-device-info
'<span class="ble-device-meta">' +
(rssiText ? '<span class="ble-device-rssi">' + rssiText + '</span>' : '') +
'<span class="ble-device-time">' + lastSeenText + '</span>' +
'</span>';
// Add action buttons
if (device.person_id) {
// Registered device - show expand for details
html += '<button class="ble-device-expand" data-mac="' + device.mac + '" title="View details">▼</button>';
} else {
// Unregistered device - show add button
html += '<button class="ble-device-add" data-mac="' + device.mac + '" title="Register device">+</button>';
}
html += '</div>';
});
html += '</div>';
return html;
}
function renderPanelContent() {
if (state.loading) {
return renderLoading();
}
if (state.error) {
return renderError(state.error);
}
var html = '';
// Add privacy notice
html += '<div class="ble-privacy-notice">' +
'📱 Phones may appear multiple times due to address rotation. ' +
'Wearables and tracker tags have stable addresses.</div>';
// Add manual pre-registration button
html += '<div class="ble-preregister-section">' +
'<button class="ble-preregister-btn" id="ble-preregister-btn">+ Pre-register Device</button>' +
'</div>';
// 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 = '<div class="ble-empty">No BLE devices discovered yet. ' +
'Devices will appear here automatically when nodes detect them.</div>';
}
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 '<div class="ble-edit-form">' +
'<h3>' + title + '</h3>' +
(isNew ? '<p class="ble-hint">Register this BLE device to track a person, pet, or object.</p>' : '') +
(device && device.device_name ? '<p class="ble-info"><strong>Detected:</strong> ' + escapeHtml(device.device_name) +
(device.manufacturer ? ' (' + escapeHtml(device.manufacturer) + ')' : '') + '</p>' : '') +
'<div class="ble-form-group">' +
'<label>Device Label</label>' +
'<input type="text" id="ble-edit-label" value="' + escapeHtml(label) + '" placeholder="e.g., Alice\'s Phone">' +
'</div>' +
'<div class="ble-form-group">' +
'<label>Assign to Person</label>' +
'<select id="ble-edit-person">' +
'<option value="">-- Unassigned --</option>' +
getPeopleOptions(device ? device.person_id : '') +
'</select>' +
'<p class="ble-hint">Or create a new person below</p>' +
'</div>' +
'<div class="ble-form-group">' +
'<label>New Person Name</label>' +
'<input type="text" id="ble-edit-new-person" placeholder="e.g., Alice">' +
'</div>' +
'<div class="ble-form-group">' +
'<label>Display Color</label>' +
'<input type="color" id="ble-edit-color" value="' + color + '">' +
'</div>' +
'<div class="ble-form-group">' +
'<label>Device Type</label>' +
'<select id="ble-edit-type">' +
'<option value="unknown">Unknown</option>' +
'<option value="person">Person (Phone)</option>' +
'<option value="pet">Pet (Tracker)</option>' +
'<option value="object">Object (Tag)</option>' +
'<option value="wearable">Wearable (Watch/Tracker)</option>' +
'<option value="headphones">Headphones/Earbuds</option>' +
'<option value="tracker">Tracker Tag (Tile/AirTag)</option>' +
'</select>' +
'</div>' +
'<div class="ble-form-actions">' +
'<button class="btn-cancel" id="ble-edit-cancel">Cancel</button>' +
'<button class="btn-primary" id="ble-edit-save">Save</button>' +
'</div>' +
'</div>';
}
function getPeopleOptions(selectedPersonId) {
var html = '';
state.people.forEach(function(p) {
var selected = p.id === selectedPersonId ? ' selected' : '';
html += '<option value="' + escapeHtml(p.id) + '"' + selected + '>' +
escapeHtml(p.name) + '</option>';
});
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 '<div class="ble-edit-form">' +
'<h3>Pre-register Device</h3>' +
'<p class="ble-hint">Manually register a tracker tag by its MAC address. Useful for pre-registering Tile, AirTag, or other trackers before they are detected.</p>' +
'<div class="ble-form-group">' +
'<label>MAC Address</label>' +
'<input type="text" id="ble-preregister-mac" placeholder="AA:BB:CC:DD:EE:FF" maxlength="17">' +
'</div>' +
'<div class="ble-form-group">' +
'<label>Label</label>' +
'<input type="text" id="ble-preregister-label" placeholder="e.g., Car Keys Tile">' +
'</div>' +
'<div class="ble-form-actions">' +
'<button class="btn-cancel" id="ble-preregister-cancel">Cancel</button>' +
'<button class="btn-primary" id="ble-preregister-submit">Pre-register</button>' +
'</div>' +
'</div>';
}
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 = '<div class="ble-device-details">' +
'<div class="ble-detail-row">' +
'<span class="ble-detail-label">MAC Address</span>' +
'<span class="ble-detail-value">' + escapeHtml(device.mac) + '</span>' +
'</div>';
if (device.manufacturer) {
html += '<div class="ble-detail-row">' +
'<span class="ble-detail-label">Manufacturer</span>' +
'<span class="ble-detail-value">' + escapeHtml(device.manufacturer) + '</span>' +
'</div>';
}
if (device.device_type && device.device_type !== 'unknown') {
html += '<div class="ble-detail-row">' +
'<span class="ble-detail-label">Device Type</span>' +
'<span class="ble-detail-value">' + getTypeLabel(device.device_type) + '</span>' +
'</div>';
}
if (device.person_name) {
html += '<div class="ble-detail-row">' +
'<span class="ble-detail-label">Assigned To</span>' +
'<span class="ble-detail-value">' + escapeHtml(device.person_name) + '</span>' +
'</div>';
}
html += '<div class="ble-detail-row">' +
'<span class="ble-detail-label">First Seen</span>' +
'<span class="ble-detail-value">' + formatTime(device.first_seen_at) + '</span>' +
'</div>' +
'<div class="ble-detail-row">' +
'<span class="ble-detail-label">Last Seen</span>' +
'<span class="ble-detail-value">' + formatTime(device.last_seen_at) + '</span>' +
'</div>';
if (device.last_seen_node) {
html += '<div class="ble-detail-row">' +
'<span class="ble-detail-label">Last Seen By</span>' +
'<span class="ble-detail-value">' + escapeHtml(device.last_seen_node) + '</span>' +
'</div>';
}
if (device.rssi_count > 0) {
html += '<div class="ble-detail-row">' +
'<span class="ble-detail-label">Sighting Count</span>' +
'<span class="ble-detail-value">' + device.rssi_count + ' times</span>' +
'</div>';
}
if (device.rssi_avg !== 0) {
html += '<div class="ble-detail-row">' +
'<span class="ble-detail-label">Average RSSI</span>' +
'<span class="ble-detail-value">' + device.rssi_avg + ' dBm</span>' +
'</div>';
}
html += '</div>'; // Close ble-device-details
// Add recent history if available
if (historyData && historyData.history && historyData.history.length > 0) {
html += '<div class="ble-section-header">Recent Sightings</div>';
html += '<div class="ble-history-list">';
historyData.history.slice(0, 10).forEach(function(entry) {
html += '<div class="ble-history-entry">' +
'<span class="ble-history-time">' + formatTime(entry.timestamp) + '</span>' +
'<span class="ble-history-rssi">' + entry.rssi_dbm + ' dBm</span>' +
'<span class="ble-history-node">from ' + escapeHtml(entry.node_mac) + '</span>' +
'</div>';
});
html += '</div>';
}
// Add action buttons
html += '<div class="ble-details-actions" data-device-mac="' + escapeHtml(device.mac) + '">' +
'<button class="btn-secondary" id="ble-edit-btn" data-mac="' + escapeHtml(device.mac) + '">Edit</button> ';
if (device.person_id) {
html += '<button class="btn-danger" id="ble-unregister-btn" data-mac="' + escapeHtml(device.mac) + '">Unregister</button> ';
}
html += '</div>';
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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');
})();

View file

@ -592,6 +592,28 @@ func main() {
log.Printf("[WARN] Failed to create BLE registry: %v (BLE API disabled)", err)
} else {
defer bleRegistry.Close()
// Wire BLE registry to dashboard hub for WebSocket broadcasts
dashboardHub.SetBLEState(bleRegistry)
// Wire ingestion server BLE handler to BLE registry
ingestSrv.SetBLEHandler(func(nodeMAC string, devices []ingestion.BLEDevice) {
// Convert ingestion.BLEDevice to ble.BLEObservation
observations := make([]ble.BLEObservation, len(devices))
for i, d := range devices {
observations[i] = ble.BLEObservation{
Addr: d.Addr,
Name: d.Name,
MfrID: d.MfrID,
MfrDataHex: d.MfrDataHex,
RSSIdBm: d.RSSIdBm,
}
}
if err := bleRegistry.ProcessRelayMessage(nodeMAC, observations); err != nil {
log.Printf("[WARN] Failed to process BLE relay from %s: %v", nodeMAC, err)
}
})
bleHandler := ble.NewHandler(bleRegistry)
if authHandler != nil {
bleRouter := chi.NewRouter()

View file

@ -28,6 +28,7 @@ func NewHandler(registry *Registry) *Handler {
// GET /api/ble/devices/{mac}/aliases — get alias history for device
// PUT /api/ble/devices/{mac} — update device (label, device_type, person_id)
// DELETE /api/ble/devices/{mac} — archive device (soft delete)
// POST /api/ble/devices/preregister — manually register a device by MAC address
// GET /api/ble/duplicates — list possible duplicate devices
// POST /api/ble/merge — merge two devices (MAC rotation)
// POST /api/ble/split — split alias from canonical device
@ -40,9 +41,11 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
// Device endpoints
r.Get("/api/ble/devices", h.listDevices)
r.Get("/api/ble/devices/{mac}", h.getDevice)
r.Get("/api/ble/devices/{mac}/history", h.getDeviceHistory)
r.Get("/api/ble/devices/{mac}/aliases", h.getDeviceAliases)
r.Put("/api/ble/devices/{mac}", h.updateDevice)
r.Delete("/api/ble/devices/{mac}", h.archiveDevice)
r.Post("/api/ble/devices/preregister", h.preregisterDevice)
// Duplicate detection
r.Get("/api/ble/duplicates", h.listDuplicates)
@ -61,8 +64,47 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
func (h *Handler) listDevices(w http.ResponseWriter, r *http.Request) {
includeArchived := r.URL.Query().Get("archived") == "true"
registered := r.URL.Query().Get("registered")
discovered := r.URL.Query().Get("discovered")
// Parse hours parameter (default: 24 hours)
hoursStr := r.URL.Query().Get("hours")
hours := 24 // Default to last 24 hours
if hoursStr != "" {
if n, err := strconv.Atoi(hoursStr); err == nil && n > 0 {
hours = n
}
}
var devices []DeviceRecord
var err error
// Filter by registration status and time window
if registered == "true" {
devices, err = h.registry.GetDevicesSeenInHours(hours, includeArchived)
// Filter to only registered devices (has person_id)
var registeredDevices []DeviceRecord
for _, d := range devices {
if d.PersonID != "" {
registeredDevices = append(registeredDevices, d)
}
}
devices = registeredDevices
} else if discovered == "true" {
devices, err = h.registry.GetDevicesSeenInHours(hours, includeArchived)
// Filter to only unregistered devices (no person_id)
var unregisteredDevices []DeviceRecord
for _, d := range devices {
if d.PersonID == "" {
unregisteredDevices = append(unregisteredDevices, d)
}
}
devices = unregisteredDevices
} else {
// Get all devices seen in the time window
devices, err = h.registry.GetDevicesSeenInHours(hours, includeArchived)
}
devices, err := h.registry.GetDevices(includeArchived)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
@ -94,6 +136,35 @@ func (h *Handler) getDevice(w http.ResponseWriter, r *http.Request) {
writeJSON(w, device)
}
func (h *Handler) getDeviceHistory(w http.ResponseWriter, r *http.Request) {
mac := chi.URLParam(r, "mac")
// Parse limit parameter
limitStr := r.URL.Query().Get("limit")
limit := 100
if limitStr != "" {
if n, err := strconv.Atoi(limitStr); err == nil && n > 0 {
limit = n
}
}
history, err := h.registry.GetDeviceSightingHistory(mac, limit)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "device not found", http.StatusNotFound)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"mac": mac,
"history": history,
"limit": limit,
})
}
type updateDeviceRequest struct {
Label string `json:"label"`
DeviceType string `json:"device_type"`
@ -368,6 +439,37 @@ func (h *Handler) deletePerson(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// ── Pre-registration endpoint ───────────────────────────────────────────────────────
type preregisterDeviceRequest struct {
MAC string `json:"mac"`
Label string `json:"label"`
}
// preregisterDevice manually creates a device entry for a known MAC address.
// This is useful for pre-registering tracker tags that haven't been seen yet.
func (h *Handler) preregisterDevice(w http.ResponseWriter, r *http.Request) {
var req preregisterDeviceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.MAC == "" {
http.Error(w, "invalid request body: mac is required", http.StatusBadRequest)
return
}
// Default label to MAC if not provided
if req.Label == "" {
req.Label = req.MAC
}
device, err := h.registry.PreregisterDevice(req.MAC, req.Label)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
writeJSON(w, device)
}
// ── Alias endpoints ─────────────────────────────────────────────────────────────
// getDeviceAliases returns the alias history for a device.

View file

@ -299,6 +299,20 @@ func (r *Registry) migrate() error {
return fmt.Errorf("create ble_device_aliases table: %w", err)
}
// Create ble_device_sightings table for sighting history timeline
if _, err := r.db.Exec(`
CREATE TABLE IF NOT EXISTS ble_device_sightings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mac TEXT NOT NULL REFERENCES ble_devices(mac) ON DELETE CASCADE,
node_mac TEXT NOT NULL,
rssi_dbm INTEGER NOT NULL,
timestamp INTEGER NOT NULL,
FOREIGN KEY (mac) REFERENCES ble_devices(mac) ON DELETE CASCADE
)
`); err != nil {
return fmt.Errorf("create ble_device_sightings table: %w", err)
}
// Create indexes
indexes := []string{
`CREATE INDEX IF NOT EXISTS idx_ble_devices_person_id ON ble_devices(person_id)`,
@ -307,6 +321,8 @@ func (r *Registry) migrate() error {
`CREATE INDEX IF NOT EXISTS idx_ble_devices_last_seen ON ble_devices(last_seen_at)`,
`CREATE INDEX IF NOT EXISTS idx_ble_device_aliases_canonical ON ble_device_aliases(canonical_addr)`,
`CREATE INDEX IF NOT EXISTS idx_ble_device_aliases_last_seen ON ble_device_aliases(last_seen)`,
`CREATE INDEX IF NOT EXISTS idx_ble_sightings_mac_timestamp ON ble_device_sightings(mac, timestamp DESC)`,
`CREATE INDEX IF NOT EXISTS idx_ble_sightings_timestamp ON ble_device_sightings(timestamp DESC)`,
}
for _, idx := range indexes {
if _, err := r.db.Exec(idx); err != nil {
@ -375,6 +391,15 @@ func (r *Registry) ProcessRelayMessage(nodeMAC string, devices []BLEObservation)
if err != nil {
log.Printf("[WARN] ble: failed to upsert device %s: %v", dev.Addr, err)
} else {
// Record this sighting to the sightings table for history
_, err2 := r.db.Exec(`
INSERT INTO ble_device_sightings (mac, node_mac, rssi_dbm, timestamp)
VALUES (?, ?, ?, ?)
`, dev.Addr, nodeMAC, dev.RSSIdBm, now)
if err2 != nil {
log.Printf("[WARN] ble: failed to record sighting for %s: %v", dev.Addr, err2)
}
}
}
@ -540,6 +565,116 @@ func (r *Registry) GetDevice(mac string) (*DeviceRecord, error) {
return scanDeviceRow(row)
}
// GetRegisteredDevices returns devices that have been assigned to a person.
func (r *Registry) GetRegisteredDevices(includeArchived bool) ([]DeviceRecord, error) {
query := `
SELECT d.mac, d.name, d.label, d.manufacturer, d.device_type, d.device_name,
d.mfr_id, d.mfr_data_hex, d.person_id, COALESCE(p.name, ''),
d.rssi_min, d.rssi_max, d.rssi_avg,
d.first_seen_at, d.last_seen_at, d.last_seen_node, d.is_archived, d.is_wearable, d.enabled,
d.last_x, d.last_y, d.last_z, d.last_confidence, d.last_loc_time
FROM ble_devices d
LEFT JOIN people p ON d.person_id = p.id
WHERE d.person_id IS NOT NULL AND d.person_id != ''
`
if !includeArchived {
query += " AND d.is_archived = 0"
}
query += " ORDER BY d.last_seen_at DESC"
rows, err := r.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var devices []DeviceRecord
for rows.Next() {
d, err := scanDeviceRow(rows)
if err != nil {
log.Printf("[WARN] ble: scan device: %v", err)
continue
}
devices = append(devices, *d)
}
return devices, rows.Err()
}
// GetDiscoveredDevices returns devices that have NOT been assigned to a person.
func (r *Registry) GetDiscoveredDevices(includeArchived bool) ([]DeviceRecord, error) {
query := `
SELECT d.mac, d.name, d.label, d.manufacturer, d.device_type, d.device_name,
d.mfr_id, d.mfr_data_hex, d.person_id, COALESCE(p.name, ''),
d.rssi_min, d.rssi_max, d.rssi_avg,
d.first_seen_at, d.last_seen_at, d.last_seen_node, d.is_archived, d.is_wearable, d.enabled,
d.last_x, d.last_y, d.last_z, d.last_confidence, d.last_loc_time
FROM ble_devices d
LEFT JOIN people p ON d.person_id = p.id
WHERE (d.person_id IS NULL OR d.person_id = '')
`
if !includeArchived {
query += " AND d.is_archived = 0"
}
query += " ORDER BY d.rssi_count DESC, d.last_seen_at DESC"
rows, err := r.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var devices []DeviceRecord
for rows.Next() {
d, err := scanDeviceRow(rows)
if err != nil {
log.Printf("[WARN] ble: scan device: %v", err)
continue
}
devices = append(devices, *d)
}
return devices, rows.Err()
}
// GetDeviceSightingHistory returns the sighting history for a device.
func (r *Registry) GetDeviceSightingHistory(mac string, limit int) ([]SightingHistoryEntry, error) {
if limit <= 0 {
limit = 100
}
// Query sightings from the sightings table
rows, err := r.db.Query(`
SELECT node_mac, rssi_dbm, timestamp
FROM ble_device_sightings
WHERE mac = ?
ORDER BY timestamp DESC
LIMIT ?
`, mac, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []SightingHistoryEntry
for rows.Next() {
var entry SightingHistoryEntry
var timestampNS int64
if err := rows.Scan(&entry.NodeMAC, &entry.RSSIdBm, &timestampNS); err != nil {
log.Printf("[WARN] ble: scan sighting: %v", err)
continue
}
entry.Timestamp = time.Unix(0, timestampNS)
entries = append(entries, entry)
}
return entries, rows.Err()
}
// SightingHistoryEntry represents a single sighting event in the device history.
type SightingHistoryEntry struct {
Timestamp time.Time `json:"timestamp"`
RSSIdBm int `json:"rssi_dbm"`
NodeMAC string `json:"node_mac"`
}
// UpdateLabel updates the user-assigned label for a device.
func (r *Registry) UpdateLabel(mac, label string) error {
_, err := r.db.Exec(`UPDATE ble_devices SET name = ? WHERE mac = ?`, label, mac)
@ -602,6 +737,65 @@ func (r *Registry) DeleteDevice(mac string) error {
return err
}
// GetDevicesSeenInHours returns devices seen within the last N hours.
func (r *Registry) GetDevicesSeenInHours(hours int, includeArchived bool) ([]DeviceRecord, error) {
cutoff := time.Now().Add(-time.Duration(hours) * time.Hour).UnixNano()
query := `
SELECT d.mac, d.name, d.label, d.manufacturer, d.device_type, d.device_name,
d.mfr_id, d.mfr_data_hex, d.person_id, COALESCE(p.name, ''),
d.rssi_min, d.rssi_max, d.rssi_avg,
d.first_seen_at, d.last_seen_at, d.last_seen_node, d.is_archived, d.is_wearable, d.enabled,
d.last_x, d.last_y, d.last_z, d.last_confidence, d.last_loc_time
FROM ble_devices d
LEFT JOIN people p ON d.person_id = p.id
WHERE d.last_seen_at >= ?
`
if !includeArchived {
query += " AND d.is_archived = 0"
}
query += " ORDER BY d.last_seen_at DESC"
rows, err := r.db.Query(query, cutoff)
if err != nil {
return nil, err
}
defer rows.Close()
var devices []DeviceRecord
for rows.Next() {
d, err := scanDeviceRow(rows)
if err != nil {
log.Printf("[WARN] ble: scan device: %v", err)
continue
}
devices = append(devices, *d)
}
return devices, rows.Err()
}
// PreregisterDevice manually creates a device entry for a known MAC address.
// This is useful for pre-registering tracker tags that haven't been seen yet.
func (r *Registry) PreregisterDevice(mac, label string) (*DeviceRecord, error) {
now := time.Now().UnixNano()
// Insert the device with minimal info
_, err := r.db.Exec(`
INSERT INTO ble_devices (
mac, name, label, device_type, first_seen_at, last_seen_at,
last_seen_node, rssi_avg, rssi_count, enabled
) VALUES (?, ?, ?, 'unknown', ?, ?, '', 0, 0, 1)
ON CONFLICT(mac) DO UPDATE SET
name = CASE WHEN excluded.name != '' THEN excluded.name ELSE name END,
label = CASE WHEN excluded.label != '' THEN excluded.label ELSE label END
`, mac, label, label, now, now)
if err != nil {
return nil, err
}
return r.GetDevice(mac)
}
// ArchiveStale marks devices not seen for longer than olderThan as archived.
func (r *Registry) ArchiveStale(olderThan time.Duration) (int64, error) {
cutoff := time.Now().Add(-olderThan).UnixNano()
@ -868,6 +1062,19 @@ func (r *Registry) GetRSSICache() *RSSICache {
return r.rssiCache
}
// GetDeviceRSSICount returns the number of RSSI observations for a device.
func (r *Registry) GetDeviceRSSICount(mac string) int {
r.mu.RLock()
defer r.mu.RUnlock()
var count int
err := r.db.QueryRow(`SELECT rssi_count FROM ble_devices WHERE mac = ?`, mac).Scan(&count)
if err != nil {
return 0
}
return count
}
// GetAllPersonDevices returns all devices assigned to any person.
func (r *Registry) GetAllPersonDevices() ([]DeviceRecord, error) {
rows, err := r.db.Query(`
@ -1154,3 +1361,37 @@ func (r *Registry) GetCanonicalDevice(addr string) (*DeviceRecord, error) {
canonicalAddr := r.ResolveAlias(addr)
return r.GetDevice(canonicalAddr)
}
// GetCurrentDevices returns all BLE devices as a slice of maps for dashboard broadcast.
// This implements the BLEState interface for the dashboard hub.
func (r *Registry) GetCurrentDevices() []map[string]interface{} {
devices, err := r.GetDevices(false) // exclude archived
if err != nil {
return nil
}
result := make([]map[string]interface{}, len(devices))
for i, d := range devices {
result[i] = map[string]interface{}{
"mac": d.Addr,
"name": d.Name,
"label": d.Label,
"manufacturer": d.Manufacturer,
"device_type": d.DeviceType,
"device_name": d.DeviceName,
"mfr_id": d.MfrID,
"person_id": d.PersonID,
"person_name": d.PersonName,
"rssi_min": d.RSSIMin,
"rssi_max": d.RSSIMax,
"rssi_avg": d.RSSIAvg,
"rssi_count": d.RSSICount(d.Addr),
"first_seen_at": d.FirstSeenAt.UnixMilli(),
"last_seen_at": d.LastSeenAt.UnixMilli(),
"last_seen_node": d.LastSeenNode,
"is_wearable": d.IsWearable,
"enabled": d.Enabled,
}
}
return result
}