From 54db023520322bfc2b76554afebb2100977439cf Mon Sep 17 00:00:00 2001 From: Smittix Date: Wed, 21 Jan 2026 15:42:33 +0000 Subject: [PATCH] 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 --- routes/__init__.py | 2 + routes/bluetooth_v2.py | 659 ++++++++++++++++++++++++ routes/tscm.py | 197 +++---- static/css/components/device-cards.css | 565 ++++++++++++++++++++ static/js/components/device-card.js | 592 +++++++++++++++++++++ static/js/components/message-card.js | 326 ++++++++++++ static/js/components/rssi-sparkline.js | 243 +++++++++ static/js/modes/bluetooth.js | 541 +++++++++++++++++++ templates/adsb_dashboard.html | 2 +- templates/index.html | 6 + templates/partials/modes/bluetooth.html | 111 ++-- tests/test_bluetooth_aggregator.py | 555 ++++++++++++++++++++ tests/test_bluetooth_api.py | 469 +++++++++++++++++ tests/test_bluetooth_heuristics.py | 357 +++++++++++++ utils/bluetooth/__init__.py | 70 +++ utils/bluetooth/aggregator.py | 347 +++++++++++++ utils/bluetooth/capability_check.py | 307 +++++++++++ utils/bluetooth/constants.py | 220 ++++++++ utils/bluetooth/dbus_scanner.py | 396 ++++++++++++++ utils/bluetooth/fallback_scanner.py | 529 +++++++++++++++++++ utils/bluetooth/heuristics.py | 205 ++++++++ utils/bluetooth/models.py | 355 +++++++++++++ utils/bluetooth/scanner.py | 413 +++++++++++++++ 23 files changed, 7324 insertions(+), 143 deletions(-) create mode 100644 routes/bluetooth_v2.py create mode 100644 static/css/components/device-cards.css create mode 100644 static/js/components/device-card.js create mode 100644 static/js/components/message-card.js create mode 100644 static/js/components/rssi-sparkline.js create mode 100644 static/js/modes/bluetooth.js create mode 100644 tests/test_bluetooth_aggregator.py create mode 100644 tests/test_bluetooth_api.py create mode 100644 tests/test_bluetooth_heuristics.py create mode 100644 utils/bluetooth/__init__.py create mode 100644 utils/bluetooth/aggregator.py create mode 100644 utils/bluetooth/capability_check.py create mode 100644 utils/bluetooth/constants.py create mode 100644 utils/bluetooth/dbus_scanner.py create mode 100644 utils/bluetooth/fallback_scanner.py create mode 100644 utils/bluetooth/heuristics.py create mode 100644 utils/bluetooth/models.py create mode 100644 utils/bluetooth/scanner.py diff --git a/routes/__init__.py b/routes/__init__.py index ffb5f48..910c29b 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -7,6 +7,7 @@ def register_blueprints(app): from .rtlamr import rtlamr_bp from .wifi import wifi_bp from .bluetooth import bluetooth_bp + from .bluetooth_v2 import bluetooth_v2_bp from .adsb import adsb_bp from .acars import acars_bp from .aprs import aprs_bp @@ -22,6 +23,7 @@ def register_blueprints(app): app.register_blueprint(rtlamr_bp) app.register_blueprint(wifi_bp) app.register_blueprint(bluetooth_bp) + app.register_blueprint(bluetooth_v2_bp) # New unified Bluetooth API app.register_blueprint(adsb_bp) app.register_blueprint(acars_bp) app.register_blueprint(aprs_bp) diff --git a/routes/bluetooth_v2.py b/routes/bluetooth_v2.py new file mode 100644 index 0000000..f4585c3 --- /dev/null +++ b/routes/bluetooth_v2.py @@ -0,0 +1,659 @@ +""" +Bluetooth API v2 - Unified scanning with DBus/BlueZ and fallbacks. + +Provides REST endpoints and SSE streaming for Bluetooth device discovery, +aggregation, and heuristics. +""" + +from __future__ import annotations + +import csv +import io +import json +import logging +from datetime import datetime +from typing import Generator + +from flask import Blueprint, Response, jsonify, request, session + +from utils.bluetooth import ( + BluetoothScanner, + BTDeviceAggregate, + get_bluetooth_scanner, + check_capabilities, + RANGE_UNKNOWN, +) +from utils.database import get_db +from utils.sse import format_sse + +logger = logging.getLogger('intercept.bluetooth_v2') + +# Blueprint +bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth') + +# ============================================================================= +# DATABASE FUNCTIONS +# ============================================================================= + + +def init_bt_tables() -> None: + """Initialize Bluetooth-specific database tables.""" + with get_db() as conn: + # Bluetooth baselines + conn.execute(''' + CREATE TABLE IF NOT EXISTS bt_baselines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + device_count INTEGER DEFAULT 0, + is_active BOOLEAN DEFAULT 0 + ) + ''') + + # Baseline device snapshots + conn.execute(''' + CREATE TABLE IF NOT EXISTS bt_baseline_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + baseline_id INTEGER NOT NULL, + device_id TEXT NOT NULL, + address TEXT NOT NULL, + address_type TEXT, + name TEXT, + manufacturer_id INTEGER, + manufacturer_name TEXT, + protocol TEXT, + FOREIGN KEY (baseline_id) REFERENCES bt_baselines(id) ON DELETE CASCADE, + UNIQUE(baseline_id, device_id) + ) + ''') + + # Observation history for long-term tracking + conn.execute(''' + CREATE TABLE IF NOT EXISTS bt_observation_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT NOT NULL, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + rssi INTEGER, + seen_count INTEGER + ) + ''') + + conn.execute(''' + CREATE INDEX IF NOT EXISTS idx_bt_obs_device_time + ON bt_observation_history(device_id, timestamp) + ''') + + conn.execute(''' + CREATE INDEX IF NOT EXISTS idx_bt_baseline_devices_baseline + ON bt_baseline_devices(baseline_id) + ''') + + +def get_active_baseline_id() -> int | None: + """Get the ID of the active baseline.""" + with get_db() as conn: + cursor = conn.execute( + 'SELECT id FROM bt_baselines WHERE is_active = 1 LIMIT 1' + ) + row = cursor.fetchone() + return row['id'] if row else None + + +def get_baseline_device_ids(baseline_id: int) -> set[str]: + """Get device IDs from a baseline.""" + with get_db() as conn: + cursor = conn.execute( + 'SELECT device_id FROM bt_baseline_devices WHERE baseline_id = ?', + (baseline_id,) + ) + return {row['device_id'] for row in cursor} + + +def save_baseline(name: str, devices: list[BTDeviceAggregate]) -> int: + """Save current devices as a new baseline.""" + with get_db() as conn: + # Deactivate existing baselines + conn.execute('UPDATE bt_baselines SET is_active = 0') + + # Create new baseline + cursor = conn.execute( + 'INSERT INTO bt_baselines (name, device_count, is_active) VALUES (?, ?, 1)', + (name, len(devices)) + ) + baseline_id = cursor.lastrowid + + # Save device snapshots + for device in devices: + conn.execute(''' + INSERT INTO bt_baseline_devices + (baseline_id, device_id, address, address_type, name, + manufacturer_id, manufacturer_name, protocol) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + baseline_id, + device.device_id, + device.address, + device.address_type, + device.name, + device.manufacturer_id, + device.manufacturer_name, + device.protocol, + )) + + return baseline_id + + +def clear_active_baseline() -> bool: + """Clear the active baseline.""" + with get_db() as conn: + cursor = conn.execute('UPDATE bt_baselines SET is_active = 0 WHERE is_active = 1') + return cursor.rowcount > 0 + + +def get_all_baselines() -> list[dict]: + """Get all baselines.""" + with get_db() as conn: + cursor = conn.execute(''' + SELECT id, name, created_at, device_count, is_active + FROM bt_baselines + ORDER BY created_at DESC + ''') + return [dict(row) for row in cursor] + + +def save_observation_history(device: BTDeviceAggregate) -> None: + """Save device observation to history.""" + with get_db() as conn: + conn.execute(''' + INSERT INTO bt_observation_history (device_id, rssi, seen_count) + VALUES (?, ?, ?) + ''', (device.device_id, device.rssi_current, device.seen_count)) + + +# ============================================================================= +# API ENDPOINTS +# ============================================================================= + + +@bluetooth_v2_bp.route('/capabilities', methods=['GET']) +def get_capabilities(): + """ + Get Bluetooth system capabilities. + + Returns: + JSON with capability information including adapters, backends, and issues. + """ + caps = check_capabilities() + return jsonify(caps.to_dict()) + + +@bluetooth_v2_bp.route('/scan/start', methods=['POST']) +def start_scan(): + """ + Start Bluetooth scanning. + + Request JSON: + - mode: Scanner mode ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl') + - duration_s: Scan duration in seconds (optional, None for indefinite) + - adapter_id: Adapter path/name (optional) + - transport: BLE transport ('auto', 'bredr', 'le') + - rssi_threshold: Minimum RSSI for discovery + + Returns: + JSON with scan status. + """ + data = request.get_json() or {} + + mode = data.get('mode', 'auto') + duration_s = data.get('duration_s') + adapter_id = data.get('adapter_id') + transport = data.get('transport', 'auto') + rssi_threshold = data.get('rssi_threshold', -100) + + # Validate mode + valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl') + if mode not in valid_modes: + return jsonify({'error': f'Invalid mode. Must be one of: {valid_modes}'}), 400 + + # Get scanner instance + scanner = get_bluetooth_scanner(adapter_id) + + # Check if already scanning + if scanner.is_scanning: + return jsonify({ + 'status': 'already_running', + 'scan_status': scanner.get_status().to_dict() + }) + + # Initialize database tables if needed + init_bt_tables() + + # Load active baseline if exists + baseline_id = get_active_baseline_id() + if baseline_id: + device_ids = get_baseline_device_ids(baseline_id) + if device_ids: + scanner._aggregator.load_baseline(device_ids, datetime.now()) + + # Start scan + success = scanner.start_scan( + mode=mode, + duration_s=duration_s, + transport=transport, + rssi_threshold=rssi_threshold, + ) + + if success: + status = scanner.get_status() + return jsonify({ + 'status': 'started', + 'mode': status.mode, + 'backend': status.backend, + 'adapter_id': status.adapter_id, + }) + else: + status = scanner.get_status() + return jsonify({ + 'status': 'failed', + 'error': status.error or 'Failed to start scan', + }), 500 + + +@bluetooth_v2_bp.route('/scan/stop', methods=['POST']) +def stop_scan(): + """ + Stop Bluetooth scanning. + + Returns: + JSON with status. + """ + scanner = get_bluetooth_scanner() + scanner.stop_scan() + + return jsonify({'status': 'stopped'}) + + +@bluetooth_v2_bp.route('/scan/status', methods=['GET']) +def get_scan_status(): + """ + Get current scan status. + + Returns: + JSON with scan status including elapsed time and device count. + """ + scanner = get_bluetooth_scanner() + status = scanner.get_status() + return jsonify(status.to_dict()) + + +@bluetooth_v2_bp.route('/devices', methods=['GET']) +def list_devices(): + """ + List discovered Bluetooth devices. + + Query parameters: + - sort: Sort field ('last_seen', 'rssi_current', 'name', 'seen_count') + - order: Sort order ('asc', 'desc') + - min_rssi: Minimum RSSI filter + - protocol: Protocol filter ('ble', 'classic') + - max_age: Maximum age in seconds + - heuristic: Filter by heuristic flag ('new', 'persistent', etc.) + + Returns: + JSON array of device summaries. + """ + scanner = get_bluetooth_scanner() + + # Parse query parameters + sort_by = request.args.get('sort', 'last_seen') + sort_desc = request.args.get('order', 'desc').lower() != 'asc' + min_rssi = request.args.get('min_rssi', type=int) + protocol = request.args.get('protocol') + max_age = request.args.get('max_age', 300, type=float) + heuristic_filter = request.args.get('heuristic') + + # Get devices + devices = scanner.get_devices( + sort_by=sort_by, + sort_desc=sort_desc, + min_rssi=min_rssi, + protocol=protocol, + max_age_seconds=max_age, + ) + + # Apply heuristic filter if specified + if heuristic_filter: + devices = [d for d in devices if heuristic_filter in d.heuristic_flags] + + return jsonify({ + 'count': len(devices), + 'devices': [d.to_summary_dict() for d in devices], + }) + + +@bluetooth_v2_bp.route('/devices/', methods=['GET']) +def get_device(device_id: str): + """ + Get detailed information about a specific device. + + Path parameters: + - device_id: Device identifier (address:address_type) + + Returns: + JSON with full device details including RSSI history. + """ + scanner = get_bluetooth_scanner() + device = scanner.get_device(device_id) + + if not device: + return jsonify({'error': 'Device not found'}), 404 + + return jsonify(device.to_dict()) + + +@bluetooth_v2_bp.route('/baseline/set', methods=['POST']) +def set_baseline(): + """ + Set current devices as baseline. + + Request JSON: + - name: Baseline name (optional) + + Returns: + JSON with baseline info. + """ + data = request.get_json() or {} + name = data.get('name', f'Baseline {datetime.now().strftime("%Y-%m-%d %H:%M")}') + + scanner = get_bluetooth_scanner() + + # Initialize tables if needed + init_bt_tables() + + # Get current devices and save to database + devices = scanner.get_devices() + baseline_id = save_baseline(name, devices) + + # Update scanner's in-memory baseline + device_count = scanner.set_baseline() + + return jsonify({ + 'status': 'success', + 'baseline_id': baseline_id, + 'name': name, + 'device_count': device_count, + }) + + +@bluetooth_v2_bp.route('/baseline/clear', methods=['POST']) +def clear_baseline(): + """ + Clear the active baseline. + + Returns: + JSON with status. + """ + scanner = get_bluetooth_scanner() + + # Clear in database + init_bt_tables() + cleared = clear_active_baseline() + + # Clear in scanner + scanner.clear_baseline() + + return jsonify({ + 'status': 'cleared' if cleared else 'no_baseline', + }) + + +@bluetooth_v2_bp.route('/baseline/list', methods=['GET']) +def list_baselines(): + """ + List all saved baselines. + + Returns: + JSON array of baselines. + """ + init_bt_tables() + baselines = get_all_baselines() + return jsonify({ + 'count': len(baselines), + 'baselines': baselines, + }) + + +@bluetooth_v2_bp.route('/export', methods=['GET']) +def export_devices(): + """ + Export devices in CSV or JSON format. + + Query parameters: + - format: Export format ('csv', 'json') + + Returns: + CSV or JSON file download. + """ + export_format = request.args.get('format', 'json').lower() + scanner = get_bluetooth_scanner() + devices = scanner.get_devices() + + if export_format == 'csv': + output = io.StringIO() + writer = csv.writer(output) + + # Header + writer.writerow([ + 'device_id', 'address', 'address_type', 'protocol', 'name', + 'manufacturer_name', 'rssi_current', 'rssi_median', 'range_band', + 'first_seen', 'last_seen', 'seen_count', 'heuristic_flags', + 'in_baseline' + ]) + + # Data rows + for device in devices: + writer.writerow([ + device.device_id, + device.address, + device.address_type, + device.protocol, + device.name or '', + device.manufacturer_name or '', + device.rssi_current or '', + round(device.rssi_median, 1) if device.rssi_median else '', + device.range_band, + device.first_seen.isoformat(), + device.last_seen.isoformat(), + device.seen_count, + ','.join(device.heuristic_flags), + 'yes' if device.in_baseline else 'no', + ]) + + output.seek(0) + return Response( + output.getvalue(), + mimetype='text/csv', + headers={ + 'Content-Disposition': f'attachment; filename=bluetooth_devices_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv' + } + ) + + else: # JSON + data = { + 'exported_at': datetime.now().isoformat(), + 'device_count': len(devices), + 'devices': [d.to_dict() for d in devices], + } + return Response( + json.dumps(data, indent=2), + mimetype='application/json', + headers={ + 'Content-Disposition': f'attachment; filename=bluetooth_devices_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json' + } + ) + + +@bluetooth_v2_bp.route('/stream', methods=['GET']) +def stream_events(): + """ + SSE event stream for real-time device updates. + + Returns: + Server-Sent Events stream. + """ + scanner = get_bluetooth_scanner() + + def event_generator() -> Generator[str, None, None]: + """Generate SSE events from scanner.""" + for event in scanner.stream_events(timeout=1.0): + yield format_sse(event) + + return Response( + event_generator(), + mimetype='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', + } + ) + + +@bluetooth_v2_bp.route('/clear', methods=['POST']) +def clear_devices(): + """ + Clear all tracked devices (does not affect baseline). + + Returns: + JSON with status. + """ + scanner = get_bluetooth_scanner() + scanner.clear_devices() + + return jsonify({'status': 'cleared'}) + + +@bluetooth_v2_bp.route('/prune', methods=['POST']) +def prune_stale(): + """ + Prune stale devices. + + Request JSON: + - max_age: Maximum age in seconds (default: 300) + + Returns: + JSON with count of pruned devices. + """ + data = request.get_json() or {} + max_age = data.get('max_age', 300) + + scanner = get_bluetooth_scanner() + pruned = scanner.prune_stale(max_age_seconds=max_age) + + return jsonify({ + 'status': 'success', + 'pruned_count': pruned, + }) + + +# ============================================================================= +# TSCM INTEGRATION HELPER +# ============================================================================= + + +def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]: + """ + Get Bluetooth snapshot for TSCM integration. + + This is called from routes/tscm.py to get unified Bluetooth data. + + Args: + duration: Scan duration in seconds. + + Returns: + List of device dictionaries in TSCM format. + """ + import time + + scanner = get_bluetooth_scanner() + + # Start scan if not running + if not scanner.is_scanning: + scanner.start_scan(mode='auto', duration_s=duration) + time.sleep(duration + 1) + + devices = scanner.get_devices() + + # Convert to TSCM format + tscm_devices = [] + for device in devices: + tscm_devices.append({ + 'mac': device.address, + 'address_type': device.address_type, + 'name': device.name or 'Unknown', + 'rssi': device.rssi_current or -100, + 'rssi_median': device.rssi_median, + 'type': _classify_device_type(device), + 'manufacturer': device.manufacturer_name, + 'protocol': device.protocol, + 'first_seen': device.first_seen.isoformat(), + 'last_seen': device.last_seen.isoformat(), + 'seen_count': device.seen_count, + 'range_band': device.range_band, + 'heuristics': { + 'is_new': device.is_new, + 'is_persistent': device.is_persistent, + 'is_beacon_like': device.is_beacon_like, + 'is_strong_stable': device.is_strong_stable, + 'has_random_address': device.has_random_address, + }, + 'in_baseline': device.in_baseline, + }) + + return tscm_devices + + +def _classify_device_type(device: BTDeviceAggregate) -> str: + """Classify device type from available data.""" + name_lower = (device.name or '').lower() + manufacturer_lower = (device.manufacturer_name or '').lower() + + # Check by name patterns + if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']): + return 'audio' + if any(x in name_lower for x in ['watch', 'band', 'fitbit', 'garmin']): + return 'wearable' + if any(x in name_lower for x in ['iphone', 'pixel', 'galaxy', 'phone']): + return 'phone' + if any(x in name_lower for x in ['macbook', 'laptop', 'thinkpad', 'surface']): + return 'computer' + if any(x in name_lower for x in ['mouse', 'keyboard', 'trackpad']): + return 'peripheral' + if any(x in name_lower for x in ['tile', 'airtag', 'smarttag', 'chipolo']): + return 'tracker' + if any(x in name_lower for x in ['speaker', 'sonos', 'echo', 'home']): + return 'speaker' + if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']): + return 'media' + + # Check by manufacturer + if 'apple' in manufacturer_lower: + return 'apple_device' + if 'samsung' in manufacturer_lower: + return 'samsung_device' + + # Check by class of device + if device.major_class: + major = device.major_class.lower() + if 'audio' in major: + return 'audio' + if 'phone' in major: + return 'phone' + if 'computer' in major: + return 'computer' + if 'peripheral' in major: + return 'peripheral' + if 'wearable' in major: + return 'wearable' + + return 'unknown' diff --git a/routes/tscm.py b/routes/tscm.py index 66a974b..2e8319d 100644 --- a/routes/tscm.py +++ b/routes/tscm.py @@ -54,6 +54,13 @@ from utils.tscm.device_identity import ( ingest_wifi_dict, ) +# Import unified Bluetooth scanner helper for TSCM integration +try: + from routes.bluetooth_v2 import get_tscm_bluetooth_snapshot + _USE_UNIFIED_BT_SCANNER = True +except ImportError: + _USE_UNIFIED_BT_SCANNER = False + logger = logging.getLogger('intercept.tscm') tscm_bp = Blueprint('tscm', __name__, url_prefix='/tscm') @@ -298,20 +305,20 @@ def _check_available_devices(wifi: bool, bt: bool, rf: bool) -> dict: @tscm_bp.route('/sweep/start', methods=['POST']) -def start_sweep(): - """Start a TSCM sweep.""" - global _sweep_running, _sweep_thread, _current_sweep_id - - if _sweep_running: +def start_sweep(): + """Start a TSCM sweep.""" + global _sweep_running, _sweep_thread, _current_sweep_id + + if _sweep_running: return jsonify({'status': 'error', 'message': 'Sweep already running'}) data = request.get_json() or {} sweep_type = data.get('sweep_type', 'standard') - baseline_id = data.get('baseline_id') - wifi_enabled = data.get('wifi', True) - bt_enabled = data.get('bluetooth', True) - rf_enabled = data.get('rf', True) - verbose_results = bool(data.get('verbose_results', False)) + baseline_id = data.get('baseline_id') + wifi_enabled = data.get('wifi', True) + bt_enabled = data.get('bluetooth', True) + rf_enabled = data.get('rf', True) + verbose_results = bool(data.get('verbose_results', False)) # Get interface selections wifi_interface = data.get('wifi_interface', '') @@ -349,12 +356,12 @@ def start_sweep(): _sweep_running = True # Start sweep thread - _sweep_thread = threading.Thread( - target=_run_sweep, - args=(sweep_type, baseline_id, wifi_enabled, bt_enabled, rf_enabled, - wifi_interface, bt_interface, sdr_device, verbose_results), - daemon=True - ) + _sweep_thread = threading.Thread( + target=_run_sweep, + args=(sweep_type, baseline_id, wifi_enabled, bt_enabled, rf_enabled, + wifi_interface, bt_interface, sdr_device, verbose_results), + daemon=True + ) _sweep_thread.start() logger.info(f"Started TSCM sweep: type={sweep_type}, id={_current_sweep_id}") @@ -1194,17 +1201,17 @@ def _scan_rf_signals(sdr_device: int | None, duration: int = 30) -> list[dict]: return signals -def _run_sweep( - sweep_type: str, - baseline_id: int | None, - wifi_enabled: bool, - bt_enabled: bool, - rf_enabled: bool, - wifi_interface: str = '', - bt_interface: str = '', - sdr_device: int | None = None, - verbose_results: bool = False -) -> None: +def _run_sweep( + sweep_type: str, + baseline_id: int | None, + wifi_enabled: bool, + bt_enabled: bool, + rf_enabled: bool, + wifi_interface: str = '', + bt_interface: str = '', + sdr_device: int | None = None, + verbose_results: bool = False +) -> None: """ Run the TSCM sweep in a background thread. @@ -1322,7 +1329,11 @@ def _run_sweep( # Perform Bluetooth scan if bt_enabled and (current_time - last_bt_scan) >= bt_scan_interval: try: - bt_devices = _scan_bluetooth_devices(bt_interface, duration=8) + # Use unified Bluetooth scanner if available + if _USE_UNIFIED_BT_SCANNER: + bt_devices = get_tscm_bluetooth_snapshot(bt_interface, duration=8) + else: + bt_devices = _scan_bluetooth_devices(bt_interface, duration=8) for device in bt_devices: mac = device.get('mac', '') if mac and mac not in all_bt: @@ -1472,61 +1483,61 @@ def _run_sweep( identity_summary = identity_engine.get_summary() identity_clusters = [c.to_dict() for c in identity_engine.get_clusters()] - if verbose_results: - wifi_payload = list(all_wifi.values()) - bt_payload = list(all_bt.values()) - rf_payload = list(all_rf) - else: - wifi_payload = [ - { - 'bssid': d.get('bssid') or d.get('mac'), - 'essid': d.get('essid') or d.get('ssid'), - 'ssid': d.get('ssid') or d.get('essid'), - 'channel': d.get('channel'), - 'power': d.get('power', d.get('signal')), - 'privacy': d.get('privacy', d.get('encryption')), - 'encryption': d.get('encryption', d.get('privacy')), - } - for d in all_wifi.values() - ] - bt_payload = [ - { - 'mac': d.get('mac') or d.get('address'), - 'name': d.get('name'), - 'rssi': d.get('rssi'), - 'manufacturer': d.get('manufacturer', d.get('manufacturer_name')), - } - for d in all_bt.values() - ] - rf_payload = [ - { - 'frequency': s.get('frequency'), - 'power': s.get('power', s.get('level')), - 'modulation': s.get('modulation'), - 'band': s.get('band'), - } - for s in all_rf - ] - - update_tscm_sweep( - _current_sweep_id, - status='completed', - results={ - 'wifi_devices': wifi_payload, - 'bt_devices': bt_payload, - 'rf_signals': rf_payload, - 'wifi_count': len(all_wifi), - 'bt_count': len(all_bt), - 'rf_count': len(all_rf), - 'severity_counts': severity_counts, - 'correlation_summary': findings.get('summary', {}), - 'identity_summary': identity_summary.get('statistics', {}), - 'baseline_comparison': baseline_comparison, - 'results_detail_level': 'full' if verbose_results else 'compact', - }, - threats_found=threats_found, - completed=True - ) + if verbose_results: + wifi_payload = list(all_wifi.values()) + bt_payload = list(all_bt.values()) + rf_payload = list(all_rf) + else: + wifi_payload = [ + { + 'bssid': d.get('bssid') or d.get('mac'), + 'essid': d.get('essid') or d.get('ssid'), + 'ssid': d.get('ssid') or d.get('essid'), + 'channel': d.get('channel'), + 'power': d.get('power', d.get('signal')), + 'privacy': d.get('privacy', d.get('encryption')), + 'encryption': d.get('encryption', d.get('privacy')), + } + for d in all_wifi.values() + ] + bt_payload = [ + { + 'mac': d.get('mac') or d.get('address'), + 'name': d.get('name'), + 'rssi': d.get('rssi'), + 'manufacturer': d.get('manufacturer', d.get('manufacturer_name')), + } + for d in all_bt.values() + ] + rf_payload = [ + { + 'frequency': s.get('frequency'), + 'power': s.get('power', s.get('level')), + 'modulation': s.get('modulation'), + 'band': s.get('band'), + } + for s in all_rf + ] + + update_tscm_sweep( + _current_sweep_id, + status='completed', + results={ + 'wifi_devices': wifi_payload, + 'bt_devices': bt_payload, + 'rf_signals': rf_payload, + 'wifi_count': len(all_wifi), + 'bt_count': len(all_bt), + 'rf_count': len(all_rf), + 'severity_counts': severity_counts, + 'correlation_summary': findings.get('summary', {}), + 'identity_summary': identity_summary.get('statistics', {}), + 'baseline_comparison': baseline_comparison, + 'results_detail_level': 'full' if verbose_results else 'compact', + }, + threats_found=threats_found, + completed=True + ) # Emit correlation findings _emit_event('correlation_findings', { @@ -1548,13 +1559,13 @@ def _run_sweep( }) # Emit device identity cluster findings (MAC-randomization resistant) - _emit_event('identity_clusters', { - 'total_clusters': identity_summary.get('statistics', {}).get('total_clusters', 0), - 'high_risk_count': identity_summary.get('statistics', {}).get('high_risk_count', 0), - 'medium_risk_count': identity_summary.get('statistics', {}).get('medium_risk_count', 0), - 'unique_fingerprints': identity_summary.get('statistics', {}).get('unique_fingerprints', 0), - 'clusters': identity_clusters, - }) + _emit_event('identity_clusters', { + 'total_clusters': identity_summary.get('statistics', {}).get('total_clusters', 0), + 'high_risk_count': identity_summary.get('statistics', {}).get('high_risk_count', 0), + 'medium_risk_count': identity_summary.get('statistics', {}).get('medium_risk_count', 0), + 'unique_fingerprints': identity_summary.get('statistics', {}).get('unique_fingerprints', 0), + 'clusters': identity_clusters, + }) _emit_event('sweep_completed', { 'sweep_id': _current_sweep_id, @@ -2465,9 +2476,9 @@ def get_baseline_diff(baseline_id: int, sweep_id: int): import json results = json.loads(results) - current_wifi = results.get('wifi_devices', []) - current_bt = results.get('bt_devices', []) - current_rf = results.get('rf_signals', []) + current_wifi = results.get('wifi_devices', []) + current_bt = results.get('bt_devices', []) + current_rf = results.get('rf_signals', []) diff = calculate_baseline_diff( baseline=baseline, diff --git a/static/css/components/device-cards.css b/static/css/components/device-cards.css new file mode 100644 index 0000000..b69a1a0 --- /dev/null +++ b/static/css/components/device-cards.css @@ -0,0 +1,565 @@ +/** + * Device Cards Component CSS + * Styling for Bluetooth device cards, heuristic badges, range bands, and sparklines + */ + +/* ============================================ + CSS VARIABLES + ============================================ */ +:root { + /* Protocol colors */ + --proto-ble: #3b82f6; + --proto-ble-bg: rgba(59, 130, 246, 0.15); + --proto-classic: #8b5cf6; + --proto-classic-bg: rgba(139, 92, 246, 0.15); + + /* Range band colors */ + --range-very-close: #ef4444; + --range-close: #f97316; + --range-nearby: #eab308; + --range-far: #6b7280; + --range-unknown: #374151; + + /* Heuristic badge colors */ + --heuristic-new: #3b82f6; + --heuristic-persistent: #22c55e; + --heuristic-beacon: #f59e0b; + --heuristic-strong: #ef4444; + --heuristic-random: #6b7280; +} + +/* ============================================ + DEVICE CARD BASE + ============================================ */ +.device-card { + cursor: pointer; + transition: all 0.15s ease; +} + +.device-card:hover { + border-color: var(--accent-cyan, #00d4ff); + box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.2); +} + +.device-card:active { + transform: scale(0.995); +} + +/* ============================================ + DEVICE IDENTITY + ============================================ */ +.device-identity { + margin-bottom: 10px; +} + +.device-name { + font-family: 'Inter', -apple-system, sans-serif; + font-size: 14px; + font-weight: 600; + color: var(--text-primary, #e0e0e0); + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.device-address { + display: flex; + align-items: center; + gap: 6px; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; +} + +.device-address .address-value { + color: var(--accent-cyan, #00d4ff); +} + +.device-address .address-type { + color: var(--text-dim, #666); + font-size: 10px; +} + +/* ============================================ + PROTOCOL BADGES + ============================================ */ +.signal-proto-badge.device-protocol { + font-family: 'JetBrains Mono', monospace; + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 2px 6px; + border-radius: 3px; + border: 1px solid; +} + +/* ============================================ + HEURISTIC BADGES + ============================================ */ +.device-heuristic-badge { + display: inline-flex; + align-items: center; + font-family: 'JetBrains Mono', monospace; + font-size: 9px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 2px 6px; + border-radius: 3px; + background: color-mix(in srgb, var(--badge-color) 15%, transparent); + color: var(--badge-color); + border: 1px solid color-mix(in srgb, var(--badge-color) 30%, transparent); +} + +.device-heuristic-badge.new { + --badge-color: var(--heuristic-new); + animation: heuristicPulse 2s ease-in-out infinite; +} + +.device-heuristic-badge.persistent { + --badge-color: var(--heuristic-persistent); +} + +.device-heuristic-badge.beacon_like { + --badge-color: var(--heuristic-beacon); +} + +.device-heuristic-badge.strong_stable { + --badge-color: var(--heuristic-strong); +} + +.device-heuristic-badge.random_address { + --badge-color: var(--heuristic-random); +} + +@keyframes heuristicPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +/* ============================================ + SIGNAL ROW & RSSI DISPLAY + ============================================ */ +.device-signal-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px; + background: var(--bg-secondary, #1a1a1a); + border-radius: 6px; + margin-bottom: 8px; +} + +.rssi-display { + display: flex; + align-items: center; + gap: 10px; +} + +.rssi-current { + font-family: 'JetBrains Mono', monospace; + font-size: 16px; + font-weight: 600; + color: var(--text-primary, #e0e0e0); + min-width: 70px; +} + +/* ============================================ + RSSI SPARKLINE + ============================================ */ +.rssi-sparkline, +.rssi-sparkline-svg { + display: inline-block; + vertical-align: middle; +} + +.rssi-sparkline-empty { + opacity: 0.5; +} + +.rssi-sparkline-wrapper { + display: flex; + align-items: center; + gap: 8px; +} + +.rssi-value { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + font-weight: 500; +} + +.rssi-current-value { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + font-weight: 500; + margin-left: 6px; +} + +.sparkline-dot { + animation: sparklinePulse 1.5s ease-in-out infinite; +} + +@keyframes sparklinePulse { + 0%, 100% { r: 2; opacity: 1; } + 50% { r: 3; opacity: 0.8; } +} + +/* ============================================ + RANGE BAND INDICATOR + ============================================ */ +.device-range-band { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: color-mix(in srgb, var(--range-color) 15%, transparent); + border-radius: 4px; + border-left: 3px solid var(--range-color); +} + +.device-range-band .range-label { + font-family: 'Inter', sans-serif; + font-size: 11px; + font-weight: 600; + color: var(--range-color); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.device-range-band .range-estimate { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--text-dim, #666); +} + +.device-range-band .range-confidence { + font-family: 'JetBrains Mono', monospace; + font-size: 9px; + color: var(--text-dim, #666); + padding: 1px 4px; + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; +} + +/* ============================================ + MANUFACTURER INFO + ============================================ */ +.device-manufacturer { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--text-secondary, #888); + margin-bottom: 6px; +} + +.device-manufacturer .mfr-icon { + font-size: 12px; + opacity: 0.7; +} + +.device-manufacturer .mfr-name { + font-family: 'Inter', sans-serif; +} + +/* ============================================ + META ROW + ============================================ */ +.device-meta-row { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 10px; + color: var(--text-dim, #666); +} + +.device-seen-count { + display: flex; + align-items: center; + gap: 3px; + font-family: 'JetBrains Mono', monospace; +} + +.device-seen-count .seen-icon { + font-size: 10px; + opacity: 0.7; +} + +.device-timestamp { + font-family: 'JetBrains Mono', monospace; +} + +/* ============================================ + SERVICE UUIDS + ============================================ */ +.device-uuids { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.device-uuid { + font-family: 'JetBrains Mono', monospace; + font-size: 9px; + padding: 2px 6px; + background: var(--bg-tertiary, #1a1a1a); + border-radius: 3px; + color: var(--text-secondary, #888); + border: 1px solid var(--border-color, #333); +} + +/* ============================================ + HEURISTICS DETAIL VIEW + ============================================ */ +.device-heuristics-detail { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 6px; +} + +.heuristic-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 8px; + background: var(--bg-tertiary, #1a1a1a); + border-radius: 4px; + border: 1px solid var(--border-color, #333); +} + +.heuristic-item.active { + background: rgba(34, 197, 94, 0.1); + border-color: rgba(34, 197, 94, 0.3); +} + +.heuristic-item .heuristic-name { + font-size: 10px; + text-transform: capitalize; + color: var(--text-secondary, #888); +} + +.heuristic-item .heuristic-status { + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + font-weight: 600; +} + +.heuristic-item.active .heuristic-status { + color: var(--accent-green, #22c55e); +} + +.heuristic-item:not(.active) .heuristic-status { + color: var(--text-dim, #666); +} + +/* ============================================ + MESSAGE CARDS + ============================================ */ +.message-card { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 14px; + background: var(--message-bg); + border: 1px solid color-mix(in srgb, var(--message-color) 30%, transparent); + border-radius: 8px; + margin-bottom: 12px; + animation: messageSlideIn 0.25s ease; + position: relative; +} + +@keyframes messageSlideIn { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.message-card.message-card-hiding { + opacity: 0; + transform: translateY(-8px); + transition: all 0.2s ease; +} + +.message-card::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: var(--message-color); + border-radius: 8px 0 0 8px; +} + +.message-card-icon { + flex-shrink: 0; + width: 20px; + height: 20px; + color: var(--message-color); +} + +.message-card-icon svg { + width: 100%; + height: 100%; +} + +.message-card-icon svg.animate-spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.message-card-content { + flex: 1; + min-width: 0; +} + +.message-card-title { + font-family: 'Inter', sans-serif; + font-size: 13px; + font-weight: 600; + color: var(--text-primary, #e0e0e0); + margin-bottom: 2px; +} + +.message-card-text { + font-size: 12px; + color: var(--text-secondary, #888); + line-height: 1.4; +} + +.message-card-details { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--text-dim, #666); + margin-top: 4px; +} + +.message-card-dismiss { + flex-shrink: 0; + width: 20px; + height: 20px; + padding: 0; + background: none; + border: none; + color: var(--text-dim, #666); + cursor: pointer; + opacity: 0.5; + transition: opacity 0.15s, color 0.15s; +} + +.message-card-dismiss:hover { + opacity: 1; + color: var(--text-primary, #e0e0e0); +} + +.message-card-dismiss svg { + width: 100%; + height: 100%; +} + +.message-card-actions { + display: flex; + gap: 8px; + margin-top: 10px; +} + +.message-action-btn { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 5px 10px; + border-radius: 4px; + border: 1px solid var(--border-color, #333); + background: var(--bg-secondary, #1a1a1a); + color: var(--text-secondary, #888); + cursor: pointer; + transition: all 0.15s; +} + +.message-action-btn:hover { + background: var(--bg-tertiary, #252525); + border-color: var(--border-light, #444); + color: var(--text-primary, #e0e0e0); +} + +.message-action-btn.primary { + background: color-mix(in srgb, var(--message-color) 20%, transparent); + border-color: color-mix(in srgb, var(--message-color) 40%, transparent); + color: var(--message-color); +} + +.message-action-btn.primary:hover { + background: color-mix(in srgb, var(--message-color) 30%, transparent); +} + +/* ============================================ + DEVICE FILTER BAR + ============================================ */ +.device-filter-bar { + flex-wrap: wrap; +} + +.device-filter-bar .signal-filter-btn .filter-dot { + width: 6px; + height: 6px; + border-radius: 50%; +} + +/* ============================================ + RESPONSIVE ADJUSTMENTS + ============================================ */ +@media (max-width: 600px) { + .device-signal-row { + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .rssi-display { + justify-content: center; + } + + .device-range-band { + justify-content: center; + } + + .device-heuristics-detail { + grid-template-columns: 1fr; + } + + .message-card { + padding: 10px 12px; + } + + .message-card-title { + font-size: 12px; + } + + .message-card-text { + font-size: 11px; + } +} + +/* ============================================ + DARK MODE OVERRIDES (if needed) + ============================================ */ +@media (prefers-color-scheme: dark) { + .device-card { + --bg-secondary: #1a1a1a; + --bg-tertiary: #141414; + } +} diff --git a/static/js/components/device-card.js b/static/js/components/device-card.js new file mode 100644 index 0000000..cd46946 --- /dev/null +++ b/static/js/components/device-card.js @@ -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 '--'; + } + + 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 = {}) { + 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 = ` +
+
+ ${protocolBadge} + ${heuristicBadges} +
+ + + ${device.in_baseline ? 'Known' : 'New'} + +
+
+
+
${escapeHtml(device.name || 'Unknown Device')}
+
+ ${escapeHtml(device.address)} + (${escapeHtml(device.address_type)}) +
+
+
+
+ + ${device.rssi_current !== null ? device.rssi_current + ' dBm' : '--'} + + ${sparkline} +
+ ${rangeBand} +
+ ${device.manufacturer_name ? ` +
+ 🏭 + ${escapeHtml(device.manufacturer_name)} +
+ ` : ''} +
+ + 👁 + ${device.seen_count}× + + + ${escapeHtml(relativeTime)} + +
+
+ +
+
+ ${createAdvancedPanel(device)} +
+
+ `; + + // 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 ` +
+
+
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'); + }); + 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 = ` + + + + + + + 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; diff --git a/static/js/components/message-card.js b/static/js/components/message-card.js new file mode 100644 index 0000000..63e6715 --- /dev/null +++ b/static/js/components/message-card.js @@ -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: ` + + + + `, + color: '#3b82f6', + bgColor: 'rgba(59, 130, 246, 0.1)' + }, + success: { + icon: ` + + + `, + color: '#22c55e', + bgColor: 'rgba(34, 197, 94, 0.1)' + }, + warning: { + icon: ` + + + + `, + color: '#f59e0b', + bgColor: 'rgba(245, 158, 11, 0.1)' + }, + error: { + icon: ` + + + + `, + color: '#ef4444', + bgColor: 'rgba(239, 68, 68, 0.1)' + }, + scanning: { + icon: ` + + `, + 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 = ` +
+ ${config.icon} +
+
+ ${title ? `
${escapeHtml(title)}
` : ''} + ${message ? `
${escapeHtml(message)}
` : ''} + ${details ? `
${escapeHtml(details)}
` : ''} +
+ ${dismissible ? ` + + ` : ''} + ${actions && actions.length > 0 ? ` +
+ ${actions.map(action => ` + + `).join('')} +
+ ` : ''} + `; + + // 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; diff --git a/static/js/components/rssi-sparkline.js b/static/js/components/rssi-sparkline.js new file mode 100644 index 0000000..5c23059 --- /dev/null +++ b/static/js/components/rssi-sparkline.js @@ -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 = ` + + + + + + + `; + } + + return ` + + ${gradientDef} + ${showGradient ? `` : ''} + + + + `; + } + + /** + * Create empty sparkline placeholder + */ + function createEmptySparkline(width, height) { + return ` + + + No data + + `; + } + + /** + * 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 ` +
+ ${svg} + ${currentRssi !== null ? currentRssi : '--'} dBm +
+ `; + } + + // Public API + return { + createSparklineSvg, + createInlineSparkline, + createSparklineWithValue, + createEmptySparkline, + LiveSparkline, + getRssiColor, + normalizeRssi, + DEFAULT_CONFIG, + RSSI_COLORS + }; +})(); + +// Make globally available +window.RSSISparkline = RSSISparkline; diff --git a/static/js/modes/bluetooth.js b/static/js/modes/bluetooth.js new file mode 100644 index 0000000..1ad1ce3 --- /dev/null +++ b/static/js/modes/bluetooth.js @@ -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 ``; + }).join(''); + } else if (adapterSelect) { + adapterSelect.innerHTML = ''; + } + + // 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 = ` +
+ ${issues.map(i => `
${i}
`).join('')} +
+ `; + } + } + + /** + * 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' + ? 'BLE' + : 'CLASSIC'; + + const badges = []; + if (device.is_new) badges.push('New'); + if (device.is_persistent) badges.push('Persistent'); + if (device.is_beacon_like) badges.push('Beacon-like'); + + const rssiColor = getRssiColor(device.rssi_current); + + return ` +
+
+
+ ${protoBadge} + ${badges.join('')} +
+
+
+
${escapeHtml(device.name || 'Unknown Device')}
+
${escapeHtml(device.address)} (${device.address_type || 'unknown'})
+
+ ${device.rssi_current !== null ? device.rssi_current + ' dBm' : '--'} +
+ ${device.manufacturer_name ? `
${escapeHtml(device.manufacturer_name)}
` : ''} +
+
+ `; + } + + /** + * 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 = `
${JSON.stringify(device, null, 2)}
`; + } + } + } + } 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; diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html index 1dc7fbc..a172b74 100644 --- a/templates/adsb_dashboard.html +++ b/templates/adsb_dashboard.html @@ -230,7 +230,7 @@