/**
* 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 = '';
const identityHtml = '' +
'
' + escapeHtml(deviceName) + '
' +
'
' +
'' + escapeHtml(deviceAddress) + '' +
'(' + escapeHtml(addressType) + ')' +
'
';
const signalHtml = '' +
'' + 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 `
Signal Strength
Median
${device.rssi_median !== null ? device.rssi_median + ' dBm' : 'N/A'}
Min
${device.rssi_min !== null ? device.rssi_min + ' dBm' : 'N/A'}
Max
${device.rssi_max !== null ? device.rssi_max + ' dBm' : 'N/A'}
Confidence
${Math.round((device.rssi_confidence || 0) * 100)}%
Device Information
Address
${escapeHtml(device.address)}
Address Type
${escapeHtml(device.address_type)}
Protocol
${protocolLabel}
${device.manufacturer_name ? `
Manufacturer
${escapeHtml(device.manufacturer_name)}
` : ''}
${device.manufacturer_id ? `
Manufacturer ID
0x${device.manufacturer_id.toString(16).padStart(4, '0').toUpperCase()}
` : ''}
Observation Timeline
First Seen
${formatRelativeTime(device.first_seen)}
Last Seen
${formatRelativeTime(device.last_seen)}
Observations
${device.seen_count}
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]) => `
${value ? '✓' : '−'}
${escapeHtml(key.replace(/_/g, ' '))}
`).join('')}
` : ''}
`;
}
/**
* 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;