mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 22:59:59 -07:00
DeviceCard.createDeviceCard() returns a DOM element, not an HTML string. Use replaceWith() and prepend() instead of outerHTML and insertAdjacentHTML. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
585 lines
20 KiB
JavaScript
585 lines
20 KiB
JavaScript
/**
|
|
* 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() {
|
|
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');
|
|
|
|
console.log('[BT] DOM elements:', {
|
|
startBtn: !!startBtn,
|
|
stopBtn: !!stopBtn,
|
|
deviceContainer: !!deviceContainer,
|
|
adapterSelect: !!adapterSelect
|
|
});
|
|
|
|
// 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';
|
|
|
|
// Clear placeholder when starting scan
|
|
if (scanning && deviceContainer) {
|
|
const placeholder = deviceContainer.querySelector('div[style*="text-align: center"]');
|
|
if (placeholder && placeholder.textContent.includes('Start scanning')) {
|
|
deviceContainer.innerHTML = '';
|
|
}
|
|
}
|
|
|
|
// 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');
|
|
console.log('[BT] SSE stream connected');
|
|
|
|
eventSource.addEventListener('device_update', (e) => {
|
|
console.log('[BT] SSE device_update event:', e.data);
|
|
try {
|
|
const device = JSON.parse(e.data);
|
|
handleDeviceUpdate(device);
|
|
} catch (err) {
|
|
console.error('Failed to parse device update:', err);
|
|
}
|
|
});
|
|
|
|
// Also listen for generic messages as fallback
|
|
eventSource.onmessage = (e) => {
|
|
console.log('[BT] SSE generic message:', e.data);
|
|
};
|
|
|
|
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) {
|
|
console.log('[BT] Device update received:', device);
|
|
devices.set(device.device_id, device);
|
|
renderDevice(device);
|
|
updateDeviceCount();
|
|
}
|
|
|
|
/**
|
|
* Update device count display
|
|
*/
|
|
function updateDeviceCount() {
|
|
const countEl = document.getElementById('btDeviceListCount');
|
|
if (countEl) {
|
|
countEl.textContent = devices.size;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render a device card
|
|
*/
|
|
function renderDevice(device) {
|
|
console.log('[BT] Rendering device:', device.device_id, 'Container:', deviceContainer);
|
|
if (!deviceContainer) {
|
|
console.warn('[BT] No device container found!');
|
|
// Try to find it again
|
|
deviceContainer = document.getElementById('btDeviceListContent');
|
|
if (!deviceContainer) {
|
|
console.error('[BT] Still no container - cannot render');
|
|
return;
|
|
}
|
|
}
|
|
|
|
const existingCard = deviceContainer.querySelector(`[data-device-id="${device.device_id}"]`);
|
|
|
|
if (typeof DeviceCard !== 'undefined') {
|
|
// DeviceCard.createDeviceCard returns a DOM element
|
|
const cardElement = DeviceCard.createDeviceCard(device);
|
|
|
|
if (existingCard) {
|
|
existingCard.replaceWith(cardElement);
|
|
} else {
|
|
deviceContainer.prepend(cardElement);
|
|
}
|
|
|
|
// Attach click handler
|
|
cardElement.addEventListener('click', () => showDeviceDetails(device.device_id));
|
|
} else {
|
|
// Fallback simple rendering (returns HTML string)
|
|
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;
|