/** * 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: [] }; // Heatmap state let heatmapData = []; /** * 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'); // Check capabilities on load checkCapabilities(); // Check scan status (in case page was reloaded during scan) checkScanStatus(); // Initialize heatmap initHeatmap(); // Initialize timeline as collapsed initTimeline(); // Set initial panel states updateVisualizationPanels(); } /** * Initialize the heatmap canvas */ function initHeatmap() { const canvas = document.getElementById('btRadarCanvas'); if (!canvas) return; // Make canvas larger for better heatmap canvas.width = 150; canvas.height = 150; drawHeatmap(); } /** * Draw heatmap visualization */ function drawHeatmap() { 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 - 5; // Clear canvas ctx.clearRect(0, 0, width, height); // Draw background circles (range indicators) ctx.strokeStyle = 'rgba(0, 212, 255, 0.15)'; 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 range labels ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'; ctx.font = '8px monospace'; ctx.textAlign = 'center'; ctx.fillText('CLOSE', centerX, centerY - maxRadius * 0.25 + 3); ctx.fillText('FAR', centerX, centerY - maxRadius * 0.85 + 3); // If no devices, show message if (devices.size === 0) { ctx.fillStyle = 'rgba(255, 255, 255, 0.4)'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('No devices', centerX, centerY); return; } // Build heatmap data from devices heatmapData = []; devices.forEach(device => { const rssi = device.rssi_current; if (rssi === null || rssi === undefined) return; // Convert RSSI to radius (stronger = closer to center) // RSSI: -30 (very close) to -100 (far) const normalizedRssi = Math.max(0, Math.min(1, (rssi + 100) / 70)); const radius = maxRadius * (1 - normalizedRssi * 0.9); // Keep some margin // Distribute devices in a spiral pattern for visibility const index = heatmapData.length; const angle = (index * 137.5 * Math.PI / 180); // Golden angle for distribution heatmapData.push({ x: centerX + Math.cos(angle) * radius, y: centerY + Math.sin(angle) * radius, rssi: rssi, intensity: normalizedRssi }); }); // Draw heatmap points with gradient heatmapData.forEach(point => { const gradient = ctx.createRadialGradient( point.x, point.y, 0, point.x, point.y, 20 ); // Color based on signal strength const color = getRssiColorRgb(point.rssi); gradient.addColorStop(0, `rgba(${color.r}, ${color.g}, ${color.b}, 0.8)`); gradient.addColorStop(0.4, `rgba(${color.r}, ${color.g}, ${color.b}, 0.3)`); gradient.addColorStop(1, `rgba(${color.r}, ${color.g}, ${color.b}, 0)`); ctx.fillStyle = gradient; ctx.beginPath(); ctx.arc(point.x, point.y, 20, 0, Math.PI * 2); ctx.fill(); // Draw center dot ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, 1)`; ctx.beginPath(); ctx.arc(point.x, point.y, 3, 0, Math.PI * 2); ctx.fill(); }); // Draw center point (user position) ctx.fillStyle = '#00d4ff'; ctx.beginPath(); ctx.arc(centerX, centerY, 4, 0, Math.PI * 2); ctx.fill(); // Device count ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'; ctx.font = '9px monospace'; ctx.textAlign = 'left'; ctx.fillText(`${devices.size} devices`, 5, height - 5); } /** * Get RSSI color as RGB object */ function getRssiColorRgb(rssi) { if (rssi === null || rssi === undefined) return { r: 102, g: 102, b: 102 }; if (rssi >= -50) return { r: 34, g: 197, b: 94 }; // Green if (rssi >= -60) return { r: 132, g: 204, b: 22 }; // Lime if (rssi >= -70) return { r: 234, g: 179, b: 8 }; // Yellow if (rssi >= -80) return { r: 249, g: 115, b: 22 }; // Orange return { r: 239, g: 68, b: 68 }; // Red } /** * Initialize timeline as collapsed */ function initTimeline() { const timelineContainer = document.getElementById('bluetoothTimelineContainer'); if (!timelineContainer) return; // Check if ActivityTimeline exists and initialize it collapsed if (typeof ActivityTimeline !== 'undefined') { // Timeline will be initialized by the main app, but we'll collapse it setTimeout(() => { const timeline = timelineContainer.querySelector('.activity-timeline'); if (timeline) { const content = timeline.querySelector('.activity-timeline-content'); const toggleBtn = timeline.querySelector('.activity-timeline-toggle'); if (content) content.style.display = 'none'; if (toggleBtn) toggleBtn.textContent = '▶'; } }, 500); } else { // Create a simple placeholder timelineContainer.innerHTML = `
Device Activity
`; } } /** * Select a device and show in Selected Device panel */ function selectDevice(deviceId) { const device = devices.get(deviceId); if (!device) return; selectedDeviceId = deviceId; // Update selected device panel const panel = document.getElementById('btSelectedDevice'); if (!panel) return; const rssi = device.rssi_current; const rssiColor = getRssiColor(rssi); const flags = device.heuristic_flags || []; panel.innerHTML = `
${escapeHtml(device.name || formatDeviceId(device.address))}
${escapeHtml(device.address)} (${device.address_type || 'unknown'})
${rssi != null ? rssi : '--'} dBm
${device.range_band || 'unknown'}
${(device.protocol || 'BLE').toUpperCase()} ${flags.map(f => `${f.replace('_', ' ').toUpperCase()}`).join('')} ${device.in_baseline ? '✓ BASELINE' : '● NEW'}
Manufacturer
${device.manufacturer_name || 'Unknown'}
Mfr ID
${device.manufacturer_id != null ? '0x' + device.manufacturer_id.toString(16).toUpperCase().padStart(4, '0') : '--'}
Seen
${device.seen_count || 0} times
Confidence
${device.rssi_confidence ? Math.round(device.rssi_confidence * 100) + '%' : '--'}
Signal Statistics
MIN
${device.rssi_min != null ? device.rssi_min : '--'}
MEDIAN
${device.rssi_median != null ? Math.round(device.rssi_median) : '--'}
MAX
${device.rssi_max != null ? device.rssi_max : '--'}
CURRENT
${rssi != null ? rssi : '--'}
${device.service_uuids && device.service_uuids.length > 0 ? `
Service UUIDs (${device.service_uuids.length})
${device.service_uuids.slice(0, 6).map(uuid => `${uuid.substring(0, 8)}...`).join('')} ${device.service_uuids.length > 6 ? `+${device.service_uuids.length - 6} more` : ''}
` : ''}
First: ${device.first_seen ? new Date(device.first_seen).toLocaleTimeString() : '--'} Last: ${device.last_seen ? new Date(device.last_seen).toLocaleTimeString() : '--'}
`; // Highlight selected card const cards = deviceContainer?.querySelectorAll('[data-bt-device-id]'); cards?.forEach(card => { if (card.dataset.btDeviceId === deviceId) { card.style.borderColor = '#00d4ff'; card.style.boxShadow = '0 0 0 1px rgba(0, 212, 255, 0.3)'; } else { card.style.borderColor = '#444'; card.style.boxShadow = 'none'; } }); } /** * 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'; 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; } 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 = ''; } if (data.issues && data.issues.length > 0) { showCapabilityWarning(data.issues); } else { hideCapabilityWarning(); } 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']); } } function showCapabilityWarning(issues) { if (!capabilityStatusEl) return; capabilityStatusEl.style.display = 'block'; capabilityStatusEl.innerHTML = `
${issues.map(i => `
⚠ ${i}
`).join('')}
`; } function hideCapabilityWarning() { if (capabilityStatusEl) { capabilityStatusEl.style.display = 'none'; capabilityStatusEl.innerHTML = ''; } } async function checkScanStatus() { try { const response = await fetch('/api/bluetooth/scan/status'); const data = await response.json(); if (data.is_scanning) { setScanning(true); startEventStream(); } if (data.baseline_count > 0) { baselineSet = true; baselineCount = data.baseline_count; updateBaselineStatus(); } } catch (err) { console.error('Failed to check scan status:', err); } } 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); } } async function stopScan() { try { await fetch('/api/bluetooth/scan/stop', { method: 'POST' }); setScanning(false); stopEventStream(); } catch (err) { console.error('Failed to stop scan:', err); } } function setScanning(scanning) { isScanning = scanning; if (startBtn) startBtn.style.display = scanning ? 'none' : 'block'; if (stopBtn) stopBtn.style.display = scanning ? 'block' : 'none'; if (scanning && deviceContainer) { deviceContainer.innerHTML = ''; devices.clear(); resetStats(); // Reset selected device panel const selectedPanel = document.getElementById('btSelectedDevice'); if (selectedPanel) { selectedPanel.innerHTML = '
Click a device to view details
'; } } const statusDot = document.getElementById('statusDot'); const statusText = document.getElementById('statusText'); if (statusDot) statusDot.classList.toggle('running', scanning); if (statusText) statusText.textContent = scanning ? 'Scanning...' : 'Idle'; } function resetStats() { deviceStats = { phones: 0, computers: 0, audio: 0, wearables: 0, other: 0, strong: 0, medium: 0, weak: 0, trackers: [], findmy: [] }; updateVisualizationPanels(); drawHeatmap(); } 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) => { setScanning(true); }); eventSource.addEventListener('scan_stopped', (e) => { setScanning(false); }); eventSource.onerror = () => { console.warn('Bluetooth SSE connection error'); }; } function stopEventStream() { if (eventSource) { eventSource.close(); eventSource = null; } } function handleDeviceUpdate(device) { devices.set(device.device_id, device); renderDevice(device); updateDeviceCount(); updateStatsFromDevices(); updateVisualizationPanels(); drawHeatmap(); // Update selected device panel if this device is selected if (selectedDeviceId === device.device_id) { selectDevice(device.device_id); } // Feed to activity timeline addToTimeline(device); } /** * Add device event to timeline */ function addToTimeline(device) { if (typeof addTimelineEvent === 'function') { const normalized = { id: device.device_id, label: device.name || formatDeviceId(device.address), strength: device.rssi_current ? Math.min(5, Math.max(1, Math.ceil((device.rssi_current + 100) / 20))) : 3, duration: 1500, type: 'bluetooth' }; addTimelineEvent('bluetooth', normalized); } // Also update our simple timeline if it exists const activityContent = document.getElementById('btActivityContent'); if (activityContent) { const time = new Date().toLocaleTimeString(); const existing = activityContent.querySelector('.bt-activity-list'); if (!existing) { activityContent.innerHTML = '
'; } const list = activityContent.querySelector('.bt-activity-list'); const entry = document.createElement('div'); entry.style.cssText = 'padding: 4px 0; border-bottom: 1px solid rgba(255,255,255,0.05); font-size: 10px;'; entry.innerHTML = ` ${time} ${escapeHtml(device.name || formatDeviceId(device.address))} ${device.rssi_current || '--'} dBm `; list.insertBefore(entry, list.firstChild); // Keep only last 50 entries while (list.children.length > 50) { list.removeChild(list.lastChild); } } } /** * Update stats from all devices */ function updateStatsFromDevices() { // Reset counts 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 mfr = (d.manufacturer_name || '').toLowerCase(); const name = (d.name || '').toLowerCase(); const rssi = d.rssi_current; const flags = d.heuristic_flags || []; // Device type classification - more lenient matching let classified = false; // Phones if (name.includes('iphone') || name.includes('phone') || name.includes('pixel') || name.includes('galaxy') || name.includes('android') || name.includes('samsung') || name.includes('oneplus') || name.includes('huawei') || name.includes('xiaomi')) { deviceStats.phones++; classified = true; } // Computers else if (name.includes('macbook') || name.includes('laptop') || name.includes('pc') || name.includes('computer') || name.includes('imac') || name.includes('mac mini') || name.includes('thinkpad') || name.includes('surface') || name.includes('dell') || name.includes('hp ') || name.includes('lenovo')) { deviceStats.computers++; classified = true; } // Audio devices else if (name.includes('airpod') || name.includes('headphone') || name.includes('speaker') || name.includes('buds') || name.includes('audio') || name.includes('beats') || name.includes('bose') || name.includes('sony wh') || name.includes('sony wf') || name.includes('jbl') || name.includes('soundbar') || name.includes('earbuds') || name.includes('jabra') || name.includes('soundcore')) { deviceStats.audio++; classified = true; } // Wearables else if (name.includes('watch') || name.includes('band') || name.includes('fitbit') || name.includes('garmin') || name.includes('whoop') || name.includes('oura') || name.includes('mi band') || name.includes('amazfit')) { deviceStats.wearables++; classified = true; } // If not classified by name, try manufacturer if (!classified) { if (mfr.includes('apple')) { // Could be various Apple devices - count as other deviceStats.other++; } else { deviceStats.other++; } } // Signal strength classification if (rssi != null) { if (rssi >= -50) deviceStats.strong++; else if (rssi >= -70) deviceStats.medium++; else deviceStats.weak++; } // Tracker detection - check for known tracker patterns const isTracker = name.includes('tile') || name.includes('airtag') || name.includes('smarttag') || name.includes('chipolo') || name.includes('tracker') || name.includes('tag'); if (isTracker) { if (!deviceStats.trackers.find(t => t.address === d.address)) { deviceStats.trackers.push(d); } } // FindMy detection - Apple devices with specific characteristics // Apple manufacturer ID is 0x004C (76) const isApple = mfr.includes('apple') || d.manufacturer_id === 76; const hasBeaconBehavior = flags.includes('beacon_like') || flags.includes('persistent'); if (isApple && hasBeaconBehavior) { if (!deviceStats.findmy.find(t => t.address === d.address)) { deviceStats.findmy.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 (devices.size === 0) { trackerList.innerHTML = '
Start scanning to detect trackers
'; } else if (deviceStats.trackers.length === 0) { trackerList.innerHTML = '
✓ No known trackers detected
'; } else { trackerList.innerHTML = deviceStats.trackers.map(t => `
${escapeHtml(t.name || formatDeviceId(t.address))} ${t.rssi_current || '--'} dBm
${t.address}
`).join(''); } } // FindMy Detection const findmyList = document.getElementById('btFindMyList'); if (findmyList) { if (devices.size === 0) { findmyList.innerHTML = '
Start scanning to detect FindMy devices
'; } else if (deviceStats.findmy.length === 0) { findmyList.innerHTML = '
No FindMy-compatible devices detected
'; } else { findmyList.innerHTML = deviceStats.findmy.map(t => `
${escapeHtml(t.name || 'Apple Device')} ${t.rssi_current || '--'} dBm
${t.address}
`).join(''); } } } function updateDeviceCount() { const countEl = document.getElementById('btDeviceListCount'); if (countEl) { countEl.textContent = devices.size; } } 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); } } 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'; } 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 + ' 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 isSelected = selectedDeviceId === device.device_id; const cardStyle = 'display:block;background:#1a1a2e;border:1px solid ' + (isSelected ? '#00d4ff' : '#444') + ';border-radius:8px;padding:14px;margin-bottom:10px;cursor:pointer;transition:border-color 0.2s;' + (isSelected ? 'box-shadow:0 0 0 1px rgba(0,212,255,0.3);' : ''); 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' + '
' + '
'; } function getRssiColor(rssi) { if (rssi == null) return '#666'; if (rssi >= -50) return '#22c55e'; if (rssi >= -60) return '#84cc16'; if (rssi >= -70) return '#eab308'; if (rssi >= -80) return '#f97316'; return '#ef4444'; } function escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = String(text); return div.innerHTML; } 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); } } 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); } } function updateBaselineStatus() { if (!baselineStatusEl) return; if (baselineSet) { baselineStatusEl.textContent = `Baseline: ${baselineCount} devices`; baselineStatusEl.style.color = '#22c55e'; } else { baselineStatusEl.textContent = 'No baseline'; baselineStatusEl.style.color = ''; } } function exportData(format) { window.open(`/api/bluetooth/export?format=${format}`, '_blank'); } function showErrorMessage(message) { console.error('[BT] Error:', message); } // Public API return { init, startScan, stopScan, checkCapabilities, setBaseline, clearBaseline, exportData, selectDevice, copyAddress, getDevices: () => Array.from(devices.values()), isScanning: () => isScanning }; })(); // Global functions for onclick handlers 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(); } } window.BluetoothMode = BluetoothMode;