mirror of
https://github.com/smittix/intercept.git
synced 2026-05-17 13:24:50 -07:00
Overhaul Bluetooth scanning with DBus-based BlueZ integration
Major changes: - Add utils/bluetooth/ package with DBus scanner, fallback scanners (bleak, hcitool, bluetoothctl), device aggregation, and heuristics - New unified API at /api/bluetooth/ with REST endpoints and SSE streaming - Device observation aggregation with RSSI statistics and range bands - Behavioral heuristics: new, persistent, beacon-like, strong+stable - Frontend components: DeviceCard, MessageCard, RSSISparkline - TSCM integration via get_tscm_bluetooth_snapshot() helper - Unit tests for aggregator, heuristics, and API endpoints Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
592
static/js/components/device-card.js
Normal file
592
static/js/components/device-card.js
Normal file
@@ -0,0 +1,592 @@
|
||||
/**
|
||||
* 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 '<span class="rssi-sparkline-empty">--</span>';
|
||||
}
|
||||
|
||||
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 `
|
||||
<svg class="rssi-sparkline" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||
<path d="${pathD}" fill="none" stroke="${strokeColor}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 `
|
||||
<span class="device-heuristic-badge ${flag}"
|
||||
style="--badge-color: ${config.color}"
|
||||
title="${escapeHtml(config.description)}">
|
||||
${escapeHtml(config.label)}
|
||||
</span>
|
||||
`;
|
||||
}).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 `
|
||||
<div class="device-range-band" style="--range-color: ${config.color}">
|
||||
<span class="range-label">${escapeHtml(config.label)}</span>
|
||||
<span class="range-estimate">${escapeHtml(config.description)}</span>
|
||||
${confidence > 0 ? `<span class="range-confidence" title="Confidence">${confidencePercent}%</span>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create protocol badge
|
||||
*/
|
||||
function createProtocolBadge(protocol) {
|
||||
const config = PROTOCOL_COLORS[protocol] || PROTOCOL_COLORS.ble;
|
||||
const label = protocol === 'classic' ? 'Classic' : 'BLE';
|
||||
|
||||
return `
|
||||
<span class="signal-proto-badge device-protocol"
|
||||
style="background: ${config.bg}; color: ${config.color}; border-color: ${config.border}">
|
||||
${escapeHtml(label)}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Bluetooth device card
|
||||
*/
|
||||
function createDeviceCard(device, options = {}) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'signal-card device-card';
|
||||
card.dataset.deviceId = device.device_id;
|
||||
card.dataset.protocol = device.protocol;
|
||||
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
|
||||
card.dataset.deviceData = JSON.stringify(device);
|
||||
|
||||
const relativeTime = formatRelativeTime(device.last_seen);
|
||||
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);
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="signal-card-header">
|
||||
<div class="signal-card-badges">
|
||||
${protocolBadge}
|
||||
${heuristicBadges}
|
||||
</div>
|
||||
<span class="signal-status-pill" data-status="${device.in_baseline ? 'baseline' : 'new'}">
|
||||
<span class="status-dot"></span>
|
||||
${device.in_baseline ? 'Known' : 'New'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="signal-card-body">
|
||||
<div class="device-identity">
|
||||
<div class="device-name">${escapeHtml(device.name || 'Unknown Device')}</div>
|
||||
<div class="device-address">
|
||||
<span class="address-value">${escapeHtml(device.address)}</span>
|
||||
<span class="address-type">(${escapeHtml(device.address_type)})</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device-signal-row">
|
||||
<div class="rssi-display">
|
||||
<span class="rssi-current" title="Current RSSI">
|
||||
${device.rssi_current !== null ? device.rssi_current + ' dBm' : '--'}
|
||||
</span>
|
||||
${sparkline}
|
||||
</div>
|
||||
${rangeBand}
|
||||
</div>
|
||||
${device.manufacturer_name ? `
|
||||
<div class="device-manufacturer">
|
||||
<span class="mfr-icon">🏭</span>
|
||||
<span class="mfr-name">${escapeHtml(device.manufacturer_name)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="device-meta-row">
|
||||
<span class="device-seen-count" title="Observation count">
|
||||
<span class="seen-icon">👁</span>
|
||||
${device.seen_count}×
|
||||
</span>
|
||||
<span class="device-timestamp" data-timestamp="${escapeHtml(device.last_seen)}">
|
||||
${escapeHtml(relativeTime)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="signal-card-footer">
|
||||
<button class="signal-advanced-toggle" onclick="DeviceCard.toggleAdvanced(this)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M6 9l6 6 6-6"/>
|
||||
</svg>
|
||||
Details
|
||||
</button>
|
||||
<div class="signal-card-actions">
|
||||
<button class="signal-action-btn" onclick="DeviceCard.copyAddress('${escapeHtml(device.address)}')">Copy</button>
|
||||
${options.showInvestigate ? `
|
||||
<button class="signal-action-btn primary" onclick="DeviceCard.investigate('${escapeHtml(device.device_id)}')">Investigate</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="signal-advanced-panel">
|
||||
<div class="signal-advanced-inner">
|
||||
${createAdvancedPanel(device)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Make card clickable
|
||||
card.addEventListener('click', (e) => {
|
||||
if (e.target.closest('button') || e.target.closest('.signal-advanced-toggle')) {
|
||||
return;
|
||||
}
|
||||
showDeviceDetails(device);
|
||||
});
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create advanced panel content
|
||||
*/
|
||||
function createAdvancedPanel(device) {
|
||||
return `
|
||||
<div class="signal-advanced-content">
|
||||
<div class="signal-advanced-section">
|
||||
<div class="signal-advanced-title">Device Details</div>
|
||||
<div class="signal-advanced-grid">
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Address</span>
|
||||
<span class="signal-advanced-value">${escapeHtml(device.address)}</span>
|
||||
</div>
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Address Type</span>
|
||||
<span class="signal-advanced-value">${escapeHtml(device.address_type)}</span>
|
||||
</div>
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Protocol</span>
|
||||
<span class="signal-advanced-value">${device.protocol === 'ble' ? 'Bluetooth Low Energy' : 'Classic Bluetooth'}</span>
|
||||
</div>
|
||||
${device.manufacturer_id ? `
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Manufacturer ID</span>
|
||||
<span class="signal-advanced-value">0x${device.manufacturer_id.toString(16).padStart(4, '0').toUpperCase()}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="signal-advanced-section">
|
||||
<div class="signal-advanced-title">Signal Statistics</div>
|
||||
<div class="signal-advanced-grid">
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Current RSSI</span>
|
||||
<span class="signal-advanced-value">${device.rssi_current !== null ? device.rssi_current + ' dBm' : 'N/A'}</span>
|
||||
</div>
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Median RSSI</span>
|
||||
<span class="signal-advanced-value">${device.rssi_median !== null ? device.rssi_median + ' dBm' : 'N/A'}</span>
|
||||
</div>
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Min/Max</span>
|
||||
<span class="signal-advanced-value">${device.rssi_min || 'N/A'} / ${device.rssi_max || 'N/A'} dBm</span>
|
||||
</div>
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Confidence</span>
|
||||
<span class="signal-advanced-value">${Math.round((device.rssi_confidence || 0) * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="signal-advanced-section">
|
||||
<div class="signal-advanced-title">Observation Times</div>
|
||||
<div class="signal-advanced-grid">
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">First Seen</span>
|
||||
<span class="signal-advanced-value">${escapeHtml(formatRelativeTime(device.first_seen))}</span>
|
||||
</div>
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Last Seen</span>
|
||||
<span class="signal-advanced-value">${escapeHtml(formatRelativeTime(device.last_seen))}</span>
|
||||
</div>
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Seen Count</span>
|
||||
<span class="signal-advanced-value">${device.seen_count} observations</span>
|
||||
</div>
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Rate</span>
|
||||
<span class="signal-advanced-value">${device.seen_rate ? device.seen_rate.toFixed(1) : '0'}/min</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${device.service_uuids && device.service_uuids.length > 0 ? `
|
||||
<div class="signal-advanced-section">
|
||||
<div class="signal-advanced-title">Service UUIDs</div>
|
||||
<div class="device-uuids">
|
||||
${device.service_uuids.map(uuid => `<span class="device-uuid">${escapeHtml(uuid)}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${device.heuristics ? `
|
||||
<div class="signal-advanced-section">
|
||||
<div class="signal-advanced-title">Behavioral Analysis</div>
|
||||
<div class="device-heuristics-detail">
|
||||
${Object.entries(device.heuristics).map(([key, value]) => `
|
||||
<div class="heuristic-item ${value ? 'active' : ''}">
|
||||
<span class="heuristic-name">${escapeHtml(key.replace(/_/g, ' '))}</span>
|
||||
<span class="heuristic-status">${value ? '✓' : '−'}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = `
|
||||
<div class="signal-details-modal-backdrop"></div>
|
||||
<div class="signal-details-modal-content">
|
||||
<div class="signal-details-modal-header">
|
||||
<span class="signal-details-modal-title"></span>
|
||||
<button class="signal-details-modal-close">×</button>
|
||||
</div>
|
||||
<div class="signal-details-modal-body"></div>
|
||||
<div class="signal-details-modal-footer">
|
||||
<button class="signal-details-copy-btn">Copy Device Info</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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');
|
||||
});
|
||||
modal.querySelector('.signal-details-copy-btn').addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(JSON.stringify(device, null, 2)).then(() => {
|
||||
if (typeof SignalCards !== 'undefined') {
|
||||
SignalCards.showToast('Device info copied to clipboard');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Populate modal
|
||||
modal.querySelector('.signal-details-modal-title').textContent =
|
||||
device.name || device.address;
|
||||
modal.querySelector('.signal-details-modal-body').innerHTML = createAdvancedPanel(device);
|
||||
|
||||
modal.classList.add('show');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = `
|
||||
<button class="signal-filter-btn active" data-filter="status" data-value="all">
|
||||
All
|
||||
<span class="signal-filter-count" data-count="all">0</span>
|
||||
</button>
|
||||
<button class="signal-filter-btn" data-filter="status" data-value="new">
|
||||
<span class="filter-dot" style="background: var(--signal-new)"></span>
|
||||
New
|
||||
<span class="signal-filter-count" data-count="new">0</span>
|
||||
</button>
|
||||
<button class="signal-filter-btn" data-filter="status" data-value="baseline">
|
||||
<span class="filter-dot" style="background: var(--signal-baseline)"></span>
|
||||
Known
|
||||
<span class="signal-filter-count" data-count="baseline">0</span>
|
||||
</button>
|
||||
|
||||
<span class="signal-filter-divider"></span>
|
||||
|
||||
<span class="signal-filter-label">Protocol</span>
|
||||
<button class="signal-filter-btn protocol-btn active" data-filter="protocol" data-value="all">All</button>
|
||||
<button class="signal-filter-btn protocol-btn" data-filter="protocol" data-value="ble">BLE</button>
|
||||
<button class="signal-filter-btn protocol-btn" data-filter="protocol" data-value="classic">Classic</button>
|
||||
|
||||
<span class="signal-filter-divider"></span>
|
||||
|
||||
<span class="signal-filter-label">Range</span>
|
||||
<button class="signal-filter-btn range-btn active" data-filter="range" data-value="all">All</button>
|
||||
<button class="signal-filter-btn range-btn" data-filter="range" data-value="close">Close</button>
|
||||
<button class="signal-filter-btn range-btn" data-filter="range" data-value="far">Far</button>
|
||||
|
||||
<div class="signal-search-container">
|
||||
<input type="text" class="signal-search-input" id="btSearchInput" placeholder="Search name or address..." />
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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;
|
||||
326
static/js/components/message-card.js
Normal file
326
static/js/components/message-card.js
Normal file
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Message Card Component
|
||||
* Status and alert messages for Bluetooth and TSCM modes
|
||||
*/
|
||||
|
||||
const MessageCard = (function() {
|
||||
'use strict';
|
||||
|
||||
// Message types and their styling
|
||||
const MESSAGE_TYPES = {
|
||||
info: {
|
||||
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||
</svg>`,
|
||||
color: '#3b82f6',
|
||||
bgColor: 'rgba(59, 130, 246, 0.1)'
|
||||
},
|
||||
success: {
|
||||
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>`,
|
||||
color: '#22c55e',
|
||||
bgColor: 'rgba(34, 197, 94, 0.1)'
|
||||
},
|
||||
warning: {
|
||||
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>`,
|
||||
color: '#f59e0b',
|
||||
bgColor: 'rgba(245, 158, 11, 0.1)'
|
||||
},
|
||||
error: {
|
||||
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||
</svg>`,
|
||||
color: '#ef4444',
|
||||
bgColor: 'rgba(239, 68, 68, 0.1)'
|
||||
},
|
||||
scanning: {
|
||||
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="animate-spin">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||
</svg>`,
|
||||
color: '#06b6d4',
|
||||
bgColor: 'rgba(6, 182, 212, 0.1)'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a message card
|
||||
*/
|
||||
function createMessageCard(options) {
|
||||
const {
|
||||
type = 'info',
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
actions,
|
||||
dismissible = true,
|
||||
autoHide = 0,
|
||||
id
|
||||
} = options;
|
||||
|
||||
const config = MESSAGE_TYPES[type] || MESSAGE_TYPES.info;
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = `message-card message-card-${type}`;
|
||||
if (id) card.id = id;
|
||||
card.style.setProperty('--message-color', config.color);
|
||||
card.style.setProperty('--message-bg', config.bgColor);
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="message-card-icon">
|
||||
${config.icon}
|
||||
</div>
|
||||
<div class="message-card-content">
|
||||
${title ? `<div class="message-card-title">${escapeHtml(title)}</div>` : ''}
|
||||
${message ? `<div class="message-card-text">${escapeHtml(message)}</div>` : ''}
|
||||
${details ? `<div class="message-card-details">${escapeHtml(details)}</div>` : ''}
|
||||
</div>
|
||||
${dismissible ? `
|
||||
<button class="message-card-dismiss" title="Dismiss">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
` : ''}
|
||||
${actions && actions.length > 0 ? `
|
||||
<div class="message-card-actions">
|
||||
${actions.map(action => `
|
||||
<button class="message-action-btn ${action.primary ? 'primary' : ''}"
|
||||
${action.id ? `id="${escapeHtml(action.id)}"` : ''}>
|
||||
${escapeHtml(action.label)}
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
// Dismiss handler
|
||||
if (dismissible) {
|
||||
card.querySelector('.message-card-dismiss').addEventListener('click', () => {
|
||||
card.classList.add('message-card-hiding');
|
||||
setTimeout(() => card.remove(), 200);
|
||||
});
|
||||
}
|
||||
|
||||
// Action handlers
|
||||
if (actions && actions.length > 0) {
|
||||
actions.forEach(action => {
|
||||
if (action.handler) {
|
||||
const btn = action.id
|
||||
? card.querySelector(`#${action.id}`)
|
||||
: card.querySelector('.message-action-btn');
|
||||
if (btn) {
|
||||
btn.addEventListener('click', (e) => {
|
||||
action.handler(e, card);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-hide
|
||||
if (autoHide > 0) {
|
||||
setTimeout(() => {
|
||||
if (card.parentElement) {
|
||||
card.classList.add('message-card-hiding');
|
||||
setTimeout(() => card.remove(), 200);
|
||||
}
|
||||
}, autoHide);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a scanning status card
|
||||
*/
|
||||
function createScanningCard(options = {}) {
|
||||
const {
|
||||
backend = 'auto',
|
||||
adapter = 'hci0',
|
||||
deviceCount = 0,
|
||||
elapsed = 0,
|
||||
remaining = null
|
||||
} = options;
|
||||
|
||||
return createMessageCard({
|
||||
type: 'scanning',
|
||||
title: 'Scanning for Bluetooth devices...',
|
||||
message: `Backend: ${backend} | Adapter: ${adapter}`,
|
||||
details: `Found ${deviceCount} device${deviceCount !== 1 ? 's' : ''}` +
|
||||
(remaining !== null ? ` | ${Math.round(remaining)}s remaining` : ''),
|
||||
dismissible: false,
|
||||
id: 'btScanningStatus'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a capability warning card
|
||||
*/
|
||||
function createCapabilityWarning(issues) {
|
||||
if (!issues || issues.length === 0) return null;
|
||||
|
||||
return createMessageCard({
|
||||
type: 'warning',
|
||||
title: 'Bluetooth Capability Issues',
|
||||
message: issues.join('. '),
|
||||
dismissible: true,
|
||||
actions: [
|
||||
{
|
||||
label: 'Retry Check',
|
||||
handler: (e, card) => {
|
||||
card.remove();
|
||||
if (typeof window.checkBtCapabilities === 'function') {
|
||||
window.checkBtCapabilities();
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a baseline status card
|
||||
*/
|
||||
function createBaselineCard(deviceCount, isSet = true) {
|
||||
if (isSet) {
|
||||
return createMessageCard({
|
||||
type: 'success',
|
||||
title: 'Baseline Set',
|
||||
message: `${deviceCount} device${deviceCount !== 1 ? 's' : ''} saved as baseline`,
|
||||
details: 'New devices will be highlighted',
|
||||
dismissible: true,
|
||||
autoHide: 5000
|
||||
});
|
||||
} else {
|
||||
return createMessageCard({
|
||||
type: 'info',
|
||||
title: 'No Baseline',
|
||||
message: 'Set a baseline to track new devices',
|
||||
dismissible: true,
|
||||
actions: [
|
||||
{
|
||||
label: 'Set Baseline',
|
||||
primary: true,
|
||||
handler: () => {
|
||||
if (typeof window.setBtBaseline === 'function') {
|
||||
window.setBtBaseline();
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a scan complete card
|
||||
*/
|
||||
function createScanCompleteCard(deviceCount, duration) {
|
||||
return createMessageCard({
|
||||
type: 'success',
|
||||
title: 'Scan Complete',
|
||||
message: `Found ${deviceCount} device${deviceCount !== 1 ? 's' : ''} in ${Math.round(duration)}s`,
|
||||
dismissible: true,
|
||||
autoHide: 5000,
|
||||
actions: [
|
||||
{
|
||||
label: 'Export Results',
|
||||
handler: () => {
|
||||
window.open('/api/bluetooth/export?format=csv', '_blank');
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error card
|
||||
*/
|
||||
function createErrorCard(error, retryHandler) {
|
||||
return createMessageCard({
|
||||
type: 'error',
|
||||
title: 'Scan Error',
|
||||
message: error,
|
||||
dismissible: true,
|
||||
actions: retryHandler ? [
|
||||
{
|
||||
label: 'Retry',
|
||||
primary: true,
|
||||
handler: retryHandler
|
||||
}
|
||||
] : []
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a message in a container
|
||||
*/
|
||||
function showMessage(container, options) {
|
||||
const card = createMessageCard(options);
|
||||
container.insertBefore(card, container.firstChild);
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a message by ID
|
||||
*/
|
||||
function removeMessage(id) {
|
||||
const card = document.getElementById(id);
|
||||
if (card) {
|
||||
card.classList.add('message-card-hiding');
|
||||
setTimeout(() => card.remove(), 200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update scanning status
|
||||
*/
|
||||
function updateScanningStatus(options) {
|
||||
const existing = document.getElementById('btScanningStatus');
|
||||
if (existing) {
|
||||
const details = existing.querySelector('.message-card-details');
|
||||
if (details) {
|
||||
details.textContent = `Found ${options.deviceCount} device${options.deviceCount !== 1 ? 's' : ''}` +
|
||||
(options.remaining !== null ? ` | ${Math.round(options.remaining)}s remaining` : '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
createMessageCard,
|
||||
createScanningCard,
|
||||
createCapabilityWarning,
|
||||
createBaselineCard,
|
||||
createScanCompleteCard,
|
||||
createErrorCard,
|
||||
showMessage,
|
||||
removeMessage,
|
||||
updateScanningStatus,
|
||||
MESSAGE_TYPES
|
||||
};
|
||||
})();
|
||||
|
||||
// Make globally available
|
||||
window.MessageCard = MessageCard;
|
||||
243
static/js/components/rssi-sparkline.js
Normal file
243
static/js/components/rssi-sparkline.js
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* RSSI Sparkline Component
|
||||
* SVG-based real-time RSSI visualization
|
||||
*/
|
||||
|
||||
const RSSISparkline = (function() {
|
||||
'use strict';
|
||||
|
||||
// Default configuration
|
||||
const DEFAULT_CONFIG = {
|
||||
width: 80,
|
||||
height: 24,
|
||||
maxSamples: 30,
|
||||
strokeWidth: 1.5,
|
||||
minRssi: -100,
|
||||
maxRssi: -30,
|
||||
showCurrentValue: true,
|
||||
showGradient: true,
|
||||
animateUpdates: true
|
||||
};
|
||||
|
||||
// Color thresholds based on RSSI
|
||||
const RSSI_COLORS = {
|
||||
excellent: { rssi: -50, color: '#22c55e' }, // Green
|
||||
good: { rssi: -60, color: '#84cc16' }, // Lime
|
||||
fair: { rssi: -70, color: '#eab308' }, // Yellow
|
||||
weak: { rssi: -80, color: '#f97316' }, // Orange
|
||||
poor: { rssi: -100, color: '#ef4444' } // Red
|
||||
};
|
||||
|
||||
/**
|
||||
* Get color for RSSI value
|
||||
*/
|
||||
function getRssiColor(rssi) {
|
||||
if (rssi >= RSSI_COLORS.excellent.rssi) return RSSI_COLORS.excellent.color;
|
||||
if (rssi >= RSSI_COLORS.good.rssi) return RSSI_COLORS.good.color;
|
||||
if (rssi >= RSSI_COLORS.fair.rssi) return RSSI_COLORS.fair.color;
|
||||
if (rssi >= RSSI_COLORS.weak.rssi) return RSSI_COLORS.weak.color;
|
||||
return RSSI_COLORS.poor.color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize RSSI value to 0-1 range
|
||||
*/
|
||||
function normalizeRssi(rssi, min, max) {
|
||||
return Math.max(0, Math.min(1, (rssi - min) / (max - min)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create sparkline SVG element
|
||||
*/
|
||||
function createSparklineSvg(samples, config = {}) {
|
||||
const cfg = { ...DEFAULT_CONFIG, ...config };
|
||||
const { width, height, minRssi, maxRssi, strokeWidth, showGradient } = cfg;
|
||||
|
||||
if (!samples || samples.length < 2) {
|
||||
return createEmptySparkline(width, height);
|
||||
}
|
||||
|
||||
// Normalize samples
|
||||
const normalized = samples.map(s => {
|
||||
const rssi = typeof s === 'object' ? s.rssi : s;
|
||||
return {
|
||||
value: normalizeRssi(rssi, minRssi, maxRssi),
|
||||
rssi: rssi
|
||||
};
|
||||
});
|
||||
|
||||
// Calculate path
|
||||
const stepX = width / (normalized.length - 1);
|
||||
let pathD = '';
|
||||
let areaD = '';
|
||||
const points = [];
|
||||
|
||||
normalized.forEach((sample, i) => {
|
||||
const x = i * stepX;
|
||||
const y = height - (sample.value * (height - 2)) - 1; // 1px padding top/bottom
|
||||
points.push({ x, y, rssi: sample.rssi });
|
||||
|
||||
if (i === 0) {
|
||||
pathD = `M${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
areaD = `M${x.toFixed(1)},${height} L${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
} else {
|
||||
pathD += ` L${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
areaD += ` L${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}
|
||||
});
|
||||
|
||||
// Close area path
|
||||
areaD += ` L${width},${height} Z`;
|
||||
|
||||
// Get current color based on latest value
|
||||
const latestRssi = normalized[normalized.length - 1].rssi;
|
||||
const strokeColor = getRssiColor(latestRssi);
|
||||
|
||||
// Create SVG
|
||||
const gradientId = `sparkline-gradient-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
let gradientDef = '';
|
||||
if (showGradient) {
|
||||
gradientDef = `
|
||||
<defs>
|
||||
<linearGradient id="${gradientId}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:${strokeColor};stop-opacity:0.3"/>
|
||||
<stop offset="100%" style="stop-color:${strokeColor};stop-opacity:0.05"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<svg class="rssi-sparkline-svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||
${gradientDef}
|
||||
${showGradient ? `<path d="${areaD}" fill="url(#${gradientId})" />` : ''}
|
||||
<path d="${pathD}" fill="none" stroke="${strokeColor}" stroke-width="${strokeWidth}"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="${points[points.length - 1].x}" cy="${points[points.length - 1].y}"
|
||||
r="2" fill="${strokeColor}" class="sparkline-dot" />
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty sparkline placeholder
|
||||
*/
|
||||
function createEmptySparkline(width, height) {
|
||||
return `
|
||||
<svg class="rssi-sparkline-svg rssi-sparkline-empty" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||
<line x1="0" y1="${height / 2}" x2="${width}" y2="${height / 2}"
|
||||
stroke="#444" stroke-width="1" stroke-dasharray="2,2" />
|
||||
<text x="${width / 2}" y="${height / 2 + 4}" text-anchor="middle"
|
||||
fill="#666" font-size="8" font-family="monospace">No data</text>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a live sparkline component with update capability
|
||||
*/
|
||||
class LiveSparkline {
|
||||
constructor(container, config = {}) {
|
||||
this.container = typeof container === 'string'
|
||||
? document.querySelector(container)
|
||||
: container;
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
this.samples = [];
|
||||
this.animationFrame = null;
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
addSample(rssi) {
|
||||
this.samples.push({
|
||||
rssi: rssi,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// Limit samples
|
||||
if (this.samples.length > this.config.maxSamples) {
|
||||
this.samples.shift();
|
||||
}
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
setSamples(samples) {
|
||||
this.samples = samples.slice(-this.config.maxSamples);
|
||||
this.render();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.container) return;
|
||||
|
||||
const svg = createSparklineSvg(this.samples, this.config);
|
||||
this.container.innerHTML = svg;
|
||||
|
||||
// Add current value display if enabled
|
||||
if (this.config.showCurrentValue && this.samples.length > 0) {
|
||||
const latest = this.samples[this.samples.length - 1];
|
||||
const rssi = typeof latest === 'object' ? latest.rssi : latest;
|
||||
const valueEl = document.createElement('span');
|
||||
valueEl.className = 'rssi-current-value';
|
||||
valueEl.textContent = `${rssi} dBm`;
|
||||
valueEl.style.color = getRssiColor(rssi);
|
||||
this.container.appendChild(valueEl);
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.samples = [];
|
||||
this.render();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.animationFrame) {
|
||||
cancelAnimationFrame(this.animationFrame);
|
||||
}
|
||||
if (this.container) {
|
||||
this.container.innerHTML = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create inline sparkline HTML (for use in templates)
|
||||
*/
|
||||
function createInlineSparkline(rssiHistory, options = {}) {
|
||||
const samples = rssiHistory.map(h => typeof h === 'object' ? h.rssi : h);
|
||||
return createSparklineSvg(samples, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create sparkline with value display
|
||||
*/
|
||||
function createSparklineWithValue(rssiHistory, currentRssi, options = {}) {
|
||||
const { width = 60, height = 20 } = options;
|
||||
const svg = createInlineSparkline(rssiHistory, { ...options, width, height });
|
||||
const color = getRssiColor(currentRssi);
|
||||
|
||||
return `
|
||||
<div class="rssi-sparkline-wrapper">
|
||||
${svg}
|
||||
<span class="rssi-value" style="color: ${color}">${currentRssi !== null ? currentRssi : '--'} dBm</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
createSparklineSvg,
|
||||
createInlineSparkline,
|
||||
createSparklineWithValue,
|
||||
createEmptySparkline,
|
||||
LiveSparkline,
|
||||
getRssiColor,
|
||||
normalizeRssi,
|
||||
DEFAULT_CONFIG,
|
||||
RSSI_COLORS
|
||||
};
|
||||
})();
|
||||
|
||||
// Make globally available
|
||||
window.RSSISparkline = RSSISparkline;
|
||||
541
static/js/modes/bluetooth.js
Normal file
541
static/js/modes/bluetooth.js
Normal file
@@ -0,0 +1,541 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// DOM elements (cached)
|
||||
let startBtn, stopBtn, messageContainer, deviceContainer;
|
||||
let adapterSelect, scanModeSelect, transportSelect, durationInput, minRssiInput;
|
||||
let baselineStatusEl, capabilityStatusEl;
|
||||
|
||||
/**
|
||||
* Initialize the Bluetooth mode
|
||||
*/
|
||||
function init() {
|
||||
// Cache DOM elements
|
||||
startBtn = document.getElementById('startBtBtn');
|
||||
stopBtn = document.getElementById('stopBtBtn');
|
||||
messageContainer = document.getElementById('btMessageContainer');
|
||||
deviceContainer = document.getElementById('output');
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 `<option value="${a.id}">${a.id} - ${a.name || 'Bluetooth Adapter'} [${status}]</option>`;
|
||||
}).join('');
|
||||
} else if (adapterSelect) {
|
||||
adapterSelect.innerHTML = '<option value="">No adapters found</option>';
|
||||
}
|
||||
|
||||
// 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 || !messageContainer) return;
|
||||
|
||||
capabilityStatusEl.style.display = 'block';
|
||||
|
||||
if (typeof MessageCard !== 'undefined') {
|
||||
const card = MessageCard.createCapabilityWarning(issues);
|
||||
if (card) {
|
||||
capabilityStatusEl.innerHTML = '';
|
||||
capabilityStatusEl.appendChild(card);
|
||||
}
|
||||
} else {
|
||||
capabilityStatusEl.innerHTML = `
|
||||
<div class="warning-text" style="color: #f59e0b;">
|
||||
${issues.map(i => `<div>${i}</div>`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
showScanningMessage(mode);
|
||||
} 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();
|
||||
removeScanningMessage();
|
||||
} 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';
|
||||
|
||||
// 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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
showScanningMessage(data.mode);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('scan_stopped', (e) => {
|
||||
setScanning(false);
|
||||
removeScanningMessage();
|
||||
const data = JSON.parse(e.data);
|
||||
showScanCompleteMessage(data.device_count, data.duration);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('error', (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
showErrorMessage(data.message);
|
||||
} catch {
|
||||
// Connection error
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a device card
|
||||
*/
|
||||
function renderDevice(device) {
|
||||
if (!deviceContainer) return;
|
||||
|
||||
const existingCard = deviceContainer.querySelector(`[data-device-id="${device.device_id}"]`);
|
||||
|
||||
if (typeof DeviceCard !== 'undefined') {
|
||||
const cardHtml = DeviceCard.createDeviceCard(device);
|
||||
|
||||
if (existingCard) {
|
||||
existingCard.outerHTML = cardHtml;
|
||||
} else {
|
||||
deviceContainer.insertAdjacentHTML('afterbegin', cardHtml);
|
||||
}
|
||||
|
||||
// Re-attach click handler
|
||||
const newCard = deviceContainer.querySelector(`[data-device-id="${device.device_id}"]`);
|
||||
if (newCard) {
|
||||
newCard.addEventListener('click', () => showDeviceDetails(device.device_id));
|
||||
}
|
||||
} else {
|
||||
// Fallback simple rendering
|
||||
const cardHtml = createSimpleDeviceCard(device);
|
||||
|
||||
if (existingCard) {
|
||||
existingCard.outerHTML = cardHtml;
|
||||
} else {
|
||||
deviceContainer.insertAdjacentHTML('afterbegin', cardHtml);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple device card fallback
|
||||
*/
|
||||
function createSimpleDeviceCard(device) {
|
||||
const protoBadge = device.protocol === 'ble'
|
||||
? '<span class="signal-proto-badge" style="background: rgba(59, 130, 246, 0.15); color: #3b82f6; border: 1px solid rgba(59, 130, 246, 0.3);">BLE</span>'
|
||||
: '<span class="signal-proto-badge" style="background: rgba(139, 92, 246, 0.15); color: #8b5cf6; border: 1px solid rgba(139, 92, 246, 0.3);">CLASSIC</span>';
|
||||
|
||||
const badges = [];
|
||||
if (device.is_new) badges.push('<span class="device-heuristic-badge new">New</span>');
|
||||
if (device.is_persistent) badges.push('<span class="device-heuristic-badge persistent">Persistent</span>');
|
||||
if (device.is_beacon_like) badges.push('<span class="device-heuristic-badge beacon">Beacon-like</span>');
|
||||
|
||||
const rssiColor = getRssiColor(device.rssi_current);
|
||||
|
||||
return `
|
||||
<div class="signal-card device-card" data-device-id="${device.device_id}">
|
||||
<div class="signal-card-header">
|
||||
<div class="signal-card-badges">
|
||||
${protoBadge}
|
||||
${badges.join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="signal-card-body">
|
||||
<div class="device-name">${escapeHtml(device.name || 'Unknown Device')}</div>
|
||||
<div class="device-address">${escapeHtml(device.address)} (${device.address_type || 'unknown'})</div>
|
||||
<div class="rssi-display">
|
||||
<span class="rssi-current" style="color: ${rssiColor}">${device.rssi_current !== null ? device.rssi_current + ' dBm' : '--'}</span>
|
||||
</div>
|
||||
${device.manufacturer_name ? `<div class="device-manufacturer">${escapeHtml(device.manufacturer_name)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show device details
|
||||
*/
|
||||
async function showDeviceDetails(deviceId) {
|
||||
try {
|
||||
const response = await fetch(`/api/bluetooth/devices/${encodeURIComponent(deviceId)}`);
|
||||
const device = await response.json();
|
||||
|
||||
// Toggle advanced panel or show modal
|
||||
const card = deviceContainer?.querySelector(`[data-device-id="${deviceId}"]`);
|
||||
if (card) {
|
||||
const panel = card.querySelector('.signal-advanced-panel');
|
||||
if (panel) {
|
||||
panel.classList.toggle('show');
|
||||
if (panel.classList.contains('show')) {
|
||||
panel.innerHTML = `<pre style="font-size: 10px; overflow: auto;">${JSON.stringify(device, null, 2)}</pre>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to get device details:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
showBaselineSetMessage(data.device_count);
|
||||
} else {
|
||||
showErrorMessage(data.message || 'Failed to set baseline');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to set baseline:', err);
|
||||
showErrorMessage('Failed to set baseline');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 set: ${baselineCount} device${baselineCount !== 1 ? 's' : ''}`;
|
||||
baselineStatusEl.style.color = '#22c55e';
|
||||
} else {
|
||||
baselineStatusEl.textContent = 'No baseline set';
|
||||
baselineStatusEl.style.color = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export data
|
||||
*/
|
||||
function exportData(format) {
|
||||
window.open(`/api/bluetooth/export?format=${format}`, '_blank');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show scanning message
|
||||
*/
|
||||
function showScanningMessage(mode) {
|
||||
if (!messageContainer || typeof MessageCard === 'undefined') return;
|
||||
|
||||
removeScanningMessage();
|
||||
const card = MessageCard.createScanningCard({
|
||||
backend: mode,
|
||||
deviceCount: devices.size
|
||||
});
|
||||
messageContainer.appendChild(card);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove scanning message
|
||||
*/
|
||||
function removeScanningMessage() {
|
||||
MessageCard?.removeMessage?.('btScanningStatus');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show scan complete message
|
||||
*/
|
||||
function showScanCompleteMessage(deviceCount, duration) {
|
||||
if (!messageContainer || typeof MessageCard === 'undefined') return;
|
||||
|
||||
const card = MessageCard.createScanCompleteCard(deviceCount, duration || 0);
|
||||
messageContainer.appendChild(card);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show baseline set message
|
||||
*/
|
||||
function showBaselineSetMessage(count) {
|
||||
if (!messageContainer || typeof MessageCard === 'undefined') return;
|
||||
|
||||
const card = MessageCard.createBaselineCard(count, true);
|
||||
messageContainer.appendChild(card);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error message
|
||||
*/
|
||||
function showErrorMessage(message) {
|
||||
if (!messageContainer || typeof MessageCard === 'undefined') return;
|
||||
|
||||
const card = MessageCard.createErrorCard(message, () => startScan());
|
||||
messageContainer.appendChild(card);
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init,
|
||||
startScan,
|
||||
stopScan,
|
||||
checkCapabilities,
|
||||
setBaseline,
|
||||
clearBaseline,
|
||||
exportData,
|
||||
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', () => {
|
||||
// Only init if we're on a page with Bluetooth mode
|
||||
if (document.getElementById('bluetoothMode')) {
|
||||
BluetoothMode.init();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (document.getElementById('bluetoothMode')) {
|
||||
BluetoothMode.init();
|
||||
}
|
||||
}
|
||||
|
||||
// Make globally available
|
||||
window.BluetoothMode = BluetoothMode;
|
||||
Reference in New Issue
Block a user