diff --git a/data/tscm_frequencies.py b/data/tscm_frequencies.py index 6789c26..5d549bc 100644 --- a/data/tscm_frequencies.py +++ b/data/tscm_frequencies.py @@ -365,10 +365,14 @@ def get_all_sweep_presets() -> dict: } -def is_known_tracker(device_name: str | None, manufacturer_data: bytes | None = None) -> dict | None: +def is_known_tracker(device_name: str | None, manufacturer_data: bytes | str | None = None) -> dict | None: """ Check if a BLE device matches known tracker signatures. + Args: + device_name: Device name to check against patterns + manufacturer_data: Manufacturer data as bytes or hex string + Returns: Tracker info dict if match found, None otherwise """ @@ -379,11 +383,20 @@ def is_known_tracker(device_name: str | None, manufacturer_data: bytes | None = if pattern in name_lower: return tracker_info - if manufacturer_data and len(manufacturer_data) >= 2: - company_id = int.from_bytes(manufacturer_data[:2], 'little') - for tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items(): - if tracker_info.get('company_id') == company_id: - return tracker_info + if manufacturer_data: + # Convert hex string to bytes if needed + mfr_bytes = manufacturer_data + if isinstance(manufacturer_data, str): + try: + mfr_bytes = bytes.fromhex(manufacturer_data) + except ValueError: + return None + + if len(mfr_bytes) >= 2: + company_id = int.from_bytes(mfr_bytes[:2], 'little') + for tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items(): + if tracker_info.get('company_id') == company_id: + return tracker_info return None diff --git a/routes/__init__.py b/routes/__init__.py index ffb5f48..5960a95 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -6,7 +6,9 @@ def register_blueprints(app): from .sensor import sensor_bp from .rtlamr import rtlamr_bp from .wifi import wifi_bp + from .wifi_v2 import wifi_v2_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 @@ -21,7 +23,9 @@ def register_blueprints(app): app.register_blueprint(sensor_bp) app.register_blueprint(rtlamr_bp) app.register_blueprint(wifi_bp) + app.register_blueprint(wifi_v2_bp) # New unified WiFi API 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..e75ec7f --- /dev/null +++ b/routes/bluetooth_v2.py @@ -0,0 +1,1220 @@ +""" +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, + TrackerType, + TrackerConfidence, + get_tracker_engine, +) +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()) + + +# ============================================================================= +# TRACKER DETECTION ENDPOINTS (v2) +# ============================================================================= + + +@bluetooth_v2_bp.route('/trackers', methods=['GET']) +def list_trackers(): + """ + List detected tracker devices with enriched tracker data. + + This is the v2 tracker endpoint that provides comprehensive + tracker detection results including confidence scores and evidence. + + Query parameters: + - min_confidence: Minimum confidence ('high', 'medium', 'low') + - max_age: Maximum age in seconds (default: 300) + - include_risk: Include risk analysis (default: true) + + Returns: + JSON with detected trackers and their analysis. + """ + scanner = get_bluetooth_scanner() + + # Parse query parameters + min_confidence = request.args.get('min_confidence', 'low') + max_age = request.args.get('max_age', 300, type=float) + include_risk = request.args.get('include_risk', 'true').lower() == 'true' + + # Get all devices + devices = scanner.get_devices(max_age_seconds=max_age) + + # Filter to only trackers + trackers = [d for d in devices if d.is_tracker] + + # Filter by confidence level if specified + confidence_order = {'high': 3, 'medium': 2, 'low': 1, 'none': 0} + min_conf_level = confidence_order.get(min_confidence.lower(), 1) + trackers = [ + t for t in trackers + if confidence_order.get(t.tracker_confidence, 0) >= min_conf_level + ] + + # Build response + tracker_list = [] + for device in trackers: + tracker_info = { + 'device_id': device.device_id, + 'device_key': device.device_key, + 'address': device.address, + 'address_type': device.address_type, + 'name': device.name, + + # Tracker detection details + 'tracker': { + 'type': device.tracker_type, + 'name': device.tracker_name, + 'confidence': device.tracker_confidence, + 'confidence_score': round(device.tracker_confidence_score, 2), + 'evidence': device.tracker_evidence, + }, + + # Location/proximity + 'rssi_current': device.rssi_current, + 'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None, + 'proximity_band': device.proximity_band, + 'estimated_distance_m': round(device.estimated_distance_m, 2) if device.estimated_distance_m else None, + + # Timing + 'first_seen': device.first_seen.isoformat(), + 'last_seen': device.last_seen.isoformat(), + 'age_seconds': round(device.age_seconds, 1), + 'seen_count': device.seen_count, + 'duration_seconds': round(device.duration_seconds, 1), + + # Status + 'is_new': device.is_new, + 'in_baseline': device.in_baseline, + + # Fingerprint for cross-MAC tracking + 'fingerprint_id': device.payload_fingerprint_id, + } + + # Include risk analysis if requested + if include_risk: + tracker_info['risk_analysis'] = { + 'risk_score': round(device.risk_score, 2), + 'risk_factors': device.risk_factors, + } + + tracker_list.append(tracker_info) + + # Sort by risk score (highest first), then confidence + tracker_list.sort( + key=lambda t: ( + t.get('risk_analysis', {}).get('risk_score', 0), + confidence_order.get(t['tracker']['confidence'], 0) + ), + reverse=True + ) + + return jsonify({ + 'count': len(tracker_list), + 'scan_active': scanner.is_scanning, + 'trackers': tracker_list, + 'summary': { + 'high_confidence': sum(1 for t in tracker_list if t['tracker']['confidence'] == 'high'), + 'medium_confidence': sum(1 for t in tracker_list if t['tracker']['confidence'] == 'medium'), + 'low_confidence': sum(1 for t in tracker_list if t['tracker']['confidence'] == 'low'), + 'high_risk': sum(1 for t in tracker_list if t.get('risk_analysis', {}).get('risk_score', 0) >= 0.5), + } + }) + + +@bluetooth_v2_bp.route('/trackers/', methods=['GET']) +def get_tracker_detail(device_id: str): + """ + Get detailed tracker information for investigation. + + Provides comprehensive data about a specific tracker including: + - Full tracker detection analysis + - Risk assessment with factors + - RSSI history and timeline + - Raw advertising payload data + - Fingerprint information + + Path parameters: + - device_id: Device identifier (address:address_type) + + Returns: + JSON with full tracker investigation data. + """ + scanner = get_bluetooth_scanner() + device = scanner.get_device(device_id) + + if not device: + return jsonify({'error': 'Device not found'}), 404 + + # Get RSSI history for timeline + rssi_history = device.get_rssi_history(max_points=100) + + # Build comprehensive response + return jsonify({ + 'device_id': device.device_id, + 'device_key': device.device_key, + 'address': device.address, + 'address_type': device.address_type, + 'name': device.name, + 'manufacturer_name': device.manufacturer_name, + 'manufacturer_id': device.manufacturer_id, + + # Tracker detection + 'tracker': { + 'is_tracker': device.is_tracker, + 'type': device.tracker_type, + 'name': device.tracker_name, + 'confidence': device.tracker_confidence, + 'confidence_score': round(device.tracker_confidence_score, 2), + 'evidence': device.tracker_evidence, + }, + + # Risk analysis + 'risk_analysis': { + 'risk_score': round(device.risk_score, 2), + 'risk_factors': device.risk_factors, + 'warning': 'Risk scores are heuristic indicators only. They do NOT prove malicious intent.', + }, + + # Fingerprint (for MAC randomization tracking) + 'fingerprint': { + 'id': device.payload_fingerprint_id, + 'stability': round(device.payload_fingerprint_stability, 2), + 'note': 'Fingerprints help track devices across MAC address changes but are probabilistic.', + }, + + # Signal data + 'signal': { + 'rssi_current': device.rssi_current, + 'rssi_median': round(device.rssi_median, 1) if device.rssi_median else None, + 'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None, + 'rssi_min': device.rssi_min, + 'rssi_max': device.rssi_max, + 'rssi_variance': round(device.rssi_variance, 2) if device.rssi_variance else None, + 'tx_power': device.tx_power, + }, + + # Proximity + 'proximity': { + 'band': device.proximity_band, + 'estimated_distance_m': round(device.estimated_distance_m, 2) if device.estimated_distance_m else None, + 'confidence': round(device.distance_confidence, 2), + }, + + # Timeline / sightings + 'timeline': { + 'first_seen': device.first_seen.isoformat(), + 'last_seen': device.last_seen.isoformat(), + 'age_seconds': round(device.age_seconds, 1), + 'duration_seconds': round(device.duration_seconds, 1), + 'seen_count': device.seen_count, + 'seen_rate': round(device.seen_rate, 2), + 'rssi_history': rssi_history, + }, + + # Raw advertisement data for investigation + 'raw_data': { + 'manufacturer_id_hex': f'0x{device.manufacturer_id:04X}' if device.manufacturer_id else None, + 'manufacturer_data_hex': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None, + 'service_uuids': device.service_uuids, + 'service_data': {k: v.hex() for k, v in device.service_data.items()}, + 'appearance': device.appearance, + }, + + # Heuristics + '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, + 'is_randomized_mac': device.is_randomized_mac, + }, + + # Baseline status + 'baseline': { + 'in_baseline': device.in_baseline, + 'baseline_id': device.baseline_id, + }, + }) + + +@bluetooth_v2_bp.route('/diagnostics', methods=['GET']) +def get_diagnostics(): + """ + Get Bluetooth system diagnostics for troubleshooting. + + Returns detailed information about: + - Adapter status and capabilities + - BlueZ version and DBus access + - Permissions and access issues + - Available scan backends + - Recent errors + + Returns: + JSON with diagnostic information. + """ + import os + import subprocess + + caps = check_capabilities() + + diagnostics = { + 'system': { + 'is_root': os.geteuid() == 0 if hasattr(os, 'geteuid') else False, + 'platform': os.uname().sysname if hasattr(os, 'uname') else 'unknown', + }, + + 'bluez': { + 'has_bluez': caps.has_bluez, + 'version': caps.bluez_version, + 'has_dbus': caps.has_dbus, + }, + + 'adapters': { + 'count': len(caps.adapters), + 'default': caps.default_adapter, + 'list': caps.adapters, + }, + + 'permissions': { + 'has_bluetooth_permission': caps.has_bluetooth_permission, + 'is_soft_blocked': caps.is_soft_blocked, + 'is_hard_blocked': caps.is_hard_blocked, + }, + + 'backends': { + 'recommended': caps.recommended_backend, + 'available': { + 'dbus': caps.has_dbus and caps.has_bluez, + 'bleak': caps.has_bleak, + 'hcitool': caps.has_hcitool, + 'bluetoothctl': caps.has_bluetoothctl, + 'btmgmt': caps.has_btmgmt, + }, + }, + + 'can_scan': caps.can_scan, + 'issues': caps.issues, + + 'recommendations': [], + } + + # Add recommendations based on issues + if not caps.can_scan: + diagnostics['recommendations'].append( + 'No scanning backends available. Install BlueZ or ensure Bluetooth adapter is present.' + ) + + if caps.is_soft_blocked: + diagnostics['recommendations'].append( + 'Bluetooth is soft-blocked. Run: sudo rfkill unblock bluetooth' + ) + + if caps.is_hard_blocked: + diagnostics['recommendations'].append( + 'Bluetooth is hard-blocked (hardware switch). Enable Bluetooth on your device.' + ) + + if not caps.has_bluetooth_permission and not diagnostics['system']['is_root']: + diagnostics['recommendations'].append( + 'May need elevated permissions for BLE scanning. Try running with sudo or add user to bluetooth group.' + ) + + if caps.has_dbus and caps.has_bluez and len(caps.adapters) == 0: + diagnostics['recommendations'].append( + 'BlueZ is available but no adapters found. Check if Bluetooth adapter is connected and enabled.' + ) + + # Check for btmon availability (useful for debugging) + try: + result = subprocess.run(['which', 'btmon'], capture_output=True, timeout=2) + diagnostics['backends']['available']['btmon'] = result.returncode == 0 + except Exception: + diagnostics['backends']['available']['btmon'] = False + + return jsonify(diagnostics) + + +@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 map_event_type(event: dict) -> tuple[str, dict]: + """Map internal event types to SSE event names.""" + event_type = event.get('type', 'unknown') + + if event_type == 'device': + # Device update - send the device data + return 'device_update', event.get('device', event) + elif event_type == 'status': + status = event.get('status', '') + if status == 'started': + return 'scan_started', event + elif status == 'stopped': + return 'scan_stopped', event + return 'status', event + elif event_type == 'error': + return 'error', event + elif event_type == 'baseline': + return 'baseline', event + elif event_type == 'ping': + return 'ping', {} + else: + return event_type, event + + def event_generator() -> Generator[str, None, None]: + """Generate SSE events from scanner.""" + for event in scanner.stream_events(timeout=1.0): + event_name, event_data = map_event_type(event) + yield format_sse(event_data, event=event_name) + + 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 + import logging + logger = logging.getLogger('intercept.bluetooth_v2') + + scanner = get_bluetooth_scanner() + + # Start scan if not running + if not scanner.is_scanning: + logger.info(f"TSCM snapshot: Scanner not running, starting scan for {duration}s") + scanner.start_scan(mode='auto', duration_s=duration) + time.sleep(duration + 1) + else: + logger.info("TSCM snapshot: Scanner already running, getting current devices") + + devices = scanner.get_devices() + logger.info(f"TSCM snapshot: get_devices() returned {len(devices)} devices") + + # Convert to TSCM format with tracker detection data + tscm_devices = [] + for device in devices: + device_data = { + 'mac': device.address, + 'address_type': device.address_type, + 'device_key': device.device_key, + 'name': device.name or 'Unknown', + 'rssi': device.rssi_current or -100, + 'rssi_median': device.rssi_median, + 'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None, + 'type': _classify_device_type(device), + 'manufacturer': device.manufacturer_name, + 'manufacturer_id': device.manufacturer_id, + 'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None, + '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, + 'proximity_band': device.proximity_band, + 'estimated_distance_m': round(device.estimated_distance_m, 2) if device.estimated_distance_m else None, + 'distance_confidence': round(device.distance_confidence, 2), + 'is_randomized_mac': device.is_randomized_mac, + 'threat_tags': device.threat_tags, + '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, + + # Tracker detection data (v2) + 'tracker': { + 'is_tracker': device.is_tracker, + 'type': device.tracker_type, + 'name': device.tracker_name, + 'confidence': device.tracker_confidence, + 'confidence_score': round(device.tracker_confidence_score, 2), + 'evidence': device.tracker_evidence, + }, + + # Risk analysis (v2) + 'risk_analysis': { + 'risk_score': round(device.risk_score, 2), + 'risk_factors': device.risk_factors, + }, + + # Fingerprint for cross-MAC tracking (v2) + 'fingerprint': { + 'id': device.payload_fingerprint_id, + 'stability': round(device.payload_fingerprint_stability, 2), + }, + + # Service UUIDs for analysis + 'service_uuids': device.service_uuids, + } + + tscm_devices.append(device_data) + + return tscm_devices + + +# ============================================================================= +# PROXIMITY & HEATMAP ENDPOINTS +# ============================================================================= + + +@bluetooth_v2_bp.route('/proximity/snapshot', methods=['GET']) +def get_proximity_snapshot(): + """ + Get proximity snapshot for radar visualization. + + All active devices with proximity data including estimated distance, + proximity band, and confidence scores. + + Query parameters: + - max_age: Maximum age in seconds (default: 60) + - min_confidence: Minimum distance confidence (default: 0) + + Returns: + JSON with proximity data for all active devices. + """ + scanner = get_bluetooth_scanner() + max_age = request.args.get('max_age', 60, type=float) + min_confidence = request.args.get('min_confidence', 0.0, type=float) + + devices = scanner.get_devices(max_age_seconds=max_age) + + # Filter by confidence if specified + if min_confidence > 0: + devices = [d for d in devices if d.distance_confidence >= min_confidence] + + # Build proximity snapshot + snapshot = { + 'timestamp': datetime.now().isoformat(), + 'device_count': len(devices), + 'zone_counts': { + 'immediate': 0, + 'near': 0, + 'far': 0, + 'unknown': 0, + }, + 'devices': [], + } + + for device in devices: + # Count by zone + band = device.proximity_band or 'unknown' + if band in snapshot['zone_counts']: + snapshot['zone_counts'][band] += 1 + else: + snapshot['zone_counts']['unknown'] += 1 + + snapshot['devices'].append({ + 'device_key': device.device_key, + 'device_id': device.device_id, + 'name': device.name, + 'address': device.address, + 'rssi_current': device.rssi_current, + 'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None, + 'estimated_distance_m': round(device.estimated_distance_m, 2) if device.estimated_distance_m else None, + 'proximity_band': device.proximity_band, + 'distance_confidence': round(device.distance_confidence, 2), + 'is_new': device.is_new, + 'is_randomized_mac': device.is_randomized_mac, + 'in_baseline': device.in_baseline, + 'heuristic_flags': device.heuristic_flags, + 'last_seen': device.last_seen.isoformat(), + 'age_seconds': round(device.age_seconds, 1), + }) + + return jsonify(snapshot) + + +@bluetooth_v2_bp.route('/heatmap/data', methods=['GET']) +def get_heatmap_data(): + """ + Get heatmap data for timeline visualization. + + Returns top N devices with downsampled RSSI timeseries. + + Query parameters: + - top_n: Number of devices (default: 20) + - window_minutes: Time window (default: 10) + - bucket_seconds: Bucket size for downsampling (default: 10) + - sort_by: Sort method - 'recency', 'strength', 'activity' (default: 'recency') + + Returns: + JSON with device timeseries data for heatmap. + """ + scanner = get_bluetooth_scanner() + + top_n = request.args.get('top_n', 20, type=int) + window_minutes = request.args.get('window_minutes', 10, type=int) + bucket_seconds = request.args.get('bucket_seconds', 10, type=int) + sort_by = request.args.get('sort_by', 'recency') + + # Validate sort_by + if sort_by not in ('recency', 'strength', 'activity'): + sort_by = 'recency' + + # Get heatmap data from aggregator + heatmap_data = scanner._aggregator.get_heatmap_data( + top_n=top_n, + window_minutes=window_minutes, + bucket_seconds=bucket_seconds, + sort_by=sort_by, + ) + + return jsonify(heatmap_data) + + +@bluetooth_v2_bp.route('/devices//timeseries', methods=['GET']) +def get_device_timeseries(device_key: str): + """ + Get timeseries data for a specific device. + + Path parameters: + - device_key: Stable device identifier + + Query parameters: + - window_minutes: Time window (default: 30) + - bucket_seconds: Bucket size for downsampling (default: 10) + + Returns: + JSON with device timeseries data. + """ + scanner = get_bluetooth_scanner() + + window_minutes = request.args.get('window_minutes', 30, type=int) + bucket_seconds = request.args.get('bucket_seconds', 10, type=int) + + # URL decode device key + from urllib.parse import unquote + device_key = unquote(device_key) + + # Get device info + device = scanner._aggregator.get_device_by_key(device_key) + + # Get timeseries data + timeseries = scanner._aggregator.get_timeseries( + device_key=device_key, + window_minutes=window_minutes, + downsample_seconds=bucket_seconds, + ) + + result = { + 'device_key': device_key, + 'window_minutes': window_minutes, + 'bucket_seconds': bucket_seconds, + 'observation_count': len(timeseries), + 'timeseries': timeseries, + } + + if device: + result.update({ + 'name': device.name, + 'address': device.address, + 'rssi_current': device.rssi_current, + 'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None, + 'proximity_band': device.proximity_band, + 'estimated_distance_m': round(device.estimated_distance_m, 2) if device.estimated_distance_m else None, + }) + + return jsonify(result) + + +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..7957a73 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}") @@ -629,166 +636,77 @@ def get_tscm_devices(): def _scan_wifi_networks(interface: str) -> list[dict]: - """Scan for WiFi networks using system tools.""" - import platform - import re - import subprocess + """ + Scan for WiFi networks using the unified WiFi scanner. - networks = [] + This is a facade that maintains backwards compatibility with TSCM + while using the new unified scanner module. - if platform.system() == 'Darwin': - # macOS: Use airport utility - airport_path = '/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport' - try: - result = subprocess.run( - [airport_path, '-s'], - capture_output=True, text=True, timeout=15 - ) - # Parse airport output - # Format: SSID BSSID RSSI CHANNEL HT CC SECURITY - lines = result.stdout.strip().split('\n') - for line in lines[1:]: # Skip header - if not line.strip(): - continue - # Parse the line - format is space-separated but SSID can have spaces - parts = line.split() - if len(parts) >= 7: - # BSSID is always XX:XX:XX:XX:XX:XX format - bssid_idx = None - for i, p in enumerate(parts): - if re.match(r'^[0-9a-fA-F:]{17}$', p): - bssid_idx = i - break - if bssid_idx is not None: - ssid = ' '.join(parts[:bssid_idx]) if bssid_idx > 0 else '[Hidden]' - bssid = parts[bssid_idx] - rssi = parts[bssid_idx + 1] if len(parts) > bssid_idx + 1 else '-100' - channel = parts[bssid_idx + 2] if len(parts) > bssid_idx + 2 else '0' - security = ' '.join(parts[bssid_idx + 5:]) if len(parts) > bssid_idx + 5 else '' - networks.append({ - 'bssid': bssid.upper(), - 'essid': ssid or '[Hidden]', - 'power': rssi, - 'channel': channel, - 'privacy': security - }) - except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError) as e: - logger.warning(f"macOS WiFi scan failed: {e}") + Automatically detects monitor mode interfaces and uses deep scan + (airodump-ng) when appropriate. - else: - # Linux: Try multiple scan methods - import shutil + Args: + interface: WiFi interface name (optional). - # Detect wireless interface if not specified - if not interface: - try: - import glob - wireless_paths = glob.glob('/sys/class/net/*/wireless') - if wireless_paths: - iface = wireless_paths[0].split('/')[4] - else: - iface = 'wlan0' - except Exception: - iface = 'wlan0' + Returns: + List of network dicts with: bssid, essid, power, channel, privacy + """ + try: + from utils.wifi import get_wifi_scanner + + scanner = get_wifi_scanner() + + # Check if interface is in monitor mode + is_monitor = False + if interface: + is_monitor = scanner._is_monitor_mode_interface(interface) + + if is_monitor: + # Use deep scan for monitor mode interfaces + logger.info(f"Interface {interface} is in monitor mode, using deep scan") + + # Check if airodump-ng is available + caps = scanner.check_capabilities() + if not caps.has_airodump_ng: + logger.warning("airodump-ng not available for monitor mode scanning") + return [] + + # Start a short deep scan + if not scanner.is_scanning: + scanner.start_deep_scan(interface=interface, band='all') + + # Wait briefly for some results + import time + time.sleep(5) + + # Get current access points + networks = [] + for ap in scanner.access_points: + networks.append(ap.to_legacy_dict()) + + logger.info(f"WiFi deep scan found {len(networks)} networks") + return networks else: - iface = interface + # Use quick scan for managed mode interfaces + result = scanner.quick_scan(interface=interface, timeout=15) - logger.info(f"WiFi scan using interface: {iface}") + if result.error: + logger.warning(f"WiFi scan error: {result.error}") - # Method 1: Try iw scan (sometimes works without root) - if shutil.which('iw'): - try: - logger.info("Trying 'iw' scan...") - result = subprocess.run( - ['iw', 'dev', iface, 'scan'], - capture_output=True, text=True, timeout=30 - ) - if result.returncode == 0 and 'BSS' in result.stdout: - # Parse iw output - current_bss = None - for line in result.stdout.split('\n'): - if line.startswith('BSS '): - if current_bss and current_bss.get('bssid'): - networks.append(current_bss) - # Extract BSSID from "BSS xx:xx:xx:xx:xx:xx(on wlan0)" - bssid_match = re.search(r'BSS ([0-9a-fA-F:]{17})', line) - if bssid_match: - current_bss = {'bssid': bssid_match.group(1).upper(), 'essid': '[Hidden]'} - elif current_bss: - line = line.strip() - if line.startswith('SSID:'): - ssid = line[5:].strip() - current_bss['essid'] = ssid or '[Hidden]' - elif line.startswith('signal:'): - sig_match = re.search(r'(-?\d+)', line) - if sig_match: - current_bss['power'] = sig_match.group(1) - elif line.startswith('freq:'): - freq = line[5:].strip() - # Convert frequency to channel - try: - freq_mhz = int(freq) - if freq_mhz < 3000: - channel = (freq_mhz - 2407) // 5 - else: - channel = (freq_mhz - 5000) // 5 - current_bss['channel'] = str(channel) - except ValueError: - pass - elif 'WPA' in line or 'RSN' in line: - current_bss['privacy'] = 'WPA2' if 'RSN' in line else 'WPA' - if current_bss and current_bss.get('bssid'): - networks.append(current_bss) - logger.info(f"iw scan found {len(networks)} networks") - elif 'Operation not permitted' in result.stderr or result.returncode != 0: - logger.warning(f"iw scan requires root: {result.stderr[:100]}") - except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e: - logger.warning(f"iw scan failed: {e}") + # Convert to legacy format for TSCM + networks = [] + for ap in result.access_points: + networks.append(ap.to_legacy_dict()) - # Method 2: Try iwlist scan if iw didn't work - if not networks and shutil.which('iwlist'): - try: - logger.info("Trying 'iwlist' scan...") - result = subprocess.run( - ['iwlist', iface, 'scan'], - capture_output=True, text=True, timeout=30 - ) - if 'Operation not permitted' in result.stderr: - logger.warning("iwlist scan requires root privileges") - else: - current_network = {} - for line in result.stdout.split('\n'): - line = line.strip() - if 'Cell' in line and 'Address:' in line: - if current_network.get('bssid'): - networks.append(current_network) - bssid = line.split('Address:')[1].strip() - current_network = {'bssid': bssid.upper(), 'essid': '[Hidden]'} - elif 'ESSID:' in line: - essid = line.split('ESSID:')[1].strip().strip('"') - current_network['essid'] = essid or '[Hidden]' - elif 'Channel:' in line: - channel = line.split('Channel:')[1].strip() - current_network['channel'] = channel - elif 'Signal level=' in line: - match = re.search(r'Signal level[=:]?\s*(-?\d+)', line) - if match: - current_network['power'] = match.group(1) - elif 'Encryption key:' in line: - encrypted = 'on' in line.lower() - current_network['encrypted'] = encrypted - elif 'WPA' in line or 'WPA2' in line: - current_network['privacy'] = 'WPA2' if 'WPA2' in line else 'WPA' - if current_network.get('bssid'): - networks.append(current_network) - logger.info(f"iwlist scan found {len(networks)} networks") - except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError) as e: - logger.warning(f"iwlist scan failed: {e}") + logger.info(f"WiFi scan found {len(networks)} networks") + return networks - if not networks: - logger.warning("WiFi scanning requires root privileges. Run with sudo for WiFi scanning.") - - return networks + except ImportError as e: + logger.error(f"Failed to import wifi scanner: {e}") + return [] + except Exception as e: + logger.exception(f"WiFi scan failed: {e}") + return [] def _scan_bluetooth_devices(interface: str, duration: int = 10) -> list[dict]: @@ -1145,11 +1063,12 @@ def _scan_rf_signals(sdr_device: int | None, duration: int = 30) -> list[dict]: db_values = [float(x) for x in parts[6:] if x.strip()] # Find peaks above noise floor + # RTL-SDR dongles have higher noise figures, so use permissive thresholds noise_floor = sum(db_values) / len(db_values) if db_values else -100 - threshold = noise_floor + 10 # Signal must be 10dB above noise + threshold = noise_floor + 6 # Signal must be 6dB above noise for idx, db in enumerate(db_values): - if db > threshold and db > -70: # Detect signals above -70dBm + if db > threshold and db > -90: # Detect signals above -90dBm freq_hz = hz_low + (idx * hz_step) freq_mhz = freq_hz / 1000000 @@ -1194,17 +1113,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. @@ -1255,7 +1174,7 @@ def _run_sweep( last_rf_scan = 0 wifi_scan_interval = 15 # Scan WiFi every 15 seconds bt_scan_interval = 20 # Scan Bluetooth every 20 seconds - rf_scan_interval = 60 # Scan RF every 60 seconds (it's slower) + rf_scan_interval = 30 # Scan RF every 30 seconds while _sweep_running and (time.time() - start_time) < duration: current_time = time.time() @@ -1322,7 +1241,15 @@ 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: + logger.info("TSCM: Using unified BT scanner for snapshot") + bt_devices = get_tscm_bluetooth_snapshot(duration=8) + logger.info(f"TSCM: Unified scanner returned {len(bt_devices)} devices") + else: + logger.info(f"TSCM: Using legacy BT scanner on {bt_interface}") + bt_devices = _scan_bluetooth_devices(bt_interface, duration=8) + logger.info(f"TSCM: Legacy scanner returned {len(bt_devices)} devices") for device in bt_devices: mac = device.get('mac', '') if mac and mac not in all_bt: @@ -1373,7 +1300,8 @@ def _run_sweep( }) last_bt_scan = current_time except Exception as e: - logger.error(f"Bluetooth scan error: {e}") + import traceback + logger.error(f"Bluetooth scan error: {e}\n{traceback.format_exc()}") # Perform RF scan using SDR if rf_enabled and (current_time - last_rf_scan) >= rf_scan_interval: @@ -1392,7 +1320,7 @@ def _run_sweep( if not rf_signals and last_rf_scan == 0: _emit_event('rf_status', { 'status': 'no_signals', - 'message': 'RF scan completed but no signals detected. Check RTL-SDR connection.', + 'message': 'RF scan completed - no signals above threshold. This may be normal in a quiet RF environment.', }) for signal in rf_signals: @@ -1472,61 +1400,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 +1476,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 +2393,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/routes/wifi.py b/routes/wifi.py index 0bd4a42..5b833cc 100644 --- a/routes/wifi.py +++ b/routes/wifi.py @@ -1098,3 +1098,318 @@ def stream_wifi(): response.headers['X-Accel-Buffering'] = 'no' response.headers['Connection'] = 'keep-alive' return response + + +# ============================================================================= +# V2 API Endpoints - Using unified WiFi scanner +# ============================================================================= + +from utils.wifi.scanner import get_wifi_scanner, reset_wifi_scanner + + +@wifi_bp.route('/v2/capabilities') +def get_v2_capabilities(): + """Get WiFi scanning capabilities on this system.""" + try: + scanner = get_wifi_scanner() + caps = scanner.check_capabilities() + return jsonify({ + 'platform': caps.platform, + 'is_root': caps.is_root, + 'can_quick_scan': caps.can_quick_scan, + 'can_deep_scan': caps.can_deep_scan, + 'preferred_quick_tool': caps.preferred_quick_tool, + 'interfaces': caps.interfaces, + 'default_interface': caps.default_interface, + 'has_monitor_capable_interface': caps.has_monitor_capable_interface, + 'monitor_interface': caps.monitor_interface, + 'issues': caps.issues, + 'tools': { + 'nmcli': caps.has_nmcli, + 'iw': caps.has_iw, + 'iwlist': caps.has_iwlist, + 'airport': caps.has_airport, + 'airmon_ng': caps.has_airmon_ng, + 'airodump_ng': caps.has_airodump_ng, + }, + }) + except Exception as e: + logger.exception("Error checking capabilities") + return jsonify({'error': str(e)}), 500 + + +@wifi_bp.route('/v2/scan/quick', methods=['POST']) +def v2_quick_scan(): + """Perform a quick one-shot WiFi scan using system tools.""" + try: + data = request.json or {} + interface = data.get('interface') + timeout = data.get('timeout', 10.0) + + scanner = get_wifi_scanner() + result = scanner.quick_scan(interface=interface, timeout=timeout) + + if result.error: + return jsonify({ + 'error': result.error, + 'access_points': [], + 'channel_stats': [], + 'recommendations': [], + }), 200 # Return 200 with error in body for cleaner handling + + return jsonify({ + 'access_points': [ap.to_summary_dict() for ap in result.access_points], + 'channel_stats': [s.to_dict() for s in result.channel_stats], + 'recommendations': [r.to_dict() for r in result.recommendations], + 'duration_seconds': result.duration_seconds, + 'warnings': result.warnings, + }) + except Exception as e: + logger.exception("Error in quick scan") + return jsonify({'error': str(e)}), 500 + + +@wifi_bp.route('/v2/scan/start', methods=['POST']) +def v2_start_scan(): + """Start continuous deep scan with airodump-ng.""" + try: + data = request.json or {} + interface = data.get('interface') + band = data.get('band', 'all') + channel = data.get('channel') + + scanner = get_wifi_scanner() + success = scanner.start_deep_scan(interface=interface, band=band, channel=channel) + + if success: + return jsonify({'status': 'started'}) + else: + status = scanner.get_status() + return jsonify({'error': status.error or 'Failed to start scan'}), 400 + except Exception as e: + logger.exception("Error starting deep scan") + return jsonify({'error': str(e)}), 500 + + +@wifi_bp.route('/v2/scan/stop', methods=['POST']) +def v2_stop_scan(): + """Stop the current scan.""" + try: + scanner = get_wifi_scanner() + scanner.stop_deep_scan() + return jsonify({'status': 'stopped'}) + except Exception as e: + logger.exception("Error stopping scan") + return jsonify({'error': str(e)}), 500 + + +@wifi_bp.route('/v2/scan/status') +def v2_scan_status(): + """Get current scan status.""" + try: + scanner = get_wifi_scanner() + status = scanner.get_status() + return jsonify({ + 'is_scanning': status.is_scanning, + 'scan_mode': status.scan_mode, + 'interface': status.interface, + 'started_at': status.started_at.isoformat() if status.started_at else None, + 'networks_found': status.networks_found, + 'clients_found': status.clients_found, + 'error': status.error, + }) + except Exception as e: + logger.exception("Error getting scan status") + return jsonify({'error': str(e)}), 500 + + +@wifi_bp.route('/v2/networks') +def v2_get_networks(): + """Get all discovered networks.""" + try: + scanner = get_wifi_scanner() + networks = scanner.access_points + return jsonify({ + 'networks': [ap.to_summary_dict() for ap in networks], + 'total': len(networks), + }) + except Exception as e: + logger.exception("Error getting networks") + return jsonify({'error': str(e)}), 500 + + +@wifi_bp.route('/v2/clients') +def v2_get_clients(): + """Get all discovered clients.""" + try: + scanner = get_wifi_scanner() + clients = scanner.clients + return jsonify({ + 'clients': [c.to_dict() for c in clients], + 'total': len(clients), + }) + except Exception as e: + logger.exception("Error getting clients") + return jsonify({'error': str(e)}), 500 + + +@wifi_bp.route('/v2/probes') +def v2_get_probes(): + """Get probe requests.""" + try: + scanner = get_wifi_scanner() + probes = scanner.probe_requests + return jsonify({ + 'probes': [p.to_dict() for p in probes[-100:]], # Last 100 + 'total': len(probes), + }) + except Exception as e: + logger.exception("Error getting probes") + return jsonify({'error': str(e)}), 500 + + +@wifi_bp.route('/v2/channels') +def v2_get_channels(): + """Get channel statistics and recommendations.""" + try: + scanner = get_wifi_scanner() + stats = scanner._calculate_channel_stats() + recommendations = scanner._generate_recommendations(stats) + return jsonify({ + 'channel_stats': [s.to_dict() for s in stats], + 'recommendations': [r.to_dict() for r in recommendations], + }) + except Exception as e: + logger.exception("Error getting channel stats") + return jsonify({'error': str(e)}), 500 + + +@wifi_bp.route('/v2/stream') +def v2_stream(): + """SSE stream for real-time WiFi events.""" + def generate(): + scanner = get_wifi_scanner() + for event in scanner.get_event_stream(): + yield format_sse(event) + + response = Response(generate(), mimetype='text/event-stream') + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + response.headers['Connection'] = 'keep-alive' + return response + + +@wifi_bp.route('/v2/export') +def v2_export(): + """Export scan data as CSV or JSON.""" + try: + format_type = request.args.get('format', 'json') + data_type = request.args.get('type', 'all') + + scanner = get_wifi_scanner() + + if format_type == 'json': + data = {} + if data_type in ('all', 'networks'): + data['networks'] = [ap.to_summary_dict() for ap in scanner.access_points] + if data_type in ('all', 'clients'): + data['clients'] = [c.to_dict() for c in scanner.clients] + if data_type in ('all', 'probes'): + data['probes'] = [p.to_dict() for p in scanner.probe_requests] + + response = Response( + json.dumps(data, indent=2, default=str), + mimetype='application/json', + ) + response.headers['Content-Disposition'] = 'attachment; filename=wifi_scan.json' + return response + + elif format_type == 'csv': + import csv + import io + + output = io.StringIO() + writer = csv.writer(output) + + # Write networks + writer.writerow(['Networks']) + writer.writerow(['BSSID', 'ESSID', 'Channel', 'Band', 'RSSI', 'Security', 'Vendor', 'Clients', 'First Seen', 'Last Seen']) + for ap in scanner.access_points: + writer.writerow([ + ap.bssid, + ap.essid or '[Hidden]', + ap.channel, + ap.band, + ap.rssi_current, + ap.security, + ap.vendor, + ap.client_count, + ap.first_seen.isoformat() if ap.first_seen else '', + ap.last_seen.isoformat() if ap.last_seen else '', + ]) + + writer.writerow([]) + + # Write clients + writer.writerow(['Clients']) + writer.writerow(['MAC', 'BSSID', 'Vendor', 'RSSI', 'Probed SSIDs', 'First Seen', 'Last Seen']) + for c in scanner.clients: + writer.writerow([ + c.mac, + c.associated_bssid or '', + c.vendor, + c.rssi_current, + ', '.join(c.probed_ssids), + c.first_seen.isoformat() if c.first_seen else '', + c.last_seen.isoformat() if c.last_seen else '', + ]) + + response = Response( + output.getvalue(), + mimetype='text/csv', + ) + response.headers['Content-Disposition'] = 'attachment; filename=wifi_scan.csv' + return response + + else: + return jsonify({'error': f'Unknown format: {format_type}'}), 400 + + except Exception as e: + logger.exception("Error exporting data") + return jsonify({'error': str(e)}), 500 + + +@wifi_bp.route('/v2/baseline/set', methods=['POST']) +def v2_set_baseline(): + """Set current networks as baseline.""" + try: + scanner = get_wifi_scanner() + scanner.set_baseline() + return jsonify({'status': 'baseline_set', 'count': len(scanner._baseline_networks)}) + except Exception as e: + logger.exception("Error setting baseline") + return jsonify({'error': str(e)}), 500 + + +@wifi_bp.route('/v2/baseline/clear', methods=['POST']) +def v2_clear_baseline(): + """Clear the baseline.""" + try: + scanner = get_wifi_scanner() + scanner.clear_baseline() + return jsonify({'status': 'baseline_cleared'}) + except Exception as e: + logger.exception("Error clearing baseline") + return jsonify({'error': str(e)}), 500 + + +@wifi_bp.route('/v2/clear', methods=['POST']) +def v2_clear_data(): + """Clear all discovered data.""" + try: + scanner = get_wifi_scanner() + scanner.clear_data() + return jsonify({'status': 'cleared'}) + except Exception as e: + logger.exception("Error clearing data") + return jsonify({'error': str(e)}), 500 diff --git a/routes/wifi_v2.py b/routes/wifi_v2.py new file mode 100644 index 0000000..07dc6fb --- /dev/null +++ b/routes/wifi_v2.py @@ -0,0 +1,516 @@ +""" +WiFi v2 API routes. + +New unified WiFi scanning API with Quick Scan and Deep Scan modes, +channel analysis, hidden SSID correlation, and SSE streaming. +""" + +from __future__ import annotations + +import csv +import io +import json +import logging +from datetime import datetime +from typing import Generator + +from flask import Blueprint, jsonify, request, Response + +from utils.wifi import ( + get_wifi_scanner, + analyze_channels, + get_hidden_correlator, + SCAN_MODE_QUICK, + SCAN_MODE_DEEP, +) +from utils.sse import format_sse + +logger = logging.getLogger(__name__) + +wifi_v2_bp = Blueprint('wifi_v2', __name__, url_prefix='/wifi/v2') + + +# ============================================================================= +# Capabilities +# ============================================================================= + +@wifi_v2_bp.route('/capabilities', methods=['GET']) +def get_capabilities(): + """ + Get WiFi scanning capabilities. + + Returns available tools, interfaces, and scan mode support. + """ + scanner = get_wifi_scanner() + caps = scanner.check_capabilities() + return jsonify(caps.to_dict()) + + +# ============================================================================= +# Quick Scan +# ============================================================================= + +@wifi_v2_bp.route('/scan/quick', methods=['POST']) +def quick_scan(): + """ + Perform a quick one-shot WiFi scan. + + Uses system tools (nmcli, iw, iwlist, airport) without monitor mode. + + Request body: + interface: Optional interface name + timeout: Optional scan timeout in seconds (default 15) + + Returns: + WiFiScanResult with discovered networks and channel analysis. + """ + data = request.get_json() or {} + interface = data.get('interface') + timeout = float(data.get('timeout', 15)) + + scanner = get_wifi_scanner() + result = scanner.quick_scan(interface=interface, timeout=timeout) + + return jsonify(result.to_dict()) + + +# ============================================================================= +# Deep Scan (Monitor Mode) +# ============================================================================= + +@wifi_v2_bp.route('/scan/start', methods=['POST']) +def start_deep_scan(): + """ + Start a deep scan using airodump-ng. + + Requires monitor mode interface and root privileges. + + Request body: + interface: Monitor mode interface (e.g., 'wlan0mon') + band: Band to scan ('2.4', '5', 'all') + channel: Optional specific channel to monitor + """ + data = request.get_json() or {} + interface = data.get('interface') + band = data.get('band', 'all') + channel = data.get('channel') + + if channel: + try: + channel = int(channel) + except ValueError: + return jsonify({'error': 'Invalid channel'}), 400 + + scanner = get_wifi_scanner() + success = scanner.start_deep_scan( + interface=interface, + band=band, + channel=channel, + ) + + if success: + return jsonify({ + 'status': 'started', + 'mode': SCAN_MODE_DEEP, + 'interface': interface or scanner._capabilities.monitor_interface, + }) + else: + return jsonify({ + 'status': 'error', + 'error': scanner._status.error, + }), 400 + + +@wifi_v2_bp.route('/scan/stop', methods=['POST']) +def stop_deep_scan(): + """Stop the deep scan.""" + scanner = get_wifi_scanner() + scanner.stop_deep_scan() + + return jsonify({ + 'status': 'stopped', + }) + + +@wifi_v2_bp.route('/scan/status', methods=['GET']) +def get_scan_status(): + """Get current scan status.""" + scanner = get_wifi_scanner() + status = scanner.get_status() + return jsonify(status.to_dict()) + + +# ============================================================================= +# Data Endpoints +# ============================================================================= + +@wifi_v2_bp.route('/networks', methods=['GET']) +def get_networks(): + """ + Get all discovered networks. + + Query params: + band: Filter by band ('2.4GHz', '5GHz', '6GHz') + security: Filter by security type ('Open', 'WEP', 'WPA', 'WPA2', 'WPA3') + hidden: Filter hidden networks only (true/false) + min_rssi: Minimum RSSI threshold + sort: Sort field ('rssi', 'channel', 'essid', 'last_seen') + order: Sort order ('asc', 'desc') + format: Response format ('full', 'summary') + """ + scanner = get_wifi_scanner() + networks = scanner.access_points + + # Apply filters + band = request.args.get('band') + if band: + networks = [n for n in networks if n.band == band] + + security = request.args.get('security') + if security: + networks = [n for n in networks if n.security == security] + + hidden = request.args.get('hidden') + if hidden == 'true': + networks = [n for n in networks if n.is_hidden] + elif hidden == 'false': + networks = [n for n in networks if not n.is_hidden] + + min_rssi = request.args.get('min_rssi') + if min_rssi: + try: + min_rssi = int(min_rssi) + networks = [n for n in networks if n.rssi_current and n.rssi_current >= min_rssi] + except ValueError: + pass + + # Apply sorting + sort_field = request.args.get('sort', 'rssi') + order = request.args.get('order', 'desc') + reverse = order == 'desc' + + sort_key_map = { + 'rssi': lambda n: n.rssi_current or -100, + 'channel': lambda n: n.channel or 0, + 'essid': lambda n: (n.essid or '').lower(), + 'last_seen': lambda n: n.last_seen, + 'clients': lambda n: n.client_count, + } + + if sort_field in sort_key_map: + networks.sort(key=sort_key_map[sort_field], reverse=reverse) + + # Format output + output_format = request.args.get('format', 'summary') + if output_format == 'full': + return jsonify([n.to_dict() for n in networks]) + else: + return jsonify([n.to_summary_dict() for n in networks]) + + +@wifi_v2_bp.route('/networks/', methods=['GET']) +def get_network(bssid): + """Get a specific network by BSSID.""" + scanner = get_wifi_scanner() + network = scanner.get_network(bssid) + + if network: + return jsonify(network.to_dict()) + else: + return jsonify({'error': 'Network not found'}), 404 + + +@wifi_v2_bp.route('/clients', methods=['GET']) +def get_clients(): + """ + Get all discovered clients. + + Query params: + associated: Filter by association status (true/false) + bssid: Filter by associated BSSID + min_rssi: Minimum RSSI threshold + """ + scanner = get_wifi_scanner() + clients = scanner.clients + + # Apply filters + associated = request.args.get('associated') + if associated == 'true': + clients = [c for c in clients if c.is_associated] + elif associated == 'false': + clients = [c for c in clients if not c.is_associated] + + bssid = request.args.get('bssid') + if bssid: + clients = [c for c in clients if c.associated_bssid == bssid.upper()] + + min_rssi = request.args.get('min_rssi') + if min_rssi: + try: + min_rssi = int(min_rssi) + clients = [c for c in clients if c.rssi_current and c.rssi_current >= min_rssi] + except ValueError: + pass + + return jsonify([c.to_dict() for c in clients]) + + +@wifi_v2_bp.route('/clients/', methods=['GET']) +def get_client(mac): + """Get a specific client by MAC address.""" + scanner = get_wifi_scanner() + client = scanner.get_client(mac) + + if client: + return jsonify(client.to_dict()) + else: + return jsonify({'error': 'Client not found'}), 404 + + +@wifi_v2_bp.route('/probes', methods=['GET']) +def get_probes(): + """ + Get captured probe requests. + + Query params: + client_mac: Filter by client MAC + ssid: Filter by probed SSID + limit: Maximum number of results + """ + scanner = get_wifi_scanner() + probes = scanner.probe_requests + + # Apply filters + client_mac = request.args.get('client_mac') + if client_mac: + probes = [p for p in probes if p.client_mac == client_mac.upper()] + + ssid = request.args.get('ssid') + if ssid: + probes = [p for p in probes if p.probed_ssid == ssid] + + # Apply limit + limit = request.args.get('limit') + if limit: + try: + limit = int(limit) + probes = probes[-limit:] # Most recent + except ValueError: + pass + + return jsonify([p.to_dict() for p in probes]) + + +# ============================================================================= +# Channel Analysis +# ============================================================================= + +@wifi_v2_bp.route('/channels', methods=['GET']) +def get_channel_stats(): + """ + Get channel utilization statistics and recommendations. + + Query params: + include_dfs: Include DFS channels in recommendations (true/false) + """ + scanner = get_wifi_scanner() + include_dfs = request.args.get('include_dfs', 'false') == 'true' + + stats, recommendations = analyze_channels( + scanner.access_points, + include_dfs=include_dfs, + ) + + return jsonify({ + 'stats': [s.to_dict() for s in stats], + 'recommendations': [r.to_dict() for r in recommendations], + }) + + +# ============================================================================= +# Hidden SSID Correlation +# ============================================================================= + +@wifi_v2_bp.route('/hidden', methods=['GET']) +def get_hidden_correlations(): + """ + Get revealed hidden SSIDs from correlation. + + Returns mapping of BSSID -> revealed SSID. + """ + correlator = get_hidden_correlator() + return jsonify(correlator.get_all_revealed()) + + +# ============================================================================= +# Baseline Management +# ============================================================================= + +@wifi_v2_bp.route('/baseline/set', methods=['POST']) +def set_baseline(): + """Mark current networks as baseline (known networks).""" + scanner = get_wifi_scanner() + scanner.set_baseline() + + return jsonify({ + 'status': 'baseline_set', + 'network_count': len(scanner._baseline_networks), + 'set_at': datetime.now().isoformat(), + }) + + +@wifi_v2_bp.route('/baseline/clear', methods=['POST']) +def clear_baseline(): + """Clear the baseline.""" + scanner = get_wifi_scanner() + scanner.clear_baseline() + + return jsonify({ + 'status': 'baseline_cleared', + }) + + +# ============================================================================= +# SSE Streaming +# ============================================================================= + +@wifi_v2_bp.route('/stream', methods=['GET']) +def event_stream(): + """ + Server-Sent Events stream for real-time updates. + + Events: + - network_update: Network discovered/updated + - client_update: Client discovered/updated + - probe_request: Probe request detected + - hidden_revealed: Hidden SSID revealed + - scan_started, scan_stopped, scan_error + - keepalive: Periodic keepalive + """ + def generate() -> Generator[str, None, None]: + scanner = get_wifi_scanner() + + for event in scanner.get_event_stream(): + yield format_sse(event) + + response = Response(generate(), mimetype='text/event-stream') + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + return response + + +# ============================================================================= +# Data Management +# ============================================================================= + +@wifi_v2_bp.route('/clear', methods=['POST']) +def clear_data(): + """Clear all discovered data.""" + scanner = get_wifi_scanner() + scanner.clear_data() + + return jsonify({ + 'status': 'cleared', + }) + + +# ============================================================================= +# Export +# ============================================================================= + +@wifi_v2_bp.route('/export', methods=['GET']) +def export_data(): + """ + Export scan data. + + Query params: + format: 'json' or 'csv' (default: json) + type: 'networks', 'clients', 'probes', 'all' (default: all) + """ + scanner = get_wifi_scanner() + export_format = request.args.get('format', 'json') + export_type = request.args.get('type', 'all') + + if export_format == 'csv': + return _export_csv(scanner, export_type) + else: + return _export_json(scanner, export_type) + + +def _export_json(scanner, export_type: str) -> Response: + """Export data as JSON.""" + data = {} + + if export_type in ('networks', 'all'): + data['networks'] = [n.to_dict() for n in scanner.access_points] + + if export_type in ('clients', 'all'): + data['clients'] = [c.to_dict() for c in scanner.clients] + + if export_type in ('probes', 'all'): + data['probes'] = [p.to_dict() for p in scanner.probe_requests] + + data['exported_at'] = datetime.now().isoformat() + data['network_count'] = len(scanner.access_points) + data['client_count'] = len(scanner.clients) + + response = Response( + json.dumps(data, indent=2), + mimetype='application/json', + ) + response.headers['Content-Disposition'] = f'attachment; filename=wifi_scan_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json' + return response + + +def _export_csv(scanner, export_type: str) -> Response: + """Export data as CSV.""" + output = io.StringIO() + + if export_type in ('networks', 'all'): + writer = csv.writer(output) + writer.writerow([ + 'BSSID', 'ESSID', 'Channel', 'Band', 'RSSI', 'Security', + 'Cipher', 'Auth', 'Vendor', 'Clients', 'First Seen', 'Last Seen' + ]) + + for n in scanner.access_points: + writer.writerow([ + n.bssid, + n.essid or '[Hidden]', + n.channel, + n.band, + n.rssi_current, + n.security, + n.cipher, + n.auth, + n.vendor or '', + n.client_count, + n.first_seen.isoformat(), + n.last_seen.isoformat(), + ]) + + if export_type == 'all': + writer.writerow([]) # Blank line separator + + if export_type in ('clients', 'all'): + writer = csv.writer(output) + if export_type == 'clients': + writer.writerow([ + 'MAC', 'Vendor', 'RSSI', 'Associated BSSID', 'Probed SSIDs', + 'First Seen', 'Last Seen' + ]) + + for c in scanner.clients: + writer.writerow([ + c.mac, + c.vendor or '', + c.rssi_current, + c.associated_bssid or '', + ', '.join(c.probed_ssids), + c.first_seen.isoformat(), + c.last_seen.isoformat(), + ]) + + response = Response(output.getvalue(), mimetype='text/csv') + response.headers['Content-Disposition'] = f'attachment; filename=wifi_scan_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv' + return response diff --git a/static/css/components/device-cards.css b/static/css/components/device-cards.css new file mode 100644 index 0000000..26bdf07 --- /dev/null +++ b/static/css/components/device-cards.css @@ -0,0 +1,879 @@ +/** + * 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; + } +} + +/* ============================================ + BLUETOOTH DEVICE LIST CONTAINER + ============================================ */ +#btDeviceListContent { + display: block !important; + padding: 10px !important; + overflow-y: auto !important; + overflow-x: hidden !important; +} + +/* Pure inline-styled cards - ensure no interference */ +#btDeviceListContent > div[data-bt-device-id] { + display: block !important; + visibility: visible !important; + opacity: 1 !important; + height: auto !important; + min-height: auto !important; + overflow: visible !important; +} + +/* Legacy card support */ +#btDeviceListContent .device-card, +#btDeviceListContent .signal-card { + margin: 0 0 10px 0; + height: auto !important; + min-height: auto !important; + overflow: visible !important; +} + +/* Ensure card body is visible */ +.device-card .signal-card-body, +.signal-card .signal-card-body { + display: flex !important; + flex-direction: column !important; + gap: 8px !important; + visibility: visible !important; + opacity: 1 !important; + height: auto !important; + overflow: visible !important; +} + +.device-card .device-identity, +.signal-card .device-identity { + display: block !important; + visibility: visible !important; +} + +.device-card .device-signal-row, +.signal-card .device-signal-row { + display: flex !important; + visibility: visible !important; +} + +.device-card .device-meta-row, +.signal-card .device-meta-row { + display: flex !important; + visibility: visible !important; +} + +/* ============================================ + ENHANCED MODAL STYLES + ============================================ */ +.signal-details-modal-header .modal-header-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.signal-details-modal-subtitle { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + color: var(--text-dim, #666); +} + +.signal-details-modal-footer { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.signal-details-copy-addr-btn { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + padding: 8px 16px; + background: var(--bg-secondary, #252525); + border: 1px solid var(--border-color, #333); + border-radius: 4px; + color: var(--text-secondary, #888); + cursor: pointer; + transition: all 0.15s ease; +} + +.signal-details-copy-addr-btn:hover { + background: var(--bg-tertiary, #1a1a1a); + color: var(--text-primary, #e0e0e0); +} + +/* Modal Header Section */ +.modal-device-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 16px; + margin-bottom: 16px; + border-bottom: 1px solid var(--border-color, #333); +} + +.modal-badges { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +/* Modal Sections */ +.modal-section { + margin-bottom: 20px; +} + +.modal-section:last-child { + margin-bottom: 0; +} + +.modal-section-title { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-dim, #666); + margin-bottom: 12px; +} + +/* Signal Display */ +.modal-signal-display { + display: flex; + align-items: center; + gap: 24px; + padding: 16px; + background: var(--bg-secondary, #1a1a1a); + border-radius: 8px; + margin-bottom: 12px; +} + +.modal-rssi-large { + font-family: 'JetBrains Mono', monospace; + font-size: 36px; + font-weight: 700; + color: var(--accent-cyan, #00d4ff); + line-height: 1; +} + +.modal-rssi-large .rssi-unit { + font-size: 14px; + font-weight: 400; + color: var(--text-dim, #666); + margin-left: 4px; +} + +.modal-sparkline { + flex: 1; + display: flex; + justify-content: flex-end; +} + +/* Signal Stats Grid */ +.modal-signal-stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; +} + +.modal-signal-stats .stat-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 10px; + background: var(--bg-secondary, #1a1a1a); + border-radius: 6px; + text-align: center; +} + +.modal-signal-stats .stat-label { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-dim, #666); + margin-bottom: 4px; +} + +.modal-signal-stats .stat-value { + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + font-weight: 600; + color: var(--text-primary, #e0e0e0); +} + +/* Info Grid */ +.modal-info-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; +} + +.modal-info-grid .info-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 12px; + background: var(--bg-secondary, #1a1a1a); + border-radius: 6px; +} + +.modal-info-grid .info-label { + font-size: 11px; + color: var(--text-dim, #666); +} + +.modal-info-grid .info-value { + font-size: 12px; + font-weight: 500; + color: var(--text-primary, #e0e0e0); +} + +.modal-info-grid .info-value.mono { + font-family: 'JetBrains Mono', monospace; + color: var(--accent-cyan, #00d4ff); +} + +/* UUID List */ +.modal-uuid-list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.modal-uuid { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + padding: 4px 8px; + background: var(--bg-secondary, #1a1a1a); + border: 1px solid var(--border-color, #333); + border-radius: 4px; + color: var(--text-secondary, #888); +} + +/* Heuristics Grid */ +.modal-heuristics-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 8px; +} + +.heuristic-check { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + background: var(--bg-secondary, #1a1a1a); + border-radius: 6px; + border: 1px solid var(--border-color, #333); +} + +.heuristic-check.active { + background: rgba(34, 197, 94, 0.1); + border-color: rgba(34, 197, 94, 0.3); +} + +.heuristic-indicator { + font-family: 'JetBrains Mono', monospace; + font-size: 14px; + font-weight: 600; + color: var(--text-dim, #666); +} + +.heuristic-check.active .heuristic-indicator { + color: var(--accent-green, #22c55e); +} + +.heuristic-label { + font-size: 11px; + text-transform: capitalize; + color: var(--text-secondary, #888); +} + +/* ============================================ + RESPONSIVE MODAL + ============================================ */ +@media (max-width: 600px) { + .modal-signal-stats { + grid-template-columns: repeat(2, 1fr); + } + + .modal-info-grid { + grid-template-columns: 1fr; + } + + .modal-signal-display { + flex-direction: column; + align-items: flex-start; + gap: 16px; + } + + .modal-sparkline { + width: 100%; + justify-content: center; + } + + .modal-device-header { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } +} + +/* ============================================ + DARK MODE OVERRIDES (if needed) + ============================================ */ +@media (prefers-color-scheme: dark) { + .device-card { + --bg-secondary: #1a1a1a; + --bg-tertiary: #141414; + } +} diff --git a/static/css/components/proximity-viz.css b/static/css/components/proximity-viz.css new file mode 100644 index 0000000..a99a8b5 --- /dev/null +++ b/static/css/components/proximity-viz.css @@ -0,0 +1,287 @@ +/** + * Proximity Visualization Components + * Styles for radar and timeline heatmap + */ + +/* ============================================ + PROXIMITY RADAR + ============================================ */ + +.proximity-radar-svg { + display: block; + margin: 0 auto; +} + +.radar-device { + transition: transform 0.2s ease; +} + +.radar-device:hover { + transform: scale(1.3); +} + +.radar-dot-pulse circle:first-child { + animation: radar-pulse 1.5s ease-out infinite; +} + +@keyframes radar-pulse { + 0% { + transform: scale(1); + opacity: 1; + } + 100% { + transform: scale(2); + opacity: 0; + } +} + +.radar-sweep { + transform-origin: 50% 50%; +} + +/* Radar filter buttons */ +.bt-radar-filter-btn { + transition: all 0.2s ease; +} + +.bt-radar-filter-btn:hover { + background: var(--bg-hover, #333) !important; + color: #fff !important; +} + +.bt-radar-filter-btn.active { + background: #00d4ff !important; + color: #000 !important; + border-color: #00d4ff !important; +} + +#btRadarPauseBtn.active { + background: #f97316 !important; + color: #000 !important; + border-color: #f97316 !important; +} + +/* ============================================ + TIMELINE HEATMAP + ============================================ */ + +.timeline-heatmap-controls { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + padding: 8px 0; + margin-bottom: 8px; + border-bottom: 1px solid var(--border-color, #333); +} + +.heatmap-control-group { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--text-dim, #888); +} + +.heatmap-select { + background: var(--bg-tertiary, #1a1a1a); + border: 1px solid var(--border-color, #333); + border-radius: 4px; + color: var(--text-primary, #e0e0e0); + font-size: 10px; + padding: 4px 8px; + cursor: pointer; +} + +.heatmap-select:hover { + border-color: var(--accent-color, #00d4ff); +} + +.heatmap-btn { + background: var(--bg-tertiary, #1a1a1a); + border: 1px solid var(--border-color, #333); + border-radius: 4px; + color: var(--text-dim, #888); + font-size: 10px; + padding: 4px 12px; + cursor: pointer; + transition: all 0.2s ease; +} + +.heatmap-btn:hover { + background: var(--bg-hover, #252525); + color: var(--text-primary, #e0e0e0); +} + +.heatmap-btn.active { + background: #f97316; + color: #000; + border-color: #f97316; +} + +.timeline-heatmap-content { + max-height: 300px; + overflow-y: auto; + overflow-x: auto; +} + +.heatmap-loading, +.heatmap-empty, +.heatmap-error { + color: var(--text-dim, #666); + text-align: center; + padding: 30px; + font-size: 12px; +} + +.heatmap-error { + color: #ef4444; +} + +.heatmap-grid { + display: flex; + flex-direction: column; + gap: 2px; + min-width: max-content; +} + +.heatmap-row { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 0; + cursor: pointer; + border-radius: 4px; + transition: background 0.2s ease; +} + +.heatmap-row:hover:not(.heatmap-header) { + background: rgba(255, 255, 255, 0.05); +} + +.heatmap-row.selected { + background: rgba(0, 212, 255, 0.1); + outline: 1px solid rgba(0, 212, 255, 0.3); +} + +.heatmap-header { + cursor: default; + border-bottom: 1px solid var(--border-color, #333); + margin-bottom: 4px; +} + +.heatmap-label { + width: 120px; + min-width: 120px; + display: flex; + flex-direction: column; + gap: 2px; + padding-right: 8px; + overflow: hidden; +} + +.heatmap-label .device-name { + font-size: 10px; + color: var(--text-primary, #e0e0e0); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.heatmap-label .device-rssi { + font-size: 9px; + color: var(--text-dim, #666); + font-family: monospace; +} + +.heatmap-cells { + display: flex; + gap: 1px; +} + +.heatmap-cell { + border-radius: 2px; + transition: transform 0.1s ease; +} + +.heatmap-cell:hover { + transform: scale(1.5); + z-index: 10; + position: relative; +} + +.heatmap-time-label { + font-size: 8px; + color: var(--text-dim, #666); + text-align: center; + transform: rotate(-45deg); + white-space: nowrap; +} + +.heatmap-legend { + display: flex; + align-items: center; + gap: 12px; + padding-top: 8px; + margin-top: 8px; + border-top: 1px solid var(--border-color, #333); + font-size: 10px; + color: var(--text-dim, #666); +} + +.legend-label { + font-weight: 500; +} + +.legend-item { + display: flex; + align-items: center; + gap: 4px; +} + +.legend-color { + width: 12px; + height: 12px; + border-radius: 2px; +} + +/* ============================================ + ZONE SUMMARY + ============================================ */ + +#btZoneSummary { + padding: 8px 0; +} + +#btZoneSummary > div { + min-width: 60px; +} + +/* ============================================ + RESPONSIVE ADJUSTMENTS + ============================================ */ + +@media (max-width: 768px) { + .timeline-heatmap-controls { + flex-direction: column; + align-items: stretch; + } + + .heatmap-control-group { + justify-content: space-between; + } + + .proximity-radar-svg { + max-width: 100%; + height: auto; + } + + #btRadarControls { + flex-direction: column; + gap: 4px; + } + + #btZoneSummary { + flex-wrap: wrap; + } +} diff --git a/static/css/index.css b/static/css/index.css index 35d8cf6..686aeb6 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -3143,120 +3143,521 @@ header h1 .tagline { background: var(--text-dim); } -/* WiFi Layout Container - side by side layout */ +/* WiFi Layout Container - 3-column layout */ .wifi-layout-container { display: flex; - gap: 15px; + flex-direction: column; + gap: 10px; padding: 15px; background: var(--bg-secondary); margin: 0 15px 10px 15px; border: 1px solid var(--border-color); - height: calc(100vh - 200px); /* Take most of the available height */ + height: calc(100vh - 200px); min-height: 400px; -} - -/* WiFi Visualizations */ -.wifi-visuals { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; - flex: 1; - overflow-y: auto; - padding-right: 10px; -} - -/* WiFi Device List (right column) */ -.wifi-device-list { - width: 350px; - min-width: 300px; - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 4px; - display: flex; - flex-direction: column; + box-sizing: border-box; overflow: hidden; } -.wifi-device-list-header { +/* WiFi Status Bar */ +.wifi-status-bar { + display: flex; + gap: 20px; + padding: 8px 12px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 11px; +} + +.wifi-status-item { + display: flex; + align-items: center; + gap: 6px; +} + +.wifi-status-label { + color: var(--text-dim); +} + +.wifi-status-value { + color: var(--accent-cyan); + font-weight: 600; +} + +.wifi-status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background: #666; +} + +.wifi-status-indicator.idle { background: #666; } +.wifi-status-indicator.scanning { background: var(--accent-green); animation: pulse 1s infinite; } +.wifi-status-indicator.error { background: var(--accent-red); } + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* WiFi Main Content - 3 columns */ +.wifi-main-content { + display: grid; + grid-template-columns: 1fr minmax(240px, 280px) minmax(240px, 280px); + gap: 10px; + flex: 1; + min-height: 0; + overflow: auto; +} + +/* WiFi Networks Panel (LEFT) */ +.wifi-networks-panel { + display: flex; + flex-direction: column; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + overflow: hidden; +} + +.wifi-networks-header { padding: 10px 12px; background: var(--bg-tertiary); border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; + flex-wrap: wrap; + gap: 8px; } -.wifi-device-list-header h5 { +.wifi-networks-header h5 { margin: 0; color: var(--accent-cyan); font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; } -.wifi-device-list-header .device-count { +.wifi-network-filters { + display: flex; + gap: 4px; +} + +.wifi-filter-btn { + padding: 4px 8px; + font-size: 10px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 3px; color: var(--text-dim); + cursor: pointer; + transition: all 0.2s; +} + +.wifi-filter-btn:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.wifi-filter-btn.active { + background: var(--accent-cyan); + color: #000; + border-color: var(--accent-cyan); +} + +.wifi-networks-table-wrapper { + flex: 1; + overflow-y: auto; +} + +.wifi-networks-table { + width: 100%; + border-collapse: collapse; font-size: 11px; } -.wifi-device-list-content { +.wifi-networks-table thead { + position: sticky; + top: 0; + background: var(--bg-tertiary); + z-index: 1; +} + +.wifi-networks-table th { + padding: 8px 10px; + text-align: left; + color: var(--text-dim); + font-weight: 500; + border-bottom: 1px solid var(--border-color); + white-space: nowrap; + cursor: pointer; +} + +.wifi-networks-table th:hover { + color: var(--accent-cyan); +} + +.wifi-networks-table th.sortable::after { + content: ' \2195'; + opacity: 0.3; +} + +.wifi-networks-table td { + padding: 8px 10px; + border-bottom: 1px solid var(--border-color); + vertical-align: middle; +} + +.wifi-network-row { + cursor: pointer; + transition: background 0.15s; +} + +.wifi-network-row:hover { + background: rgba(0, 255, 255, 0.05); +} + +.wifi-network-row.selected { + background: rgba(0, 255, 255, 0.1); + border-left: 2px solid var(--accent-cyan); +} + +.wifi-network-row .essid { + font-weight: 500; + color: var(--text-primary); +} + +.wifi-network-row .badge { + display: inline-block; + padding: 2px 5px; + font-size: 9px; + border-radius: 3px; + margin-left: 6px; +} + +.wifi-network-row .badge-hidden { + background: rgba(255, 165, 0, 0.2); + color: var(--accent-orange); +} + +.wifi-network-row .badge-new { + background: rgba(0, 255, 0, 0.2); + color: var(--accent-green); +} + +.wifi-network-row .rssi-value { + font-family: monospace; + font-weight: 600; +} + +.wifi-network-row .rssi-value.signal-strong { color: var(--accent-green); } +.wifi-network-row .rssi-value.signal-medium { color: var(--accent-yellow); } +.wifi-network-row .rssi-value.signal-weak { color: var(--accent-orange); } +.wifi-network-row .rssi-value.signal-very-weak { color: var(--accent-red); } + +.wifi-network-row .security-badge { + display: inline-block; + padding: 2px 6px; + font-size: 9px; + border-radius: 3px; +} + +.wifi-network-row .security-badge.security-wpa3 { background: rgba(0, 255, 0, 0.15); color: var(--accent-green); } +.wifi-network-row .security-badge.security-wpa { background: rgba(0, 255, 255, 0.15); color: var(--accent-cyan); } +.wifi-network-row .security-badge.security-wep { background: rgba(255, 165, 0, 0.15); color: var(--accent-orange); } +.wifi-network-row .security-badge.security-open { background: rgba(255, 0, 0, 0.15); color: var(--accent-red); } + +.wifi-network-placeholder td { + text-align: center; + padding: 40px 20px; +} + +.wifi-network-placeholder .placeholder-text { + color: var(--text-dim); +} + +/* WiFi Radar Panel (CENTER) */ +.wifi-radar-panel { + display: flex; + flex-direction: column; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 12px; +} + +.wifi-radar-panel h5 { + margin: 0 0 10px 0; + color: var(--accent-cyan); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.wifi-radar-container { flex: 1; - overflow-y: auto; - padding: 10px; + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; } -/* WiFi network cards in device list - more compact */ -.wifi-network-card { - margin-bottom: 8px; - padding: 10px !important; +.wifi-zone-summary { + display: flex; + justify-content: center; + gap: 20px; + padding-top: 12px; + border-top: 1px solid var(--border-color); + margin-top: 12px; } -.wifi-network-card .header { - font-size: 12px; +.wifi-zone { + text-align: center; } -/* WiFi client cards in device list */ -.wifi-client-card { - margin-bottom: 8px; - padding: 10px !important; - border-left-color: var(--accent-purple) !important; - background: rgba(153, 51, 255, 0.05); +.wifi-zone-count { + display: block; + font-size: 20px; + font-weight: 600; + line-height: 1; } -.wifi-client-card .header { - font-size: 12px; -} - -.wifi-client-card .sensor-data { +.wifi-zone-label { font-size: 10px; + color: var(--text-dim); } -.wifi-network-card .sensor-data { +.wifi-zone.near .wifi-zone-count { color: var(--accent-green); } +.wifi-zone.mid .wifi-zone-count { color: var(--accent-yellow); } +.wifi-zone.far .wifi-zone-count { color: var(--accent-red); } + +/* WiFi Analysis Panel (RIGHT) */ +.wifi-analysis-panel { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; + overflow: hidden; +} + +.wifi-channel-section, +.wifi-security-section { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 12px; +} + +.wifi-channel-section { + flex: 1; +} + +.wifi-channel-section h5, +.wifi-security-section h5 { + margin: 0 0 10px 0; + color: var(--accent-cyan); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.wifi-channel-tabs { + display: flex; + gap: 4px; + margin-bottom: 10px; +} + +.channel-band-tab { + flex: 1; + padding: 6px 10px; font-size: 10px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-dim); + cursor: pointer; + transition: all 0.2s; } -.wifi-network-card .preset-btn { - font-size: 9px !important; - padding: 3px 6px !important; +.channel-band-tab:hover { + background: var(--bg-secondary); +} + +.channel-band-tab.active { + background: var(--accent-cyan); + color: #000; + border-color: var(--accent-cyan); +} + +.wifi-channel-chart { + min-height: 120px; + overflow-x: auto; + overflow-y: hidden; +} + +.wifi-security-stats { + display: flex; + flex-direction: column; + gap: 6px; +} + +.wifi-security-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; +} + +.wifi-security-dot { + width: 10px; + height: 10px; + border-radius: 2px; +} + +.wifi-security-item.wpa3 .wifi-security-dot { background: var(--accent-green); } +.wifi-security-item.wpa2 .wifi-security-dot { background: var(--accent-cyan); } +.wifi-security-item.wep .wifi-security-dot { background: var(--accent-orange); } +.wifi-security-item.open .wifi-security-dot { background: var(--accent-red); } + +.wifi-security-count { + margin-left: auto; + font-weight: 600; + color: var(--text-primary); +} + +/* WiFi Detail Drawer */ +.wifi-detail-drawer { + display: none; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + overflow: hidden; +} + +.wifi-detail-drawer.open { + display: block; +} + +.wifi-detail-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 15px; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-color); +} + +.wifi-detail-title { + display: flex; + flex-direction: column; + gap: 2px; +} + +.wifi-detail-essid { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.wifi-detail-bssid { + font-size: 11px; + font-family: monospace; + color: var(--text-dim); +} + +.wifi-detail-close { + background: none; + border: none; + color: var(--text-dim); + font-size: 20px; + cursor: pointer; + padding: 0 5px; +} + +.wifi-detail-close:hover { + color: var(--accent-red); +} + +.wifi-detail-content { + padding: 15px; +} + +.wifi-detail-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 15px; +} + +.wifi-detail-stat { + display: flex; + flex-direction: column; + gap: 3px; +} + +.wifi-detail-stat .label { + font-size: 10px; + color: var(--text-dim); + text-transform: uppercase; +} + +.wifi-detail-stat .value { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); +} + +.wifi-detail-clients { + margin-top: 15px; + padding-top: 15px; + border-top: 1px solid var(--border-color); +} + +.wifi-detail-clients h6 { + margin: 0 0 10px 0; + font-size: 11px; + color: var(--accent-cyan); + text-transform: uppercase; +} + +/* WiFi Responsive */ +@media (max-width: 1400px) { + .wifi-main-content { + grid-template-columns: 1fr 240px 240px; + } } @media (max-width: 1200px) { .wifi-layout-container { - flex-direction: column; height: auto; max-height: calc(100vh - 200px); } - .wifi-visuals { - grid-template-columns: 1fr; - max-height: 50vh; + .wifi-main-content { + grid-template-columns: 1fr 1fr; + grid-template-rows: auto auto; } - .wifi-device-list { - width: 100%; - min-width: auto; + .wifi-networks-panel { + grid-column: span 2; max-height: 300px; } } +@media (max-width: 768px) { + .wifi-main-content { + grid-template-columns: 1fr; + } + + .wifi-networks-panel { + grid-column: span 1; + } + + .wifi-detail-grid { + grid-template-columns: repeat(2, 1fr); + } +} + /* Bluetooth Layout Container */ .bt-layout-container { display: flex; @@ -3269,8 +3670,256 @@ header h1 .tagline { min-height: 400px; } -.bt-layout-container .wifi-visuals { +.bt-visuals-column { flex: 1; + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; +} + +.bt-main-area { + display: flex; + gap: 12px; + flex: 1; + min-height: 0; +} + +.bt-side-panels { + display: flex; + flex-direction: column; + gap: 12px; + width: 220px; + flex-shrink: 0; +} + +.bt-side-panel { + flex: 1; + min-height: 0; + overflow: hidden; +} + +.bt-radar-panel { + flex: 1; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.bt-radar-panel #btProximityRadar { + flex: 1; + min-height: 0; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} + +/* Bluetooth Device Detail Panel */ +.bt-detail-panel { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 6px; + flex-shrink: 0; + height: 140px; + overflow: hidden; +} + +.bt-detail-panel .bt-detail-header { + padding: 6px 10px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-secondary); +} + +.bt-detail-panel .bt-detail-header h5 { + margin: 0; + font-size: 11px; + font-weight: 600; + color: var(--accent-cyan); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.bt-detail-body { + padding: 8px 10px; + height: calc(100% - 30px); +} + +.bt-detail-placeholder { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-dim); + font-size: 11px; +} + +.bt-detail-content { + height: 100%; +} + +.bt-detail-top-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 6px; +} + +.bt-detail-identity { + min-width: 0; + flex: 1; +} + +.bt-detail-name { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.bt-detail-address { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: #00d4ff; +} + +.bt-detail-rssi-display { + display: flex; + align-items: baseline; + gap: 2px; + flex-shrink: 0; + margin-left: 12px; +} + +.bt-detail-rssi-value { + font-family: 'JetBrains Mono', monospace; + font-size: 20px; + font-weight: 700; +} + +.bt-detail-rssi-unit { + font-size: 10px; + color: var(--text-dim); +} + +.bt-detail-badges { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 6px; +} + +.bt-detail-badge { + padding: 2px 6px; + border-radius: 3px; + font-size: 8px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.bt-detail-badge.ble { + background: rgba(59, 130, 246, 0.2); + color: #3b82f6; +} + +.bt-detail-badge.classic { + background: rgba(139, 92, 246, 0.2); + color: #8b5cf6; +} + +.bt-detail-badge.new { + background: rgba(59, 130, 246, 0.2); + color: #3b82f6; +} + +.bt-detail-badge.baseline { + background: rgba(34, 197, 94, 0.2); + color: #22c55e; +} + +.bt-detail-badge.flag { + background: rgba(107, 114, 128, 0.2); + color: #9ca3af; +} + +.bt-detail-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 4px; + margin-bottom: 6px; +} + +.bt-detail-stat { + background: var(--bg-secondary); + padding: 4px 6px; + border-radius: 3px; + min-width: 0; +} + +.bt-detail-stat-label { + display: block; + font-size: 8px; + color: var(--text-dim); + text-transform: uppercase; +} + +.bt-detail-stat-value { + display: block; + font-size: 10px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.bt-detail-bottom-row { + display: flex; + justify-content: space-between; + align-items: center; +} + +.bt-detail-services { + flex: 1; + min-width: 0; + overflow: hidden; +} + +.bt-detail-services-list { + font-family: 'JetBrains Mono', monospace; + font-size: 8px; + color: var(--text-dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.bt-detail-btn { + padding: 4px 10px; + font-size: 10px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 3px; + color: var(--text-primary); + cursor: pointer; + transition: all 0.2s; + flex-shrink: 0; +} + +.bt-detail-btn:hover { + background: var(--accent-cyan); + border-color: var(--accent-cyan); + color: #000; +} + +/* Selected device highlight */ +.bt-device-row.selected { + background: rgba(0, 212, 255, 0.1); + border-color: var(--accent-cyan); } .bt-device-list { @@ -3281,100 +3930,419 @@ header h1 .tagline { color: var(--accent-purple); } -/* Bluetooth Device Type Overview */ -.bt-type-overview { +/* Bluetooth Device Filters */ +.bt-device-filters { display: flex; - flex-direction: column; gap: 6px; + padding: 8px 12px; + border-bottom: 1px solid var(--border-color); + flex-wrap: wrap; +} + +.bt-filter-btn { + padding: 5px 12px; font-size: 11px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-dim); + cursor: pointer; + transition: all 0.2s ease; } -.bt-type-item { - display: flex; - align-items: center; - gap: 8px; - padding: 4px 8px; - background: rgba(0,0,0,0.2); - border-radius: 3px; +.bt-filter-btn:hover { + background: var(--bg-secondary); + color: var(--text-primary); } -.bt-type-icon { - font-size: 14px; +.bt-filter-btn.active { + background: var(--accent-purple); + border-color: var(--accent-purple); + color: white; } /* Bluetooth Signal Distribution */ .bt-signal-dist { display: flex; flex-direction: column; - gap: 8px; - font-size: 10px; + gap: 12px; + font-size: 11px; + padding: 4px 0; } .signal-range { display: flex; align-items: center; - gap: 8px; + gap: 10px; } .signal-range span:first-child { - width: 70px; + width: 80px; color: var(--text-dim); + font-size: 10px; } .signal-range span:last-child { - width: 20px; + width: 28px; text-align: right; + font-weight: 600; + font-size: 12px; } .signal-bar-bg { flex: 1; - height: 8px; + height: 16px; background: var(--bg-tertiary); border-radius: 4px; overflow: hidden; } -.signal-bar { +.bt-signal-dist .signal-bar { + width: auto; height: 100%; border-radius: 4px; transition: width 0.3s ease; } -.signal-bar.strong { - background: var(--accent-green); +.bt-signal-dist .signal-bar.strong { + background: linear-gradient(90deg, #22c55e, #16a34a); } -.signal-bar.medium { - background: var(--accent-orange); +.bt-signal-dist .signal-bar.medium { + background: linear-gradient(90deg, #eab308, #ca8a04); } -.signal-bar.weak { - background: var(--accent-red); +.bt-signal-dist .signal-bar.weak { + background: linear-gradient(90deg, #ef4444, #dc2626); } -/* Bluetooth Device Cards */ -.bt-device-card { - margin-bottom: 8px; - padding: 10px !important; - border-left-color: var(--accent-purple) !important; +/* Bluetooth Device Row - Compact Design */ +.bt-device-row { + display: flex; + flex-direction: column; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-left: 4px solid #666; + border-radius: 6px; + padding: 10px 12px; + margin-bottom: 6px; + cursor: pointer; + transition: all 0.15s ease; } -.bt-device-card .header { - font-size: 12px; +.bt-device-row:hover { + background: rgba(0, 212, 255, 0.05); + border-color: var(--accent-cyan); } -.bt-device-card .sensor-data { +.bt-row-main { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.bt-row-left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1; +} + +.bt-row-right { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.bt-proto-badge { + display: inline-block; + padding: 2px 6px; + border-radius: 3px; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.3px; + flex-shrink: 0; +} + +.bt-proto-badge.ble { + background: rgba(59, 130, 246, 0.2); + color: #3b82f6; + border: 1px solid rgba(59, 130, 246, 0.3); +} + +.bt-proto-badge.classic { + background: rgba(139, 92, 246, 0.2); + color: #8b5cf6; + border: 1px solid rgba(139, 92, 246, 0.3); +} + +.bt-device-name { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.bt-rssi-container { + display: flex; + align-items: center; + gap: 6px; +} + +.bt-rssi-bar-bg { + width: 50px; + height: 8px; + background: var(--bg-secondary); + border-radius: 4px; + overflow: hidden; +} + +.bt-rssi-bar { + height: 100%; + border-radius: 4px; + transition: width 0.3s ease; +} + +.bt-rssi-value { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + font-weight: 600; + min-width: 28px; + text-align: right; +} + +.bt-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.bt-status-dot.new { + background: #3b82f6; + box-shadow: 0 0 6px rgba(59, 130, 246, 0.5); +} + +.bt-status-dot.known { + background: #22c55e; +} + +.bt-row-secondary { font-size: 10px; + color: var(--text-dim); + margin-top: 4px; + padding-left: 42px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.bt-device-card.tracker { - border-left-color: var(--accent-orange) !important; - background: rgba(255, 165, 0, 0.05); +/* Bluetooth Device Modal */ +.bt-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + backdrop-filter: blur(4px); } -.bt-device-card.findmy { - border-left-color: #007aff !important; - background: rgba(0, 122, 255, 0.05); +.bt-modal { + background: var(--bg-secondary, #1a1a2e); + border: 1px solid var(--border-color, #333); + border-radius: 12px; + width: 90%; + max-width: 500px; + max-height: 85vh; + overflow: hidden; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} + +.bt-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--border-color, #333); + background: var(--bg-tertiary, #141428); +} + +.bt-modal-header h4 { + margin: 0; + color: var(--text-primary, #e0e0e0); + font-size: 16px; + font-weight: 600; +} + +.bt-modal-close { + background: none; + border: none; + color: var(--text-dim, #666); + font-size: 24px; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + transition: all 0.2s; +} + +.bt-modal-close:hover { + background: rgba(255, 255, 255, 0.1); + color: var(--text-primary, #e0e0e0); +} + +.bt-modal-body { + padding: 20px; + overflow-y: auto; + max-height: calc(85vh - 60px); +} + +.bt-modal-section { + margin-bottom: 16px; +} + +.bt-modal-section:last-child { + margin-bottom: 0; +} + +.bt-modal-section-title { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-dim, #666); + margin-bottom: 8px; +} + +.bt-modal-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; +} + +.bt-modal-stat { + background: var(--bg-tertiary, #141428); + padding: 10px 12px; + border-radius: 6px; +} + +.bt-modal-stat-label { + font-size: 9px; + text-transform: uppercase; + color: var(--text-dim, #666); + margin-bottom: 4px; +} + +.bt-modal-stat-value { + font-size: 13px; + color: var(--text-primary, #e0e0e0); + font-family: monospace; +} + +.bt-modal-badge { + display: inline-block; + padding: 3px 8px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + margin-right: 6px; + margin-bottom: 6px; +} + +.bt-modal-badge.ble { + background: rgba(59, 130, 246, 0.15); + color: #3b82f6; + border: 1px solid rgba(59, 130, 246, 0.3); +} + +.bt-modal-badge.classic { + background: rgba(139, 92, 246, 0.15); + color: #8b5cf6; + border: 1px solid rgba(139, 92, 246, 0.3); +} + +.bt-modal-badge.new { + background: rgba(59, 130, 246, 0.15); + color: #3b82f6; +} + +.bt-modal-badge.baseline { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; +} + +.bt-modal-badge.flag { + background: rgba(107, 114, 128, 0.15); + color: #9ca3af; +} + +.bt-modal-rssi { + text-align: center; + padding: 16px; + background: var(--bg-tertiary, #141428); + border-radius: 8px; + margin-bottom: 16px; +} + +.bt-modal-rssi-value { + font-size: 36px; + font-weight: 700; + font-family: monospace; +} + +.bt-modal-rssi-label { + font-size: 11px; + color: var(--text-dim, #666); + margin-top: 4px; +} + +.bt-modal-actions { + display: flex; + gap: 10px; + margin-top: 16px; +} + +.bt-modal-actions button { + flex: 1; + padding: 10px; + border-radius: 6px; + font-size: 12px; + cursor: pointer; + transition: all 0.2s; +} + +.bt-modal-btn-primary { + background: var(--accent-cyan, #00d4ff); + border: none; + color: #000; + font-weight: 600; +} + +.bt-modal-btn-primary:hover { + background: #00b8e6; +} + +.bt-modal-btn-secondary { + background: var(--bg-tertiary, #141428); + border: 1px solid var(--border-color, #333); + color: var(--text-primary, #e0e0e0); +} + +.bt-modal-btn-secondary:hover { + background: var(--bg-secondary, #1a1a2e); } @media (max-width: 1200px) { diff --git a/static/js/components/channel-chart.js b/static/js/components/channel-chart.js new file mode 100644 index 0000000..a69c670 --- /dev/null +++ b/static/js/components/channel-chart.js @@ -0,0 +1,286 @@ +/** + * WiFi Channel Utilization Chart Component + * + * Displays channel utilization as a bar chart with recommendations. + * Shows AP count, client count, and utilization score per channel. + */ + +const ChannelChart = (function() { + 'use strict'; + + // ========================================================================== + // Configuration + // ========================================================================== + + const CONFIG = { + height: 120, + barWidth: 14, + barSpacing: 2, + padding: { top: 15, right: 10, bottom: 25, left: 30 }, + colors: { + low: '#22c55e', // Green - low utilization + medium: '#eab308', // Yellow - medium + high: '#ef4444', // Red - high + recommended: '#3b82f6', // Blue - recommended + }, + thresholds: { + low: 0.3, + medium: 0.6, + }, + }; + + // 2.4 GHz non-overlapping channels + const CHANNELS_2_4 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; + const NON_OVERLAPPING_2_4 = [1, 6, 11]; + + // 5 GHz channels (non-DFS) + const CHANNELS_5 = [36, 40, 44, 48, 149, 153, 157, 161, 165]; + + // ========================================================================== + // State + // ========================================================================== + + let container = null; + let currentBand = '2.4'; + let channelStats = []; + let recommendations = []; + + // ========================================================================== + // Initialization + // ========================================================================== + + function init(containerId, options = {}) { + container = document.getElementById(containerId); + if (!container) { + console.warn('[ChannelChart] Container not found:', containerId); + return; + } + + Object.assign(CONFIG, options); + render(); + } + + // ========================================================================== + // Update + // ========================================================================== + + function update(stats, recs) { + channelStats = stats || []; + recommendations = recs || []; + render(); + } + + function setBand(band) { + currentBand = band; + render(); + } + + // ========================================================================== + // Rendering + // ========================================================================== + + function render() { + if (!container) return; + + const channels = currentBand === '2.4' ? CHANNELS_2_4 : CHANNELS_5; + const nonOverlapping = currentBand === '2.4' ? NON_OVERLAPPING_2_4 : CHANNELS_5; + + // Build stats map + const statsMap = {}; + channelStats.forEach(s => { + statsMap[s.channel] = s; + }); + + // Build recommendations map + const recsMap = {}; + recommendations.forEach((r, i) => { + recsMap[r.channel] = { rank: i + 1, ...r }; + }); + + // Calculate dimensions + const width = channels.length * (CONFIG.barWidth + CONFIG.barSpacing) + CONFIG.padding.left + CONFIG.padding.right; + const height = CONFIG.height + CONFIG.padding.top + CONFIG.padding.bottom; + const chartHeight = CONFIG.height; + + // Find max values for scaling + let maxApCount = 1; + channelStats.forEach(s => { + if (s.ap_count > maxApCount) maxApCount = s.ap_count; + }); + + // Build SVG with viewBox for responsive scaling + let svg = ` + + + + + + + + + + + + + + + + + + APs + + + ${renderYAxis(chartHeight, maxApCount)} + + + + ${channels.map((ch, i) => { + const stats = statsMap[ch] || { ap_count: 0, utilization_score: 0 }; + const rec = recsMap[ch]; + const isNonOverlapping = nonOverlapping.includes(ch); + return renderBar(i, ch, stats, rec, isNonOverlapping, chartHeight, maxApCount); + }).join('')} + + + + + ${channels.map((ch, i) => { + const x = i * (CONFIG.barWidth + CONFIG.barSpacing) + CONFIG.barWidth / 2; + const isNonOverlapping = nonOverlapping.includes(ch); + return `${ch}`; + }).join('')} + + + `; + + // Add legend + svg += renderLegend(); + + // Add recommendations + if (recommendations.length > 0) { + svg += renderRecommendations(); + } + + container.innerHTML = svg; + } + + function renderYAxis(chartHeight, maxApCount) { + const ticks = []; + const tickCount = Math.min(5, maxApCount); + const step = Math.ceil(maxApCount / tickCount); + + for (let i = 0; i <= maxApCount; i += step) { + const y = CONFIG.padding.top + chartHeight - (i / maxApCount * chartHeight); + ticks.push(` + + ${i} + `); + } + + return ticks.join(''); + } + + function renderBar(index, channel, stats, rec, isNonOverlapping, chartHeight, maxApCount) { + const x = index * (CONFIG.barWidth + CONFIG.barSpacing); + const barHeight = (stats.ap_count / maxApCount) * chartHeight; + const y = chartHeight - barHeight; + + // Determine color based on utilization + let gradient = 'utilGradientLow'; + if (stats.utilization_score >= CONFIG.thresholds.medium) { + gradient = 'utilGradientHigh'; + } else if (stats.utilization_score >= CONFIG.thresholds.low) { + gradient = 'utilGradientMed'; + } + + // Recommended channel indicator + const isRecommended = rec && rec.rank <= 3; + const recIndicator = isRecommended ? + ` + ${rec.rank}` : ''; + + // Non-overlapping channel marker + const channelMarker = isNonOverlapping ? + `` : ''; + + return ` + + + + + + + + + ${stats.ap_count > 0 ? ` + + ${stats.ap_count} + + ` : ''} + + ${channelMarker} + ${recIndicator} + + + + + `; + } + + function renderLegend() { + return ` +
+
+ + Low +
+
+ + Medium +
+
+ + High +
+
+ + Non-overlapping +
+
+ `; + } + + function renderRecommendations() { + const topRecs = recommendations.slice(0, 3); + if (topRecs.length === 0) return ''; + + return ` +
+
Recommended Channels:
+
+ ${topRecs.map((rec, i) => ` +
+ #${i + 1} + Ch ${rec.channel} + (${rec.band}) + ${rec.is_dfs ? 'DFS' : ''} +
+ `).join('')} +
+
+ `; + } + + // ========================================================================== + // Public API + // ========================================================================== + + return { + init, + update, + setBand, + }; +})(); diff --git a/static/js/components/device-card.js b/static/js/components/device-card.js new file mode 100644 index 0000000..05847f9 --- /dev/null +++ b/static/js/components/device-card.js @@ -0,0 +1,718 @@ +/** + * Device Card Component + * Unified device display for Bluetooth and TSCM modes + */ + +const DeviceCard = (function() { + 'use strict'; + + // Range band configuration + const RANGE_BANDS = { + very_close: { label: 'Very Close', color: '#ef4444', description: '< 3m' }, + close: { label: 'Close', color: '#f97316', description: '3-10m' }, + nearby: { label: 'Nearby', color: '#eab308', description: '10-20m' }, + far: { label: 'Far', color: '#6b7280', description: '> 20m' }, + unknown: { label: 'Unknown', color: '#374151', description: 'N/A' } + }; + + // Protocol badge colors + const PROTOCOL_COLORS = { + ble: { bg: 'rgba(59, 130, 246, 0.15)', color: '#3b82f6', border: 'rgba(59, 130, 246, 0.3)' }, + classic: { bg: 'rgba(139, 92, 246, 0.15)', color: '#8b5cf6', border: 'rgba(139, 92, 246, 0.3)' } + }; + + // Heuristic badge configuration + const HEURISTIC_BADGES = { + new: { label: 'New', color: '#3b82f6', description: 'Not in baseline' }, + persistent: { label: 'Persistent', color: '#22c55e', description: 'Continuously present' }, + beacon_like: { label: 'Beacon', color: '#f59e0b', description: 'Regular advertising' }, + strong_stable: { label: 'Strong', color: '#ef4444', description: 'Strong stable signal' }, + random_address: { label: 'Random', color: '#6b7280', description: 'Privacy address' } + }; + + /** + * Escape HTML to prevent XSS + */ + function escapeHtml(text) { + if (text === null || text === undefined) return ''; + const div = document.createElement('div'); + div.textContent = String(text); + return div.innerHTML; + } + + /** + * Format relative time + */ + function formatRelativeTime(isoString) { + if (!isoString) return ''; + const date = new Date(isoString); + const now = new Date(); + const diff = Math.floor((now - date) / 1000); + + if (diff < 10) return 'Just now'; + if (diff < 60) return `${diff}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return date.toLocaleDateString(); + } + + /** + * Create RSSI sparkline SVG + */ + function createSparkline(rssiHistory, options = {}) { + if (!rssiHistory || rssiHistory.length < 2) { + return '--'; + } + + const width = options.width || 60; + const height = options.height || 20; + const samples = rssiHistory.slice(-20); // Last 20 samples + + // Normalize RSSI values (-100 to -30 range) + const minRssi = -100; + const maxRssi = -30; + const normalizedValues = samples.map(s => { + const rssi = s.rssi || s; + const normalized = (rssi - minRssi) / (maxRssi - minRssi); + return Math.max(0, Math.min(1, normalized)); + }); + + // Generate path + const stepX = width / (normalizedValues.length - 1); + let pathD = ''; + normalizedValues.forEach((val, i) => { + const x = i * stepX; + const y = height - (val * height); + pathD += i === 0 ? `M${x},${y}` : ` L${x},${y}`; + }); + + // Determine color based on latest value + const latestRssi = samples[samples.length - 1].rssi || samples[samples.length - 1]; + let strokeColor = '#6b7280'; + if (latestRssi > -50) strokeColor = '#22c55e'; + else if (latestRssi > -65) strokeColor = '#f59e0b'; + else if (latestRssi > -80) strokeColor = '#f97316'; + + return ` + + + + `; + } + + /** + * Create heuristic badges HTML + */ + function createHeuristicBadges(flags) { + if (!flags || flags.length === 0) return ''; + + return flags.map(flag => { + const config = HEURISTIC_BADGES[flag]; + if (!config) return ''; + return ` + + ${escapeHtml(config.label)} + + `; + }).join(''); + } + + /** + * Create range band indicator + */ + function createRangeBand(band, confidence) { + const config = RANGE_BANDS[band] || RANGE_BANDS.unknown; + const confidencePercent = Math.round((confidence || 0) * 100); + + return ` +
+ ${escapeHtml(config.label)} + ${escapeHtml(config.description)} + ${confidence > 0 ? `${confidencePercent}%` : ''} +
+ `; + } + + /** + * Create protocol badge + */ + function createProtocolBadge(protocol) { + const config = PROTOCOL_COLORS[protocol] || PROTOCOL_COLORS.ble; + const label = protocol === 'classic' ? 'Classic' : 'BLE'; + + return ` + + ${escapeHtml(label)} + + `; + } + + /** + * Create a Bluetooth device card + */ + function createDeviceCard(device, options = {}) { + // Debug: log received device data + console.log('[DeviceCard] Creating card for:', device.address, device); + + const card = document.createElement('article'); + card.className = 'signal-card device-card'; + card.dataset.deviceId = device.device_id || ''; + card.dataset.protocol = device.protocol || 'ble'; + card.dataset.address = device.address || ''; + + // Add status classes + if (device.heuristic_flags && device.heuristic_flags.includes('new')) { + card.dataset.status = 'new'; + } else if (device.in_baseline) { + card.dataset.status = 'baseline'; + } + + // Store full device data for details modal + try { + card.dataset.deviceData = JSON.stringify(device); + } catch (e) { + card.dataset.deviceData = '{}'; + } + + const relativeTime = formatRelativeTime(device.last_seen) || 'Unknown'; + const sparkline = createSparkline(device.rssi_history) || ''; + const heuristicBadges = createHeuristicBadges(device.heuristic_flags) || ''; + const rangeBand = createRangeBand(device.range_band, device.range_confidence) || ''; + const protocolBadge = createProtocolBadge(device.protocol) || ''; + + // Build card with explicit defaults for all values + const deviceName = device.name || device.device_id || 'Unknown Device'; + const deviceAddress = device.address || 'Unknown'; + const addressType = device.address_type || 'unknown'; + const rssiDisplay = (device.rssi_current !== null && device.rssi_current !== undefined) + ? device.rssi_current + ' dBm' : '--'; + const seenCount = device.seen_count || 0; + const inBaseline = device.in_baseline || false; + const mfrName = device.manufacturer_name || ''; + + // Build the HTML parts separately to avoid template issues + const headerHtml = '
' + + '
' + protocolBadge + heuristicBadges + '
' + + '' + + '' + (inBaseline ? 'Known' : 'New') + '' + + '
'; + + const identityHtml = '
' + + '
' + escapeHtml(deviceName) + '
' + + '
' + + '' + escapeHtml(deviceAddress) + '' + + '(' + escapeHtml(addressType) + ')' + + '
'; + + const signalHtml = '
' + + '
' + + '' + rssiDisplay + '' + + sparkline + '
' + rangeBand + '
'; + + const mfrHtml = mfrName ? + '
' + + '🏭' + + '' + escapeHtml(mfrName) + '
' : ''; + + const metaHtml = '
' + + '' + + '👁' + seenCount + '×' + + '' + + escapeHtml(relativeTime) + '
'; + + const bodyHtml = '
' + + identityHtml + signalHtml + mfrHtml + metaHtml + '
'; + + card.innerHTML = headerHtml + bodyHtml; + + // Make card clickable - opens modal with full details + card.addEventListener('click', () => { + showDeviceDetails(device); + }); + + return card; + } + + /** + * Create advanced panel content + */ + function createAdvancedPanel(device) { + return ` +
+
+
Device Details
+
+
+ Address + ${escapeHtml(device.address)} +
+
+ Address Type + ${escapeHtml(device.address_type)} +
+
+ Protocol + ${device.protocol === 'ble' ? 'Bluetooth Low Energy' : 'Classic Bluetooth'} +
+ ${device.manufacturer_id ? ` +
+ Manufacturer ID + 0x${device.manufacturer_id.toString(16).padStart(4, '0').toUpperCase()} +
+ ` : ''} +
+
+
+
Signal Statistics
+
+
+ Current RSSI + ${device.rssi_current !== null ? device.rssi_current + ' dBm' : 'N/A'} +
+
+ Median RSSI + ${device.rssi_median !== null ? device.rssi_median + ' dBm' : 'N/A'} +
+
+ Min/Max + ${device.rssi_min || 'N/A'} / ${device.rssi_max || 'N/A'} dBm +
+
+ Confidence + ${Math.round((device.rssi_confidence || 0) * 100)}% +
+
+
+
+
Observation Times
+
+
+ First Seen + ${escapeHtml(formatRelativeTime(device.first_seen))} +
+
+ Last Seen + ${escapeHtml(formatRelativeTime(device.last_seen))} +
+
+ Seen Count + ${device.seen_count} observations +
+
+ Rate + ${device.seen_rate ? device.seen_rate.toFixed(1) : '0'}/min +
+
+
+ ${device.service_uuids && device.service_uuids.length > 0 ? ` +
+
Service UUIDs
+
+ ${device.service_uuids.map(uuid => `${escapeHtml(uuid)}`).join('')} +
+
+ ` : ''} + ${device.heuristics ? ` +
+
Behavioral Analysis
+
+ ${Object.entries(device.heuristics).map(([key, value]) => ` +
+ ${escapeHtml(key.replace(/_/g, ' '))} + ${value ? '✓' : '−'} +
+ `).join('')} +
+
+ ` : ''} +
+ `; + } + + /** + * Show device details in modal + */ + function showDeviceDetails(device) { + let modal = document.getElementById('deviceDetailsModal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'deviceDetailsModal'; + modal.className = 'signal-details-modal'; + modal.innerHTML = ` +
+
+
+ + +
+
+ +
+ `; + document.body.appendChild(modal); + + // Close handlers + modal.querySelector('.signal-details-modal-backdrop').addEventListener('click', () => { + modal.classList.remove('show'); + }); + modal.querySelector('.signal-details-modal-close').addEventListener('click', () => { + modal.classList.remove('show'); + }); + // Escape key + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && modal.classList.contains('show')) { + modal.classList.remove('show'); + } + }); + } + + // Update copy button handlers with current device + const copyBtn = modal.querySelector('.signal-details-copy-btn'); + const copyAddrBtn = modal.querySelector('.signal-details-copy-addr-btn'); + + copyBtn.onclick = () => { + navigator.clipboard.writeText(JSON.stringify(device, null, 2)).then(() => { + copyBtn.textContent = 'Copied!'; + setTimeout(() => { copyBtn.textContent = 'Copy JSON'; }, 1500); + }); + }; + + copyAddrBtn.onclick = () => { + navigator.clipboard.writeText(device.address).then(() => { + copyAddrBtn.textContent = 'Copied!'; + setTimeout(() => { copyAddrBtn.textContent = 'Copy Address'; }, 1500); + }); + }; + + // Populate modal header + modal.querySelector('.signal-details-modal-title').textContent = device.name || 'Unknown Device'; + modal.querySelector('.signal-details-modal-subtitle').textContent = device.address; + + // Populate modal body with enhanced content + modal.querySelector('.signal-details-modal-body').innerHTML = createModalContent(device); + + modal.classList.add('show'); + } + + /** + * Create enhanced modal content + */ + function createModalContent(device) { + const protocolLabel = device.protocol === 'ble' ? 'Bluetooth Low Energy' : 'Classic Bluetooth'; + const sparkline = createSparkline(device.rssi_history, { width: 120, height: 30 }); + + return ` + + + + + + + + + ${device.service_uuids && device.service_uuids.length > 0 ? ` + + ` : ''} + + ${device.heuristics ? ` + + ` : ''} + `; + } + + /** + * Toggle advanced panel + */ + function toggleAdvanced(button) { + const card = button.closest('.signal-card'); + const panel = card.querySelector('.signal-advanced-panel'); + button.classList.toggle('open'); + panel.classList.toggle('open'); + } + + /** + * Copy address to clipboard + */ + function copyAddress(address) { + navigator.clipboard.writeText(address).then(() => { + if (typeof SignalCards !== 'undefined') { + SignalCards.showToast('Address copied'); + } + }); + } + + /** + * Investigate device (placeholder for future implementation) + */ + function investigate(deviceId) { + console.log('Investigate device:', deviceId); + // Could open service discovery, detailed analysis, etc. + } + + /** + * Update all device timestamps + */ + function updateTimestamps(container) { + container.querySelectorAll('.device-timestamp[data-timestamp]').forEach(el => { + const timestamp = el.dataset.timestamp; + if (timestamp) { + el.textContent = formatRelativeTime(timestamp); + } + }); + } + + /** + * Create device filter bar for Bluetooth mode + */ + function createDeviceFilterBar(container, options = {}) { + const filterBar = document.createElement('div'); + filterBar.className = 'signal-filter-bar device-filter-bar'; + filterBar.id = 'btDeviceFilterBar'; + + filterBar.innerHTML = ` + + + + + + + Protocol + + + + + + + Range + + + + +
+ +
+ `; + + // Filter state + const filters = { status: 'all', protocol: 'all', range: 'all', search: '' }; + + // Apply filters function + const applyFilters = () => { + const cards = container.querySelectorAll('.device-card'); + const counts = { all: 0, new: 0, baseline: 0 }; + + cards.forEach(card => { + const cardStatus = card.dataset.status || 'baseline'; + const cardProtocol = card.dataset.protocol; + const deviceData = JSON.parse(card.dataset.deviceData || '{}'); + const cardName = (deviceData.name || '').toLowerCase(); + const cardAddress = (deviceData.address || '').toLowerCase(); + const cardRange = deviceData.range_band || 'unknown'; + + counts.all++; + if (cardStatus === 'new') counts.new++; + else counts.baseline++; + + // Check filters + const statusMatch = filters.status === 'all' || cardStatus === filters.status; + const protocolMatch = filters.protocol === 'all' || cardProtocol === filters.protocol; + const rangeMatch = filters.range === 'all' || + (filters.range === 'close' && ['very_close', 'close'].includes(cardRange)) || + (filters.range === 'far' && ['nearby', 'far', 'unknown'].includes(cardRange)); + const searchMatch = !filters.search || + cardName.includes(filters.search) || + cardAddress.includes(filters.search); + + if (statusMatch && protocolMatch && rangeMatch && searchMatch) { + card.classList.remove('hidden'); + } else { + card.classList.add('hidden'); + } + }); + + // Update counts + Object.keys(counts).forEach(key => { + const badge = filterBar.querySelector(`[data-count="${key}"]`); + if (badge) badge.textContent = counts[key]; + }); + }; + + // Status filter handlers + filterBar.querySelectorAll('.signal-filter-btn[data-filter="status"]').forEach(btn => { + btn.addEventListener('click', () => { + filterBar.querySelectorAll('.signal-filter-btn[data-filter="status"]').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + filters.status = btn.dataset.value; + applyFilters(); + }); + }); + + // Protocol filter handlers + filterBar.querySelectorAll('.signal-filter-btn[data-filter="protocol"]').forEach(btn => { + btn.addEventListener('click', () => { + filterBar.querySelectorAll('.signal-filter-btn[data-filter="protocol"]').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + filters.protocol = btn.dataset.value; + applyFilters(); + }); + }); + + // Range filter handlers + filterBar.querySelectorAll('.signal-filter-btn[data-filter="range"]').forEach(btn => { + btn.addEventListener('click', () => { + filterBar.querySelectorAll('.signal-filter-btn[data-filter="range"]').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + filters.range = btn.dataset.value; + applyFilters(); + }); + }); + + // Search handler + const searchInput = filterBar.querySelector('#btSearchInput'); + let searchTimeout; + searchInput.addEventListener('input', (e) => { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + filters.search = e.target.value.toLowerCase(); + applyFilters(); + }, 200); + }); + + filterBar.applyFilters = applyFilters; + return filterBar; + } + + // Public API + return { + createDeviceCard, + createSparkline, + createHeuristicBadges, + createRangeBand, + createDeviceFilterBar, + showDeviceDetails, + toggleAdvanced, + copyAddress, + investigate, + updateTimestamps, + escapeHtml, + formatRelativeTime, + RANGE_BANDS, + HEURISTIC_BADGES + }; +})(); + +// Make globally available +window.DeviceCard = DeviceCard; 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/proximity-radar.js b/static/js/components/proximity-radar.js new file mode 100644 index 0000000..4302cd6 --- /dev/null +++ b/static/js/components/proximity-radar.js @@ -0,0 +1,395 @@ +/** + * Proximity Radar Component + * + * SVG-based circular radar visualization for Bluetooth device proximity. + * Displays devices positioned by estimated distance with concentric rings + * for proximity bands. + */ + +const ProximityRadar = (function() { + 'use strict'; + + // Configuration + const CONFIG = { + size: 280, + padding: 20, + centerRadius: 8, + rings: [ + { band: 'immediate', radius: 0.25, color: '#22c55e', label: '< 1m' }, + { band: 'near', radius: 0.5, color: '#eab308', label: '1-3m' }, + { band: 'far', radius: 0.85, color: '#ef4444', label: '3-10m' }, + ], + dotMinSize: 4, + dotMaxSize: 12, + pulseAnimationDuration: 2000, + newDeviceThreshold: 30, // seconds + }; + + // State + let container = null; + let svg = null; + let devices = new Map(); + let isPaused = false; + let activeFilter = null; + let onDeviceClick = null; + let selectedDeviceKey = null; + + /** + * Initialize the radar component + */ + function init(containerId, options = {}) { + container = document.getElementById(containerId); + if (!container) { + console.error('[ProximityRadar] Container not found:', containerId); + return; + } + + if (options.onDeviceClick) { + onDeviceClick = options.onDeviceClick; + } + + createSVG(); + } + + /** + * Create the SVG radar structure + */ + function createSVG() { + const size = CONFIG.size; + const center = size / 2; + + container.innerHTML = ` + + + + + + + + + + + + + + + + + + + + + ${CONFIG.rings.map((ring, i) => { + const r = ring.radius * (center - CONFIG.padding); + return ` + + ${ring.label} + `; + }).join('')} + + + + + + + + + + + + + + PROXIMITY + (signal strength) + + + `; + + svg = container.querySelector('svg'); + + // Add sweep animation + animateSweep(); + } + + /** + * Animate the radar sweep line + */ + function animateSweep() { + const sweepLine = svg.querySelector('.radar-sweep'); + if (!sweepLine) return; + + let angle = 0; + const center = CONFIG.size / 2; + + function rotate() { + if (isPaused) { + requestAnimationFrame(rotate); + return; + } + + angle = (angle + 1) % 360; + const rad = (angle * Math.PI) / 180; + const radius = center - CONFIG.padding; + const x2 = center + Math.sin(rad) * radius; + const y2 = center - Math.cos(rad) * radius; + + sweepLine.setAttribute('x2', x2); + sweepLine.setAttribute('y2', y2); + + requestAnimationFrame(rotate); + } + + requestAnimationFrame(rotate); + } + + /** + * Update devices on the radar + */ + function updateDevices(deviceList) { + if (isPaused) return; + + // Update device map + deviceList.forEach(device => { + devices.set(device.device_key, device); + }); + + // Apply filter and render + renderDevices(); + } + + /** + * Render device dots on the radar + */ + function renderDevices() { + const devicesGroup = svg.querySelector('.radar-devices'); + if (!devicesGroup) return; + + const center = CONFIG.size / 2; + const maxRadius = center - CONFIG.padding; + + // Filter devices + let visibleDevices = Array.from(devices.values()); + + if (activeFilter === 'newOnly') { + visibleDevices = visibleDevices.filter(d => d.is_new || d.age_seconds < CONFIG.newDeviceThreshold); + } else if (activeFilter === 'strongest') { + visibleDevices = visibleDevices + .filter(d => d.rssi_current != null) + .sort((a, b) => (b.rssi_current || -100) - (a.rssi_current || -100)) + .slice(0, 10); + } else if (activeFilter === 'unapproved') { + visibleDevices = visibleDevices.filter(d => !d.in_baseline); + } + + // Build SVG for each device + const dots = visibleDevices.map(device => { + // Calculate position + const { x, y, radius } = calculateDevicePosition(device, center, maxRadius); + + // Calculate dot size based on confidence + const confidence = device.distance_confidence || 0.5; + const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence; + + // Get color based on proximity band + const color = getBandColor(device.proximity_band); + + // Check if newly seen (pulse animation) + const isNew = device.age_seconds < 5; + const pulseClass = isNew ? 'radar-dot-pulse' : ''; + const isSelected = selectedDeviceKey && device.device_key === selectedDeviceKey; + + return ` + + ${isSelected ? ` + + + ` : ''} + + ${device.is_new && !isSelected ? `` : ''} + ${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm) + + `; + }).join(''); + + devicesGroup.innerHTML = dots; + + // Attach click handlers + devicesGroup.querySelectorAll('.radar-device').forEach(el => { + el.addEventListener('click', (e) => { + const deviceKey = el.getAttribute('data-device-key'); + if (onDeviceClick && deviceKey) { + onDeviceClick(deviceKey); + } + }); + }); + } + + /** + * Calculate device position on radar + */ + function calculateDevicePosition(device, center, maxRadius) { + // Calculate radius based on proximity band/distance + let radiusRatio; + const band = device.proximity_band || 'unknown'; + + if (device.estimated_distance_m != null) { + // Use actual distance (log scale) + const maxDistance = 15; + radiusRatio = Math.min(1, Math.log10(device.estimated_distance_m + 1) / Math.log10(maxDistance + 1)); + } else { + // Use band-based positioning + switch (band) { + case 'immediate': radiusRatio = 0.15; break; + case 'near': radiusRatio = 0.4; break; + case 'far': radiusRatio = 0.7; break; + default: radiusRatio = 0.9; break; + } + } + + // Calculate angle based on device key hash (stable positioning) + const angle = hashToAngle(device.device_key || device.device_id); + const radius = radiusRatio * maxRadius; + + const x = center + Math.sin(angle) * radius; + const y = center - Math.cos(angle) * radius; + + return { x, y, radius }; + } + + /** + * Hash string to angle for stable positioning + */ + function hashToAngle(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash) + str.charCodeAt(i); + hash = hash & hash; + } + return (Math.abs(hash) % 360) * (Math.PI / 180); + } + + /** + * Get color for proximity band + */ + function getBandColor(band) { + switch (band) { + case 'immediate': return '#22c55e'; + case 'near': return '#eab308'; + case 'far': return '#ef4444'; + default: return '#6b7280'; + } + } + + /** + * Set filter mode + */ + function setFilter(filter) { + activeFilter = filter === activeFilter ? null : filter; + renderDevices(); + } + + /** + * Toggle pause state + */ + function setPaused(paused) { + isPaused = paused; + } + + /** + * Clear all devices + */ + function clear() { + devices.clear(); + selectedDeviceKey = null; + renderDevices(); + } + + /** + * Highlight a specific device on the radar + */ + function highlightDevice(deviceKey) { + selectedDeviceKey = deviceKey; + renderDevices(); + } + + /** + * Clear device highlighting + */ + function clearHighlight() { + selectedDeviceKey = null; + renderDevices(); + } + + /** + * Get zone counts + */ + function getZoneCounts() { + const counts = { immediate: 0, near: 0, far: 0, unknown: 0 }; + devices.forEach(device => { + const band = device.proximity_band || 'unknown'; + if (counts.hasOwnProperty(band)) { + counts[band]++; + } else { + counts.unknown++; + } + }); + return counts; + } + + /** + * Escape HTML for safe rendering + */ + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = String(text); + return div.innerHTML; + } + + /** + * Escape attribute value + */ + function escapeAttr(text) { + if (!text) return ''; + return String(text) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>'); + } + + // Public API + return { + init, + updateDevices, + setFilter, + setPaused, + clear, + getZoneCounts, + highlightDevice, + clearHighlight, + isPaused: () => isPaused, + getFilter: () => activeFilter, + getSelectedDevice: () => selectedDeviceKey, + }; +})(); + +// Export for module systems +if (typeof module !== 'undefined' && module.exports) { + module.exports = ProximityRadar; +} + +window.ProximityRadar = ProximityRadar; 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/components/timeline-heatmap.js b/static/js/components/timeline-heatmap.js new file mode 100644 index 0000000..b7d4a3e --- /dev/null +++ b/static/js/components/timeline-heatmap.js @@ -0,0 +1,409 @@ +/** + * Timeline Heatmap Component + * + * Displays RSSI signal history as a heatmap grid. + * Y-axis: devices, X-axis: time buckets, Cell color: RSSI strength + */ + +const TimelineHeatmap = (function() { + 'use strict'; + + // Configuration + const CONFIG = { + cellWidth: 8, + cellHeight: 20, + labelWidth: 120, + maxDevices: 20, + refreshInterval: 5000, + // RSSI color scale (green = strong, red = weak) + colorScale: [ + { rssi: -40, color: '#22c55e' }, // Strong - green + { rssi: -55, color: '#84cc16' }, // Good - lime + { rssi: -65, color: '#eab308' }, // Medium - yellow + { rssi: -75, color: '#f97316' }, // Weak - orange + { rssi: -90, color: '#ef4444' }, // Very weak - red + ], + noDataColor: '#2a2a3e', + }; + + // State + let container = null; + let contentEl = null; + let controlsEl = null; + let data = null; + let isPaused = false; + let refreshTimer = null; + let selectedDeviceKey = null; + let onDeviceSelect = null; + + // Settings + let settings = { + windowMinutes: 10, + bucketSeconds: 10, + sortBy: 'recency', + topN: 20, + }; + + /** + * Initialize the heatmap component + */ + function init(containerId, options = {}) { + container = document.getElementById(containerId); + if (!container) { + console.error('[TimelineHeatmap] Container not found:', containerId); + return; + } + + if (options.onDeviceSelect) { + onDeviceSelect = options.onDeviceSelect; + } + + // Merge options into settings + Object.assign(settings, options); + + createStructure(); + startAutoRefresh(); + } + + /** + * Create the heatmap DOM structure + */ + function createStructure() { + container.innerHTML = ` +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
Loading signal history...
+
+
+ Signal: + Strong + Medium + Weak + No data +
+ `; + + contentEl = container.querySelector('.timeline-heatmap-content'); + controlsEl = container.querySelector('.timeline-heatmap-controls'); + + // Attach event listeners + attachEventListeners(); + } + + /** + * Attach event listeners to controls + */ + function attachEventListeners() { + const windowSelect = container.querySelector('#heatmapWindow'); + const bucketSelect = container.querySelector('#heatmapBucket'); + const sortSelect = container.querySelector('#heatmapSort'); + const pauseBtn = container.querySelector('#heatmapPauseBtn'); + + windowSelect?.addEventListener('change', (e) => { + settings.windowMinutes = parseInt(e.target.value, 10); + refresh(); + }); + + bucketSelect?.addEventListener('change', (e) => { + settings.bucketSeconds = parseInt(e.target.value, 10); + refresh(); + }); + + sortSelect?.addEventListener('change', (e) => { + settings.sortBy = e.target.value; + refresh(); + }); + + pauseBtn?.addEventListener('click', () => { + isPaused = !isPaused; + pauseBtn.textContent = isPaused ? 'Resume' : 'Pause'; + pauseBtn.classList.toggle('active', isPaused); + }); + } + + /** + * Start auto-refresh timer + */ + function startAutoRefresh() { + if (refreshTimer) clearInterval(refreshTimer); + + refreshTimer = setInterval(() => { + if (!isPaused) { + refresh(); + } + }, CONFIG.refreshInterval); + } + + /** + * Fetch and render heatmap data + */ + async function refresh() { + if (!container) return; + + try { + const params = new URLSearchParams({ + top_n: settings.topN, + window_minutes: settings.windowMinutes, + bucket_seconds: settings.bucketSeconds, + sort_by: settings.sortBy, + }); + + const response = await fetch(`/api/bluetooth/heatmap/data?${params}`); + if (!response.ok) throw new Error('Failed to fetch heatmap data'); + + data = await response.json(); + render(); + } catch (err) { + console.error('[TimelineHeatmap] Refresh error:', err); + contentEl.innerHTML = '
Failed to load data
'; + } + } + + /** + * Render the heatmap grid + */ + function render() { + if (!data || !data.devices || data.devices.length === 0) { + contentEl.innerHTML = '
No signal history available yet
'; + return; + } + + // Calculate time buckets + const windowMs = settings.windowMinutes * 60 * 1000; + const bucketMs = settings.bucketSeconds * 1000; + const numBuckets = Math.ceil(windowMs / bucketMs); + const now = new Date(); + + // Generate time labels + const timeLabels = []; + for (let i = 0; i < numBuckets; i++) { + const time = new Date(now.getTime() - (numBuckets - 1 - i) * bucketMs); + if (i % Math.ceil(numBuckets / 6) === 0) { + timeLabels.push(time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })); + } else { + timeLabels.push(''); + } + } + + // Build heatmap HTML + let html = '
'; + + // Time axis header + html += `
+
+
+ ${timeLabels.map(label => + `
${label}
` + ).join('')} +
+
`; + + // Device rows + data.devices.forEach(device => { + const isSelected = device.device_key === selectedDeviceKey; + const rowClass = isSelected ? 'heatmap-row selected' : 'heatmap-row'; + + // Create lookup for timeseries data + const tsLookup = new Map(); + device.timeseries.forEach(point => { + const ts = new Date(point.timestamp).getTime(); + tsLookup.set(ts, point.rssi); + }); + + // Generate cells for each time bucket + const cells = []; + for (let i = 0; i < numBuckets; i++) { + const bucketTime = new Date(now.getTime() - (numBuckets - 1 - i) * bucketMs); + const bucketKey = Math.floor(bucketTime.getTime() / bucketMs) * bucketMs; + + // Find closest timestamp in data + let rssi = null; + const tolerance = bucketMs; + tsLookup.forEach((val, ts) => { + if (Math.abs(ts - bucketKey) < tolerance) { + rssi = val; + } + }); + + const color = rssi !== null ? getRssiColor(rssi) : CONFIG.noDataColor; + const title = rssi !== null ? `${rssi} dBm` : 'No data'; + + cells.push(`
`); + } + + const displayName = device.name || formatAddress(device.address) || device.device_key.substring(0, 12); + const rssiDisplay = device.rssi_ema != null ? `${Math.round(device.rssi_ema)} dBm` : '--'; + + html += ` +
+
+ ${escapeHtml(displayName)} + ${rssiDisplay} +
+
${cells.join('')}
+
+ `; + }); + + html += '
'; + contentEl.innerHTML = html; + + // Attach row click handlers + contentEl.querySelectorAll('.heatmap-row:not(.heatmap-header)').forEach(row => { + row.addEventListener('click', () => { + const deviceKey = row.getAttribute('data-device-key'); + selectDevice(deviceKey); + }); + }); + } + + /** + * Get color for RSSI value + */ + function getRssiColor(rssi) { + const scale = CONFIG.colorScale; + + // Find the appropriate color from scale + for (let i = 0; i < scale.length; i++) { + if (rssi >= scale[i].rssi) { + return scale[i].color; + } + } + return scale[scale.length - 1].color; + } + + /** + * Format MAC address for display + */ + function formatAddress(address) { + if (!address) return null; + const parts = address.split(':'); + if (parts.length === 6) { + return `${parts[0]}:${parts[1]}:..${parts[5]}`; + } + return address; + } + + /** + * Select a device row + */ + function selectDevice(deviceKey) { + selectedDeviceKey = deviceKey === selectedDeviceKey ? null : deviceKey; + + // Update row highlighting + contentEl.querySelectorAll('.heatmap-row').forEach(row => { + const isSelected = row.getAttribute('data-device-key') === selectedDeviceKey; + row.classList.toggle('selected', isSelected); + }); + + // Callback + if (onDeviceSelect && selectedDeviceKey) { + const device = data?.devices?.find(d => d.device_key === selectedDeviceKey); + onDeviceSelect(selectedDeviceKey, device); + } + } + + /** + * Update with new data directly (for SSE integration) + */ + function updateData(newData) { + if (isPaused) return; + data = newData; + render(); + } + + /** + * Set paused state + */ + function setPaused(paused) { + isPaused = paused; + const pauseBtn = container?.querySelector('#heatmapPauseBtn'); + if (pauseBtn) { + pauseBtn.textContent = isPaused ? 'Resume' : 'Pause'; + pauseBtn.classList.toggle('active', isPaused); + } + } + + /** + * Destroy the component + */ + function destroy() { + if (refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; + } + if (container) { + container.innerHTML = ''; + } + } + + /** + * Escape HTML for safe rendering + */ + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = String(text); + return div.innerHTML; + } + + /** + * Escape attribute value + */ + function escapeAttr(text) { + if (!text) return ''; + return String(text) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>'); + } + + // Public API + return { + init, + refresh, + updateData, + setPaused, + destroy, + selectDevice, + getSelectedDevice: () => selectedDeviceKey, + isPaused: () => isPaused, + }; +})(); + +// Export for module systems +if (typeof module !== 'undefined' && module.exports) { + module.exports = TimelineHeatmap; +} + +window.TimelineHeatmap = TimelineHeatmap; diff --git a/static/js/core/app.js b/static/js/core/app.js index 1c9bebb..321dfaf 100644 --- a/static/js/core/app.js +++ b/static/js/core/app.js @@ -119,7 +119,6 @@ function switchMode(mode) { document.getElementById('aircraftStats').style.display = mode === 'aircraft' ? 'flex' : 'none'; document.getElementById('satelliteStats').style.display = mode === 'satellite' ? 'flex' : 'none'; document.getElementById('wifiStats').style.display = mode === 'wifi' ? 'flex' : 'none'; - document.getElementById('btStats').style.display = mode === 'bluetooth' ? 'flex' : 'none'; // Hide signal meter - individual panels show signal strength where needed document.getElementById('signalMeter').style.display = 'none'; diff --git a/static/js/modes/bluetooth.js b/static/js/modes/bluetooth.js new file mode 100644 index 0000000..be7e4f4 --- /dev/null +++ b/static/js/modes/bluetooth.js @@ -0,0 +1,1062 @@ +/** + * 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; + + // Stats tracking + let deviceStats = { + strong: 0, + medium: 0, + weak: 0, + trackers: [] + }; + + // Zone counts for proximity display + let zoneCounts = { veryClose: 0, close: 0, nearby: 0, far: 0 }; + + // New visualization components + let radarInitialized = false; + let radarPaused = false; + + // Device list filter + let currentDeviceFilter = 'all'; + + /** + * 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'); + + // Check capabilities on load + checkCapabilities(); + + // Check scan status (in case page was reloaded during scan) + checkScanStatus(); + + // Initialize proximity visualization + initProximityRadar(); + + // Initialize legacy heatmap (zone counts) + initHeatmap(); + + // Initialize device list filters + initDeviceFilters(); + + // Set initial panel states + updateVisualizationPanels(); + } + + /** + * Initialize device list filter buttons + */ + function initDeviceFilters() { + const filterContainer = document.getElementById('btDeviceFilters'); + if (!filterContainer) return; + + filterContainer.addEventListener('click', (e) => { + const btn = e.target.closest('.bt-filter-btn'); + if (!btn) return; + + const filter = btn.dataset.filter; + if (!filter) return; + + // Update active state + filterContainer.querySelectorAll('.bt-filter-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + + // Apply filter + currentDeviceFilter = filter; + applyDeviceFilter(); + }); + } + + /** + * Apply current filter to device list + */ + function applyDeviceFilter() { + if (!deviceContainer) return; + + const cards = deviceContainer.querySelectorAll('[data-bt-device-id]'); + cards.forEach(card => { + const isNew = card.dataset.isNew === 'true'; + const hasName = card.dataset.hasName === 'true'; + const rssi = parseInt(card.dataset.rssi) || -100; + const isTracker = card.dataset.isTracker === 'true'; + + let visible = true; + switch (currentDeviceFilter) { + case 'new': + visible = isNew; + break; + case 'named': + visible = hasName; + break; + case 'strong': + visible = rssi >= -70; + break; + case 'trackers': + visible = isTracker; + break; + case 'all': + default: + visible = true; + } + + card.style.display = visible ? '' : 'none'; + }); + + // Update visible count + updateFilteredCount(); + } + + /** + * Update the device count display based on visible devices + */ + function updateFilteredCount() { + const countEl = document.getElementById('btDeviceListCount'); + if (!countEl || !deviceContainer) return; + + if (currentDeviceFilter === 'all') { + countEl.textContent = devices.size; + } else { + const visible = deviceContainer.querySelectorAll('[data-bt-device-id]:not([style*="display: none"])').length; + countEl.textContent = visible + '/' + devices.size; + } + } + + /** + * Initialize the new proximity radar component + */ + function initProximityRadar() { + const radarContainer = document.getElementById('btProximityRadar'); + if (!radarContainer) return; + + if (typeof ProximityRadar !== 'undefined') { + ProximityRadar.init('btProximityRadar', { + onDeviceClick: (deviceKey) => { + // Find device by key and show modal + const device = Array.from(devices.values()).find(d => d.device_key === deviceKey); + if (device) { + selectDevice(device.device_id); + } + } + }); + radarInitialized = true; + + // Setup radar controls + setupRadarControls(); + } + } + + /** + * Setup radar control button handlers + */ + function setupRadarControls() { + // Filter buttons + document.querySelectorAll('#btRadarControls button[data-filter]').forEach(btn => { + btn.addEventListener('click', () => { + const filter = btn.getAttribute('data-filter'); + if (typeof ProximityRadar !== 'undefined') { + ProximityRadar.setFilter(filter); + + // Update button states + document.querySelectorAll('#btRadarControls button[data-filter]').forEach(b => { + b.classList.remove('active'); + }); + if (ProximityRadar.getFilter() === filter) { + btn.classList.add('active'); + } + } + }); + }); + + // Pause button + const pauseBtn = document.getElementById('btRadarPauseBtn'); + if (pauseBtn) { + pauseBtn.addEventListener('click', () => { + radarPaused = !radarPaused; + if (typeof ProximityRadar !== 'undefined') { + ProximityRadar.setPaused(radarPaused); + } + pauseBtn.textContent = radarPaused ? 'Resume' : 'Pause'; + pauseBtn.classList.toggle('active', radarPaused); + }); + } + } + + /** + * Update the proximity radar with current devices + */ + function updateRadar() { + if (!radarInitialized || typeof ProximityRadar === 'undefined') return; + + // Convert devices map to array for radar + const deviceList = Array.from(devices.values()).map(d => ({ + device_key: d.device_key || d.device_id, + device_id: d.device_id, + name: d.name, + address: d.address, + rssi_current: d.rssi_current, + rssi_ema: d.rssi_ema, + estimated_distance_m: d.estimated_distance_m, + proximity_band: d.proximity_band || 'unknown', + distance_confidence: d.distance_confidence || 0.5, + is_new: d.is_new || !d.in_baseline, + is_randomized_mac: d.is_randomized_mac, + in_baseline: d.in_baseline, + heuristic_flags: d.heuristic_flags || [], + age_seconds: d.age_seconds || 0, + })); + + ProximityRadar.updateDevices(deviceList); + + // Update zone counts from radar + const counts = ProximityRadar.getZoneCounts(); + updateProximityZoneCounts(counts); + } + + /** + * Update proximity zone counts display (new system) + */ + function updateProximityZoneCounts(counts) { + const immediateEl = document.getElementById('btZoneImmediate'); + const nearEl = document.getElementById('btZoneNear'); + const farEl = document.getElementById('btZoneFar'); + + if (immediateEl) immediateEl.textContent = counts.immediate || 0; + if (nearEl) nearEl.textContent = counts.near || 0; + if (farEl) farEl.textContent = counts.far || 0; + } + + /** + * Initialize proximity zones display + */ + function initHeatmap() { + updateProximityZones(); + } + + /** + * Update proximity zone counts (simple HTML, no canvas) + */ + function updateProximityZones() { + zoneCounts = { veryClose: 0, close: 0, nearby: 0, far: 0 }; + + devices.forEach(device => { + const rssi = device.rssi_current; + if (rssi == null) return; + + if (rssi >= -40) zoneCounts.veryClose++; + else if (rssi >= -55) zoneCounts.close++; + else if (rssi >= -70) zoneCounts.nearby++; + else zoneCounts.far++; + }); + + // Update DOM elements + const veryCloseEl = document.getElementById('btZoneVeryClose'); + const closeEl = document.getElementById('btZoneClose'); + const nearbyEl = document.getElementById('btZoneNearby'); + const farEl = document.getElementById('btZoneFar'); + + if (veryCloseEl) veryCloseEl.textContent = zoneCounts.veryClose; + if (closeEl) closeEl.textContent = zoneCounts.close; + if (nearbyEl) nearbyEl.textContent = zoneCounts.nearby; + if (farEl) farEl.textContent = zoneCounts.far; + } + + // Currently selected device + let selectedDeviceId = null; + + /** + * Show device detail panel + */ + function showDeviceDetail(deviceId) { + const device = devices.get(deviceId); + if (!device) return; + + selectedDeviceId = deviceId; + + const placeholder = document.getElementById('btDetailPlaceholder'); + const content = document.getElementById('btDetailContent'); + if (!placeholder || !content) return; + + const rssi = device.rssi_current; + const rssiColor = getRssiColor(rssi); + const flags = device.heuristic_flags || []; + const protocol = device.protocol || 'ble'; + + // Update panel elements + document.getElementById('btDetailName').textContent = device.name || formatDeviceId(device.address); + document.getElementById('btDetailAddress').textContent = device.address; + + // RSSI + const rssiEl = document.getElementById('btDetailRssi'); + rssiEl.textContent = rssi != null ? rssi : '--'; + rssiEl.style.color = rssiColor; + + // Badges + const badgesEl = document.getElementById('btDetailBadges'); + let badgesHtml = `${protocol.toUpperCase()}`; + badgesHtml += `${device.in_baseline ? '✓ KNOWN' : '● NEW'}`; + + // Tracker badge + if (device.is_tracker) { + const conf = device.tracker_confidence || 'low'; + const confClass = conf === 'high' ? 'tracker-high' : conf === 'medium' ? 'tracker-medium' : 'tracker-low'; + const typeLabel = device.tracker_name || device.tracker_type || 'TRACKER'; + badgesHtml += `${escapeHtml(typeLabel)}`; + } + + flags.forEach(f => { + badgesHtml += `${f.replace(/_/g, ' ').toUpperCase()}`; + }); + badgesEl.innerHTML = badgesHtml; + + // Tracker analysis section + const trackerSection = document.getElementById('btDetailTrackerAnalysis'); + if (trackerSection) { + if (device.is_tracker) { + const confidence = device.tracker_confidence || 'low'; + const confScore = device.tracker_confidence_score || 0; + const riskScore = device.risk_score || 0; + const evidence = device.tracker_evidence || []; + const riskFactors = device.risk_factors || []; + + let trackerHtml = '
'; + trackerHtml += '
Tracker Detection Analysis
'; + + // Confidence + const confColor = confidence === 'high' ? '#ef4444' : confidence === 'medium' ? '#f97316' : '#eab308'; + trackerHtml += '
Confidence:' + confidence.toUpperCase() + ' (' + Math.round(confScore * 100) + '%)
'; + + // Evidence + if (evidence.length > 0) { + trackerHtml += '
Evidence:
    '; + evidence.forEach(e => { + trackerHtml += '
  • ' + escapeHtml(e) + '
  • '; + }); + trackerHtml += '
'; + } + + // Risk analysis + if (riskScore >= 0.1 || riskFactors.length > 0) { + const riskColor = riskScore >= 0.5 ? '#ef4444' : riskScore >= 0.3 ? '#f97316' : '#888'; + trackerHtml += '
Risk Score:' + Math.round(riskScore * 100) + '%
'; + if (riskFactors.length > 0) { + trackerHtml += '
Risk Factors:
    '; + riskFactors.forEach(f => { + trackerHtml += '
  • ' + escapeHtml(f) + '
  • '; + }); + trackerHtml += '
'; + } + } + + trackerHtml += '
Note: Detection is heuristic-based. Results indicate patterns consistent with tracking devices but cannot prove intent.
'; + trackerHtml += '
'; + + trackerSection.style.display = 'block'; + trackerSection.innerHTML = trackerHtml; + } else { + trackerSection.style.display = 'none'; + trackerSection.innerHTML = ''; + } + } + + // Stats grid + document.getElementById('btDetailMfr').textContent = device.manufacturer_name || '--'; + document.getElementById('btDetailMfrId').textContent = device.manufacturer_id != null + ? '0x' + device.manufacturer_id.toString(16).toUpperCase().padStart(4, '0') + : '--'; + document.getElementById('btDetailAddrType').textContent = device.address_type || '--'; + document.getElementById('btDetailSeen').textContent = (device.seen_count || 0) + '×'; + document.getElementById('btDetailRange').textContent = device.range_band || '--'; + + // Min/Max combined + const minMax = []; + if (device.rssi_min != null) minMax.push(device.rssi_min); + if (device.rssi_max != null) minMax.push(device.rssi_max); + document.getElementById('btDetailRssiRange').textContent = minMax.length === 2 + ? minMax[0] + '/' + minMax[1] + : '--'; + + document.getElementById('btDetailFirstSeen').textContent = device.first_seen + ? new Date(device.first_seen).toLocaleTimeString() + : '--'; + document.getElementById('btDetailLastSeen').textContent = device.last_seen + ? new Date(device.last_seen).toLocaleTimeString() + : '--'; + + // Services + const servicesContainer = document.getElementById('btDetailServices'); + const servicesList = document.getElementById('btDetailServicesList'); + if (device.service_uuids && device.service_uuids.length > 0) { + servicesContainer.style.display = 'block'; + servicesList.textContent = device.service_uuids.join(', '); + } else { + servicesContainer.style.display = 'none'; + } + + // Show content, hide placeholder + placeholder.style.display = 'none'; + content.style.display = 'block'; + + // Highlight selected device in list + highlightSelectedDevice(deviceId); + } + + /** + * Clear device selection + */ + function clearSelection() { + selectedDeviceId = null; + + const placeholder = document.getElementById('btDetailPlaceholder'); + const content = document.getElementById('btDetailContent'); + if (placeholder) placeholder.style.display = 'flex'; + if (content) content.style.display = 'none'; + + // Remove highlight from device list + if (deviceContainer) { + deviceContainer.querySelectorAll('.bt-device-row.selected').forEach(el => { + el.classList.remove('selected'); + }); + } + + // Clear radar highlight + if (typeof ProximityRadar !== 'undefined') { + ProximityRadar.clearHighlight(); + } + } + + /** + * Highlight selected device in the list + */ + function highlightSelectedDevice(deviceId) { + if (!deviceContainer) return; + + // Remove existing highlights + deviceContainer.querySelectorAll('.bt-device-row.selected').forEach(el => { + el.classList.remove('selected'); + }); + + // Add highlight to selected device + const escapedId = CSS.escape(deviceId); + const card = deviceContainer.querySelector(`[data-bt-device-id="${escapedId}"]`); + if (card) { + card.classList.add('selected'); + } + + // Also highlight on the radar + const device = devices.get(deviceId); + if (device && typeof ProximityRadar !== 'undefined') { + ProximityRadar.highlightDevice(device.device_key || device.device_id); + } + } + + /** + * Copy selected device address to clipboard + */ + function copyAddress() { + if (!selectedDeviceId) return; + const device = devices.get(selectedDeviceId); + if (!device) return; + + navigator.clipboard.writeText(device.address).then(() => { + const btn = document.querySelector('.bt-detail-btn'); + if (btn) { + const originalText = btn.textContent; + btn.textContent = 'Copied!'; + btn.style.background = '#22c55e'; + setTimeout(() => { + btn.textContent = originalText; + btn.style.background = ''; + }, 1500); + } + }); + } + + /** + * Select a device - opens modal with details + */ + function selectDevice(deviceId) { + showDeviceDetail(deviceId); + } + + /** + * Format device ID for display (when no name available) + */ + function formatDeviceId(address) { + if (!address) return 'Unknown Device'; + const parts = address.split(':'); + if (parts.length === 6) { + return parts[0] + ':' + parts[1] + ':...:' + parts[4] + ':' + parts[5]; + } + return address; + } + + /** + * 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; + } + + 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 = ''; + } + + if (data.issues && data.issues.length > 0) { + showCapabilityWarning(data.issues); + } else { + hideCapabilityWarning(); + } + + 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']); + } + } + + function showCapabilityWarning(issues) { + if (!capabilityStatusEl) return; + capabilityStatusEl.style.display = 'block'; + capabilityStatusEl.innerHTML = ` +
+ ${issues.map(i => `
⚠ ${i}
`).join('')} +
+ `; + } + + function hideCapabilityWarning() { + if (capabilityStatusEl) { + capabilityStatusEl.style.display = 'none'; + capabilityStatusEl.innerHTML = ''; + } + } + + async function checkScanStatus() { + try { + const response = await fetch('/api/bluetooth/scan/status'); + const data = await response.json(); + + if (data.is_scanning) { + setScanning(true); + startEventStream(); + } + + if (data.baseline_count > 0) { + baselineSet = true; + baselineCount = data.baseline_count; + updateBaselineStatus(); + } + + } catch (err) { + console.error('Failed to check scan status:', err); + } + } + + 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(); + } else { + showErrorMessage(data.message || 'Failed to start scan'); + } + + } catch (err) { + console.error('Failed to start scan:', err); + showErrorMessage('Failed to start scan: ' + err.message); + } + } + + async function stopScan() { + try { + await fetch('/api/bluetooth/scan/stop', { method: 'POST' }); + setScanning(false); + stopEventStream(); + } catch (err) { + console.error('Failed to stop scan:', err); + } + } + + function setScanning(scanning) { + isScanning = scanning; + + if (startBtn) startBtn.style.display = scanning ? 'none' : 'block'; + if (stopBtn) stopBtn.style.display = scanning ? 'block' : 'none'; + + if (scanning && deviceContainer) { + deviceContainer.innerHTML = ''; + devices.clear(); + resetStats(); + } + + const statusDot = document.getElementById('statusDot'); + const statusText = document.getElementById('statusText'); + if (statusDot) statusDot.classList.toggle('running', scanning); + if (statusText) statusText.textContent = scanning ? 'Scanning...' : 'Idle'; + } + + function resetStats() { + deviceStats = { + strong: 0, + medium: 0, + weak: 0, + trackers: [] + }; + updateVisualizationPanels(); + updateProximityZones(); + + // Clear radar + if (radarInitialized && typeof ProximityRadar !== 'undefined') { + ProximityRadar.clear(); + } + } + + 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) => { + setScanning(true); + }); + + eventSource.addEventListener('scan_stopped', (e) => { + setScanning(false); + }); + + eventSource.onerror = () => { + console.warn('Bluetooth SSE connection error'); + }; + } + + function stopEventStream() { + if (eventSource) { + eventSource.close(); + eventSource = null; + } + } + + function handleDeviceUpdate(device) { + devices.set(device.device_id, device); + renderDevice(device); + updateDeviceCount(); + updateStatsFromDevices(); + updateVisualizationPanels(); + updateProximityZones(); + + // Update new proximity radar + updateRadar(); + } + + /** + * Update stats from all devices + */ + function updateStatsFromDevices() { + // Reset counts + deviceStats.strong = 0; + deviceStats.medium = 0; + deviceStats.weak = 0; + deviceStats.trackers = []; + + devices.forEach(d => { + const rssi = d.rssi_current; + + // Signal strength classification + if (rssi != null) { + if (rssi >= -50) deviceStats.strong++; + else if (rssi >= -70) deviceStats.medium++; + else deviceStats.weak++; + } + + // Use actual tracker detection from backend (v2) + // The is_tracker field comes from the TrackerSignatureEngine + if (d.is_tracker === true) { + if (!deviceStats.trackers.find(t => t.address === d.address)) { + deviceStats.trackers.push(d); + } + } + }); + } + + /** + * Update visualization panels + */ + function updateVisualizationPanels() { + // Signal Distribution + const total = devices.size || 1; + const strongBar = document.getElementById('btSignalStrong'); + const mediumBar = document.getElementById('btSignalMedium'); + const weakBar = document.getElementById('btSignalWeak'); + const strongCount = document.getElementById('btSignalStrongCount'); + const mediumCount = document.getElementById('btSignalMediumCount'); + const weakCount = document.getElementById('btSignalWeakCount'); + + if (strongBar) strongBar.style.width = (deviceStats.strong / total * 100) + '%'; + if (mediumBar) mediumBar.style.width = (deviceStats.medium / total * 100) + '%'; + if (weakBar) weakBar.style.width = (deviceStats.weak / total * 100) + '%'; + if (strongCount) strongCount.textContent = deviceStats.strong; + if (mediumCount) mediumCount.textContent = deviceStats.medium; + if (weakCount) weakCount.textContent = deviceStats.weak; + + // Tracker Detection - Enhanced display with confidence and evidence + const trackerList = document.getElementById('btTrackerList'); + if (trackerList) { + if (devices.size === 0) { + trackerList.innerHTML = '
Start scanning to detect trackers
'; + } else if (deviceStats.trackers.length === 0) { + trackerList.innerHTML = '
No trackers detected
'; + } else { + // Sort by risk score (highest first), then confidence + const sortedTrackers = [...deviceStats.trackers].sort((a, b) => { + const riskA = a.risk_score || 0; + const riskB = b.risk_score || 0; + if (riskB !== riskA) return riskB - riskA; + const confA = a.tracker_confidence_score || 0; + const confB = b.tracker_confidence_score || 0; + return confB - confA; + }); + + trackerList.innerHTML = sortedTrackers.map(t => { + // Get tracker type badge color based on confidence + const confidence = t.tracker_confidence || 'low'; + const confColor = confidence === 'high' ? '#ef4444' : + confidence === 'medium' ? '#f97316' : '#eab308'; + const confBg = confidence === 'high' ? 'rgba(239,68,68,0.2)' : + confidence === 'medium' ? 'rgba(249,115,22,0.2)' : 'rgba(234,179,8,0.2)'; + + // Risk score indicator + const riskScore = t.risk_score || 0; + const riskColor = riskScore >= 0.5 ? '#ef4444' : riskScore >= 0.3 ? '#f97316' : '#666'; + + // Tracker type label + const trackerType = t.tracker_name || t.tracker_type || 'Unknown Tracker'; + + // Build evidence tooltip (first 2 items) + const evidence = (t.tracker_evidence || []).slice(0, 2); + const evidenceHtml = evidence.length > 0 + ? '
' + + evidence.map(e => '• ' + escapeHtml(e)).join('
') + + '
' + : ''; + + const deviceIdEscaped = escapeHtml(t.device_id).replace(/'/g, "\\'"); + + return '
' + + '
' + + '
' + + '' + confidence.toUpperCase() + '' + + '' + escapeHtml(trackerType) + '' + + '
' + + '
' + + (riskScore >= 0.3 ? 'RISK ' + Math.round(riskScore * 100) + '%' : '') + + '' + (t.rssi_current || '--') + ' dBm' + + '
' + + '
' + + '
' + + '' + t.address + '' + + 'Seen ' + (t.seen_count || 0) + 'x' + + '
' + + evidenceHtml + + '
'; + }).join(''); + } + } + + } + + function updateDeviceCount() { + updateFilteredCount(); + } + + function renderDevice(device) { + if (!deviceContainer) { + deviceContainer = document.getElementById('btDeviceListContent'); + if (!deviceContainer) return; + } + + const escapedId = CSS.escape(device.device_id); + const existingCard = deviceContainer.querySelector('[data-bt-device-id="' + escapedId + '"]'); + const cardHtml = createSimpleDeviceCard(device); + + if (existingCard) { + existingCard.outerHTML = cardHtml; + } else { + deviceContainer.insertAdjacentHTML('afterbegin', cardHtml); + } + + // Re-apply filter after rendering + if (currentDeviceFilter !== 'all') { + applyDeviceFilter(); + } + } + + function createSimpleDeviceCard(device) { + const protocol = device.protocol || 'ble'; + const rssi = device.rssi_current; + const rssiColor = getRssiColor(rssi); + const inBaseline = device.in_baseline || false; + const isNew = !inBaseline; + const hasName = !!device.name; + const isTracker = device.is_tracker === true; + const trackerType = device.tracker_type; + const trackerConfidence = device.tracker_confidence; + const riskScore = device.risk_score || 0; + + // Calculate RSSI bar width (0-100%) + // RSSI typically ranges from -100 (weak) to -30 (very strong) + const rssiPercent = rssi != null ? Math.max(0, Math.min(100, ((rssi + 100) / 70) * 100)) : 0; + + const displayName = device.name || formatDeviceId(device.address); + const name = escapeHtml(displayName); + const addr = escapeHtml(device.address || 'Unknown'); + const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : ''; + const seenCount = device.seen_count || 0; + const deviceIdEscaped = escapeHtml(device.device_id).replace(/'/g, "\\'"); + + // Protocol badge - compact + const protoBadge = protocol === 'ble' + ? 'BLE' + : 'CLASSIC'; + + // Tracker badge - show if device is detected as tracker + let trackerBadge = ''; + if (isTracker) { + const confColor = trackerConfidence === 'high' ? '#ef4444' : + trackerConfidence === 'medium' ? '#f97316' : '#eab308'; + const confBg = trackerConfidence === 'high' ? 'rgba(239,68,68,0.15)' : + trackerConfidence === 'medium' ? 'rgba(249,115,22,0.15)' : 'rgba(234,179,8,0.15)'; + const typeLabel = trackerType === 'airtag' ? 'AirTag' : + trackerType === 'tile' ? 'Tile' : + trackerType === 'samsung_smarttag' ? 'SmartTag' : + trackerType === 'findmy_accessory' ? 'FindMy' : + trackerType === 'chipolo' ? 'Chipolo' : 'TRACKER'; + trackerBadge = '' + typeLabel + ''; + } + + // Risk badge - show if risk score is significant + let riskBadge = ''; + if (riskScore >= 0.3) { + const riskColor = riskScore >= 0.5 ? '#ef4444' : '#f97316'; + riskBadge = '' + Math.round(riskScore * 100) + '% RISK'; + } + + // Status indicator + let statusDot; + if (isTracker && trackerConfidence === 'high') { + statusDot = ''; + } else if (isNew) { + statusDot = ''; + } else { + statusDot = ''; + } + + // Build secondary info line + let secondaryParts = [addr]; + if (mfr) secondaryParts.push(mfr); + secondaryParts.push('Seen ' + seenCount + '×'); + const secondaryInfo = secondaryParts.join(' · '); + + // Row border color - highlight trackers in red/orange + const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444' : + isTracker ? '#f97316' : rssiColor; + + return '
' + + '
' + + '
' + + protoBadge + + '' + name + '' + + trackerBadge + + riskBadge + + '
' + + '
' + + '
' + + '
' + + '' + (rssi != null ? rssi : '--') + '' + + '
' + + statusDot + + '
' + + '
' + + '
' + secondaryInfo + '
' + + '
'; + } + + function getRssiColor(rssi) { + if (rssi == null) return '#666'; + if (rssi >= -50) return '#22c55e'; + if (rssi >= -60) return '#84cc16'; + if (rssi >= -70) return '#eab308'; + if (rssi >= -80) return '#f97316'; + return '#ef4444'; + } + + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = String(text); + return div.innerHTML; + } + + 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(); + } + } catch (err) { + console.error('Failed to set baseline:', err); + } + } + + 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); + } + } + + function updateBaselineStatus() { + if (!baselineStatusEl) return; + + if (baselineSet) { + baselineStatusEl.textContent = `Baseline: ${baselineCount} devices`; + baselineStatusEl.style.color = '#22c55e'; + } else { + baselineStatusEl.textContent = 'No baseline'; + baselineStatusEl.style.color = ''; + } + } + + function exportData(format) { + window.open(`/api/bluetooth/export?format=${format}`, '_blank'); + } + + function showErrorMessage(message) { + console.error('[BT] Error:', message); + } + + // Public API + return { + init, + startScan, + stopScan, + checkCapabilities, + setBaseline, + clearBaseline, + exportData, + selectDevice, + clearSelection, + copyAddress, + getDevices: () => Array.from(devices.values()), + isScanning: () => isScanning + }; +})(); + +// Global functions for onclick handlers +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', () => { + if (document.getElementById('bluetoothMode')) { + BluetoothMode.init(); + } + }); +} else { + if (document.getElementById('bluetoothMode')) { + BluetoothMode.init(); + } +} + +window.BluetoothMode = BluetoothMode; diff --git a/static/js/modes/wifi.js b/static/js/modes/wifi.js new file mode 100644 index 0000000..d6b3438 --- /dev/null +++ b/static/js/modes/wifi.js @@ -0,0 +1,1109 @@ +/** + * WiFi Mode Controller (v2) + * + * Unified WiFi scanning with dual-mode architecture: + * - Quick Scan: System tools without monitor mode + * - Deep Scan: airodump-ng with monitor mode + * + * Features: + * - Proximity radar visualization + * - Channel utilization analysis + * - Hidden SSID correlation + * - Real-time SSE streaming + */ + +const WiFiMode = (function() { + 'use strict'; + + // ========================================================================== + // Configuration + // ========================================================================== + + const CONFIG = { + apiBase: '/wifi/v2', + pollInterval: 5000, + keepaliveTimeout: 30000, + maxNetworks: 500, + maxClients: 500, + maxProbes: 1000, + }; + + // ========================================================================== + // State + // ========================================================================== + + let isScanning = false; + let scanMode = 'quick'; // 'quick' or 'deep' + let eventSource = null; + let pollTimer = null; + + // Data stores + let networks = new Map(); // bssid -> network + let clients = new Map(); // mac -> client + let probeRequests = []; + let channelStats = []; + let recommendations = []; + + // UI state + let selectedNetwork = null; + let currentFilter = 'all'; + let currentSort = { field: 'rssi', order: 'desc' }; + + // Capabilities + let capabilities = null; + + // Callbacks for external integration + let onNetworkUpdate = null; + let onClientUpdate = null; + let onProbeRequest = null; + + // ========================================================================== + // Initialization + // ========================================================================== + + function init() { + console.log('[WiFiMode] Initializing...'); + + // Cache DOM elements + cacheDOM(); + + // Check capabilities + checkCapabilities(); + + // Initialize components + initScanModeTabs(); + initNetworkFilters(); + initSortControls(); + initProximityRadar(); + initChannelChart(); + + // Check if already scanning + checkScanStatus(); + + console.log('[WiFiMode] Initialized'); + } + + // DOM element cache + let elements = {}; + + function cacheDOM() { + elements = { + // Scan controls + quickScanBtn: document.getElementById('wifiQuickScanBtn'), + deepScanBtn: document.getElementById('wifiDeepScanBtn'), + stopScanBtn: document.getElementById('wifiStopScanBtn'), + scanModeQuick: document.getElementById('wifiScanModeQuick'), + scanModeDeep: document.getElementById('wifiScanModeDeep'), + + // Status bar + scanStatus: document.getElementById('wifiScanStatus'), + networkCount: document.getElementById('wifiNetworkCount'), + clientCount: document.getElementById('wifiClientCount'), + hiddenCount: document.getElementById('wifiHiddenCount'), + + // Network table + networkTable: document.getElementById('wifiNetworkTable'), + networkTableBody: document.getElementById('wifiNetworkTableBody'), + networkFilters: document.getElementById('wifiNetworkFilters'), + + // Visualizations + proximityRadar: document.getElementById('wifiProximityRadar'), + channelChart: document.getElementById('wifiChannelChart'), + channelBandTabs: document.getElementById('wifiChannelBandTabs'), + + // Zone summary + zoneImmediate: document.getElementById('wifiZoneImmediate'), + zoneNear: document.getElementById('wifiZoneNear'), + zoneFar: document.getElementById('wifiZoneFar'), + + // Security counts + wpa3Count: document.getElementById('wpa3Count'), + wpa2Count: document.getElementById('wpa2Count'), + wepCount: document.getElementById('wepCount'), + openCount: document.getElementById('openCount'), + + // Detail drawer + detailDrawer: document.getElementById('wifiDetailDrawer'), + detailEssid: document.getElementById('wifiDetailEssid'), + detailBssid: document.getElementById('wifiDetailBssid'), + detailRssi: document.getElementById('wifiDetailRssi'), + detailChannel: document.getElementById('wifiDetailChannel'), + detailBand: document.getElementById('wifiDetailBand'), + detailSecurity: document.getElementById('wifiDetailSecurity'), + detailCipher: document.getElementById('wifiDetailCipher'), + detailVendor: document.getElementById('wifiDetailVendor'), + detailClients: document.getElementById('wifiDetailClients'), + detailFirstSeen: document.getElementById('wifiDetailFirstSeen'), + detailClientList: document.getElementById('wifiDetailClientList'), + + // Interface select + interfaceSelect: document.getElementById('wifiInterfaceSelect'), + + // Capability status + capabilityStatus: document.getElementById('wifiCapabilityStatus'), + + // Export buttons + exportCsvBtn: document.getElementById('wifiExportCsv'), + exportJsonBtn: document.getElementById('wifiExportJson'), + }; + } + + // ========================================================================== + // Capabilities + // ========================================================================== + + async function checkCapabilities() { + try { + const response = await fetch(`${CONFIG.apiBase}/capabilities`); + if (!response.ok) throw new Error('Failed to fetch capabilities'); + + capabilities = await response.json(); + console.log('[WiFiMode] Capabilities:', capabilities); + + updateCapabilityUI(); + populateInterfaceSelect(); + } catch (error) { + console.error('[WiFiMode] Capability check failed:', error); + showCapabilityError('Failed to check WiFi capabilities'); + } + } + + function updateCapabilityUI() { + if (!capabilities || !elements.capabilityStatus) return; + + let html = ''; + + if (!capabilities.can_quick_scan && !capabilities.can_deep_scan) { + html = ` +
+ WiFi scanning not available +
    + ${capabilities.issues.map(i => `
  • ${escapeHtml(i)}
  • `).join('')} +
+
+ `; + } else { + // Show available modes + const modes = []; + if (capabilities.can_quick_scan) modes.push('Quick Scan'); + if (capabilities.can_deep_scan) modes.push('Deep Scan'); + + html = ` +
+ Available modes: ${modes.join(', ')} + ${capabilities.preferred_quick_tool ? ` (using ${capabilities.preferred_quick_tool})` : ''} +
+ `; + + if (capabilities.issues.length > 0) { + html += ` +
+ ${capabilities.issues.join('. ')} +
+ `; + } + } + + elements.capabilityStatus.innerHTML = html; + elements.capabilityStatus.style.display = html ? 'block' : 'none'; + + // Enable/disable scan buttons based on capabilities + if (elements.quickScanBtn) { + elements.quickScanBtn.disabled = !capabilities.can_quick_scan; + } + if (elements.deepScanBtn) { + elements.deepScanBtn.disabled = !capabilities.can_deep_scan; + } + } + + function showCapabilityError(message) { + if (!elements.capabilityStatus) return; + + elements.capabilityStatus.innerHTML = ` +
${escapeHtml(message)}
+ `; + elements.capabilityStatus.style.display = 'block'; + } + + function populateInterfaceSelect() { + if (!elements.interfaceSelect || !capabilities) return; + + elements.interfaceSelect.innerHTML = ''; + + if (capabilities.interfaces.length === 0) { + elements.interfaceSelect.innerHTML = ''; + return; + } + + capabilities.interfaces.forEach(iface => { + const option = document.createElement('option'); + option.value = iface.name; + option.textContent = `${iface.name}${iface.supports_monitor ? ' (monitor capable)' : ''}`; + elements.interfaceSelect.appendChild(option); + }); + + // Select default + if (capabilities.default_interface) { + elements.interfaceSelect.value = capabilities.default_interface; + } + } + + // ========================================================================== + // Scan Mode Tabs + // ========================================================================== + + function initScanModeTabs() { + if (elements.scanModeQuick) { + elements.scanModeQuick.addEventListener('click', () => setScanMode('quick')); + } + if (elements.scanModeDeep) { + elements.scanModeDeep.addEventListener('click', () => setScanMode('deep')); + } + } + + function setScanMode(mode) { + scanMode = mode; + + // Update tab UI + if (elements.scanModeQuick) { + elements.scanModeQuick.classList.toggle('active', mode === 'quick'); + } + if (elements.scanModeDeep) { + elements.scanModeDeep.classList.toggle('active', mode === 'deep'); + } + + console.log('[WiFiMode] Scan mode set to:', mode); + } + + // ========================================================================== + // Scanning + // ========================================================================== + + async function startQuickScan() { + if (isScanning) return; + + console.log('[WiFiMode] Starting quick scan...'); + setScanning(true, 'quick'); + + try { + const iface = elements.interfaceSelect?.value || null; + + const response = await fetch(`${CONFIG.apiBase}/scan/quick`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ interface: iface }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Quick scan failed'); + } + + const result = await response.json(); + console.log('[WiFiMode] Quick scan complete:', result); + + // Check for error first + if (result.error) { + console.error('[WiFiMode] Quick scan error from server:', result.error); + showError(result.error); + setScanning(false); + return; + } + + // Check if we got results + if (!result.access_points || result.access_points.length === 0) { + // No error but no results + let msg = 'Quick scan found no networks in range.'; + if (result.warnings && result.warnings.length > 0) { + msg += ' Warnings: ' + result.warnings.join('; '); + } + console.warn('[WiFiMode] ' + msg); + showError(msg + ' Try Deep Scan with monitor mode.'); + setScanning(false); + return; + } + + // Show any warnings even on success + if (result.warnings && result.warnings.length > 0) { + console.warn('[WiFiMode] Quick scan warnings:', result.warnings); + } + + // Process results + processQuickScanResult(result); + + // For quick scan, we're done after one scan + // But keep polling if user wants continuous updates + if (scanMode === 'quick') { + startQuickScanPolling(); + } + } catch (error) { + console.error('[WiFiMode] Quick scan error:', error); + showError(error.message + '. Try using Deep Scan instead.'); + setScanning(false); + } + } + + async function startDeepScan() { + if (isScanning) return; + + console.log('[WiFiMode] Starting deep scan...'); + setScanning(true, 'deep'); + + try { + const iface = elements.interfaceSelect?.value || null; + const band = document.getElementById('wifiBand')?.value || 'all'; + const channel = document.getElementById('wifiChannel')?.value || null; + + const response = await fetch(`${CONFIG.apiBase}/scan/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + interface: iface, + band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5', + channel: channel ? parseInt(channel) : null, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to start deep scan'); + } + + // Start SSE stream for real-time updates + startEventStream(); + } catch (error) { + console.error('[WiFiMode] Deep scan error:', error); + showError(error.message); + setScanning(false); + } + } + + async function stopScan() { + console.log('[WiFiMode] Stopping scan...'); + + // Stop polling + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + + // Close event stream + if (eventSource) { + eventSource.close(); + eventSource = null; + } + + // Stop deep scan on server + if (scanMode === 'deep') { + try { + await fetch(`${CONFIG.apiBase}/scan/stop`, { method: 'POST' }); + } catch (error) { + console.warn('[WiFiMode] Error stopping scan:', error); + } + } + + setScanning(false); + } + + function setScanning(scanning, mode = null) { + isScanning = scanning; + if (mode) scanMode = mode; + + // Update buttons + if (elements.quickScanBtn) { + elements.quickScanBtn.style.display = scanning ? 'none' : 'inline-block'; + } + if (elements.deepScanBtn) { + elements.deepScanBtn.style.display = scanning ? 'none' : 'inline-block'; + } + if (elements.stopScanBtn) { + elements.stopScanBtn.style.display = scanning ? 'inline-block' : 'none'; + } + + // Update status + if (elements.scanStatus) { + elements.scanStatus.textContent = scanning + ? `Scanning (${scanMode === 'quick' ? 'Quick' : 'Deep'})...` + : 'Idle'; + elements.scanStatus.className = scanning ? 'status-scanning' : 'status-idle'; + } + } + + async function checkScanStatus() { + try { + const response = await fetch(`${CONFIG.apiBase}/scan/status`); + if (!response.ok) return; + + const status = await response.json(); + + if (status.is_scanning) { + setScanning(true, status.scan_mode); + if (status.scan_mode === 'deep') { + startEventStream(); + } else { + startQuickScanPolling(); + } + } + } catch (error) { + console.debug('[WiFiMode] Status check failed:', error); + } + } + + // ========================================================================== + // Quick Scan Polling + // ========================================================================== + + function startQuickScanPolling() { + if (pollTimer) return; + + pollTimer = setInterval(async () => { + if (!isScanning || scanMode !== 'quick') { + clearInterval(pollTimer); + pollTimer = null; + return; + } + + try { + const iface = elements.interfaceSelect?.value || null; + const response = await fetch(`${CONFIG.apiBase}/scan/quick`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ interface: iface }), + }); + + if (response.ok) { + const result = await response.json(); + processQuickScanResult(result); + } + } catch (error) { + console.debug('[WiFiMode] Poll error:', error); + } + }, CONFIG.pollInterval); + } + + function processQuickScanResult(result) { + // Update networks + result.access_points.forEach(ap => { + networks.set(ap.bssid, ap); + }); + + // Update channel stats (calculate from networks if not provided by API) + channelStats = result.channel_stats || []; + recommendations = result.recommendations || []; + + // If no channel stats from API, calculate from networks + if (channelStats.length === 0 && networks.size > 0) { + channelStats = calculateChannelStats(); + } + + // Update UI + updateNetworkTable(); + updateStats(); + updateProximityRadar(); + updateChannelChart(); + + // Callbacks + result.access_points.forEach(ap => { + if (onNetworkUpdate) onNetworkUpdate(ap); + }); + } + + // ========================================================================== + // SSE Event Stream + // ========================================================================== + + function startEventStream() { + if (eventSource) { + eventSource.close(); + } + + console.log('[WiFiMode] Starting event stream...'); + eventSource = new EventSource(`${CONFIG.apiBase}/stream`); + + eventSource.onopen = () => { + console.log('[WiFiMode] Event stream connected'); + }; + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + handleStreamEvent(data); + } catch (error) { + console.debug('[WiFiMode] Event parse error:', error); + } + }; + + eventSource.onerror = (error) => { + console.warn('[WiFiMode] Event stream error:', error); + if (isScanning) { + // Attempt to reconnect + setTimeout(() => { + if (isScanning && scanMode === 'deep') { + startEventStream(); + } + }, 3000); + } + }; + } + + function handleStreamEvent(event) { + switch (event.type) { + case 'network_update': + handleNetworkUpdate(event.network); + break; + + case 'client_update': + handleClientUpdate(event.client); + break; + + case 'probe_request': + handleProbeRequest(event.probe); + break; + + case 'hidden_revealed': + handleHiddenRevealed(event.bssid, event.revealed_essid); + break; + + case 'scan_started': + console.log('[WiFiMode] Scan started:', event); + break; + + case 'scan_stopped': + console.log('[WiFiMode] Scan stopped'); + setScanning(false); + break; + + case 'scan_error': + console.error('[WiFiMode] Scan error:', event.error); + showError(event.error); + setScanning(false); + break; + + case 'keepalive': + // Ignore keepalives + break; + + default: + console.debug('[WiFiMode] Unknown event type:', event.type); + } + } + + function handleNetworkUpdate(network) { + networks.set(network.bssid, network); + updateNetworkRow(network); + updateStats(); + updateProximityRadar(); + + if (onNetworkUpdate) onNetworkUpdate(network); + } + + function handleClientUpdate(client) { + clients.set(client.mac, client); + updateStats(); + + if (onClientUpdate) onClientUpdate(client); + } + + function handleProbeRequest(probe) { + probeRequests.push(probe); + if (probeRequests.length > CONFIG.maxProbes) { + probeRequests.shift(); + } + + if (onProbeRequest) onProbeRequest(probe); + } + + function handleHiddenRevealed(bssid, revealedSsid) { + const network = networks.get(bssid); + if (network) { + network.revealed_essid = revealedSsid; + network.display_name = `${revealedSsid} (revealed)`; + updateNetworkRow(network); + + // Show notification + showInfo(`Hidden SSID revealed: ${revealedSsid}`); + } + } + + // ========================================================================== + // Network Table + // ========================================================================== + + function initNetworkFilters() { + if (!elements.networkFilters) return; + + elements.networkFilters.addEventListener('click', (e) => { + if (e.target.matches('.wifi-filter-btn')) { + const filter = e.target.dataset.filter; + setNetworkFilter(filter); + } + }); + } + + function setNetworkFilter(filter) { + currentFilter = filter; + + // Update button states + if (elements.networkFilters) { + elements.networkFilters.querySelectorAll('.wifi-filter-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.filter === filter); + }); + } + + updateNetworkTable(); + } + + function initSortControls() { + if (!elements.networkTable) return; + + elements.networkTable.addEventListener('click', (e) => { + const th = e.target.closest('th[data-sort]'); + if (th) { + const field = th.dataset.sort; + if (currentSort.field === field) { + currentSort.order = currentSort.order === 'desc' ? 'asc' : 'desc'; + } else { + currentSort.field = field; + currentSort.order = 'desc'; + } + updateNetworkTable(); + } + }); + } + + function updateNetworkTable() { + if (!elements.networkTableBody) return; + + // Filter networks + let filtered = Array.from(networks.values()); + + switch (currentFilter) { + case 'hidden': + filtered = filtered.filter(n => n.is_hidden); + break; + case 'open': + filtered = filtered.filter(n => n.security === 'Open'); + break; + case 'strong': + filtered = filtered.filter(n => n.rssi_current && n.rssi_current >= -60); + break; + case '2.4': + filtered = filtered.filter(n => n.band === '2.4GHz'); + break; + case '5': + filtered = filtered.filter(n => n.band === '5GHz'); + break; + } + + // Sort networks + filtered.sort((a, b) => { + let aVal, bVal; + + switch (currentSort.field) { + case 'rssi': + aVal = a.rssi_current || -100; + bVal = b.rssi_current || -100; + break; + case 'channel': + aVal = a.channel || 0; + bVal = b.channel || 0; + break; + case 'essid': + aVal = (a.essid || '').toLowerCase(); + bVal = (b.essid || '').toLowerCase(); + break; + case 'clients': + aVal = a.client_count || 0; + bVal = b.client_count || 0; + break; + default: + aVal = a.rssi_current || -100; + bVal = b.rssi_current || -100; + } + + if (currentSort.order === 'desc') { + return bVal > aVal ? 1 : bVal < aVal ? -1 : 0; + } else { + return aVal > bVal ? 1 : aVal < bVal ? -1 : 0; + } + }); + + // Render table + elements.networkTableBody.innerHTML = filtered.map(n => createNetworkRow(n)).join(''); + } + + function createNetworkRow(network) { + const rssi = network.rssi_current; + const signalClass = rssi >= -50 ? 'signal-strong' : + rssi >= -70 ? 'signal-medium' : + rssi >= -85 ? 'signal-weak' : 'signal-very-weak'; + + const securityClass = network.security === 'Open' ? 'security-open' : + network.security === 'WEP' ? 'security-wep' : + network.security.includes('WPA3') ? 'security-wpa3' : 'security-wpa'; + + const hiddenBadge = network.is_hidden ? 'Hidden' : ''; + const newBadge = network.is_new ? 'New' : ''; + + return ` + + + ${escapeHtml(network.display_name || network.essid || '[Hidden]')} + ${hiddenBadge}${newBadge} + + ${escapeHtml(network.bssid)} + ${network.channel || '-'} + + ${rssi !== null ? rssi : '-'} + + + ${escapeHtml(network.security)} + + ${network.client_count || 0} + + `; + } + + function updateNetworkRow(network) { + const row = elements.networkTableBody?.querySelector(`tr[data-bssid="${network.bssid}"]`); + if (row) { + row.outerHTML = createNetworkRow(network); + } else { + // Add new row + updateNetworkTable(); + } + } + + function selectNetwork(bssid) { + selectedNetwork = bssid; + + // Update row selection + elements.networkTableBody?.querySelectorAll('.wifi-network-row').forEach(row => { + row.classList.toggle('selected', row.dataset.bssid === bssid); + }); + + // Update detail panel + updateDetailPanel(bssid); + + // Highlight on radar + if (typeof WiFiProximityRadar !== 'undefined') { + WiFiProximityRadar.highlightNetwork(bssid); + } + } + + // ========================================================================== + // Detail Panel + // ========================================================================== + + function updateDetailPanel(bssid) { + if (!elements.detailDrawer) return; + + const network = networks.get(bssid); + if (!network) { + closeDetail(); + return; + } + + // Update drawer header + if (elements.detailEssid) { + elements.detailEssid.textContent = network.display_name || network.essid || '[Hidden SSID]'; + } + if (elements.detailBssid) { + elements.detailBssid.textContent = network.bssid; + } + + // Update detail stats + if (elements.detailRssi) { + elements.detailRssi.textContent = network.rssi_current ? `${network.rssi_current} dBm` : '--'; + } + if (elements.detailChannel) { + elements.detailChannel.textContent = network.channel || '--'; + } + if (elements.detailBand) { + elements.detailBand.textContent = network.band || '--'; + } + if (elements.detailSecurity) { + elements.detailSecurity.textContent = network.security || '--'; + } + if (elements.detailCipher) { + elements.detailCipher.textContent = network.cipher || '--'; + } + if (elements.detailVendor) { + elements.detailVendor.textContent = network.vendor || 'Unknown'; + } + if (elements.detailClients) { + elements.detailClients.textContent = network.client_count || '0'; + } + if (elements.detailFirstSeen) { + elements.detailFirstSeen.textContent = formatTime(network.first_seen); + } + + // Show the drawer + elements.detailDrawer.classList.add('open'); + } + + function closeDetail() { + selectedNetwork = null; + if (elements.detailDrawer) { + elements.detailDrawer.classList.remove('open'); + } + elements.networkTableBody?.querySelectorAll('.wifi-network-row').forEach(row => { + row.classList.remove('selected'); + }); + } + + // ========================================================================== + // Statistics + // ========================================================================== + + function updateStats() { + const networksList = Array.from(networks.values()); + + // Update counts in status bar + if (elements.networkCount) { + elements.networkCount.textContent = networks.size; + } + if (elements.clientCount) { + elements.clientCount.textContent = clients.size; + } + if (elements.hiddenCount) { + const hidden = networksList.filter(n => n.is_hidden).length; + elements.hiddenCount.textContent = hidden; + } + + // Update security counts + const securityCounts = { wpa3: 0, wpa2: 0, wep: 0, open: 0 }; + networksList.forEach(n => { + const sec = (n.security || '').toLowerCase(); + if (sec.includes('wpa3')) securityCounts.wpa3++; + else if (sec.includes('wpa2') || sec.includes('wpa')) securityCounts.wpa2++; + else if (sec.includes('wep')) securityCounts.wep++; + else if (sec === 'open' || sec === '') securityCounts.open++; + }); + + if (elements.wpa3Count) elements.wpa3Count.textContent = securityCounts.wpa3; + if (elements.wpa2Count) elements.wpa2Count.textContent = securityCounts.wpa2; + if (elements.wepCount) elements.wepCount.textContent = securityCounts.wep; + if (elements.openCount) elements.openCount.textContent = securityCounts.open; + + // Update zone summary + const zoneCounts = { immediate: 0, near: 0, far: 0 }; + networksList.forEach(n => { + const rssi = n.rssi_current; + if (rssi >= -50) zoneCounts.immediate++; + else if (rssi >= -70) zoneCounts.near++; + else zoneCounts.far++; + }); + + if (elements.zoneImmediate) elements.zoneImmediate.textContent = zoneCounts.immediate; + if (elements.zoneNear) elements.zoneNear.textContent = zoneCounts.near; + if (elements.zoneFar) elements.zoneFar.textContent = zoneCounts.far; + } + + // ========================================================================== + // Proximity Radar + // ========================================================================== + + function initProximityRadar() { + if (!elements.proximityRadar) return; + + // Initialize radar component + if (typeof ProximityRadar !== 'undefined') { + ProximityRadar.init('wifiProximityRadar', { + mode: 'wifi', + size: 280, + onDeviceClick: (bssid) => selectNetwork(bssid), + }); + } + } + + function updateProximityRadar() { + if (typeof ProximityRadar === 'undefined') return; + + // Convert networks to radar-compatible format + const devices = Array.from(networks.values()).map(n => ({ + device_key: n.bssid, + device_id: n.bssid, + name: n.essid || '[Hidden]', + rssi_current: n.rssi_current, + rssi_ema: n.rssi_ema, + proximity_band: n.proximity_band, + estimated_distance_m: n.estimated_distance_m, + is_new: n.is_new, + heuristic_flags: n.heuristic_flags || [], + })); + + ProximityRadar.updateDevices(devices); + } + + // ========================================================================== + // Channel Chart + // ========================================================================== + + function initChannelChart() { + if (!elements.channelChart) return; + + // Initialize channel chart component + if (typeof ChannelChart !== 'undefined') { + ChannelChart.init('wifiChannelChart'); + } + + // Band tabs + if (elements.channelBandTabs) { + elements.channelBandTabs.addEventListener('click', (e) => { + if (e.target.matches('.channel-band-tab')) { + const band = e.target.dataset.band; + elements.channelBandTabs.querySelectorAll('.channel-band-tab').forEach(t => { + t.classList.toggle('active', t.dataset.band === band); + }); + updateChannelChart(band); + } + }); + } + } + + function calculateChannelStats() { + // Calculate channel stats from current networks + const stats = {}; + const networksList = Array.from(networks.values()); + + // Initialize all channels + // 2.4 GHz: channels 1-13 + for (let ch = 1; ch <= 13; ch++) { + stats[ch] = { channel: ch, band: '2.4GHz', ap_count: 0, client_count: 0, utilization_score: 0 }; + } + // 5 GHz: common channels + [36, 40, 44, 48, 52, 56, 60, 64, 100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144, 149, 153, 157, 161, 165].forEach(ch => { + stats[ch] = { channel: ch, band: '5GHz', ap_count: 0, client_count: 0, utilization_score: 0 }; + }); + + // Count APs per channel + networksList.forEach(net => { + const ch = parseInt(net.channel); + if (stats[ch]) { + stats[ch].ap_count++; + stats[ch].client_count += (net.client_count || 0); + } + }); + + // Calculate utilization score (0-1) + const maxAPs = Math.max(1, ...Object.values(stats).map(s => s.ap_count)); + Object.values(stats).forEach(s => { + s.utilization_score = s.ap_count / maxAPs; + }); + + return Object.values(stats).filter(s => s.ap_count > 0 || [1, 6, 11, 36, 40, 44, 48, 149, 153, 157, 161, 165].includes(s.channel)); + } + + function updateChannelChart(band = '2.4') { + if (typeof ChannelChart === 'undefined') return; + + // Recalculate channel stats from networks if needed + if (channelStats.length === 0 && networks.size > 0) { + channelStats = calculateChannelStats(); + } + + // Filter stats by band + const bandFilter = band === '2.4' ? '2.4GHz' : band === '5' ? '5GHz' : '6GHz'; + const filteredStats = channelStats.filter(s => s.band === bandFilter); + const filteredRecs = recommendations.filter(r => r.band === bandFilter); + + ChannelChart.update(filteredStats, filteredRecs); + } + + // ========================================================================== + // Export + // ========================================================================== + + async function exportData(format) { + try { + const response = await fetch(`${CONFIG.apiBase}/export?format=${format}&type=all`); + if (!response.ok) throw new Error('Export failed'); + + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `wifi_scan_${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.${format}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (error) { + console.error('[WiFiMode] Export error:', error); + showError('Export failed: ' + error.message); + } + } + + // ========================================================================== + // Utilities + // ========================================================================== + + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + function formatTime(isoString) { + if (!isoString) return '-'; + const date = new Date(isoString); + return date.toLocaleTimeString(); + } + + function showError(message) { + // Use global notification if available + if (typeof showNotification === 'function') { + showNotification('WiFi Error', message, 'error'); + } else { + console.error('[WiFiMode]', message); + } + } + + function showInfo(message) { + if (typeof showNotification === 'function') { + showNotification('WiFi', message, 'info'); + } else { + console.log('[WiFiMode]', message); + } + } + + // ========================================================================== + // Public API + // ========================================================================== + + return { + init, + startQuickScan, + startDeepScan, + stopScan, + selectNetwork, + closeDetail, + setFilter: setNetworkFilter, + exportData, + checkCapabilities, + + // Getters + getNetworks: () => Array.from(networks.values()), + getClients: () => Array.from(clients.values()), + getProbes: () => [...probeRequests], + isScanning: () => isScanning, + getScanMode: () => scanMode, + + // Callbacks + onNetworkUpdate: (cb) => { onNetworkUpdate = cb; }, + onClientUpdate: (cb) => { onClientUpdate = cb; }, + onProbeRequest: (cb) => { onProbeRequest = cb; }, + }; +})(); + +// Auto-initialize when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + // Only init if we're in WiFi mode + if (typeof currentMode !== 'undefined' && currentMode === 'wifi') { + WiFiMode.init(); + } +}); 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 @@