/** * Bluetooth Mode Controller * Uses the new unified Bluetooth API at /api/bluetooth/ */ const BluetoothMode = (function() { 'use strict'; // State let isScanning = false; let eventSource = null; let devices = new Map(); let baselineSet = false; let baselineCount = 0; let selectedDeviceId = null; // DOM elements (cached) let startBtn, stopBtn, messageContainer, deviceContainer; let adapterSelect, scanModeSelect, transportSelect, durationInput, minRssiInput; let baselineStatusEl, capabilityStatusEl; // Stats tracking let deviceStats = { phones: 0, computers: 0, audio: 0, wearables: 0, other: 0, strong: 0, medium: 0, weak: 0, trackers: [], findmy: [] }; /** * Initialize the Bluetooth mode */ function init() { console.log('[BT] Initializing BluetoothMode'); // Cache DOM elements startBtn = document.getElementById('startBtBtn'); stopBtn = document.getElementById('stopBtBtn'); messageContainer = document.getElementById('btMessageContainer'); deviceContainer = document.getElementById('btDeviceListContent'); adapterSelect = document.getElementById('btAdapterSelect'); scanModeSelect = document.getElementById('btScanMode'); transportSelect = document.getElementById('btTransport'); durationInput = document.getElementById('btScanDuration'); minRssiInput = document.getElementById('btMinRssi'); baselineStatusEl = document.getElementById('btBaselineStatus'); capabilityStatusEl = document.getElementById('btCapabilityStatus'); // Create modal if it doesn't exist createModal(); // Check capabilities on load checkCapabilities(); // Check scan status (in case page was reloaded during scan) checkScanStatus(); // Initialize radar canvas initRadar(); } /** * Create the device details modal */ function createModal() { if (document.getElementById('btDeviceModal')) return; const modal = document.createElement('div'); modal.id = 'btDeviceModal'; modal.style.cssText = 'display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.8);z-index:10000;align-items:center;justify-content:center;'; modal.innerHTML = `

Device Details

`; modal.onclick = (e) => { if (e.target === modal) closeModal(); }; document.body.appendChild(modal); } /** * Show device details modal */ function showModal(deviceId) { const device = devices.get(deviceId); if (!device) return; selectedDeviceId = deviceId; const modal = document.getElementById('btDeviceModal'); const title = document.getElementById('btModalTitle'); const body = document.getElementById('btModalBody'); title.textContent = device.name || formatDeviceId(device.address); const rssi = device.rssi_current; const rssiColor = getRssiColor(rssi); const flags = device.heuristic_flags || []; body.innerHTML = `
${(device.protocol || 'ble').toUpperCase()} ${flags.map(f => `${f.replace('_', ' ').toUpperCase()}`).join('')} ${device.in_baseline ? '✓ In Baseline' : '● New Device'}
Signal Strength
${rssi !== null ? rssi : '--'}dBm
Range
${device.range_band || 'Unknown'}
Min
${device.rssi_min !== null ? device.rssi_min : '--'}
Max
${device.rssi_max !== null ? device.rssi_max : '--'}
Median
${device.rssi_median !== null ? device.rssi_median : '--'}
Confidence
${device.rssi_confidence ? Math.round(device.rssi_confidence * 100) + '%' : '--'}
Address
${device.address}
Address Type
${device.address_type || 'Unknown'}
Manufacturer
${device.manufacturer_name || 'Unknown'}
Manufacturer ID
${device.manufacturer_id !== null ? '0x' + device.manufacturer_id.toString(16).toUpperCase().padStart(4, '0') : '--'}
Observation Stats
First Seen
${device.first_seen ? new Date(device.first_seen).toLocaleTimeString() : '--'}
Last Seen
${device.last_seen ? new Date(device.last_seen).toLocaleTimeString() : '--'}
Seen Count
${device.seen_count || 0} times
${device.service_uuids && device.service_uuids.length > 0 ? `
Service UUIDs
${device.service_uuids.map(uuid => `${uuid}`).join('')}
` : ''}
`; modal.style.display = 'flex'; } /** * Close device details modal */ function closeModal() { const modal = document.getElementById('btDeviceModal'); if (modal) modal.style.display = 'none'; selectedDeviceId = null; } /** * Copy address to clipboard */ function copyAddress(address) { navigator.clipboard.writeText(address).then(() => { // Brief visual feedback const btn = event.target; const originalText = btn.textContent; btn.textContent = 'Copied!'; btn.style.background = '#22c55e'; setTimeout(() => { btn.textContent = originalText; btn.style.background = '#252538'; }, 1500); }); } /** * Format device ID for display (when no name available) */ function formatDeviceId(address) { if (!address) return 'Unknown Device'; // Return shortened format: first 2 and last 2 octets const parts = address.split(':'); if (parts.length === 6) { return parts[0] + ':' + parts[1] + ':...:' + parts[4] + ':' + parts[5]; } return address; } /** * Check system capabilities */ async function checkCapabilities() { try { const response = await fetch('/api/bluetooth/capabilities'); const data = await response.json(); if (!data.available) { showCapabilityWarning(['Bluetooth not available on this system']); return; } // Update adapter select if (adapterSelect && data.adapters && data.adapters.length > 0) { adapterSelect.innerHTML = data.adapters.map(a => { const status = a.powered ? 'UP' : 'DOWN'; return ``; }).join(''); } else if (adapterSelect) { adapterSelect.innerHTML = ''; } // Show any issues if (data.issues && data.issues.length > 0) { showCapabilityWarning(data.issues); } else { hideCapabilityWarning(); } // Update scan mode based on preferred backend if (scanModeSelect && data.preferred_backend) { const option = scanModeSelect.querySelector(`option[value="${data.preferred_backend}"]`); if (option) option.selected = true; } } catch (err) { console.error('Failed to check capabilities:', err); showCapabilityWarning(['Failed to check Bluetooth capabilities']); } } /** * Show capability warning */ function showCapabilityWarning(issues) { if (!capabilityStatusEl) return; capabilityStatusEl.style.display = 'block'; capabilityStatusEl.innerHTML = `
${issues.map(i => `
⚠ ${i}
`).join('')}
`; } /** * Hide capability warning */ function hideCapabilityWarning() { if (capabilityStatusEl) { capabilityStatusEl.style.display = 'none'; capabilityStatusEl.innerHTML = ''; } } /** * Check current scan status */ async function checkScanStatus() { try { const response = await fetch('/api/bluetooth/scan/status'); const data = await response.json(); if (data.is_scanning) { setScanning(true); startEventStream(); } // Update baseline status if (data.baseline_count > 0) { baselineSet = true; baselineCount = data.baseline_count; updateBaselineStatus(); } } catch (err) { console.error('Failed to check scan status:', err); } } /** * Start scanning */ async function startScan() { const adapter = adapterSelect?.value || ''; const mode = scanModeSelect?.value || 'auto'; const transport = transportSelect?.value || 'auto'; const duration = parseInt(durationInput?.value || '0', 10); const minRssi = parseInt(minRssiInput?.value || '-100', 10); try { const response = await fetch('/api/bluetooth/scan/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: mode, adapter_id: adapter || undefined, duration_s: duration > 0 ? duration : undefined, transport: transport, rssi_threshold: minRssi }) }); const data = await response.json(); if (data.status === 'started' || data.status === 'already_scanning') { setScanning(true); startEventStream(); } else { showErrorMessage(data.message || 'Failed to start scan'); } } catch (err) { console.error('Failed to start scan:', err); showErrorMessage('Failed to start scan: ' + err.message); } } /** * Stop scanning */ async function stopScan() { try { await fetch('/api/bluetooth/scan/stop', { method: 'POST' }); setScanning(false); stopEventStream(); } catch (err) { console.error('Failed to stop scan:', err); } } /** * Set scanning state */ function setScanning(scanning) { isScanning = scanning; if (startBtn) startBtn.style.display = scanning ? 'none' : 'block'; if (stopBtn) stopBtn.style.display = scanning ? 'block' : 'none'; // Clear container when starting scan if (scanning && deviceContainer) { deviceContainer.innerHTML = ''; devices.clear(); resetStats(); } // Update global status if available const statusDot = document.getElementById('statusDot'); const statusText = document.getElementById('statusText'); if (statusDot) statusDot.classList.toggle('running', scanning); if (statusText) statusText.textContent = scanning ? 'Scanning...' : 'Idle'; } /** * Reset stats */ function resetStats() { deviceStats = { phones: 0, computers: 0, audio: 0, wearables: 0, other: 0, strong: 0, medium: 0, weak: 0, trackers: [], findmy: [] }; updateVisualizationPanels(); } /** * Start SSE event stream */ function startEventStream() { if (eventSource) eventSource.close(); eventSource = new EventSource('/api/bluetooth/stream'); eventSource.addEventListener('device_update', (e) => { try { const device = JSON.parse(e.data); handleDeviceUpdate(device); } catch (err) { console.error('Failed to parse device update:', err); } }); eventSource.addEventListener('scan_started', (e) => { const data = JSON.parse(e.data); setScanning(true); }); eventSource.addEventListener('scan_stopped', (e) => { setScanning(false); const data = JSON.parse(e.data); }); eventSource.onerror = () => { console.warn('Bluetooth SSE connection error'); }; } /** * Stop SSE event stream */ function stopEventStream() { if (eventSource) { eventSource.close(); eventSource = null; } } /** * Handle device update from SSE */ function handleDeviceUpdate(device) { devices.set(device.device_id, device); renderDevice(device); updateDeviceCount(); updateStatsFromDevice(device); updateVisualizationPanels(); updateRadar(); } /** * Update stats from device */ function updateStatsFromDevice(device) { // Categorize by manufacturer/type const mfr = (device.manufacturer_name || '').toLowerCase(); const name = (device.name || '').toLowerCase(); // Reset counts and recalculate from all devices deviceStats.phones = 0; deviceStats.computers = 0; deviceStats.audio = 0; deviceStats.wearables = 0; deviceStats.other = 0; deviceStats.strong = 0; deviceStats.medium = 0; deviceStats.weak = 0; deviceStats.trackers = []; deviceStats.findmy = []; devices.forEach(d => { const m = (d.manufacturer_name || '').toLowerCase(); const n = (d.name || '').toLowerCase(); const rssi = d.rssi_current; // Device type classification if (n.includes('iphone') || n.includes('phone') || n.includes('pixel') || n.includes('galaxy') || n.includes('android')) { deviceStats.phones++; } else if (n.includes('macbook') || n.includes('laptop') || n.includes('pc') || n.includes('computer') || n.includes('imac')) { deviceStats.computers++; } else if (n.includes('airpod') || n.includes('headphone') || n.includes('speaker') || n.includes('buds') || n.includes('audio') || n.includes('beats')) { deviceStats.audio++; } else if (n.includes('watch') || n.includes('band') || n.includes('fitbit') || n.includes('garmin')) { deviceStats.wearables++; } else { deviceStats.other++; } // Signal strength classification if (rssi !== null && rssi !== undefined) { if (rssi >= -50) deviceStats.strong++; else if (rssi >= -70) deviceStats.medium++; else deviceStats.weak++; } // Tracker detection (Apple, Tile, etc.) if (m.includes('apple') && (d.heuristic_flags || []).includes('beacon_like')) { if (!deviceStats.findmy.find(t => t.address === d.address)) { deviceStats.findmy.push(d); } } if (n.includes('tile') || n.includes('airtag') || n.includes('smarttag')) { if (!deviceStats.trackers.find(t => t.address === d.address)) { deviceStats.trackers.push(d); } } }); } /** * Update visualization panels */ function updateVisualizationPanels() { // Device Types const phoneCount = document.getElementById('btPhoneCount'); const computerCount = document.getElementById('btComputerCount'); const audioCount = document.getElementById('btAudioCount'); const wearableCount = document.getElementById('btWearableCount'); const otherCount = document.getElementById('btOtherCount'); if (phoneCount) phoneCount.textContent = deviceStats.phones; if (computerCount) computerCount.textContent = deviceStats.computers; if (audioCount) audioCount.textContent = deviceStats.audio; if (wearableCount) wearableCount.textContent = deviceStats.wearables; if (otherCount) otherCount.textContent = deviceStats.other; // Signal Distribution const total = devices.size || 1; const strongBar = document.getElementById('btSignalStrong'); const mediumBar = document.getElementById('btSignalMedium'); const weakBar = document.getElementById('btSignalWeak'); const strongCount = document.getElementById('btSignalStrongCount'); const mediumCount = document.getElementById('btSignalMediumCount'); const weakCount = document.getElementById('btSignalWeakCount'); if (strongBar) strongBar.style.width = (deviceStats.strong / total * 100) + '%'; if (mediumBar) mediumBar.style.width = (deviceStats.medium / total * 100) + '%'; if (weakBar) weakBar.style.width = (deviceStats.weak / total * 100) + '%'; if (strongCount) strongCount.textContent = deviceStats.strong; if (mediumCount) mediumCount.textContent = deviceStats.medium; if (weakCount) weakCount.textContent = deviceStats.weak; // Tracker Detection const trackerList = document.getElementById('btTrackerList'); if (trackerList) { if (deviceStats.trackers.length === 0) { trackerList.innerHTML = '
No trackers detected
'; } else { trackerList.innerHTML = deviceStats.trackers.map(t => `
${t.name || formatDeviceId(t.address)} ${t.rssi_current || '--'} dBm
${t.address}
`).join(''); } } // FindMy Detection const findmyList = document.getElementById('btFindMyList'); if (findmyList) { if (deviceStats.findmy.length === 0) { findmyList.innerHTML = '
No FindMy devices detected
'; } else { findmyList.innerHTML = deviceStats.findmy.map(t => `
${t.name || 'Apple Device'} ${t.rssi_current || '--'} dBm
${t.address}
`).join(''); } } } /** * Initialize radar canvas */ function initRadar() { const canvas = document.getElementById('btRadarCanvas'); if (!canvas) return; const ctx = canvas.getContext('2d'); drawRadarBackground(ctx, canvas.width, canvas.height); } /** * Draw radar background */ function drawRadarBackground(ctx, width, height) { const centerX = width / 2; const centerY = height / 2; const maxRadius = Math.min(width, height) / 2 - 10; ctx.clearRect(0, 0, width, height); // Draw concentric circles ctx.strokeStyle = 'rgba(0, 212, 255, 0.2)'; ctx.lineWidth = 1; for (let i = 1; i <= 4; i++) { ctx.beginPath(); ctx.arc(centerX, centerY, maxRadius * i / 4, 0, Math.PI * 2); ctx.stroke(); } // Draw cross lines ctx.beginPath(); ctx.moveTo(centerX, 10); ctx.lineTo(centerX, height - 10); ctx.moveTo(10, centerY); ctx.lineTo(width - 10, centerY); ctx.stroke(); // Center dot ctx.fillStyle = '#00d4ff'; ctx.beginPath(); ctx.arc(centerX, centerY, 3, 0, Math.PI * 2); ctx.fill(); } /** * Update radar with device positions */ function updateRadar() { const canvas = document.getElementById('btRadarCanvas'); if (!canvas) return; const ctx = canvas.getContext('2d'); const width = canvas.width; const height = canvas.height; const centerX = width / 2; const centerY = height / 2; const maxRadius = Math.min(width, height) / 2 - 15; // Redraw background drawRadarBackground(ctx, width, height); // Plot devices let angle = 0; const angleStep = (Math.PI * 2) / Math.max(devices.size, 1); devices.forEach(device => { const rssi = device.rssi_current; if (rssi === null || rssi === undefined) return; // Convert RSSI to distance (closer = smaller radius) // RSSI typically ranges from -30 (very close) to -100 (far) const normalizedRssi = Math.max(0, Math.min(1, (rssi + 100) / 70)); const radius = maxRadius * (1 - normalizedRssi); const x = centerX + Math.cos(angle) * radius; const y = centerY + Math.sin(angle) * radius; // Color based on signal strength const color = getRssiColor(rssi); ctx.fillStyle = color; ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2); ctx.fill(); // Glow effect ctx.fillStyle = color.replace(')', ', 0.3)').replace('rgb', 'rgba'); ctx.beginPath(); ctx.arc(x, y, 8, 0, Math.PI * 2); ctx.fill(); angle += angleStep; }); } /** * Update device count display */ function updateDeviceCount() { const countEl = document.getElementById('btDeviceListCount'); if (countEl) { countEl.textContent = devices.size; } } /** * Render a device card */ function renderDevice(device) { if (!deviceContainer) { deviceContainer = document.getElementById('btDeviceListContent'); if (!deviceContainer) return; } const escapedId = CSS.escape(device.device_id); const existingCard = deviceContainer.querySelector('[data-bt-device-id="' + escapedId + '"]'); const cardHtml = createSimpleDeviceCard(device); if (existingCard) { existingCard.outerHTML = cardHtml; } else { deviceContainer.insertAdjacentHTML('afterbegin', cardHtml); } } /** * Simple device card with click handler */ function createSimpleDeviceCard(device) { const protocol = device.protocol || 'ble'; const protoBadge = protocol === 'ble' ? 'BLE' : 'CLASSIC'; const flags = device.heuristic_flags || []; let badgesHtml = ''; if (flags.includes('random_address')) { badgesHtml += 'RANDOM'; } if (flags.includes('persistent')) { badgesHtml += 'PERSISTENT'; } // Use device name if available, otherwise format the address nicely const displayName = device.name || formatDeviceId(device.address); const name = escapeHtml(displayName); const addr = escapeHtml(device.address || 'Unknown'); const addrType = escapeHtml(device.address_type || 'unknown'); const rssi = device.rssi_current; const rssiStr = (rssi !== null && rssi !== undefined) ? rssi + ' dBm' : '--'; const rssiColor = getRssiColor(rssi); const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : ''; const seenCount = device.seen_count || 0; const rangeBand = device.range_band || 'unknown'; const inBaseline = device.in_baseline || false; const cardStyle = 'display:block;background:#1a1a2e;border:1px solid #444;border-radius:8px;padding:14px;margin-bottom:10px;cursor:pointer;transition:border-color 0.2s;'; const headerStyle = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;'; const nameStyle = 'font-size:14px;font-weight:600;color:#e0e0e0;margin-bottom:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;'; const addrStyle = 'font-family:monospace;font-size:11px;color:#00d4ff;'; const rssiRowStyle = 'display:flex;justify-content:space-between;align-items:center;background:#141428;padding:10px;border-radius:6px;margin:10px 0;'; const rssiValueStyle = 'font-family:monospace;font-size:16px;font-weight:700;color:' + rssiColor + ';'; const rangeBandStyle = 'font-size:10px;color:#888;text-transform:uppercase;letter-spacing:0.5px;'; const mfrStyle = 'font-size:11px;color:#888;margin-bottom:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;'; const metaStyle = 'display:flex;justify-content:space-between;font-size:10px;color:#666;'; const statusPillStyle = 'background:' + (inBaseline ? 'rgba(34,197,94,0.15)' : 'rgba(59,130,246,0.15)') + ';color:' + (inBaseline ? '#22c55e' : '#3b82f6') + ';padding:3px 10px;border-radius:12px;font-size:10px;font-weight:500;'; const deviceIdEscaped = escapeHtml(device.device_id).replace(/'/g, "\\'"); return '
' + '
' + '
' + protoBadge + badgesHtml + '
' + '' + (inBaseline ? '✓ Known' : '● New') + '' + '
' + '
' + '
' + name + '
' + '
' + addr + ' (' + addrType + ')
' + '
' + '
' + '' + rssiStr + '' + '' + rangeBand + '' + '
' + (mfr ? '
' + mfr + '
' : '') + '
' + 'Seen ' + seenCount + '×' + 'Just now' + '
' + '
'; } /** * Get RSSI color */ function getRssiColor(rssi) { if (rssi === null || rssi === undefined) return '#666'; if (rssi >= -50) return '#22c55e'; if (rssi >= -60) return '#84cc16'; if (rssi >= -70) return '#eab308'; if (rssi >= -80) return '#f97316'; return '#ef4444'; } /** * Escape HTML */ function escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = String(text); return div.innerHTML; } /** * Set baseline */ async function setBaseline() { try { const response = await fetch('/api/bluetooth/baseline/set', { method: 'POST' }); const data = await response.json(); if (data.status === 'success') { baselineSet = true; baselineCount = data.device_count; updateBaselineStatus(); } } catch (err) { console.error('Failed to set baseline:', err); } } /** * Clear baseline */ async function clearBaseline() { try { const response = await fetch('/api/bluetooth/baseline/clear', { method: 'POST' }); const data = await response.json(); if (data.status === 'success') { baselineSet = false; baselineCount = 0; updateBaselineStatus(); } } catch (err) { console.error('Failed to clear baseline:', err); } } /** * Update baseline status display */ function updateBaselineStatus() { if (!baselineStatusEl) return; if (baselineSet) { baselineStatusEl.textContent = `Baseline: ${baselineCount} devices`; baselineStatusEl.style.color = '#22c55e'; } else { baselineStatusEl.textContent = 'No baseline'; baselineStatusEl.style.color = ''; } } /** * Export data */ function exportData(format) { window.open(`/api/bluetooth/export?format=${format}`, '_blank'); } /** * Show error message */ function showErrorMessage(message) { console.error('[BT] Error:', message); // Could show a toast notification here } // Public API return { init, startScan, stopScan, checkCapabilities, setBaseline, clearBaseline, exportData, showModal, closeModal, copyAddress, getDevices: () => Array.from(devices.values()), isScanning: () => isScanning }; })(); // Global functions for onclick handlers in HTML function btStartScan() { BluetoothMode.startScan(); } function btStopScan() { BluetoothMode.stopScan(); } function btCheckCapabilities() { BluetoothMode.checkCapabilities(); } function btSetBaseline() { BluetoothMode.setBaseline(); } function btClearBaseline() { BluetoothMode.clearBaseline(); } function btExport(format) { BluetoothMode.exportData(format); } // Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { if (document.getElementById('bluetoothMode')) { BluetoothMode.init(); } }); } else { if (document.getElementById('bluetoothMode')) { BluetoothMode.init(); } } // Make globally available window.BluetoothMode = BluetoothMode;