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:
parent
d2e0ea2111
commit
3781ab7f86
6 changed files with 1761 additions and 1 deletions
423
dashboard/css/ble-panel.css
Normal file
423
dashboard/css/ble-panel.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
931
dashboard/js/ble-panel.js
Normal 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, '&')
|
||||
.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');
|
||||
})();
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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, ×tampNS); 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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue