/** * Device Card Component * Unified device display for Bluetooth and TSCM modes */ const DeviceCard = (function() { 'use strict'; // Range band configuration const RANGE_BANDS = { very_close: { label: 'Very Close', color: '#ef4444', description: '< 3m' }, close: { label: 'Close', color: '#f97316', description: '3-10m' }, nearby: { label: 'Nearby', color: '#eab308', description: '10-20m' }, far: { label: 'Far', color: '#6b7280', description: '> 20m' }, unknown: { label: 'Unknown', color: '#374151', description: 'N/A' } }; // Protocol badge colors const PROTOCOL_COLORS = { ble: { bg: 'rgba(59, 130, 246, 0.15)', color: '#3b82f6', border: 'rgba(59, 130, 246, 0.3)' }, classic: { bg: 'rgba(139, 92, 246, 0.15)', color: '#8b5cf6', border: 'rgba(139, 92, 246, 0.3)' } }; // Heuristic badge configuration const HEURISTIC_BADGES = { new: { label: 'New', color: '#3b82f6', description: 'Not in baseline' }, persistent: { label: 'Persistent', color: '#22c55e', description: 'Continuously present' }, beacon_like: { label: 'Beacon', color: '#f59e0b', description: 'Regular advertising' }, strong_stable: { label: 'Strong', color: '#ef4444', description: 'Strong stable signal' }, random_address: { label: 'Random', color: '#6b7280', description: 'Privacy address' } }; /** * Escape HTML to prevent XSS */ function escapeHtml(text) { if (text === null || text === undefined) return ''; const div = document.createElement('div'); div.textContent = String(text); return div.innerHTML; } /** * Format relative time */ function formatRelativeTime(isoString) { if (!isoString) return ''; const date = new Date(isoString); const now = new Date(); const diff = Math.floor((now - date) / 1000); if (diff < 10) return 'Just now'; if (diff < 60) return `${diff}s ago`; if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; return date.toLocaleDateString(); } /** * Create RSSI sparkline SVG */ function createSparkline(rssiHistory, options = {}) { if (!rssiHistory || rssiHistory.length < 2) { return '--'; } const width = options.width || 60; const height = options.height || 20; const samples = rssiHistory.slice(-20); // Last 20 samples // Normalize RSSI values (-100 to -30 range) const minRssi = -100; const maxRssi = -30; const normalizedValues = samples.map(s => { const rssi = s.rssi || s; const normalized = (rssi - minRssi) / (maxRssi - minRssi); return Math.max(0, Math.min(1, normalized)); }); // Generate path const stepX = width / (normalizedValues.length - 1); let pathD = ''; normalizedValues.forEach((val, i) => { const x = i * stepX; const y = height - (val * height); pathD += i === 0 ? `M${x},${y}` : ` L${x},${y}`; }); // Determine color based on latest value const latestRssi = samples[samples.length - 1].rssi || samples[samples.length - 1]; let strokeColor = '#6b7280'; if (latestRssi > -50) strokeColor = '#22c55e'; else if (latestRssi > -65) strokeColor = '#f59e0b'; else if (latestRssi > -80) strokeColor = '#f97316'; return ` `; } /** * Create heuristic badges HTML */ function createHeuristicBadges(flags) { if (!flags || flags.length === 0) return ''; return flags.map(flag => { const config = HEURISTIC_BADGES[flag]; if (!config) return ''; return ` ${escapeHtml(config.label)} `; }).join(''); } /** * Create range band indicator */ function createRangeBand(band, confidence) { const config = RANGE_BANDS[band] || RANGE_BANDS.unknown; const confidencePercent = Math.round((confidence || 0) * 100); return `
${escapeHtml(config.label)} ${escapeHtml(config.description)} ${confidence > 0 ? `${confidencePercent}%` : ''}
`; } /** * Create protocol badge */ function createProtocolBadge(protocol) { const config = PROTOCOL_COLORS[protocol] || PROTOCOL_COLORS.ble; const label = protocol === 'classic' ? 'Classic' : 'BLE'; return ` ${escapeHtml(label)} `; } /** * Create a Bluetooth device card */ function createDeviceCard(device, options = {}) { // Debug: log received device data console.log('[DeviceCard] Creating card for:', device.address, device); const card = document.createElement('article'); card.className = 'signal-card device-card'; card.dataset.deviceId = device.device_id || ''; card.dataset.protocol = device.protocol || 'ble'; card.dataset.address = device.address || ''; // Add status classes if (device.heuristic_flags && device.heuristic_flags.includes('new')) { card.dataset.status = 'new'; } else if (device.in_baseline) { card.dataset.status = 'baseline'; } // Store full device data for details modal try { card.dataset.deviceData = JSON.stringify(device); } catch (e) { card.dataset.deviceData = '{}'; } const relativeTime = formatRelativeTime(device.last_seen) || 'Unknown'; const sparkline = createSparkline(device.rssi_history) || ''; const heuristicBadges = createHeuristicBadges(device.heuristic_flags) || ''; const rangeBand = createRangeBand(device.range_band, device.range_confidence) || ''; const protocolBadge = createProtocolBadge(device.protocol) || ''; // Build card with explicit defaults for all values const deviceName = device.name || device.device_id || 'Unknown Device'; const deviceAddress = device.address || 'Unknown'; const addressType = device.address_type || 'unknown'; const rssiDisplay = (device.rssi_current !== null && device.rssi_current !== undefined) ? device.rssi_current + ' dBm' : '--'; const seenCount = device.seen_count || 0; const inBaseline = device.in_baseline || false; const mfrName = device.manufacturer_name || ''; // Build the HTML parts separately to avoid template issues const headerHtml = '
' + '
' + protocolBadge + heuristicBadges + '
' + '' + '' + (inBaseline ? 'Known' : 'New') + '' + '
'; const identityHtml = '
' + '
' + escapeHtml(deviceName) + '
' + '
' + '' + escapeHtml(deviceAddress) + '' + '(' + escapeHtml(addressType) + ')' + '
'; const signalHtml = '
' + '
' + '' + rssiDisplay + '' + sparkline + '
' + rangeBand + '
'; const mfrHtml = mfrName ? '
' + '🏭' + '' + escapeHtml(mfrName) + '
' : ''; const metaHtml = '
' + '' + '👁' + seenCount + '×' + '' + escapeHtml(relativeTime) + '
'; const bodyHtml = '
' + identityHtml + signalHtml + mfrHtml + metaHtml + '
'; card.innerHTML = headerHtml + bodyHtml; // Make card clickable - opens modal with full details card.addEventListener('click', () => { showDeviceDetails(device); }); return card; } /** * Create advanced panel content */ function createAdvancedPanel(device) { return `
Device Details
Address ${escapeHtml(device.address)}
Address Type ${escapeHtml(device.address_type)}
Protocol ${device.protocol === 'ble' ? 'Bluetooth Low Energy' : 'Classic Bluetooth'}
${device.manufacturer_id ? `
Manufacturer ID 0x${device.manufacturer_id.toString(16).padStart(4, '0').toUpperCase()}
` : ''}
Signal Statistics
Current RSSI ${device.rssi_current !== null ? device.rssi_current + ' dBm' : 'N/A'}
Median RSSI ${device.rssi_median !== null ? device.rssi_median + ' dBm' : 'N/A'}
Min/Max ${device.rssi_min || 'N/A'} / ${device.rssi_max || 'N/A'} dBm
Confidence ${Math.round((device.rssi_confidence || 0) * 100)}%
Observation Times
First Seen ${escapeHtml(formatRelativeTime(device.first_seen))}
Last Seen ${escapeHtml(formatRelativeTime(device.last_seen))}
Seen Count ${device.seen_count} observations
Rate ${device.seen_rate ? device.seen_rate.toFixed(1) : '0'}/min
${device.service_uuids && device.service_uuids.length > 0 ? `
Service UUIDs
${device.service_uuids.map(uuid => `${escapeHtml(uuid)}`).join('')}
` : ''} ${device.heuristics ? `
Behavioral Analysis
${Object.entries(device.heuristics).map(([key, value]) => `
${escapeHtml(key.replace(/_/g, ' '))} ${value ? '✓' : '−'}
`).join('')}
` : ''}
`; } /** * Show device details in modal */ function showDeviceDetails(device) { let modal = document.getElementById('deviceDetailsModal'); if (!modal) { modal = document.createElement('div'); modal.id = 'deviceDetailsModal'; modal.className = 'signal-details-modal'; modal.innerHTML = `
`; document.body.appendChild(modal); // Close handlers modal.querySelector('.signal-details-modal-backdrop').addEventListener('click', () => { modal.classList.remove('show'); }); modal.querySelector('.signal-details-modal-close').addEventListener('click', () => { modal.classList.remove('show'); }); // Escape key document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && modal.classList.contains('show')) { modal.classList.remove('show'); } }); } // Update copy button handlers with current device const copyBtn = modal.querySelector('.signal-details-copy-btn'); const copyAddrBtn = modal.querySelector('.signal-details-copy-addr-btn'); copyBtn.onclick = () => { navigator.clipboard.writeText(JSON.stringify(device, null, 2)).then(() => { copyBtn.textContent = 'Copied!'; setTimeout(() => { copyBtn.textContent = 'Copy JSON'; }, 1500); }); }; copyAddrBtn.onclick = () => { navigator.clipboard.writeText(device.address).then(() => { copyAddrBtn.textContent = 'Copied!'; setTimeout(() => { copyAddrBtn.textContent = 'Copy Address'; }, 1500); }); }; // Populate modal header modal.querySelector('.signal-details-modal-title').textContent = device.name || 'Unknown Device'; modal.querySelector('.signal-details-modal-subtitle').textContent = device.address; // Populate modal body with enhanced content modal.querySelector('.signal-details-modal-body').innerHTML = createModalContent(device); modal.classList.add('show'); } /** * Create enhanced modal content */ function createModalContent(device) { const protocolLabel = device.protocol === 'ble' ? 'Bluetooth Low Energy' : 'Classic Bluetooth'; const sparkline = createSparkline(device.rssi_history, { width: 120, height: 30 }); return ` ${device.service_uuids && device.service_uuids.length > 0 ? ` ` : ''} ${device.heuristics ? ` ` : ''} `; } /** * Toggle advanced panel */ function toggleAdvanced(button) { const card = button.closest('.signal-card'); const panel = card.querySelector('.signal-advanced-panel'); button.classList.toggle('open'); panel.classList.toggle('open'); } /** * Copy address to clipboard */ function copyAddress(address) { navigator.clipboard.writeText(address).then(() => { if (typeof SignalCards !== 'undefined') { SignalCards.showToast('Address copied'); } }); } /** * Investigate device (placeholder for future implementation) */ function investigate(deviceId) { console.log('Investigate device:', deviceId); // Could open service discovery, detailed analysis, etc. } /** * Update all device timestamps */ function updateTimestamps(container) { container.querySelectorAll('.device-timestamp[data-timestamp]').forEach(el => { const timestamp = el.dataset.timestamp; if (timestamp) { el.textContent = formatRelativeTime(timestamp); } }); } /** * Create device filter bar for Bluetooth mode */ function createDeviceFilterBar(container, options = {}) { const filterBar = document.createElement('div'); filterBar.className = 'signal-filter-bar device-filter-bar'; filterBar.id = 'btDeviceFilterBar'; filterBar.innerHTML = ` Protocol Range
`; // Filter state const filters = { status: 'all', protocol: 'all', range: 'all', search: '' }; // Apply filters function const applyFilters = () => { const cards = container.querySelectorAll('.device-card'); const counts = { all: 0, new: 0, baseline: 0 }; cards.forEach(card => { const cardStatus = card.dataset.status || 'baseline'; const cardProtocol = card.dataset.protocol; const deviceData = JSON.parse(card.dataset.deviceData || '{}'); const cardName = (deviceData.name || '').toLowerCase(); const cardAddress = (deviceData.address || '').toLowerCase(); const cardRange = deviceData.range_band || 'unknown'; counts.all++; if (cardStatus === 'new') counts.new++; else counts.baseline++; // Check filters const statusMatch = filters.status === 'all' || cardStatus === filters.status; const protocolMatch = filters.protocol === 'all' || cardProtocol === filters.protocol; const rangeMatch = filters.range === 'all' || (filters.range === 'close' && ['very_close', 'close'].includes(cardRange)) || (filters.range === 'far' && ['nearby', 'far', 'unknown'].includes(cardRange)); const searchMatch = !filters.search || cardName.includes(filters.search) || cardAddress.includes(filters.search); if (statusMatch && protocolMatch && rangeMatch && searchMatch) { card.classList.remove('hidden'); } else { card.classList.add('hidden'); } }); // Update counts Object.keys(counts).forEach(key => { const badge = filterBar.querySelector(`[data-count="${key}"]`); if (badge) badge.textContent = counts[key]; }); }; // Status filter handlers filterBar.querySelectorAll('.signal-filter-btn[data-filter="status"]').forEach(btn => { btn.addEventListener('click', () => { filterBar.querySelectorAll('.signal-filter-btn[data-filter="status"]').forEach(b => b.classList.remove('active')); btn.classList.add('active'); filters.status = btn.dataset.value; applyFilters(); }); }); // Protocol filter handlers filterBar.querySelectorAll('.signal-filter-btn[data-filter="protocol"]').forEach(btn => { btn.addEventListener('click', () => { filterBar.querySelectorAll('.signal-filter-btn[data-filter="protocol"]').forEach(b => b.classList.remove('active')); btn.classList.add('active'); filters.protocol = btn.dataset.value; applyFilters(); }); }); // Range filter handlers filterBar.querySelectorAll('.signal-filter-btn[data-filter="range"]').forEach(btn => { btn.addEventListener('click', () => { filterBar.querySelectorAll('.signal-filter-btn[data-filter="range"]').forEach(b => b.classList.remove('active')); btn.classList.add('active'); filters.range = btn.dataset.value; applyFilters(); }); }); // Search handler const searchInput = filterBar.querySelector('#btSearchInput'); let searchTimeout; searchInput.addEventListener('input', (e) => { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { filters.search = e.target.value.toLowerCase(); applyFilters(); }, 200); }); filterBar.applyFilters = applyFilters; return filterBar; } // Public API return { createDeviceCard, createSparkline, createHeuristicBadges, createRangeBand, createDeviceFilterBar, showDeviceDetails, toggleAdvanced, copyAddress, investigate, updateTimestamps, escapeHtml, formatRelativeTime, RANGE_BANDS, HEURISTIC_BADGES }; })(); // Make globally available window.DeviceCard = DeviceCard;