diff --git a/app.py b/app.py index da0dc8e..fa75223 100644 --- a/app.py +++ b/app.py @@ -25,7 +25,7 @@ import subprocess from typing import Any -from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session +from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session, send_from_directory from werkzeug.security import check_password_hash from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES @@ -96,32 +96,32 @@ def add_security_headers(response): # CONTEXT PROCESSORS # ============================================ -@app.context_processor -def inject_offline_settings(): - """Inject offline settings into all templates.""" - from utils.database import get_setting - - # Privacy-first defaults: keep dashboard assets/fonts local to avoid - # third-party tracker/storage defenses in strict browsers. - assets_source = str(get_setting('offline.assets_source', 'local') or 'local').lower() - fonts_source = str(get_setting('offline.fonts_source', 'local') or 'local').lower() - if assets_source not in ('local', 'cdn'): - assets_source = 'local' - if fonts_source not in ('local', 'cdn'): - fonts_source = 'local' - # Force local delivery for core dashboard pages. - assets_source = 'local' - fonts_source = 'local' - - return { - 'offline_settings': { - 'enabled': get_setting('offline.enabled', False), - 'assets_source': assets_source, - 'fonts_source': fonts_source, - 'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'), - 'tile_server_url': get_setting('offline.tile_server_url', '') - } - } +@app.context_processor +def inject_offline_settings(): + """Inject offline settings into all templates.""" + from utils.database import get_setting + + # Privacy-first defaults: keep dashboard assets/fonts local to avoid + # third-party tracker/storage defenses in strict browsers. + assets_source = str(get_setting('offline.assets_source', 'local') or 'local').lower() + fonts_source = str(get_setting('offline.fonts_source', 'local') or 'local').lower() + if assets_source not in ('local', 'cdn'): + assets_source = 'local' + if fonts_source not in ('local', 'cdn'): + fonts_source = 'local' + # Force local delivery for core dashboard pages. + assets_source = 'local' + fonts_source = 'local' + + return { + 'offline_settings': { + 'enabled': get_setting('offline.enabled', False), + 'assets_source': assets_source, + 'fonts_source': fonts_source, + 'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'), + 'tile_server_url': get_setting('offline.tile_server_url', '') + } + } # ============================================ @@ -190,9 +190,9 @@ dsc_rtl_process = None dsc_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) dsc_lock = threading.Lock() -# TSCM (Technical Surveillance Countermeasures) -tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) -tscm_lock = threading.Lock() +# TSCM (Technical Surveillance Countermeasures) +tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) +tscm_lock = threading.Lock() # SubGHz Transceiver (HackRF) subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) @@ -396,6 +396,18 @@ def favicon() -> Response: return send_file('favicon.svg', mimetype='image/svg+xml') +@app.route('/sw.js') +def service_worker() -> Response: + resp = send_from_directory('static', 'sw.js', mimetype='application/javascript') + resp.headers['Service-Worker-Allowed'] = '/' + return resp + + +@app.route('/manifest.json') +def pwa_manifest() -> Response: + return send_from_directory('static', 'manifest.json', mimetype='application/manifest+json') + + @app.route('/devices') def get_devices() -> Response: """Get all detected SDR devices with hardware type info.""" @@ -659,105 +671,105 @@ def export_bluetooth() -> Response: }) -def _get_subghz_active() -> bool: - """Check if SubGHz manager has an active process.""" - try: - from utils.subghz import get_subghz_manager - return get_subghz_manager().active_mode != 'idle' - except Exception: - return False - - -def _get_bluetooth_health() -> tuple[bool, int]: - """Return Bluetooth active state and best-effort device count.""" - legacy_running = bt_process is not None and (bt_process.poll() is None if bt_process else False) - scanner_running = False - scanner_count = 0 - - try: - from utils.bluetooth.scanner import _scanner_instance as bt_scanner - if bt_scanner is not None: - scanner_running = bool(bt_scanner.is_scanning) - scanner_count = int(bt_scanner.device_count) - except Exception: - scanner_running = False - scanner_count = 0 - - locate_running = False - try: - from utils.bt_locate import get_locate_session - session = get_locate_session() - if session and getattr(session, 'active', False): - scanner = getattr(session, '_scanner', None) - locate_running = bool(scanner and scanner.is_scanning) - except Exception: - locate_running = False - - return (legacy_running or scanner_running or locate_running), max(len(bt_devices), scanner_count) - - -def _get_wifi_health() -> tuple[bool, int, int]: - """Return WiFi active state and best-effort network/client counts.""" - legacy_running = wifi_process is not None and (wifi_process.poll() is None if wifi_process else False) - scanner_running = False - scanner_networks = 0 - scanner_clients = 0 - - try: - from utils.wifi.scanner import _scanner_instance as wifi_scanner - if wifi_scanner is not None: - status = wifi_scanner.get_status() - scanner_running = bool(status.is_scanning) - scanner_networks = int(status.networks_found or 0) - scanner_clients = int(status.clients_found or 0) - except Exception: - scanner_running = False - scanner_networks = 0 - scanner_clients = 0 - - return ( - legacy_running or scanner_running, - max(len(wifi_networks), scanner_networks), - max(len(wifi_clients), scanner_clients), - ) - - -@app.route('/health') -def health_check() -> Response: - """Health check endpoint for monitoring.""" - import time - bt_active, bt_device_count = _get_bluetooth_health() - wifi_active, wifi_network_count, wifi_client_count = _get_wifi_health() - return jsonify({ - 'status': 'healthy', - 'version': VERSION, - 'uptime_seconds': round(time.time() - _app_start_time, 2), - 'processes': { +def _get_subghz_active() -> bool: + """Check if SubGHz manager has an active process.""" + try: + from utils.subghz import get_subghz_manager + return get_subghz_manager().active_mode != 'idle' + except Exception: + return False + + +def _get_bluetooth_health() -> tuple[bool, int]: + """Return Bluetooth active state and best-effort device count.""" + legacy_running = bt_process is not None and (bt_process.poll() is None if bt_process else False) + scanner_running = False + scanner_count = 0 + + try: + from utils.bluetooth.scanner import _scanner_instance as bt_scanner + if bt_scanner is not None: + scanner_running = bool(bt_scanner.is_scanning) + scanner_count = int(bt_scanner.device_count) + except Exception: + scanner_running = False + scanner_count = 0 + + locate_running = False + try: + from utils.bt_locate import get_locate_session + session = get_locate_session() + if session and getattr(session, 'active', False): + scanner = getattr(session, '_scanner', None) + locate_running = bool(scanner and scanner.is_scanning) + except Exception: + locate_running = False + + return (legacy_running or scanner_running or locate_running), max(len(bt_devices), scanner_count) + + +def _get_wifi_health() -> tuple[bool, int, int]: + """Return WiFi active state and best-effort network/client counts.""" + legacy_running = wifi_process is not None and (wifi_process.poll() is None if wifi_process else False) + scanner_running = False + scanner_networks = 0 + scanner_clients = 0 + + try: + from utils.wifi.scanner import _scanner_instance as wifi_scanner + if wifi_scanner is not None: + status = wifi_scanner.get_status() + scanner_running = bool(status.is_scanning) + scanner_networks = int(status.networks_found or 0) + scanner_clients = int(status.clients_found or 0) + except Exception: + scanner_running = False + scanner_networks = 0 + scanner_clients = 0 + + return ( + legacy_running or scanner_running, + max(len(wifi_networks), scanner_networks), + max(len(wifi_clients), scanner_clients), + ) + + +@app.route('/health') +def health_check() -> Response: + """Health check endpoint for monitoring.""" + import time + bt_active, bt_device_count = _get_bluetooth_health() + wifi_active, wifi_network_count, wifi_client_count = _get_wifi_health() + return jsonify({ + 'status': 'healthy', + 'version': VERSION, + 'uptime_seconds': round(time.time() - _app_start_time, 2), + 'processes': { 'pager': current_process is not None and (current_process.poll() is None if current_process else False), 'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False), 'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False), - 'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False), - 'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False), - 'vdl2': vdl2_process is not None and (vdl2_process.poll() is None if vdl2_process else False), - 'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False), - 'wifi': wifi_active, - 'bluetooth': bt_active, - 'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False), - 'subghz': _get_subghz_active(), - }, - 'data': { - 'aircraft_count': len(adsb_aircraft), - 'vessel_count': len(ais_vessels), - 'wifi_networks_count': wifi_network_count, - 'wifi_clients_count': wifi_client_count, - 'bt_devices_count': bt_device_count, - 'dsc_messages_count': len(dsc_messages), - } - }) + 'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False), + 'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False), + 'vdl2': vdl2_process is not None and (vdl2_process.poll() is None if vdl2_process else False), + 'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False), + 'wifi': wifi_active, + 'bluetooth': bt_active, + 'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False), + 'subghz': _get_subghz_active(), + }, + 'data': { + 'aircraft_count': len(adsb_aircraft), + 'vessel_count': len(ais_vessels), + 'wifi_networks_count': wifi_network_count, + 'wifi_clients_count': wifi_client_count, + 'bt_devices_count': bt_device_count, + 'dsc_messages_count': len(dsc_messages), + } + }) @app.route('/killall', methods=['POST']) -def kill_all() -> Response: +def kill_all() -> Response: """Kill all decoder, WiFi, and Bluetooth processes.""" global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process global vdl2_process @@ -773,7 +785,7 @@ def kill_all() -> Response: 'rtl_fm', 'multimon-ng', 'rtl_433', 'airodump-ng', 'aireplay-ng', 'airmon-ng', 'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher', - 'hcitool', 'bluetoothctl', 'satdump', + 'hcitool', 'bluetoothctl', 'satdump', 'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg', 'hackrf_transfer', 'hackrf_sweep' ] @@ -823,7 +835,7 @@ def kill_all() -> Response: dsc_process = None dsc_rtl_process = None - # Reset Bluetooth state (legacy) + # Reset Bluetooth state (legacy) with bt_lock: if bt_process: try: @@ -843,16 +855,16 @@ def kill_all() -> Response: except Exception: pass - # Reset SubGHz state - try: - from utils.subghz import get_subghz_manager - get_subghz_manager().stop_all() - except Exception: - pass - - # Clear SDR device registry - with sdr_device_registry_lock: - sdr_device_registry.clear() + # Reset SubGHz state + try: + from utils.subghz import get_subghz_manager + get_subghz_manager().stop_all() + except Exception: + pass + + # Clear SDR device registry + with sdr_device_registry_lock: + sdr_device_registry.clear() return jsonify({'status': 'killed', 'processes': killed}) diff --git a/routes/__init__.py b/routes/__init__.py index 844a117..50eea2f 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -32,10 +32,10 @@ def register_blueprints(app): from .websdr import websdr_bp from .alerts import alerts_bp from .recordings import recordings_bp - from .subghz import subghz_bp - from .bt_locate import bt_locate_bp - from .analytics import analytics_bp - from .space_weather import space_weather_bp + from .subghz import subghz_bp + from .bt_locate import bt_locate_bp + from .space_weather import space_weather_bp + from .fingerprint import fingerprint_bp app.register_blueprint(pager_bp) app.register_blueprint(sensor_bp) @@ -68,11 +68,11 @@ def register_blueprints(app): app.register_blueprint(alerts_bp) # Cross-mode alerts app.register_blueprint(recordings_bp) # Session recordings app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF) - app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking - app.register_blueprint(analytics_bp) # Cross-mode analytics dashboard - app.register_blueprint(space_weather_bp) # Space weather monitoring - - # Initialize TSCM state with queue and lock from app + app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking + app.register_blueprint(space_weather_bp) # Space weather monitoring + app.register_blueprint(fingerprint_bp) # RF fingerprinting + + # Initialize TSCM state with queue and lock from app import app as app_module if hasattr(app_module, 'tscm_queue') and hasattr(app_module, 'tscm_lock'): init_tscm_state(app_module.tscm_queue, app_module.tscm_lock) diff --git a/routes/analytics.py b/routes/analytics.py deleted file mode 100644 index 17c8db4..0000000 --- a/routes/analytics.py +++ /dev/null @@ -1,528 +0,0 @@ -"""Analytics dashboard: cross-mode summary, activity sparklines, export, geofence CRUD.""" - -from __future__ import annotations - -import csv -import io -import json -from datetime import datetime, timezone -from typing import Any - -from flask import Blueprint, Response, jsonify, request - -import app as app_module -from utils.analytics import ( - get_activity_tracker, - get_cross_mode_summary, - get_emergency_squawks, - get_mode_health, -) -from utils.alerts import get_alert_manager -from utils.flight_correlator import get_flight_correlator -from utils.geofence import get_geofence_manager -from utils.temporal_patterns import get_pattern_detector - -analytics_bp = Blueprint('analytics', __name__, url_prefix='/analytics') - - -# Map mode names to DataStore attribute(s) -MODE_STORES: dict[str, list[str]] = { - 'adsb': ['adsb_aircraft'], - 'ais': ['ais_vessels'], - 'wifi': ['wifi_networks', 'wifi_clients'], - 'bluetooth': ['bt_devices'], - 'dsc': ['dsc_messages'], -} - - -@analytics_bp.route('/summary') -def analytics_summary(): - """Return cross-mode counts, health, and emergency squawks.""" - return jsonify({ - 'status': 'success', - 'counts': get_cross_mode_summary(), - 'health': get_mode_health(), - 'squawks': get_emergency_squawks(), - 'flight_messages': { - 'acars': get_flight_correlator().acars_count, - 'vdl2': get_flight_correlator().vdl2_count, - }, - }) - - -@analytics_bp.route('/activity') -def analytics_activity(): - """Return sparkline arrays for each mode.""" - tracker = get_activity_tracker() - return jsonify({ - 'status': 'success', - 'sparklines': tracker.get_all_sparklines(), - }) - - -@analytics_bp.route('/squawks') -def analytics_squawks(): - """Return current emergency squawk codes from ADS-B.""" - return jsonify({ - 'status': 'success', - 'squawks': get_emergency_squawks(), - }) - - -@analytics_bp.route('/patterns') -def analytics_patterns(): - """Return detected temporal patterns.""" - return jsonify({ - 'status': 'success', - 'patterns': get_pattern_detector().get_all_patterns(), - }) - - -@analytics_bp.route('/target') -def analytics_target(): - """Search entities across multiple modes for a target-centric view.""" - query = (request.args.get('q') or '').strip() - requested_limit = request.args.get('limit', default=120, type=int) or 120 - limit = max(1, min(500, requested_limit)) - - if not query: - return jsonify({ - 'status': 'success', - 'query': '', - 'results': [], - 'mode_counts': {}, - }) - - needle = query.lower() - results: list[dict[str, Any]] = [] - mode_counts: dict[str, int] = {} - - def push(mode: str, entity_id: str, title: str, subtitle: str, last_seen: str | None = None) -> None: - if len(results) >= limit: - return - results.append({ - 'mode': mode, - 'id': entity_id, - 'title': title, - 'subtitle': subtitle, - 'last_seen': last_seen, - }) - mode_counts[mode] = mode_counts.get(mode, 0) + 1 - - # ADS-B - for icao, aircraft in app_module.adsb_aircraft.items(): - if not isinstance(aircraft, dict): - continue - fields = [ - icao, - aircraft.get('icao'), - aircraft.get('hex'), - aircraft.get('callsign'), - aircraft.get('registration'), - aircraft.get('flight'), - ] - if not _matches_query(needle, fields): - continue - title = str(aircraft.get('callsign') or icao or 'Aircraft').strip() - subtitle = f"ICAO {aircraft.get('icao') or icao} | Alt {aircraft.get('altitude', '--')} | Speed {aircraft.get('speed', '--')}" - push('adsb', str(icao), title, subtitle, aircraft.get('lastSeen') or aircraft.get('last_seen')) - if len(results) >= limit: - break - - # AIS - if len(results) < limit: - for mmsi, vessel in app_module.ais_vessels.items(): - if not isinstance(vessel, dict): - continue - fields = [ - mmsi, - vessel.get('mmsi'), - vessel.get('name'), - vessel.get('shipname'), - vessel.get('callsign'), - vessel.get('imo'), - ] - if not _matches_query(needle, fields): - continue - vessel_name = vessel.get('name') or vessel.get('shipname') or mmsi or 'Vessel' - subtitle = f"MMSI {vessel.get('mmsi') or mmsi} | Type {vessel.get('ship_type') or vessel.get('type') or '--'}" - push('ais', str(mmsi), str(vessel_name), subtitle, vessel.get('lastSeen') or vessel.get('last_seen')) - if len(results) >= limit: - break - - # WiFi networks and clients - if len(results) < limit: - for bssid, net in app_module.wifi_networks.items(): - if not isinstance(net, dict): - continue - fields = [bssid, net.get('bssid'), net.get('ssid'), net.get('vendor')] - if not _matches_query(needle, fields): - continue - title = str(net.get('ssid') or net.get('bssid') or bssid or 'WiFi Network') - subtitle = f"BSSID {net.get('bssid') or bssid} | CH {net.get('channel', '--')} | RSSI {net.get('signal', '--')}" - push('wifi', str(bssid), title, subtitle, net.get('lastSeen') or net.get('last_seen')) - if len(results) >= limit: - break - - if len(results) < limit: - for client_mac, client in app_module.wifi_clients.items(): - if not isinstance(client, dict): - continue - fields = [client_mac, client.get('mac'), client.get('bssid'), client.get('ssid'), client.get('vendor')] - if not _matches_query(needle, fields): - continue - title = str(client.get('mac') or client_mac or 'WiFi Client') - subtitle = f"BSSID {client.get('bssid') or '--'} | Probe {client.get('ssid') or '--'}" - push('wifi', str(client_mac), title, subtitle, client.get('lastSeen') or client.get('last_seen')) - if len(results) >= limit: - break - - # Bluetooth - if len(results) < limit: - for address, dev in app_module.bt_devices.items(): - if not isinstance(dev, dict): - continue - fields = [ - address, - dev.get('address'), - dev.get('mac'), - dev.get('name'), - dev.get('manufacturer'), - dev.get('vendor'), - ] - if not _matches_query(needle, fields): - continue - title = str(dev.get('name') or dev.get('address') or address or 'Bluetooth Device') - subtitle = f"MAC {dev.get('address') or address} | RSSI {dev.get('rssi', '--')} | Vendor {dev.get('manufacturer') or dev.get('vendor') or '--'}" - push('bluetooth', str(address), title, subtitle, dev.get('lastSeen') or dev.get('last_seen')) - if len(results) >= limit: - break - - # DSC recent messages - if len(results) < limit: - for msg_id, msg in app_module.dsc_messages.items(): - if not isinstance(msg, dict): - continue - fields = [ - msg_id, - msg.get('mmsi'), - msg.get('from_mmsi'), - msg.get('to_mmsi'), - msg.get('from_callsign'), - msg.get('to_callsign'), - msg.get('category'), - ] - if not _matches_query(needle, fields): - continue - title = str(msg.get('from_mmsi') or msg.get('mmsi') or msg_id or 'DSC Message') - subtitle = f"To {msg.get('to_mmsi') or '--'} | Cat {msg.get('category') or '--'} | Freq {msg.get('frequency') or '--'}" - push('dsc', str(msg_id), title, subtitle, msg.get('timestamp') or msg.get('lastSeen') or msg.get('last_seen')) - if len(results) >= limit: - break - - return jsonify({ - 'status': 'success', - 'query': query, - 'results': results, - 'mode_counts': mode_counts, - }) - - -@analytics_bp.route('/insights') -def analytics_insights(): - """Return actionable insight cards and top changes.""" - counts = get_cross_mode_summary() - tracker = get_activity_tracker() - sparklines = tracker.get_all_sparklines() - squawks = get_emergency_squawks() - patterns = get_pattern_detector().get_all_patterns() - alerts = get_alert_manager().list_events(limit=120) - - top_changes = _compute_mode_changes(sparklines) - busiest_mode, busiest_count = _get_busiest_mode(counts) - critical_1h = _count_recent_alerts(alerts, severities={'critical', 'high'}, max_age_seconds=3600) - recurring_emitters = sum(1 for p in patterns if float(p.get('confidence') or 0.0) >= 0.7) - - cards = [] - if top_changes: - lead = top_changes[0] - direction = 'up' if lead['delta'] >= 0 else 'down' - cards.append({ - 'id': 'fastest_change', - 'title': 'Fastest Change', - 'value': f"{lead['mode_label']} ({lead['signed_delta']})", - 'label': 'last window vs prior', - 'severity': 'high' if lead['delta'] > 0 else 'low', - 'detail': f"Traffic is trending {direction} in {lead['mode_label']}.", - }) - else: - cards.append({ - 'id': 'fastest_change', - 'title': 'Fastest Change', - 'value': 'Insufficient data', - 'label': 'wait for activity history', - 'severity': 'low', - 'detail': 'Sparklines need more samples to score momentum.', - }) - - cards.append({ - 'id': 'busiest_mode', - 'title': 'Busiest Mode', - 'value': f"{busiest_mode} ({busiest_count})", - 'label': 'current observed entities', - 'severity': 'medium' if busiest_count > 0 else 'low', - 'detail': 'Highest live entity count across monitoring modes.', - }) - cards.append({ - 'id': 'critical_alerts', - 'title': 'Critical Alerts (1h)', - 'value': str(critical_1h), - 'label': 'critical/high severities', - 'severity': 'critical' if critical_1h > 0 else 'low', - 'detail': 'Prioritize triage if this count is non-zero.', - }) - cards.append({ - 'id': 'emergency_squawks', - 'title': 'Emergency Squawks', - 'value': str(len(squawks)), - 'label': 'active ADS-B emergency codes', - 'severity': 'critical' if squawks else 'low', - 'detail': 'Immediate aviation anomalies currently visible.', - }) - cards.append({ - 'id': 'recurring_emitters', - 'title': 'Recurring Emitters', - 'value': str(recurring_emitters), - 'label': 'pattern confidence >= 0.70', - 'severity': 'medium' if recurring_emitters > 0 else 'low', - 'detail': 'Potentially stationary or periodic emitters detected.', - }) - - return jsonify({ - 'status': 'success', - 'generated_at': datetime.now(timezone.utc).isoformat(), - 'cards': cards, - 'top_changes': top_changes[:5], - }) - - -def _compute_mode_changes(sparklines: dict[str, list[int]]) -> list[dict]: - mode_labels = { - 'adsb': 'ADS-B', - 'ais': 'AIS', - 'wifi': 'WiFi', - 'bluetooth': 'Bluetooth', - 'dsc': 'DSC', - 'acars': 'ACARS', - 'vdl2': 'VDL2', - 'aprs': 'APRS', - 'meshtastic': 'Meshtastic', - } - rows = [] - for mode, samples in (sparklines or {}).items(): - if not isinstance(samples, list) or len(samples) < 4: - continue - - window = max(2, min(12, len(samples) // 2)) - recent = samples[-window:] - previous = samples[-(window * 2):-window] - if not previous: - continue - - recent_avg = sum(recent) / len(recent) - prev_avg = sum(previous) / len(previous) - delta = round(recent_avg - prev_avg, 1) - rows.append({ - 'mode': mode, - 'mode_label': mode_labels.get(mode, mode.upper()), - 'delta': delta, - 'signed_delta': ('+' if delta >= 0 else '') + str(delta), - 'recent_avg': round(recent_avg, 1), - 'previous_avg': round(prev_avg, 1), - 'direction': 'up' if delta > 0 else ('down' if delta < 0 else 'flat'), - }) - - rows.sort(key=lambda r: abs(r['delta']), reverse=True) - return rows - - -def _matches_query(needle: str, values: list[Any]) -> bool: - for value in values: - if value is None: - continue - if needle in str(value).lower(): - return True - return False - - -def _count_recent_alerts(alerts: list[dict], severities: set[str], max_age_seconds: int) -> int: - now = datetime.now(timezone.utc) - count = 0 - for event in alerts: - sev = str(event.get('severity') or '').lower() - if sev not in severities: - continue - created_raw = event.get('created_at') - if not created_raw: - continue - try: - created = datetime.fromisoformat(str(created_raw).replace('Z', '+00:00')) - except ValueError: - continue - if created.tzinfo is None: - created = created.replace(tzinfo=timezone.utc) - age = (now - created).total_seconds() - if 0 <= age <= max_age_seconds: - count += 1 - return count - - -def _get_busiest_mode(counts: dict[str, int]) -> tuple[str, int]: - mode_labels = { - 'adsb': 'ADS-B', - 'ais': 'AIS', - 'wifi': 'WiFi', - 'bluetooth': 'Bluetooth', - 'dsc': 'DSC', - 'acars': 'ACARS', - 'vdl2': 'VDL2', - 'aprs': 'APRS', - 'meshtastic': 'Meshtastic', - } - filtered = {k: int(v or 0) for k, v in (counts or {}).items() if k in mode_labels} - if not filtered: - return ('None', 0) - mode = max(filtered, key=filtered.get) - return (mode_labels.get(mode, mode.upper()), filtered[mode]) - - -@analytics_bp.route('/export/') -def analytics_export(mode: str): - """Export current DataStore contents as JSON or CSV.""" - fmt = request.args.get('format', 'json').lower() - - if mode == 'sensor': - # Sensor doesn't use DataStore; return recent queue-based data - return jsonify({'status': 'success', 'data': [], 'message': 'Sensor data is stream-only'}) - - store_names = MODE_STORES.get(mode) - if not store_names: - return jsonify({'status': 'error', 'message': f'Unknown mode: {mode}'}), 400 - - all_items: list[dict] = [] - - # Try v2 scanners first for wifi/bluetooth - if mode == 'wifi': - try: - from utils.wifi.scanner import _scanner_instance as wifi_scanner - if wifi_scanner is not None: - for ap in wifi_scanner.access_points: - all_items.append(ap.to_dict()) - for client in wifi_scanner.clients: - item = client.to_dict() - item['_store'] = 'wifi_clients' - all_items.append(item) - except Exception: - pass - elif mode == 'bluetooth': - try: - from utils.bluetooth.scanner import _scanner_instance as bt_scanner - if bt_scanner is not None: - for dev in bt_scanner.get_devices(): - all_items.append(dev.to_dict()) - except Exception: - pass - - # Fall back to legacy DataStores if v2 scanners yielded nothing - if not all_items: - for store_name in store_names: - store = getattr(app_module, store_name, None) - if store is None: - continue - for key, value in store.items(): - item = dict(value) if isinstance(value, dict) else {'id': key, 'value': value} - item.setdefault('_store', store_name) - all_items.append(item) - - if fmt == 'csv': - if not all_items: - output = '' - else: - # Collect all keys across items - fieldnames: list[str] = [] - seen: set[str] = set() - for item in all_items: - for k in item: - if k not in seen: - fieldnames.append(k) - seen.add(k) - - buf = io.StringIO() - writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction='ignore') - writer.writeheader() - for item in all_items: - # Serialize non-scalar values - row = {} - for k in fieldnames: - v = item.get(k) - if isinstance(v, (dict, list)): - row[k] = json.dumps(v) - else: - row[k] = v - writer.writerow(row) - output = buf.getvalue() - - response = Response(output, mimetype='text/csv') - response.headers['Content-Disposition'] = f'attachment; filename={mode}_export.csv' - return response - - # Default: JSON - return jsonify({'status': 'success', 'mode': mode, 'count': len(all_items), 'data': all_items}) - - -# ========================================================================= -# Geofence CRUD -# ========================================================================= - -@analytics_bp.route('/geofences') -def list_geofences(): - return jsonify({ - 'status': 'success', - 'zones': get_geofence_manager().list_zones(), - }) - - -@analytics_bp.route('/geofences', methods=['POST']) -def create_geofence(): - data = request.get_json() or {} - name = data.get('name') - lat = data.get('lat') - lon = data.get('lon') - radius_m = data.get('radius_m') - - if not all([name, lat is not None, lon is not None, radius_m is not None]): - return jsonify({'status': 'error', 'message': 'name, lat, lon, radius_m are required'}), 400 - - try: - lat = float(lat) - lon = float(lon) - radius_m = float(radius_m) - except (TypeError, ValueError): - return jsonify({'status': 'error', 'message': 'lat, lon, radius_m must be numbers'}), 400 - - if not (-90 <= lat <= 90) or not (-180 <= lon <= 180): - return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400 - if radius_m <= 0: - return jsonify({'status': 'error', 'message': 'radius_m must be positive'}), 400 - - alert_on = data.get('alert_on', 'enter_exit') - zone_id = get_geofence_manager().add_zone(name, lat, lon, radius_m, alert_on) - return jsonify({'status': 'success', 'zone_id': zone_id}) - - -@analytics_bp.route('/geofences/', methods=['DELETE']) -def delete_geofence(zone_id: int): - ok = get_geofence_manager().delete_zone(zone_id) - if not ok: - return jsonify({'status': 'error', 'message': 'Zone not found'}), 404 - return jsonify({'status': 'success'}) diff --git a/routes/fingerprint.py b/routes/fingerprint.py new file mode 100644 index 0000000..6852e77 --- /dev/null +++ b/routes/fingerprint.py @@ -0,0 +1,113 @@ +"""RF Fingerprinting CRUD + compare API.""" + +from __future__ import annotations + +import os +import threading +from flask import Blueprint, jsonify, request + +fingerprint_bp = Blueprint("fingerprint", __name__, url_prefix="/fingerprint") + +_fingerprinter = None +_fingerprinter_lock = threading.Lock() + +_active_session_id: int | None = None +_session_lock = threading.Lock() + + +def _get_fingerprinter(): + global _fingerprinter + if _fingerprinter is None: + with _fingerprinter_lock: + if _fingerprinter is None: + from utils.rf_fingerprint import RFFingerprinter + db_path = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "instance", "rf_fingerprints.db" + ) + os.makedirs(os.path.dirname(db_path), exist_ok=True) + _fingerprinter = RFFingerprinter(db_path) + return _fingerprinter + + +@fingerprint_bp.route("/start", methods=["POST"]) +def start_session(): + global _active_session_id + data = request.get_json(force=True) or {} + name = data.get("name", "Unnamed Session") + location = data.get("location") + fp = _get_fingerprinter() + with _session_lock: + if _active_session_id is not None: + return jsonify({"error": "Session already active", "session_id": _active_session_id}), 409 + session_id = fp.start_session(name, location) + _active_session_id = session_id + return jsonify({"session_id": session_id, "name": name}) + + +@fingerprint_bp.route("/stop", methods=["POST"]) +def stop_session(): + global _active_session_id + fp = _get_fingerprinter() + with _session_lock: + if _active_session_id is None: + return jsonify({"error": "No active session"}), 400 + session_id = _active_session_id + result = fp.finalize(session_id) + _active_session_id = None + return jsonify(result) + + +@fingerprint_bp.route("/observation", methods=["POST"]) +def add_observation(): + global _active_session_id + fp = _get_fingerprinter() + data = request.get_json(force=True) or {} + observations = data.get("observations", []) + with _session_lock: + session_id = _active_session_id + if session_id is None: + return jsonify({"error": "No active session"}), 400 + if not observations: + return jsonify({"added": 0}) + fp.add_observations_batch(session_id, observations) + return jsonify({"added": len(observations)}) + + +@fingerprint_bp.route("/list", methods=["GET"]) +def list_sessions(): + fp = _get_fingerprinter() + sessions = fp.list_sessions() + with _session_lock: + active_id = _active_session_id + return jsonify({"sessions": sessions, "active_session_id": active_id}) + + +@fingerprint_bp.route("/compare", methods=["POST"]) +def compare(): + fp = _get_fingerprinter() + data = request.get_json(force=True) or {} + baseline_id = data.get("baseline_id") + observations = data.get("observations", []) + if not baseline_id: + return jsonify({"error": "baseline_id required"}), 400 + anomalies = fp.compare(int(baseline_id), observations) + bands = fp.get_baseline_bands(int(baseline_id)) + return jsonify({"anomalies": anomalies, "baseline_bands": bands}) + + +@fingerprint_bp.route("/", methods=["DELETE"]) +def delete_session(session_id: int): + global _active_session_id + fp = _get_fingerprinter() + with _session_lock: + if _active_session_id == session_id: + _active_session_id = None + fp.delete_session(session_id) + return jsonify({"deleted": session_id}) + + +@fingerprint_bp.route("/status", methods=["GET"]) +def session_status(): + with _session_lock: + active_id = _active_session_id + return jsonify({"active_session_id": active_id}) diff --git a/routes/listening_post.py b/routes/listening_post.py index b09772e..9a019a5 100644 --- a/routes/listening_post.py +++ b/routes/listening_post.py @@ -5,15 +5,16 @@ from __future__ import annotations import json import math import os -import queue -import select -import signal -import shutil -import subprocess -import threading -import time +import queue +import select +import signal +import shutil +import struct +import subprocess +import threading +import time from datetime import datetime -from typing import Generator, Optional, List, Dict +from typing import Any, Dict, Generator, List, Optional from flask import Blueprint, jsonify, request, Response @@ -40,9 +41,10 @@ listening_post_bp = Blueprint('listening_post', __name__, url_prefix='/listening audio_process = None audio_rtl_process = None audio_lock = threading.Lock() -audio_running = False -audio_frequency = 0.0 -audio_modulation = 'fm' +audio_running = False +audio_frequency = 0.0 +audio_modulation = 'fm' +audio_source = 'process' # Scanner state scanner_thread: Optional[threading.Thread] = None @@ -117,6 +119,22 @@ def _rtl_fm_demod_mode(modulation: str) -> str: """Map UI modulation names to rtl_fm demod tokens.""" mod = str(modulation or '').lower().strip() return 'wbfm' if mod == 'wfm' else mod + + +def _wav_header(sample_rate: int = 48000, bits_per_sample: int = 16, channels: int = 1) -> bytes: + """Create a streaming WAV header with unknown data length.""" + bytes_per_sample = bits_per_sample // 8 + byte_rate = sample_rate * channels * bytes_per_sample + block_align = channels * bytes_per_sample + return ( + b'RIFF' + + struct.pack(' Response: # ============================================ @listening_post_bp.route('/audio/start', methods=['POST']) -def start_audio() -> Response: - """Start audio at specific frequency (manual mode).""" - global scanner_running, scanner_active_device, listening_active_device, scanner_power_process, scanner_thread +def start_audio() -> Response: + """Start audio at specific frequency (manual mode).""" + global scanner_running, scanner_active_device, listening_active_device, scanner_power_process, scanner_thread + global audio_running, audio_frequency, audio_modulation, audio_source # Stop scanner if running if scanner_running: @@ -1273,18 +1302,23 @@ def start_audio() -> Response: data = request.json or {} - try: - frequency = float(data.get('frequency', 0)) - modulation = normalize_modulation(data.get('modulation', 'wfm')) - squelch = int(data.get('squelch', 0)) - gain = int(data.get('gain', 40)) - device = int(data.get('device', 0)) - sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower() - except (ValueError, TypeError) as e: - return jsonify({ - 'status': 'error', - 'message': f'Invalid parameter: {e}' - }), 400 + try: + frequency = float(data.get('frequency', 0)) + modulation = normalize_modulation(data.get('modulation', 'wfm')) + squelch = int(data.get('squelch', 0)) + gain = int(data.get('gain', 40)) + device = int(data.get('device', 0)) + sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower() + bias_t_raw = data.get('bias_t', scanner_config.get('bias_t', False)) + if isinstance(bias_t_raw, str): + bias_t = bias_t_raw.strip().lower() in {'1', 'true', 'yes', 'on'} + else: + bias_t = bool(bias_t_raw) + except (ValueError, TypeError) as e: + return jsonify({ + 'status': 'error', + 'message': f'Invalid parameter: {e}' + }), 400 if frequency <= 0: return jsonify({ @@ -1301,14 +1335,51 @@ def start_audio() -> Response: # Update config for audio scanner_config['squelch'] = squelch - scanner_config['gain'] = gain - scanner_config['device'] = device - scanner_config['sdr_type'] = sdr_type - - # Stop waterfall if it's using the same SDR (SSE path) - if waterfall_running and waterfall_active_device == device: - _stop_waterfall_internal() - time.sleep(0.2) + scanner_config['gain'] = gain + scanner_config['device'] = device + scanner_config['sdr_type'] = sdr_type + scanner_config['bias_t'] = bias_t + + # Preferred path: when waterfall WebSocket is active on the same SDR, + # derive monitor audio from that IQ stream instead of spawning rtl_fm. + try: + from routes.waterfall_websocket import ( + get_shared_capture_status, + start_shared_monitor_from_capture, + ) + + shared = get_shared_capture_status() + if shared.get('running') and shared.get('device') == device: + _stop_audio_stream() + ok, msg = start_shared_monitor_from_capture( + device=device, + frequency_mhz=frequency, + modulation=modulation, + squelch=squelch, + ) + if ok: + audio_running = True + audio_frequency = frequency + audio_modulation = modulation + audio_source = 'waterfall' + # Shared monitor uses the waterfall's existing SDR claim. + if listening_active_device is not None: + app_module.release_sdr_device(listening_active_device) + listening_active_device = None + return jsonify({ + 'status': 'started', + 'frequency': frequency, + 'modulation': modulation, + 'source': 'waterfall', + }) + logger.warning(f"Shared waterfall monitor unavailable: {msg}") + except Exception as e: + logger.debug(f"Shared waterfall monitor probe failed: {e}") + + # Stop waterfall if it's using the same SDR (SSE path) + if waterfall_running and waterfall_active_device == device: + _stop_waterfall_internal() + time.sleep(0.2) # Claim device for listening audio. The WebSocket waterfall handler # may still be tearing down its IQ capture process (thread join + @@ -1319,22 +1390,15 @@ def start_audio() -> Response: app_module.release_sdr_device(listening_active_device) listening_active_device = None - error = None - max_claim_attempts = 6 - for attempt in range(max_claim_attempts): - # Force-release a stale waterfall registry entry on each - # attempt — the WebSocket handler may not have finished - # cleanup yet. - device_status = app_module.get_sdr_device_status() - if device_status.get(device) == 'waterfall': - app_module.release_sdr_device(device) - - error = app_module.claim_sdr_device(device, 'listening') - if not error: - break - if attempt < max_claim_attempts - 1: - logger.debug( - f"Device claim attempt {attempt + 1}/{max_claim_attempts} " + error = None + max_claim_attempts = 6 + for attempt in range(max_claim_attempts): + error = app_module.claim_sdr_device(device, 'listening') + if not error: + break + if attempt < max_claim_attempts - 1: + logger.debug( + f"Device claim attempt {attempt + 1}/{max_claim_attempts} " f"failed, retrying in 0.5s: {error}" ) time.sleep(0.5) @@ -1347,19 +1411,40 @@ def start_audio() -> Response: }), 409 listening_active_device = device - _start_audio_stream(frequency, modulation) - - if audio_running: - return jsonify({ - 'status': 'started', - 'frequency': frequency, - 'modulation': modulation - }) - else: - return jsonify({ - 'status': 'error', - 'message': 'Failed to start audio. Check SDR device.' - }), 500 + _start_audio_stream(frequency, modulation) + + if audio_running: + audio_source = 'process' + return jsonify({ + 'status': 'started', + 'frequency': frequency, + 'modulation': modulation, + 'source': 'process', + }) + else: + # Avoid leaving a stale device claim after startup failure. + if listening_active_device is not None: + app_module.release_sdr_device(listening_active_device) + listening_active_device = None + + start_error = '' + for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'): + try: + with open(log_path, 'r') as handle: + content = handle.read().strip() + if content: + start_error = content.splitlines()[-1] + break + except Exception: + continue + + message = 'Failed to start audio. Check SDR device.' + if start_error: + message = f'Failed to start audio: {start_error}' + return jsonify({ + 'status': 'error', + 'message': message + }), 500 @listening_post_bp.route('/audio/stop', methods=['POST']) @@ -1373,19 +1458,30 @@ def stop_audio() -> Response: return jsonify({'status': 'stopped'}) -@listening_post_bp.route('/audio/status') -def audio_status() -> Response: - """Get audio status.""" - return jsonify({ - 'running': audio_running, - 'frequency': audio_frequency, - 'modulation': audio_modulation - }) +@listening_post_bp.route('/audio/status') +def audio_status() -> Response: + """Get audio status.""" + running = audio_running + if audio_source == 'waterfall': + try: + from routes.waterfall_websocket import get_shared_capture_status + + shared = get_shared_capture_status() + running = bool(shared.get('running') and shared.get('monitor_enabled')) + except Exception: + running = False + + return jsonify({ + 'running': running, + 'frequency': audio_frequency, + 'modulation': audio_modulation, + 'source': audio_source, + }) @listening_post_bp.route('/audio/debug') -def audio_debug() -> Response: - """Get audio debug status and recent stderr logs.""" +def audio_debug() -> Response: + """Get audio debug status and recent stderr logs.""" rtl_log_path = '/tmp/rtl_fm_stderr.log' ffmpeg_log_path = '/tmp/ffmpeg_stderr.log' sample_path = '/tmp/audio_probe.bin' @@ -1397,28 +1493,53 @@ def audio_debug() -> Response: except Exception: return '' - return jsonify({ - 'running': audio_running, - 'frequency': audio_frequency, - 'modulation': audio_modulation, - 'sdr_type': scanner_config.get('sdr_type', 'rtlsdr'), - 'device': scanner_config.get('device', 0), - 'gain': scanner_config.get('gain', 0), - 'squelch': scanner_config.get('squelch', 0), - 'audio_process_alive': bool(audio_process and audio_process.poll() is None), - 'rtl_fm_stderr': _read_log(rtl_log_path), - 'ffmpeg_stderr': _read_log(ffmpeg_log_path), - 'audio_probe_bytes': os.path.getsize(sample_path) if os.path.exists(sample_path) else 0, - }) + shared = {} + if audio_source == 'waterfall': + try: + from routes.waterfall_websocket import get_shared_capture_status + + shared = get_shared_capture_status() + except Exception: + shared = {} + + return jsonify({ + 'running': audio_running, + 'frequency': audio_frequency, + 'modulation': audio_modulation, + 'source': audio_source, + 'sdr_type': scanner_config.get('sdr_type', 'rtlsdr'), + 'device': scanner_config.get('device', 0), + 'gain': scanner_config.get('gain', 0), + 'squelch': scanner_config.get('squelch', 0), + 'audio_process_alive': bool(audio_process and audio_process.poll() is None), + 'shared_capture': shared, + 'rtl_fm_stderr': _read_log(rtl_log_path), + 'ffmpeg_stderr': _read_log(ffmpeg_log_path), + 'audio_probe_bytes': os.path.getsize(sample_path) if os.path.exists(sample_path) else 0, + }) -@listening_post_bp.route('/audio/probe') -def audio_probe() -> Response: - """Grab a small chunk of audio bytes from the pipeline for debugging.""" - global audio_process - - if not audio_process or not audio_process.stdout: - return jsonify({'status': 'error', 'message': 'audio process not running'}), 400 +@listening_post_bp.route('/audio/probe') +def audio_probe() -> Response: + """Grab a small chunk of audio bytes from the pipeline for debugging.""" + global audio_process + + if audio_source == 'waterfall': + try: + from routes.waterfall_websocket import read_shared_monitor_audio_chunk + + data = read_shared_monitor_audio_chunk(timeout=2.0) + if not data: + return jsonify({'status': 'error', 'message': 'no shared audio data available'}), 504 + sample_path = '/tmp/audio_probe.bin' + with open(sample_path, 'wb') as handle: + handle.write(data) + return jsonify({'status': 'ok', 'bytes': len(data), 'source': 'waterfall'}) + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}), 500 + + if not audio_process or not audio_process.stdout: + return jsonify({'status': 'error', 'message': 'audio process not running'}), 400 sample_path = '/tmp/audio_probe.bin' size = 0 @@ -1438,17 +1559,61 @@ def audio_probe() -> Response: return jsonify({'status': 'ok', 'bytes': size}) -@listening_post_bp.route('/audio/stream') -def stream_audio() -> Response: - """Stream WAV audio.""" - # Wait for audio to be ready (up to 2 seconds for modulation/squelch changes) - for _ in range(40): - if audio_running and audio_process: - break - time.sleep(0.05) - - if not audio_running or not audio_process: - return Response(b'', mimetype='audio/mpeg', status=204) +@listening_post_bp.route('/audio/stream') +def stream_audio() -> Response: + """Stream WAV audio.""" + if audio_source == 'waterfall': + for _ in range(40): + if audio_running: + break + time.sleep(0.05) + + if not audio_running: + return Response(b'', mimetype='audio/wav', status=204) + + def generate_shared(): + global audio_running, audio_source + try: + from routes.waterfall_websocket import ( + get_shared_capture_status, + read_shared_monitor_audio_chunk, + ) + except Exception: + return + + # Browser expects an immediate WAV header. + yield _wav_header(sample_rate=48000) + + while audio_running and audio_source == 'waterfall': + chunk = read_shared_monitor_audio_chunk(timeout=1.0) + if chunk: + yield chunk + continue + shared = get_shared_capture_status() + if not shared.get('running') or not shared.get('monitor_enabled'): + audio_running = False + audio_source = 'process' + break + + return Response( + generate_shared(), + mimetype='audio/wav', + headers={ + 'Content-Type': 'audio/wav', + 'Cache-Control': 'no-cache, no-store', + 'X-Accel-Buffering': 'no', + 'Transfer-Encoding': 'chunked', + } + ) + + # Wait for audio process to be ready (up to 2 seconds). + for _ in range(40): + if audio_running and audio_process: + break + time.sleep(0.05) + + if not audio_running or not audio_process: + return Response(b'', mimetype='audio/wav', status=204) def generate(): # Capture local reference to avoid race condition with stop @@ -1473,25 +1638,29 @@ def stream_audio() -> Response: if header_chunk: yield header_chunk - # Stream real-time audio - first_chunk_deadline = time.time() + 3.0 - while audio_running and proc.poll() is None: - # Use select to avoid blocking forever - ready, _, _ = select.select([proc.stdout], [], [], 2.0) - if ready: - chunk = proc.stdout.read(8192) - if chunk: - yield chunk - else: - break - else: - # If no data arrives shortly after start, exit so caller can retry - if time.time() > first_chunk_deadline: - logger.warning("Audio stream timed out waiting for first chunk") - break - # Timeout - check if process died - if proc.poll() is not None: - break + # Stream real-time audio + first_chunk_deadline = time.time() + 20.0 + warned_wait = False + while audio_running and proc.poll() is None: + # Use select to avoid blocking forever + ready, _, _ = select.select([proc.stdout], [], [], 2.0) + if ready: + chunk = proc.stdout.read(8192) + if chunk: + warned_wait = False + yield chunk + else: + break + else: + # Keep connection open while demodulator settles. + if time.time() > first_chunk_deadline: + if not warned_wait: + logger.warning("Audio stream still waiting for first chunk") + warned_wait = True + continue + # Timeout - check if process died + if proc.poll() is not None: + break except GeneratorExit: pass except Exception as e: @@ -1617,15 +1786,26 @@ def _parse_rtl_power_line(line: str) -> tuple[str | None, float | None, float | return timestamp, None, None, [] -def _waterfall_loop(): - """Continuous rtl_power sweep loop emitting waterfall data.""" - global waterfall_running, waterfall_process - - rtl_power_path = find_rtl_power() - if not rtl_power_path: - logger.error("rtl_power not found for waterfall") - waterfall_running = False - return +def _waterfall_loop(): + """Continuous rtl_power sweep loop emitting waterfall data.""" + global waterfall_running, waterfall_process + + def _queue_waterfall_error(message: str) -> None: + try: + waterfall_queue.put_nowait({ + 'type': 'waterfall_error', + 'message': message, + 'timestamp': datetime.now().isoformat(), + }) + except queue.Full: + pass + + rtl_power_path = find_rtl_power() + if not rtl_power_path: + logger.error("rtl_power not found for waterfall") + _queue_waterfall_error('rtl_power not found') + waterfall_running = False + return start_hz = int(waterfall_config['start_freq'] * 1e6) end_hz = int(waterfall_config['end_freq'] * 1e6) @@ -1643,32 +1823,49 @@ def _waterfall_loop(): ] try: - waterfall_process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - bufsize=1, - text=True, - ) - - current_ts = None - all_bins: list[float] = [] - sweep_start_hz = start_hz - sweep_end_hz = end_hz - - if not waterfall_process.stdout: - return - - for line in waterfall_process.stdout: - if not waterfall_running: - break - - ts, seg_start, seg_end, bins = _parse_rtl_power_line(line) - if ts is None or not bins: - continue - - if current_ts is None: - current_ts = ts + waterfall_process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=1, + text=True, + ) + + # Detect immediate startup failures (e.g. device busy / no device). + time.sleep(0.35) + if waterfall_process.poll() is not None: + stderr_text = '' + try: + if waterfall_process.stderr: + stderr_text = waterfall_process.stderr.read().strip() + except Exception: + stderr_text = '' + msg = stderr_text or f'rtl_power exited early (code {waterfall_process.returncode})' + logger.error(f"Waterfall startup failed: {msg}") + _queue_waterfall_error(msg) + return + + current_ts = None + all_bins: list[float] = [] + sweep_start_hz = start_hz + sweep_end_hz = end_hz + received_any = False + + if not waterfall_process.stdout: + _queue_waterfall_error('rtl_power stdout unavailable') + return + + for line in waterfall_process.stdout: + if not waterfall_running: + break + + ts, seg_start, seg_end, bins = _parse_rtl_power_line(line) + if ts is None or not bins: + continue + received_any = True + + if current_ts is None: + current_ts = ts if ts != current_ts and all_bins: max_bins = int(waterfall_config.get('max_bins') or 0) @@ -1706,11 +1903,11 @@ def _waterfall_loop(): sweep_end_hz = max(sweep_end_hz, seg_end) # Flush any remaining bins - if all_bins and waterfall_running: - max_bins = int(waterfall_config.get('max_bins') or 0) - bins_to_send = all_bins - if max_bins > 0 and len(bins_to_send) > max_bins: - bins_to_send = _downsample_bins(bins_to_send, max_bins) + if all_bins and waterfall_running: + max_bins = int(waterfall_config.get('max_bins') or 0) + bins_to_send = all_bins + if max_bins > 0 and len(bins_to_send) > max_bins: + bins_to_send = _downsample_bins(bins_to_send, max_bins) msg = { 'type': 'waterfall_sweep', 'start_freq': sweep_start_hz / 1e6, @@ -1718,13 +1915,17 @@ def _waterfall_loop(): 'bins': bins_to_send, 'timestamp': datetime.now().isoformat(), } - try: - waterfall_queue.put_nowait(msg) - except queue.Full: - pass - - except Exception as e: - logger.error(f"Waterfall loop error: {e}") + try: + waterfall_queue.put_nowait(msg) + except queue.Full: + pass + + if waterfall_running and not received_any: + _queue_waterfall_error('No waterfall FFT data received from rtl_power') + + except Exception as e: + logger.error(f"Waterfall loop error: {e}") + _queue_waterfall_error(f"Waterfall loop error: {e}") finally: waterfall_running = False if waterfall_process and waterfall_process.poll() is None: @@ -1766,9 +1967,14 @@ def start_waterfall() -> Response: """Start the waterfall/spectrogram display.""" global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device - with waterfall_lock: - if waterfall_running: - return jsonify({'status': 'error', 'message': 'Waterfall already running'}), 409 + with waterfall_lock: + if waterfall_running: + return jsonify({ + 'status': 'started', + 'already_running': True, + 'message': 'Waterfall already running', + 'config': waterfall_config, + }) if not find_rtl_power(): return jsonify({'status': 'error', 'message': 'rtl_power not found'}), 503 diff --git a/routes/waterfall_websocket.py b/routes/waterfall_websocket.py index 5512d6f..75c937b 100644 --- a/routes/waterfall_websocket.py +++ b/routes/waterfall_websocket.py @@ -1,13 +1,16 @@ -"""WebSocket-based waterfall streaming with I/Q capture and server-side FFT.""" - -import json -import queue -import socket -import subprocess -import threading -import time - -from flask import Flask +"""WebSocket-based waterfall streaming with I/Q capture and server-side FFT.""" + +import json +import queue +import socket +import subprocess +import threading +import time +from contextlib import suppress +from typing import Any + +import numpy as np +from flask import Flask try: from flask_sock import Sock @@ -16,31 +19,277 @@ except ImportError: WEBSOCKET_AVAILABLE = False Sock = None -from utils.logging import get_logger -from utils.process import safe_terminate, register_process, unregister_process -from utils.waterfall_fft import ( - build_binary_frame, - compute_power_spectrum, - cu8_to_complex, - quantize_to_uint8, -) -from utils.sdr import SDRFactory, SDRType -from utils.sdr.base import SDRCapabilities, SDRDevice +from utils.logging import get_logger +from utils.process import register_process, safe_terminate, unregister_process +from utils.sdr import SDRFactory, SDRType +from utils.sdr.base import SDRCapabilities, SDRDevice +from utils.waterfall_fft import ( + build_binary_frame, + compute_power_spectrum, + cu8_to_complex, + quantize_to_uint8, +) + +logger = get_logger('intercept.waterfall_ws') + +AUDIO_SAMPLE_RATE = 48000 +_shared_state_lock = threading.Lock() +_shared_audio_queue: queue.Queue[bytes] = queue.Queue(maxsize=80) +_shared_state: dict[str, Any] = { + 'running': False, + 'device': None, + 'center_mhz': 0.0, + 'span_mhz': 0.0, + 'sample_rate': 0, + 'monitor_enabled': False, + 'monitor_freq_mhz': 0.0, + 'monitor_modulation': 'wfm', + 'monitor_squelch': 0, +} -logger = get_logger('intercept.waterfall_ws') - -# Maximum bandwidth per SDR type (Hz) -MAX_BANDWIDTH = { - SDRType.RTL_SDR: 2400000, - SDRType.HACKRF: 20000000, - SDRType.LIME_SDR: 20000000, - SDRType.AIRSPY: 10000000, - SDRType.SDRPLAY: 2000000, -} - - -def _resolve_sdr_type(sdr_type_str: str) -> SDRType: - """Convert client sdr_type string to SDRType enum.""" +# Maximum bandwidth per SDR type (Hz) +MAX_BANDWIDTH = { + SDRType.RTL_SDR: 2400000, + SDRType.HACKRF: 20000000, + SDRType.LIME_SDR: 20000000, + SDRType.AIRSPY: 10000000, + SDRType.SDRPLAY: 2000000, +} + + +def _clear_shared_audio_queue() -> None: + while True: + try: + _shared_audio_queue.get_nowait() + except queue.Empty: + break + + +def _set_shared_capture_state( + *, + running: bool, + device: int | None = None, + center_mhz: float | None = None, + span_mhz: float | None = None, + sample_rate: int | None = None, +) -> None: + with _shared_state_lock: + _shared_state['running'] = bool(running) + _shared_state['device'] = device if running else None + if center_mhz is not None: + _shared_state['center_mhz'] = float(center_mhz) + if span_mhz is not None: + _shared_state['span_mhz'] = float(span_mhz) + if sample_rate is not None: + _shared_state['sample_rate'] = int(sample_rate) + if not running: + _shared_state['monitor_enabled'] = False + if not running: + _clear_shared_audio_queue() + + +def _set_shared_monitor( + *, + enabled: bool, + frequency_mhz: float | None = None, + modulation: str | None = None, + squelch: int | None = None, +) -> None: + was_enabled = False + with _shared_state_lock: + was_enabled = bool(_shared_state.get('monitor_enabled')) + _shared_state['monitor_enabled'] = bool(enabled) + if frequency_mhz is not None: + _shared_state['monitor_freq_mhz'] = float(frequency_mhz) + if modulation is not None: + _shared_state['monitor_modulation'] = str(modulation).lower().strip() + if squelch is not None: + _shared_state['monitor_squelch'] = max(0, min(100, int(squelch))) + if was_enabled and not enabled: + _clear_shared_audio_queue() + + +def get_shared_capture_status() -> dict[str, Any]: + with _shared_state_lock: + return { + 'running': bool(_shared_state['running']), + 'device': _shared_state['device'], + 'center_mhz': float(_shared_state.get('center_mhz', 0.0) or 0.0), + 'span_mhz': float(_shared_state.get('span_mhz', 0.0) or 0.0), + 'sample_rate': int(_shared_state.get('sample_rate', 0) or 0), + 'monitor_enabled': bool(_shared_state.get('monitor_enabled')), + 'monitor_freq_mhz': float(_shared_state.get('monitor_freq_mhz', 0.0) or 0.0), + 'monitor_modulation': str(_shared_state.get('monitor_modulation', 'wfm')), + 'monitor_squelch': int(_shared_state.get('monitor_squelch', 0) or 0), + } + + +def start_shared_monitor_from_capture( + *, + device: int, + frequency_mhz: float, + modulation: str, + squelch: int, +) -> tuple[bool, str]: + with _shared_state_lock: + if not _shared_state['running']: + return False, 'Waterfall IQ stream not active' + if _shared_state['device'] != device: + return False, 'Waterfall stream is using a different SDR device' + _shared_state['monitor_enabled'] = True + _shared_state['monitor_freq_mhz'] = float(frequency_mhz) + _shared_state['monitor_modulation'] = str(modulation).lower().strip() + _shared_state['monitor_squelch'] = max(0, min(100, int(squelch))) + _clear_shared_audio_queue() + return True, 'started' + + +def stop_shared_monitor_from_capture() -> None: + _set_shared_monitor(enabled=False) + + +def read_shared_monitor_audio_chunk(timeout: float = 1.0) -> bytes | None: + with _shared_state_lock: + if not _shared_state['running'] or not _shared_state['monitor_enabled']: + return None + try: + return _shared_audio_queue.get(timeout=max(0.0, float(timeout))) + except queue.Empty: + return None + + +def _snapshot_monitor_config() -> dict[str, Any] | None: + with _shared_state_lock: + if not (_shared_state['running'] and _shared_state['monitor_enabled']): + return None + return { + 'center_mhz': float(_shared_state['center_mhz']), + 'monitor_freq_mhz': float(_shared_state['monitor_freq_mhz']), + 'modulation': str(_shared_state['monitor_modulation']), + 'squelch': int(_shared_state['monitor_squelch']), + } + + +def _push_shared_audio_chunk(chunk: bytes) -> None: + if not chunk: + return + if _shared_audio_queue.full(): + with suppress(queue.Empty): + _shared_audio_queue.get_nowait() + with suppress(queue.Full): + _shared_audio_queue.put_nowait(chunk) + + +def _demodulate_monitor_audio( + samples: np.ndarray, + sample_rate: int, + center_mhz: float, + monitor_freq_mhz: float, + modulation: str, + squelch: int, +) -> bytes | None: + if samples.size < 32 or sample_rate <= 0: + return None + + fs = float(sample_rate) + freq_offset_hz = (float(monitor_freq_mhz) - float(center_mhz)) * 1e6 + nyquist = fs * 0.5 + if abs(freq_offset_hz) > nyquist * 0.98: + return None + + n = np.arange(samples.size, dtype=np.float32) + rotator = np.exp(-1j * (2.0 * np.pi * freq_offset_hz / fs) * n) + shifted = samples * rotator + + mod = str(modulation or 'wfm').lower().strip() + target_bb = 220000.0 if mod == 'wfm' else 48000.0 + pre_decim = max(1, int(fs // target_bb)) + if pre_decim > 1: + usable = (shifted.size // pre_decim) * pre_decim + if usable < pre_decim: + return None + shifted = shifted[:usable].reshape(-1, pre_decim).mean(axis=1) + fs1 = fs / pre_decim + if shifted.size < 16: + return None + + if mod in ('wfm', 'fm'): + audio = np.angle(shifted[1:] * np.conj(shifted[:-1])).astype(np.float32) + elif mod == 'am': + envelope = np.abs(shifted).astype(np.float32) + audio = envelope - float(np.mean(envelope)) + elif mod == 'usb': + audio = np.real(shifted).astype(np.float32) + elif mod == 'lsb': + audio = -np.real(shifted).astype(np.float32) + else: + audio = np.real(shifted).astype(np.float32) + + if audio.size < 8: + return None + + audio = audio - float(np.mean(audio)) + + if mod in ('fm', 'am', 'usb', 'lsb'): + taps = int(max(1, min(31, fs1 / 12000.0))) + if taps > 1: + kernel = np.ones(taps, dtype=np.float32) / float(taps) + audio = np.convolve(audio, kernel, mode='same') + + out_len = int(audio.size * AUDIO_SAMPLE_RATE / fs1) + if out_len < 32: + return None + x_old = np.linspace(0.0, 1.0, audio.size, endpoint=False, dtype=np.float32) + x_new = np.linspace(0.0, 1.0, out_len, endpoint=False, dtype=np.float32) + audio = np.interp(x_new, x_old, audio).astype(np.float32) + + rms = float(np.sqrt(np.mean(audio * audio) + 1e-12)) + level = min(100.0, rms * 450.0) + if squelch > 0 and level < float(squelch): + audio.fill(0.0) + + peak = float(np.max(np.abs(audio))) if audio.size else 0.0 + if peak > 0: + audio = audio * min(20.0, 0.85 / peak) + + pcm = np.clip(audio, -1.0, 1.0) + return (pcm * 32767.0).astype(np.int16).tobytes() + + +def _parse_center_freq_mhz(payload: dict[str, Any]) -> float: + """Parse center frequency from mixed legacy/new payload formats.""" + if payload.get('center_freq_mhz') is not None: + return float(payload['center_freq_mhz']) + + if payload.get('center_freq_hz') is not None: + return float(payload['center_freq_hz']) / 1e6 + + raw = float(payload.get('center_freq', 100.0)) + # Backward compatibility: some clients still send center_freq in Hz. + if raw > 100000: + return raw / 1e6 + return raw + + +def _parse_span_mhz(payload: dict[str, Any]) -> float: + """Parse display span in MHz from mixed payload formats.""" + if payload.get('span_hz') is not None: + return float(payload['span_hz']) / 1e6 + return float(payload.get('span_mhz', 2.0)) + + +def _pick_sample_rate(span_hz: int, caps: SDRCapabilities, sdr_type: SDRType) -> int: + """Pick a valid hardware sample rate nearest the requested span.""" + valid_rates = sorted({int(r) for r in caps.sample_rates if int(r) > 0}) + if valid_rates: + return min(valid_rates, key=lambda rate: abs(rate - span_hz)) + + max_bw = MAX_BANDWIDTH.get(sdr_type, 2400000) + return max(62500, min(span_hz, max_bw)) + + +def _resolve_sdr_type(sdr_type_str: str) -> SDRType: + """Convert client sdr_type string to SDRType enum.""" mapping = { 'rtlsdr': SDRType.RTL_SDR, 'rtl_sdr': SDRType.RTL_SDR, @@ -83,12 +332,16 @@ def init_waterfall_websocket(app: Flask): # Import app module for device claiming import app as app_module - iq_process = None - reader_thread = None - stop_event = threading.Event() - claimed_device = None - # Queue for outgoing messages — only the main loop touches ws.send() - send_queue = queue.Queue(maxsize=120) + iq_process = None + reader_thread = None + stop_event = threading.Event() + claimed_device = None + capture_center_mhz = 0.0 + capture_start_freq = 0.0 + capture_end_freq = 0.0 + capture_span_mhz = 0.0 + # Queue for outgoing messages — only the main loop touches ws.send() + send_queue = queue.Queue(maxsize=120) try: while True: @@ -105,7 +358,7 @@ def init_waterfall_websocket(app: Flask): break try: - msg = ws.receive(timeout=0.1) + msg = ws.receive(timeout=0.01) except Exception as e: err = str(e).lower() if "closed" in err: @@ -130,257 +383,370 @@ def init_waterfall_websocket(app: Flask): cmd = data.get('cmd') - if cmd == 'start': - # Stop any existing capture - was_restarting = iq_process is not None - stop_event.set() - if reader_thread and reader_thread.is_alive(): - reader_thread.join(timeout=2) - if iq_process: + if cmd == 'start': + # Stop any existing capture + was_restarting = iq_process is not None + stop_event.set() + if reader_thread and reader_thread.is_alive(): + reader_thread.join(timeout=2) + if iq_process: + safe_terminate(iq_process) + unregister_process(iq_process) + iq_process = None + if claimed_device is not None: + app_module.release_sdr_device(claimed_device) + claimed_device = None + _set_shared_capture_state(running=False) + stop_event.clear() + # Flush stale frames from previous capture + while not send_queue.empty(): + try: + send_queue.get_nowait() + except queue.Empty: + break + # Allow USB device to be released by the kernel + if was_restarting: + time.sleep(0.5) + + # Parse config + try: + center_freq_mhz = _parse_center_freq_mhz(data) + span_mhz = _parse_span_mhz(data) + gain_raw = data.get('gain') + if gain_raw is None or str(gain_raw).lower() == 'auto': + gain = None + else: + gain = float(gain_raw) + device_index = int(data.get('device', 0)) + sdr_type_str = data.get('sdr_type', 'rtlsdr') + fft_size = int(data.get('fft_size', 1024)) + fps = int(data.get('fps', 25)) + avg_count = int(data.get('avg_count', 4)) + ppm = data.get('ppm') + if ppm is not None: + ppm = int(ppm) + bias_t = bool(data.get('bias_t', False)) + db_min = data.get('db_min') + db_max = data.get('db_max') + if db_min is not None: + db_min = float(db_min) + if db_max is not None: + db_max = float(db_max) + except (TypeError, ValueError) as exc: + ws.send(json.dumps({ + 'status': 'error', + 'message': f'Invalid waterfall configuration: {exc}', + })) + continue + + # Clamp and normalize runtime settings + fft_size = max(256, min(8192, fft_size)) + fps = max(2, min(60, fps)) + avg_count = max(1, min(32, avg_count)) + if center_freq_mhz <= 0 or span_mhz <= 0: + ws.send(json.dumps({ + 'status': 'error', + 'message': 'center_freq_mhz and span_mhz must be > 0', + })) + continue + + # Resolve SDR type and choose a valid sample rate + sdr_type = _resolve_sdr_type(sdr_type_str) + builder = SDRFactory.get_builder(sdr_type) + caps = builder.get_capabilities() + requested_span_hz = max(1000, int(span_mhz * 1e6)) + sample_rate = _pick_sample_rate(requested_span_hz, caps, sdr_type) + + # Compute effective frequency range + effective_span_mhz = sample_rate / 1e6 + start_freq = center_freq_mhz - effective_span_mhz / 2 + end_freq = center_freq_mhz + effective_span_mhz / 2 + + # Claim the device + claim_err = app_module.claim_sdr_device(device_index, 'waterfall') + if claim_err: + ws.send(json.dumps({ + 'status': 'error', + 'message': claim_err, + 'error_type': 'DEVICE_BUSY', + })) + continue + claimed_device = device_index + + # Build I/Q capture command + try: + device = _build_dummy_device(device_index, sdr_type) + iq_cmd = builder.build_iq_capture_command( + device=device, + frequency_mhz=center_freq_mhz, + sample_rate=sample_rate, + gain=gain, + ppm=ppm, + bias_t=bias_t, + ) + except NotImplementedError as e: + app_module.release_sdr_device(device_index) + claimed_device = None + ws.send(json.dumps({ + 'status': 'error', + 'message': str(e), + })) + continue + + # Spawn I/Q capture process (retry to handle USB release lag) + max_attempts = 3 if was_restarting else 1 + try: + for attempt in range(max_attempts): + logger.info( + f"Starting I/Q capture: {center_freq_mhz:.6f} MHz, " + f"span={effective_span_mhz:.1f} MHz, " + f"sr={sample_rate}, fft={fft_size}" + ) + iq_process = subprocess.Popen( + iq_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + bufsize=0, + ) + register_process(iq_process) + + # Brief check that process started + time.sleep(0.3) + if iq_process.poll() is not None: + unregister_process(iq_process) + iq_process = None + if attempt < max_attempts - 1: + logger.info( + f"I/Q process exited immediately, " + f"retrying ({attempt + 1}/{max_attempts})..." + ) + time.sleep(0.5) + continue + raise RuntimeError( + "I/Q capture process exited immediately" + ) + break # Process started successfully + except Exception as e: + logger.error(f"Failed to start I/Q capture: {e}") + if iq_process: + safe_terminate(iq_process) + unregister_process(iq_process) + iq_process = None + app_module.release_sdr_device(device_index) + claimed_device = None + ws.send(json.dumps({ + 'status': 'error', + 'message': f'Failed to start I/Q capture: {e}', + })) + continue + + capture_center_mhz = center_freq_mhz + capture_start_freq = start_freq + capture_end_freq = end_freq + capture_span_mhz = effective_span_mhz + + _set_shared_capture_state( + running=True, + device=device_index, + center_mhz=center_freq_mhz, + span_mhz=effective_span_mhz, + sample_rate=sample_rate, + ) + _set_shared_monitor( + enabled=False, + frequency_mhz=center_freq_mhz, + modulation='wfm', + squelch=0, + ) + + # Send started confirmation + ws.send(json.dumps({ + 'status': 'started', + 'center_mhz': center_freq_mhz, + 'start_freq': start_freq, + 'end_freq': end_freq, + 'fft_size': fft_size, + 'sample_rate': sample_rate, + 'effective_span_mhz': effective_span_mhz, + 'db_min': db_min, + 'db_max': db_max, + 'vfo_freq_mhz': center_freq_mhz, + })) + + # Start reader thread — puts frames on queue, never calls ws.send() + def fft_reader( + proc, _send_q, stop_evt, + _fft_size, _avg_count, _fps, _sample_rate, + _start_freq, _end_freq, _center_mhz, + _db_min=None, _db_max=None, + ): + """Read I/Q from subprocess, compute FFT, enqueue binary frames.""" + required_fft_samples = _fft_size * _avg_count + timeslice_samples = max(required_fft_samples, int(_sample_rate / max(1, _fps))) + bytes_per_frame = timeslice_samples * 2 + frame_interval = 1.0 / _fps + + try: + while not stop_evt.is_set(): + if proc.poll() is not None: + break + + frame_start = time.monotonic() + + # Read raw I/Q bytes + raw = b'' + remaining = bytes_per_frame + while remaining > 0 and not stop_evt.is_set(): + chunk = proc.stdout.read(min(remaining, 65536)) + if not chunk: + break + raw += chunk + remaining -= len(chunk) + + if len(raw) < _fft_size * 2: + break + + # Process FFT pipeline + samples = cu8_to_complex(raw) + fft_samples = samples[-required_fft_samples:] if len(samples) > required_fft_samples else samples + power_db = compute_power_spectrum( + fft_samples, + fft_size=_fft_size, + avg_count=_avg_count, + ) + quantized = quantize_to_uint8( + power_db, + db_min=_db_min, + db_max=_db_max, + ) + frame = build_binary_frame( + _start_freq, _end_freq, quantized, + ) + + # Drop frame if main loop cannot keep up. + with suppress(queue.Full): + _send_q.put_nowait(frame) + + monitor_cfg = _snapshot_monitor_config() + if monitor_cfg: + audio_chunk = _demodulate_monitor_audio( + samples=samples, + sample_rate=_sample_rate, + center_mhz=monitor_cfg.get('center_mhz', _center_mhz), + monitor_freq_mhz=monitor_cfg.get('monitor_freq_mhz', _center_mhz), + modulation=monitor_cfg.get('modulation', 'wfm'), + squelch=int(monitor_cfg.get('squelch', 0)), + ) + if audio_chunk: + _push_shared_audio_chunk(audio_chunk) + + # Pace to target FPS + elapsed = time.monotonic() - frame_start + sleep_time = frame_interval - elapsed + if sleep_time > 0: + stop_evt.wait(sleep_time) + + except Exception as e: + logger.debug(f"FFT reader stopped: {e}") + + reader_thread = threading.Thread( + target=fft_reader, + args=( + iq_process, send_queue, stop_event, + fft_size, avg_count, fps, sample_rate, + start_freq, end_freq, center_freq_mhz, + db_min, db_max, + ), + daemon=True, + ) + reader_thread.start() + + elif cmd in ('tune', 'set_vfo'): + if not iq_process or claimed_device is None or iq_process.poll() is not None: + ws.send(json.dumps({ + 'status': 'error', + 'message': 'Waterfall capture is not running', + })) + continue + try: + shared = get_shared_capture_status() + vfo_freq_mhz = float( + data.get( + 'vfo_freq_mhz', + data.get('frequency_mhz', data.get('center_freq_mhz', capture_center_mhz)), + ) + ) + squelch = int(data.get('squelch', shared.get('monitor_squelch', 0))) + modulation = str(data.get('modulation', shared.get('monitor_modulation', 'wfm'))) + except (TypeError, ValueError) as exc: + ws.send(json.dumps({ + 'status': 'error', + 'message': f'Invalid tune request: {exc}', + })) + continue + + if not (capture_start_freq <= vfo_freq_mhz <= capture_end_freq): + ws.send(json.dumps({ + 'status': 'retune_required', + 'message': 'Frequency outside current capture span', + 'capture_start_freq': capture_start_freq, + 'capture_end_freq': capture_end_freq, + 'vfo_freq_mhz': vfo_freq_mhz, + })) + continue + + monitor_enabled = bool(shared.get('monitor_enabled')) + _set_shared_monitor( + enabled=monitor_enabled, + frequency_mhz=vfo_freq_mhz, + modulation=modulation, + squelch=squelch, + ) + ws.send(json.dumps({ + 'status': 'tuned', + 'vfo_freq_mhz': vfo_freq_mhz, + 'start_freq': capture_start_freq, + 'end_freq': capture_end_freq, + 'center_mhz': capture_center_mhz, + })) + + elif cmd == 'stop': + stop_event.set() + if reader_thread and reader_thread.is_alive(): + reader_thread.join(timeout=2) + reader_thread = None + if iq_process: safe_terminate(iq_process) unregister_process(iq_process) iq_process = None - if claimed_device is not None: - app_module.release_sdr_device(claimed_device) - claimed_device = None - stop_event.clear() - # Flush stale frames from previous capture - while not send_queue.empty(): - try: - send_queue.get_nowait() - except queue.Empty: - break - # Allow USB device to be released by the kernel - if was_restarting: - time.sleep(0.5) - - # Parse config - center_freq = float(data.get('center_freq', 100.0)) - span_mhz = float(data.get('span_mhz', 2.0)) - gain = data.get('gain') - if gain is not None: - gain = float(gain) - device_index = int(data.get('device', 0)) - sdr_type_str = data.get('sdr_type', 'rtlsdr') - fft_size = int(data.get('fft_size', 1024)) - fps = int(data.get('fps', 25)) - avg_count = int(data.get('avg_count', 4)) - ppm = data.get('ppm') - if ppm is not None: - ppm = int(ppm) - bias_t = bool(data.get('bias_t', False)) - - # Clamp FFT size to valid powers of 2 - fft_size = max(256, min(8192, fft_size)) - - # Resolve SDR type and bandwidth - sdr_type = _resolve_sdr_type(sdr_type_str) - max_bw = MAX_BANDWIDTH.get(sdr_type, 2400000) - span_hz = int(span_mhz * 1e6) - sample_rate = min(span_hz, max_bw) - - # Compute effective frequency range - effective_span_mhz = sample_rate / 1e6 - start_freq = center_freq - effective_span_mhz / 2 - end_freq = center_freq + effective_span_mhz / 2 - - # Claim the device - claim_err = app_module.claim_sdr_device(device_index, 'waterfall') - if claim_err: - ws.send(json.dumps({ - 'status': 'error', - 'message': claim_err, - 'error_type': 'DEVICE_BUSY', - })) - continue - claimed_device = device_index - - # Build I/Q capture command - try: - builder = SDRFactory.get_builder(sdr_type) - device = _build_dummy_device(device_index, sdr_type) - iq_cmd = builder.build_iq_capture_command( - device=device, - frequency_mhz=center_freq, - sample_rate=sample_rate, - gain=gain, - ppm=ppm, - bias_t=bias_t, - ) - except NotImplementedError as e: - app_module.release_sdr_device(device_index) - claimed_device = None - ws.send(json.dumps({ - 'status': 'error', - 'message': str(e), - })) - continue - - # Spawn I/Q capture process (retry to handle USB release lag) - max_attempts = 3 if was_restarting else 1 - try: - for attempt in range(max_attempts): - logger.info( - f"Starting I/Q capture: {center_freq} MHz, " - f"span={effective_span_mhz:.1f} MHz, " - f"sr={sample_rate}, fft={fft_size}" - ) - iq_process = subprocess.Popen( - iq_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - bufsize=0, - ) - register_process(iq_process) - - # Brief check that process started - time.sleep(0.3) - if iq_process.poll() is not None: - unregister_process(iq_process) - iq_process = None - if attempt < max_attempts - 1: - logger.info( - f"I/Q process exited immediately, " - f"retrying ({attempt + 1}/{max_attempts})..." - ) - time.sleep(0.5) - continue - raise RuntimeError( - "I/Q capture process exited immediately" - ) - break # Process started successfully - except Exception as e: - logger.error(f"Failed to start I/Q capture: {e}") - if iq_process: - safe_terminate(iq_process) - unregister_process(iq_process) - iq_process = None - app_module.release_sdr_device(device_index) - claimed_device = None - ws.send(json.dumps({ - 'status': 'error', - 'message': f'Failed to start I/Q capture: {e}', - })) - continue - - # Send started confirmation - ws.send(json.dumps({ - 'status': 'started', - 'start_freq': start_freq, - 'end_freq': end_freq, - 'fft_size': fft_size, - 'sample_rate': sample_rate, - })) - - # Start reader thread — puts frames on queue, never calls ws.send() - def fft_reader( - proc, _send_q, stop_evt, - _fft_size, _avg_count, _fps, - _start_freq, _end_freq, - ): - """Read I/Q from subprocess, compute FFT, enqueue binary frames.""" - bytes_per_frame = _fft_size * _avg_count * 2 - frame_interval = 1.0 / _fps - - try: - while not stop_evt.is_set(): - if proc.poll() is not None: - break - - frame_start = time.monotonic() - - # Read raw I/Q bytes - raw = b'' - remaining = bytes_per_frame - while remaining > 0 and not stop_evt.is_set(): - chunk = proc.stdout.read(min(remaining, 65536)) - if not chunk: - break - raw += chunk - remaining -= len(chunk) - - if len(raw) < _fft_size * 2: - break - - # Process FFT pipeline - samples = cu8_to_complex(raw) - power_db = compute_power_spectrum( - samples, - fft_size=_fft_size, - avg_count=_avg_count, - ) - quantized = quantize_to_uint8(power_db) - frame = build_binary_frame( - _start_freq, _end_freq, quantized, - ) - - try: - _send_q.put_nowait(frame) - except queue.Full: - # Drop frame if main loop can't keep up - pass - - # Pace to target FPS - elapsed = time.monotonic() - frame_start - sleep_time = frame_interval - elapsed - if sleep_time > 0: - stop_evt.wait(sleep_time) - - except Exception as e: - logger.debug(f"FFT reader stopped: {e}") - - reader_thread = threading.Thread( - target=fft_reader, - args=( - iq_process, send_queue, stop_event, - fft_size, avg_count, fps, - start_freq, end_freq, - ), - daemon=True, - ) - reader_thread.start() - - elif cmd == 'stop': - stop_event.set() - if reader_thread and reader_thread.is_alive(): - reader_thread.join(timeout=2) - reader_thread = None - if iq_process: - safe_terminate(iq_process) - unregister_process(iq_process) - iq_process = None - if claimed_device is not None: - app_module.release_sdr_device(claimed_device) - claimed_device = None - stop_event.clear() - ws.send(json.dumps({'status': 'stopped'})) + if claimed_device is not None: + app_module.release_sdr_device(claimed_device) + claimed_device = None + _set_shared_capture_state(running=False) + stop_event.clear() + ws.send(json.dumps({'status': 'stopped'})) except Exception as e: logger.info(f"WebSocket waterfall closed: {e}") - finally: - # Cleanup - stop_event.set() - if reader_thread and reader_thread.is_alive(): - reader_thread.join(timeout=2) + finally: + # Cleanup + stop_event.set() + if reader_thread and reader_thread.is_alive(): + reader_thread.join(timeout=2) if iq_process: safe_terminate(iq_process) unregister_process(iq_process) - if claimed_device is not None: - app_module.release_sdr_device(claimed_device) - # Complete WebSocket close handshake, then shut down the - # raw socket so Werkzeug cannot write its HTTP 200 response + if claimed_device is not None: + app_module.release_sdr_device(claimed_device) + _set_shared_capture_state(running=False) + # Complete WebSocket close handshake, then shut down the + # raw socket so Werkzeug cannot write its HTTP 200 response # on top of the WebSocket stream (which browsers see as # "Invalid frame header"). - try: - ws.close() - except Exception: - pass - try: - ws.sock.shutdown(socket.SHUT_RDWR) - except Exception: - pass - try: - ws.sock.close() - except Exception: - pass - logger.info("WebSocket waterfall client disconnected") + with suppress(Exception): + ws.close() + with suppress(Exception): + ws.sock.shutdown(socket.SHUT_RDWR) + with suppress(Exception): + ws.sock.close() + logger.info("WebSocket waterfall client disconnected") diff --git a/static/css/index.css b/static/css/index.css index 99691c7..84987cc 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -1802,6 +1802,14 @@ header h1 .tagline { box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3); } +@keyframes stop-btn-pulse { + 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(239,68,68,0); } + 50% { opacity: 0.75; box-shadow: 0 0 8px 2px rgba(239,68,68,0.45); } +} +.stop-btn { + animation: stop-btn-pulse 1.2s ease-in-out infinite; +} + .output-panel { background: var(--bg-primary); display: flex; diff --git a/static/css/modes/analytics.css b/static/css/modes/analytics.css deleted file mode 100644 index 31d6120..0000000 --- a/static/css/modes/analytics.css +++ /dev/null @@ -1,500 +0,0 @@ -/* Analytics Dashboard Styles */ - -/* Analytics is a sidebar-only mode — hide the output panel and expand the sidebar */ -@media (min-width: 1024px) { - .main-content.analytics-active { - grid-template-columns: 1fr !important; - } - .main-content.analytics-active > .output-panel { - display: none !important; - } - .main-content.analytics-active > .sidebar { - max-width: 100% !important; - width: 100% !important; - } - .main-content.analytics-active .sidebar-collapse-btn { - display: none !important; - } -} - -@media (max-width: 1023px) { - .main-content.analytics-active > .output-panel { - display: none !important; - } -} - -.analytics-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); - gap: var(--space-3, 12px); - margin-bottom: var(--space-4, 16px); -} - -.analytics-insight-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); - gap: var(--space-3, 12px); -} - -.analytics-insight-card { - background: var(--bg-card, #151f2b); - border: 1px solid var(--border-color, #1e2d3d); - border-radius: var(--radius-md, 8px); - padding: 10px; - display: flex; - flex-direction: column; - gap: 4px; -} - -.analytics-insight-card.low { - border-color: rgba(90, 106, 122, 0.5); -} - -.analytics-insight-card.medium { - border-color: rgba(74, 163, 255, 0.45); -} - -.analytics-insight-card.high { - border-color: rgba(214, 168, 94, 0.55); -} - -.analytics-insight-card.critical { - border-color: rgba(226, 93, 93, 0.65); -} - -.analytics-insight-card .insight-title { - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--text-dim, #5a6a7a); -} - -.analytics-insight-card .insight-value { - font-size: 16px; - font-weight: 700; - color: var(--text-primary, #e0e6ed); - line-height: 1.2; -} - -.analytics-insight-card .insight-label { - font-size: 10px; - color: var(--text-secondary, #9aabba); -} - -.analytics-insight-card .insight-detail { - font-size: 10px; - color: var(--text-dim, #5a6a7a); -} - -.analytics-top-changes { - margin-top: 12px; -} - -.analytics-change-row { - display: flex; - align-items: center; - gap: 10px; - padding: 7px 0; - border-bottom: 1px solid var(--border-color, #1e2d3d); - font-size: 10px; -} - -.analytics-change-row:last-child { - border-bottom: none; -} - -.analytics-change-row .mode { - min-width: 84px; - color: var(--text-primary, #e0e6ed); - font-weight: 600; -} - -.analytics-change-row .delta { - min-width: 48px; - font-family: var(--font-mono, monospace); -} - -.analytics-change-row .delta.up { - color: var(--accent-green, #38c180); -} - -.analytics-change-row .delta.down { - color: var(--accent-red, #e25d5d); -} - -.analytics-change-row .delta.flat { - color: var(--text-dim, #5a6a7a); -} - -.analytics-change-row .avg { - color: var(--text-dim, #5a6a7a); -} - -.analytics-card { - background: var(--bg-card, #151f2b); - border: 1px solid var(--border-color, #1e2d3d); - border-radius: var(--radius-md, 8px); - padding: var(--space-3, 12px); - text-align: center; - transition: var(--transition-fast, 150ms ease); -} - -.analytics-card:hover { - border-color: var(--accent-cyan, #4aa3ff); -} - -.analytics-card .card-count { - font-size: var(--text-2xl, 24px); - font-weight: 700; - color: var(--text-primary, #e0e6ed); - line-height: 1.2; -} - -.analytics-card .card-label { - font-size: var(--text-xs, 10px); - color: var(--text-dim, #5a6a7a); - text-transform: uppercase; - letter-spacing: 0.05em; - margin-top: var(--space-1, 4px); -} - -.analytics-card .card-sparkline { - height: 24px; - margin-top: var(--space-2, 8px); -} - -.analytics-card .card-sparkline svg { - width: 100%; - height: 100%; -} - -.analytics-card .card-sparkline polyline { - fill: none; - stroke: var(--accent-cyan, #4aa3ff); - stroke-width: 1.5; - stroke-linecap: round; - stroke-linejoin: round; -} - -/* Health indicators */ -.analytics-health { - display: flex; - flex-wrap: wrap; - gap: var(--space-2, 8px); - margin-bottom: var(--space-4, 16px); -} - -.health-item { - display: flex; - align-items: center; - gap: var(--space-1, 4px); - font-size: var(--text-xs, 10px); - color: var(--text-dim, #5a6a7a); - text-transform: uppercase; -} - -.health-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--accent-red, #e25d5d); -} - -.health-dot.running { - background: var(--accent-green, #38c180); -} - -/* Emergency squawk panel */ -.squawk-emergency { - background: rgba(226, 93, 93, 0.1); - border: 1px solid var(--accent-red, #e25d5d); - border-radius: var(--radius-md, 8px); - padding: var(--space-3, 12px); - margin-bottom: var(--space-3, 12px); -} - -.squawk-emergency .squawk-title { - color: var(--accent-red, #e25d5d); - font-weight: 700; - font-size: var(--text-sm, 12px); - text-transform: uppercase; - margin-bottom: var(--space-2, 8px); -} - -.squawk-emergency .squawk-item { - font-size: var(--text-sm, 12px); - color: var(--text-primary, #e0e6ed); - padding: var(--space-1, 4px) 0; - border-bottom: 1px solid rgba(226, 93, 93, 0.2); -} - -.squawk-emergency .squawk-item:last-child { - border-bottom: none; -} - -/* Alert feed */ -.analytics-alert-feed { - max-height: 200px; - overflow-y: auto; - margin-bottom: var(--space-4, 16px); -} - -.analytics-alert-item { - display: flex; - align-items: flex-start; - gap: var(--space-2, 8px); - padding: var(--space-2, 8px); - border-bottom: 1px solid var(--border-color, #1e2d3d); - font-size: var(--text-xs, 10px); -} - -.analytics-alert-item .alert-severity { - padding: 1px 6px; - border-radius: var(--radius-sm, 4px); - font-weight: 600; - text-transform: uppercase; - font-size: 9px; - white-space: nowrap; -} - -.alert-severity.critical { background: var(--accent-red, #e25d5d); color: #fff; } -.alert-severity.high { background: var(--accent-orange, #d6a85e); color: #000; } -.alert-severity.medium { background: var(--accent-cyan, #4aa3ff); color: #fff; } -.alert-severity.low { background: var(--border-color, #1e2d3d); color: var(--text-dim, #5a6a7a); } - -/* Correlation panel */ -.analytics-correlation-pair { - display: flex; - align-items: center; - gap: var(--space-2, 8px); - padding: var(--space-2, 8px); - border-bottom: 1px solid var(--border-color, #1e2d3d); - font-size: var(--text-xs, 10px); -} - -.analytics-correlation-pair .confidence-bar { - height: 4px; - background: var(--bg-secondary, #101823); - border-radius: 2px; - flex: 1; - max-width: 60px; -} - -.analytics-correlation-pair .confidence-fill { - height: 100%; - background: var(--accent-green, #38c180); - border-radius: 2px; -} - -.analytics-pattern-item { - padding: 8px; - border-bottom: 1px solid var(--border-color, #1e2d3d); - display: flex; - flex-direction: column; - gap: 4px; -} - -.analytics-pattern-item:last-child { - border-bottom: none; -} - -.analytics-pattern-item .pattern-main { - display: flex; - justify-content: space-between; - align-items: center; - gap: 8px; -} - -.analytics-pattern-item .pattern-mode { - font-size: 10px; - font-weight: 600; - color: var(--text-primary, #e0e6ed); - text-transform: uppercase; - letter-spacing: 0.04em; -} - -.analytics-pattern-item .pattern-device { - font-size: 10px; - color: var(--text-dim, #5a6a7a); - font-family: var(--font-mono, monospace); -} - -.analytics-pattern-item .pattern-meta { - display: flex; - gap: 10px; - font-size: 10px; - color: var(--text-dim, #5a6a7a); - flex-wrap: wrap; -} - -.analytics-pattern-item .pattern-confidence { - color: var(--accent-green, #38c180); - font-weight: 600; -} - -/* Geofence zone list */ -.geofence-zone-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--space-2, 8px); - border-bottom: 1px solid var(--border-color, #1e2d3d); - font-size: var(--text-xs, 10px); -} - -.geofence-zone-item .zone-name { - font-weight: 600; - color: var(--text-primary, #e0e6ed); -} - -.geofence-zone-item .zone-radius { - color: var(--text-dim, #5a6a7a); -} - -.geofence-zone-item .zone-delete { - cursor: pointer; - color: var(--accent-red, #e25d5d); - padding: 2px 6px; - border: 1px solid var(--accent-red, #e25d5d); - border-radius: var(--radius-sm, 4px); - background: transparent; - font-size: 9px; -} - -/* Export controls */ -.export-controls { - display: flex; - gap: var(--space-2, 8px); - align-items: center; - flex-wrap: wrap; -} - -.export-controls select, -.export-controls button { - font-size: var(--text-xs, 10px); - padding: var(--space-1, 4px) var(--space-2, 8px); - background: var(--bg-card, #151f2b); - color: var(--text-primary, #e0e6ed); - border: 1px solid var(--border-color, #1e2d3d); - border-radius: var(--radius-sm, 4px); -} - -.export-controls button { - cursor: pointer; - background: var(--accent-cyan, #4aa3ff); - color: #fff; - border-color: var(--accent-cyan, #4aa3ff); -} - -.export-controls button:hover { - opacity: 0.9; -} - -/* Section headers */ -.analytics-section-header { - font-size: var(--text-xs, 10px); - font-weight: 600; - color: var(--text-dim, #5a6a7a); - text-transform: uppercase; - letter-spacing: 0.05em; - margin-bottom: var(--space-2, 8px); - padding-bottom: var(--space-1, 4px); - border-bottom: 1px solid var(--border-color, #1e2d3d); -} - -/* Empty state */ -.analytics-empty { - text-align: center; - color: var(--text-dim, #5a6a7a); - font-size: var(--text-xs, 10px); - padding: var(--space-4, 16px); - font-style: italic; -} - -.analytics-target-toolbar, -.analytics-replay-toolbar { - display: flex; - gap: 8px; - flex-wrap: wrap; - align-items: center; - margin-bottom: 10px; -} - -.analytics-target-toolbar input { - flex: 1; - min-width: 220px; - background: var(--bg-card, #151f2b); - color: var(--text-primary, #e0e6ed); - border: 1px solid var(--border-color, #1e2d3d); - border-radius: 4px; - padding: 6px 8px; - font-size: 11px; -} - -.analytics-target-toolbar button, -.analytics-replay-toolbar button, -.analytics-replay-toolbar select { - font-size: 10px; - padding: 5px 9px; - border-radius: 4px; - border: 1px solid var(--border-color, #1e2d3d); - background: var(--bg-card, #151f2b); - color: var(--text-primary, #e0e6ed); -} - -.analytics-target-toolbar button, -.analytics-replay-toolbar button { - cursor: pointer; - background: rgba(74, 163, 255, 0.2); - border-color: rgba(74, 163, 255, 0.45); -} - -.analytics-target-summary { - font-size: 10px; - color: var(--text-dim, #5a6a7a); - margin-bottom: 8px; -} - -.analytics-target-item, -.analytics-replay-item { - border-bottom: 1px solid var(--border-color, #1e2d3d); - padding: 7px 0; - display: grid; - gap: 4px; -} - -.analytics-target-item:last-child, -.analytics-replay-item:last-child { - border-bottom: none; -} - -.analytics-target-item .title, -.analytics-replay-item .title { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; - font-size: 11px; - color: var(--text-primary, #e0e6ed); - font-weight: 600; -} - -.analytics-target-item .mode, -.analytics-replay-item .mode { - font-size: 9px; - text-transform: uppercase; - letter-spacing: 0.05em; - border: 1px solid rgba(74, 163, 255, 0.35); - color: var(--accent-cyan, #4aa3ff); - border-radius: 4px; - padding: 1px 6px; -} - -.analytics-target-item .meta, -.analytics-replay-item .meta { - font-size: 10px; - color: var(--text-dim, #5a6a7a); - display: flex; - gap: 10px; - flex-wrap: wrap; -} diff --git a/static/css/modes/bt_locate.css b/static/css/modes/bt_locate.css index 52eda01..d83a15b 100644 --- a/static/css/modes/bt_locate.css +++ b/static/css/modes/bt_locate.css @@ -163,29 +163,29 @@ margin-top: 2px; } -.btl-hud-controls { - display: flex; - flex-direction: column; - gap: 6px; - flex-shrink: 0; -} - -.btl-hud-export-row { - display: flex; - gap: 5px; - align-items: center; -} - -.btl-hud-export-format { - min-width: 62px; - padding: 3px 6px; - font-size: 10px; - font-family: var(--font-mono); - color: var(--text-secondary); - background: rgba(0, 0, 0, 0.45); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 4px; -} +.btl-hud-controls { + display: flex; + flex-direction: column; + gap: 6px; + flex-shrink: 0; +} + +.btl-hud-export-row { + display: flex; + gap: 5px; + align-items: center; +} + +.btl-hud-export-format { + min-width: 62px; + padding: 3px 6px; + font-size: 10px; + font-family: var(--font-mono); + color: var(--text-secondary); + background: rgba(0, 0, 0, 0.45); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 4px; +} .btl-hud-audio-toggle { display: flex; @@ -266,112 +266,114 @@ display: flex; flex-direction: column; gap: 8px; - height: 100%; + flex: 1; + min-height: 0; + overflow: hidden; padding: 8px; } -.btl-map-container { - flex: 1; - min-height: 250px; - position: relative; - border-radius: 8px; - overflow: hidden; - border: 1px solid rgba(255, 255, 255, 0.1); -} - +.btl-map-container { + flex: 1; + min-height: 250px; + position: relative; + border-radius: 8px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.1); +} + #btLocateMap { - width: 100%; - height: 100%; - background: #1a1a2e; -} - -.btl-map-overlay-controls { - position: absolute; - top: 10px; - right: 10px; - z-index: 450; - display: flex; - flex-direction: column; - gap: 4px; - padding: 7px 8px; - border-radius: 7px; - background: rgba(0, 0, 0, 0.6); - border: 1px solid rgba(255, 255, 255, 0.15); - backdrop-filter: blur(4px); -} - -.btl-map-overlay-toggle { - display: flex; - align-items: center; - gap: 5px; - font-size: 10px; - color: var(--text-secondary); - font-family: var(--font-mono); - cursor: pointer; - white-space: nowrap; -} - -.btl-map-overlay-toggle input[type="checkbox"] { - margin: 0; -} - -.btl-map-overlay-toggle input[type="checkbox"]:disabled + span { - opacity: 0.45; -} - -.btl-map-heat-legend { - position: absolute; - left: 10px; - bottom: 10px; - z-index: 430; - min-width: 120px; - padding: 6px 8px; - border-radius: 7px; - background: rgba(0, 0, 0, 0.6); - border: 1px solid rgba(255, 255, 255, 0.14); - backdrop-filter: blur(4px); -} - -.btl-map-heat-label { - display: block; - font-size: 9px; - color: var(--text-dim); - text-transform: uppercase; - letter-spacing: 0.7px; - margin-bottom: 4px; -} - -.btl-map-heat-bar { - height: 7px; - border-radius: 4px; - background: linear-gradient(90deg, #2563eb 0%, #16a34a 40%, #f59e0b 70%, #ef4444 100%); - border: 1px solid rgba(255, 255, 255, 0.15); -} - -.btl-map-heat-scale { - display: flex; - justify-content: space-between; - margin-top: 3px; - font-size: 8px; - color: var(--text-dim); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.btl-map-track-stats { - position: absolute; - right: 10px; - bottom: 10px; - z-index: 430; - padding: 5px 8px; - border-radius: 7px; - background: rgba(0, 0, 0, 0.6); - border: 1px solid rgba(255, 255, 255, 0.14); - color: var(--text-secondary); - font-size: 10px; - font-family: var(--font-mono); - backdrop-filter: blur(4px); -} + position: absolute; + inset: 0; + background: #1a1a2e; +} + +.btl-map-overlay-controls { + position: absolute; + top: 10px; + right: 10px; + z-index: 450; + display: flex; + flex-direction: column; + gap: 4px; + padding: 7px 8px; + border-radius: 7px; + background: rgba(0, 0, 0, 0.6); + border: 1px solid rgba(255, 255, 255, 0.15); + backdrop-filter: blur(4px); +} + +.btl-map-overlay-toggle { + display: flex; + align-items: center; + gap: 5px; + font-size: 10px; + color: var(--text-secondary); + font-family: var(--font-mono); + cursor: pointer; + white-space: nowrap; +} + +.btl-map-overlay-toggle input[type="checkbox"] { + margin: 0; +} + +.btl-map-overlay-toggle input[type="checkbox"]:disabled + span { + opacity: 0.45; +} + +.btl-map-heat-legend { + position: absolute; + left: 10px; + bottom: 10px; + z-index: 430; + min-width: 120px; + padding: 6px 8px; + border-radius: 7px; + background: rgba(0, 0, 0, 0.6); + border: 1px solid rgba(255, 255, 255, 0.14); + backdrop-filter: blur(4px); +} + +.btl-map-heat-label { + display: block; + font-size: 9px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.7px; + margin-bottom: 4px; +} + +.btl-map-heat-bar { + height: 7px; + border-radius: 4px; + background: linear-gradient(90deg, #2563eb 0%, #16a34a 40%, #f59e0b 70%, #ef4444 100%); + border: 1px solid rgba(255, 255, 255, 0.15); +} + +.btl-map-heat-scale { + display: flex; + justify-content: space-between; + margin-top: 3px; + font-size: 8px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.btl-map-track-stats { + position: absolute; + right: 10px; + bottom: 10px; + z-index: 430; + padding: 5px 8px; + border-radius: 7px; + background: rgba(0, 0, 0, 0.6); + border: 1px solid rgba(255, 255, 255, 0.14); + color: var(--text-secondary); + font-size: 10px; + font-family: var(--font-mono); + backdrop-filter: blur(4px); +} .btl-rssi-chart-container { height: 100px; @@ -511,7 +513,7 @@ RESPONSIVE — stack HUD vertically on narrow ============================================ */ -@media (max-width: 900px) { +@media (max-width: 900px) { .btl-hud { flex-wrap: wrap; gap: 10px; @@ -528,33 +530,33 @@ justify-content: space-around; } - .btl-hud-controls { - flex-direction: row; - width: 100%; - justify-content: center; - flex-wrap: wrap; - } - - .btl-hud-export-row { - width: 100%; - justify-content: center; - } - - .btl-map-overlay-controls { - top: 8px; - right: 8px; - gap: 3px; - padding: 6px 7px; - } - - .btl-map-heat-legend { - left: 8px; - bottom: 8px; - } - - .btl-map-track-stats { - right: 8px; - bottom: 8px; - font-size: 9px; - } -} + .btl-hud-controls { + flex-direction: row; + width: 100%; + justify-content: center; + flex-wrap: wrap; + } + + .btl-hud-export-row { + width: 100%; + justify-content: center; + } + + .btl-map-overlay-controls { + top: 8px; + right: 8px; + gap: 3px; + padding: 6px 7px; + } + + .btl-map-heat-legend { + left: 8px; + bottom: 8px; + } + + .btl-map-track-stats { + right: 8px; + bottom: 8px; + font-size: 9px; + } +} diff --git a/static/css/modes/fingerprint.css b/static/css/modes/fingerprint.css new file mode 100644 index 0000000..f37b05c --- /dev/null +++ b/static/css/modes/fingerprint.css @@ -0,0 +1,78 @@ +/* Signal Fingerprinting Mode Styles */ + +.fp-tab-btn { + flex: 1; + padding: 5px 10px; + font-family: var(--font-mono, monospace); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 4px; + color: var(--text-secondary, #aaa); + cursor: pointer; + transition: all 0.15s; +} + +.fp-tab-btn.active { + background: rgba(74,163,255,0.15); + border-color: var(--accent-cyan, #4aa3ff); + color: var(--accent-cyan, #4aa3ff); +} + +.fp-anomaly-item { + display: flex; + flex-direction: column; + gap: 2px; + padding: 6px 8px; + border-radius: 4px; + border: 1px solid rgba(255,255,255,0.08); + margin-bottom: 4px; + font-family: var(--font-mono, monospace); + font-size: 10px; +} + +.fp-anomaly-item.severity-alert { + background: rgba(239,68,68,0.12); + border-color: rgba(239,68,68,0.4); +} + +.fp-anomaly-item.severity-warn { + background: rgba(251,191,36,0.1); + border-color: rgba(251,191,36,0.4); +} + +.fp-anomaly-item.severity-new { + background: rgba(168,85,247,0.12); + border-color: rgba(168,85,247,0.4); +} + +.fp-anomaly-band { + font-weight: 700; + color: var(--text-primary, #fff); + font-size: 12px; +} + +.fp-anomaly-type-badge { + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 1px 5px; + border-radius: 3px; + display: inline-block; +} + +.fp-chart-container { + flex: 1; + min-height: 0; + padding: 10px; + overflow: hidden; +} + +#fpChartCanvas { + width: 100%; + height: 100%; +} diff --git a/static/css/modes/rfheatmap.css b/static/css/modes/rfheatmap.css new file mode 100644 index 0000000..e4a9957 --- /dev/null +++ b/static/css/modes/rfheatmap.css @@ -0,0 +1,44 @@ +/* RF Heatmap Mode Styles */ + +.rfhm-map-container { + flex: 1; + min-height: 0; + position: relative; +} + +#rfheatmapMapEl { + width: 100%; + height: 100%; +} + +.rfhm-overlay { + position: absolute; + top: 8px; + right: 8px; + z-index: 450; + display: flex; + flex-direction: column; + gap: 4px; + pointer-events: none; +} + +.rfhm-stat-chip { + background: rgba(0,0,0,0.75); + border: 1px solid rgba(255,255,255,0.15); + border-radius: 6px; + padding: 4px 8px; + font-family: var(--font-mono, monospace); + font-size: 10px; + color: var(--accent-cyan, #4aa3ff); + pointer-events: none; + backdrop-filter: blur(4px); +} + +.rfhm-recording-pulse { + animation: rfhm-rec 1s ease-in-out infinite; +} + +@keyframes rfhm-rec { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} diff --git a/static/css/modes/waterfall.css b/static/css/modes/waterfall.css new file mode 100644 index 0000000..476fba9 --- /dev/null +++ b/static/css/modes/waterfall.css @@ -0,0 +1,666 @@ +/* Spectrum Waterfall Mode Styles */ + +.wf-container { + --wf-border: rgba(92, 153, 255, 0.24); + --wf-surface: linear-gradient(180deg, rgba(12, 19, 31, 0.97) 0%, rgba(5, 9, 17, 0.98) 100%); + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; + background: radial-gradient(circle at 14% -18%, rgba(36, 129, 255, 0.2) 0%, rgba(36, 129, 255, 0) 38%), + radial-gradient(circle at 86% -26%, rgba(255, 161, 54, 0.2) 0%, rgba(255, 161, 54, 0) 36%), + #03070f; + border: 1px solid var(--wf-border); + border-radius: 10px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03), 0 10px 34px rgba(2, 8, 22, 0.55); + position: relative; +} + +.wf-headline { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + padding: 8px 12px; + background: rgba(8, 14, 25, 0.86); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + flex-shrink: 0; +} + +.wf-headline-left, +.wf-headline-right { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.wf-headline-tag { + border-radius: 999px; + padding: 1px 8px; + border: 1px solid rgba(74, 163, 255, 0.45); + background: rgba(74, 163, 255, 0.13); + color: #8ec5ff; + font-size: 10px; + font-family: var(--font-mono, monospace); + letter-spacing: 0.06em; + text-transform: uppercase; + white-space: nowrap; +} + +.wf-headline-sub { + font-size: 11px; + color: var(--text-dim); + font-family: var(--font-mono, monospace); + white-space: nowrap; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.wf-range-text, +.wf-tune-text { + font-family: var(--font-mono, monospace); + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; +} + +.wf-tune-text { + color: #ffd782; +} + +.wf-monitor-strip { + display: grid; + grid-template-columns: minmax(240px, 1.5fr) minmax(220px, 1fr) minmax(230px, 1.2fr) minmax(130px, 0.7fr) minmax(220px, 1fr); + gap: 10px; + align-items: stretch; + padding: 8px 12px; + background: var(--wf-surface); + border-bottom: 1px solid rgba(255, 255, 255, 0.07); + flex-shrink: 0; +} + +.wf-rx-vfo { + border: 1px solid rgba(102, 171, 255, 0.27); + border-radius: 8px; + background: linear-gradient(180deg, rgba(8, 16, 31, 0.92) 0%, rgba(4, 9, 18, 0.95) 100%); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03); + padding: 7px 10px; + display: flex; + flex-direction: column; + justify-content: space-between; + min-height: 72px; +} + +.wf-rx-vfo-top, +.wf-rx-vfo-bottom { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.wf-rx-vfo-name { + font-family: var(--font-mono, monospace); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-muted); +} + +.wf-rx-vfo-status { + font-family: var(--font-mono, monospace); + font-size: 10px; + color: #a6cbff; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.wf-rx-vfo-readout { + display: flex; + align-items: baseline; + gap: 7px; + font-family: var(--font-mono, monospace); + color: #7bc4ff; + line-height: 1; +} + +#wfRxFreqReadout { + font-size: 32px; + letter-spacing: 0.03em; + font-variant-numeric: tabular-nums; + text-shadow: 0 0 16px rgba(44, 153, 255, 0.28); +} + +.wf-rx-vfo-unit { + font-size: 13px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.wf-rx-vfo-bottom { + font-family: var(--font-mono, monospace); + font-size: 10px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.wf-rx-modebank { + border: 1px solid rgba(92, 153, 255, 0.24); + border-radius: 8px; + padding: 8px; + background: rgba(4, 10, 20, 0.86); + display: grid; + grid-template-columns: repeat(5, minmax(42px, 1fr)); + gap: 6px; + align-content: center; +} + +.wf-mode-btn { + border: 1px solid rgba(118, 176, 255, 0.26); + border-radius: 6px; + background: linear-gradient(180deg, rgba(20, 37, 66, 0.95) 0%, rgba(13, 26, 49, 0.95) 100%); + color: #d1e5ff; + font-family: var(--font-mono, monospace); + font-size: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; + cursor: pointer; + height: 32px; + transition: border-color 120ms ease, background 120ms ease, transform 120ms ease; +} + +.wf-mode-btn:hover { + transform: translateY(-1px); + border-color: rgba(143, 196, 255, 0.52); +} + +.wf-mode-btn:disabled { + opacity: 0.55; + cursor: not-allowed; + transform: none; +} + +.wf-mode-btn.is-active, +.wf-mode-btn.active { + border-color: rgba(97, 198, 255, 0.62); + background: linear-gradient(180deg, rgba(23, 85, 146, 0.92) 0%, rgba(18, 57, 104, 0.95) 100%); + color: #f3fbff; + box-shadow: 0 0 14px rgba(53, 152, 255, 0.28); +} + +.wf-monitor-select-hidden { + display: none; +} + +.wf-rx-levels { + border: 1px solid rgba(92, 153, 255, 0.22); + border-radius: 8px; + background: rgba(4, 10, 20, 0.85); + padding: 7px 10px; + display: grid; + grid-template-columns: 1fr; + gap: 6px; +} + +.wf-monitor-group { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.wf-monitor-label { + color: var(--text-muted); + font-family: var(--font-mono, monospace); + font-size: 9px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.wf-monitor-select { + width: 100%; + height: 30px; + border-radius: 6px; + border: 1px solid rgba(92, 153, 255, 0.28); + background: rgba(4, 8, 16, 0.8); + color: var(--text-primary); + font-family: var(--font-mono, monospace); + font-size: 12px; + padding: 0 8px; +} + +.wf-monitor-slider-wrap { + display: flex; + align-items: center; + gap: 8px; +} + +.wf-monitor-slider-wrap input[type="range"] { + flex: 1; + accent-color: var(--accent-cyan); +} + +.wf-monitor-value { + min-width: 28px; + text-align: right; + color: var(--text-secondary); + font-family: var(--font-mono, monospace); + font-size: 11px; +} + +.wf-rx-meter-wrap { + border: 1px solid rgba(92, 153, 255, 0.22); + border-radius: 8px; + background: rgba(4, 10, 20, 0.85); + padding: 7px 10px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 6px; +} + +.wf-rx-smeter { + position: relative; + width: 100%; + height: 12px; + border-radius: 999px; + background: linear-gradient(90deg, rgba(18, 44, 22, 0.95) 0%, rgba(46, 67, 20, 0.95) 55%, rgba(78, 28, 24, 0.95) 100%); + border: 1px solid rgba(255, 255, 255, 0.09); + overflow: hidden; +} + +.wf-rx-smeter-fill { + position: absolute; + inset: 0 auto 0 0; + width: 0%; + background: linear-gradient(90deg, rgba(86, 243, 146, 0.75) 0%, rgba(255, 208, 94, 0.78) 64%, rgba(255, 118, 118, 0.82) 100%); + box-shadow: 0 0 10px rgba(97, 229, 255, 0.35); + transition: width 90ms linear; +} + +.wf-rx-smeter-text { + font-family: var(--font-mono, monospace); + font-size: 11px; + color: var(--text-secondary); +} + +.wf-rx-actions { + border: 1px solid rgba(92, 153, 255, 0.22); + border-radius: 8px; + background: rgba(4, 10, 20, 0.85); + padding: 7px 10px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 6px; +} + +.wf-rx-action-row { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.wf-monitor-btn { + height: 32px; + min-width: 90px; + border-radius: 6px; + border: 1px solid rgba(86, 195, 124, 0.5); + background: linear-gradient(180deg, rgba(33, 125, 67, 0.95) 0%, rgba(21, 88, 47, 0.95) 100%); + color: #d2ffe2; + font-family: var(--font-mono, monospace); + font-size: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; + cursor: pointer; + transition: filter 140ms ease, transform 140ms ease; +} + +.wf-monitor-btn:hover { + filter: brightness(1.07); + transform: translateY(-1px); +} + +.wf-monitor-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + filter: saturate(0.6); + transform: none; +} + +.wf-monitor-btn-secondary { + border-color: rgba(92, 153, 255, 0.5); + background: linear-gradient(180deg, rgba(34, 66, 121, 0.95) 0%, rgba(19, 41, 84, 0.95) 100%); + color: #d4e7ff; +} + +.wf-monitor-btn-unlock { + border-color: rgba(214, 168, 94, 0.55); + background: linear-gradient(180deg, rgba(134, 93, 31, 0.95) 0%, rgba(98, 65, 19, 0.95) 100%); + color: #ffe8bd; +} + +.wf-monitor-btn.is-active { + border-color: rgba(255, 129, 129, 0.55); + background: linear-gradient(180deg, rgba(127, 36, 48, 0.95) 0%, rgba(84, 21, 31, 0.95) 100%); + color: #ffd9de; +} + +.wf-monitor-state { + font-family: var(--font-mono, monospace); + font-size: 11px; + color: var(--text-secondary); + line-height: 1.35; +} + +#wfAudioPlayer { + display: none; +} + +/* Frequency control bar */ + +.wf-freq-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + background: rgba(8, 13, 24, 0.78); + border-bottom: 1px solid rgba(255, 255, 255, 0.07); + flex-shrink: 0; + min-height: 38px; + user-select: none; +} + +.wf-freq-bar-label { + font-family: var(--font-mono, monospace); + font-size: 9px; + color: var(--text-muted, #555); + text-transform: uppercase; + letter-spacing: 0.06em; + white-space: nowrap; +} + +.wf-step-btn { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.12); + color: var(--accent-cyan, #4aa3ff); + font-size: 14px; + width: 28px; + height: 28px; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + line-height: 1; + flex-shrink: 0; + transition: background 0.1s, border-color 0.1s; +} + +.wf-step-btn:hover { + background: rgba(74, 163, 255, 0.17); + border-color: rgba(74, 163, 255, 0.45); +} + +.wf-step-btn:active { + background: rgba(74, 163, 255, 0.28); +} + +.wf-freq-display-wrap { + display: flex; + align-items: center; + gap: 5px; + background: rgba(0, 0, 0, 0.55); + border: 1px solid rgba(74, 163, 255, 0.28); + border-radius: 5px; + padding: 3px 8px; + flex-shrink: 0; +} + +.wf-freq-center-input { + background: transparent; + border: none; + outline: none; + color: var(--accent-cyan, #4aa3ff); + font-family: var(--font-mono, monospace); + font-size: 17px; + font-weight: 700; + width: 110px; + text-align: right; + padding: 0; + cursor: text; + letter-spacing: 0.02em; +} + +.wf-freq-center-input:focus { + color: #fff; +} + +.wf-freq-bar-unit { + font-family: var(--font-mono, monospace); + font-size: 11px; + color: var(--text-dim, #555); + letter-spacing: 0.05em; +} + +.wf-step-select { + background: rgba(0, 0, 0, 0.55); + border: 1px solid rgba(255, 255, 255, 0.14); + color: var(--text-secondary, #aaa); + font-family: var(--font-mono, monospace); + font-size: 11px; + border-radius: 4px; + padding: 2px 4px; + height: 26px; + cursor: pointer; + flex-shrink: 0; +} + +.wf-freq-bar-sep { + width: 1px; + height: 20px; + background: rgba(255, 255, 255, 0.09); + margin: 0 2px; + flex-shrink: 0; +} + +.wf-span-display { + font-family: var(--font-mono, monospace); + font-size: 12px; + color: var(--text-secondary, #888); + min-width: 60px; + white-space: nowrap; +} + +/* Spectrum canvas */ + +.wf-spectrum-canvas-wrap { + height: 108px; + flex-shrink: 0; + position: relative; + border-bottom: 1px solid rgba(255, 255, 255, 0.09); + background: radial-gradient(circle at 50% -120%, rgba(84, 140, 237, 0.18) 0%, rgba(84, 140, 237, 0) 65%); +} + +#wfSpectrumCanvas { + width: 100%; + height: 100%; + display: block; +} + +/* Resize handle */ + +.wf-resize-handle { + height: 7px; + flex-shrink: 0; + background: rgba(255, 255, 255, 0.03); + cursor: ns-resize; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; + position: relative; + z-index: 10; +} + +.wf-resize-handle:hover, +.wf-resize-handle.dragging { + background: rgba(74, 163, 255, 0.14); +} + +.wf-resize-grip { + width: 40px; + height: 2px; + background: rgba(255, 255, 255, 0.2); + border-radius: 1px; + transition: background 0.15s; +} + +.wf-resize-handle:hover .wf-resize-grip, +.wf-resize-handle.dragging .wf-resize-grip { + background: rgba(74, 163, 255, 0.6); +} + +/* Waterfall canvas */ + +.wf-waterfall-canvas-wrap { + flex: 1; + min-height: 0; + position: relative; + overflow: hidden; + background-image: linear-gradient(to right, rgba(255, 255, 255, 0.025) 1px, transparent 1px); + background-size: 44px 100%; +} + +#wfWaterfallCanvas { + width: 100%; + height: 100%; + display: block; +} + +/* Center/tune lines */ + +.wf-center-line, +.wf-tune-line { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + pointer-events: none; + z-index: 5; +} + +.wf-center-line { + left: calc(50% - 0.5px); + background: rgba(255, 215, 0, 0.38); +} + +.wf-tune-line { + left: calc(50% - 0.5px); + background: rgba(130, 220, 255, 0.75); + box-shadow: 0 0 8px rgba(74, 163, 255, 0.4); + opacity: 0; + transition: opacity 140ms ease; +} + +.wf-tune-line.is-visible { + opacity: 1; +} + +/* Frequency axis */ + +.wf-freq-axis { + height: 21px; + flex-shrink: 0; + position: relative; + background: rgba(8, 13, 24, 0.86); + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.wf-freq-tick { + position: absolute; + top: 0; + font-family: var(--font-mono, monospace); + font-size: 9px; + color: var(--text-dim, #555); + transform: translateX(-50%); + white-space: nowrap; + padding-top: 3px; +} + +.wf-freq-tick::before { + content: ''; + position: absolute; + top: 0; + left: 50%; + width: 1px; + height: 3px; + background: rgba(255, 255, 255, 0.2); +} + +/* Hover tooltip */ + +.wf-tooltip { + position: absolute; + top: 4px; + background: rgba(0, 0, 0, 0.84); + color: var(--accent-cyan, #4aa3ff); + font-family: var(--font-mono, monospace); + font-size: 11px; + padding: 2px 7px; + border-radius: 4px; + pointer-events: none; + display: none; + z-index: 10; + white-space: nowrap; + border: 1px solid rgba(74, 163, 255, 0.22); +} + +@media (max-width: 1100px) { + .wf-monitor-strip { + grid-template-columns: repeat(2, minmax(220px, 1fr)); + grid-auto-rows: minmax(70px, auto); + } + + .wf-rx-actions { + grid-column: span 2; + } + + .wf-rx-action-row { + justify-content: flex-start; + } +} + +@media (max-width: 720px) { + .wf-headline { + flex-direction: column; + align-items: flex-start; + } + + .wf-headline-right { + flex-wrap: wrap; + } + + .wf-monitor-strip { + grid-template-columns: 1fr; + } + + .wf-rx-actions { + grid-column: auto; + } + + .wf-freq-bar { + flex-wrap: wrap; + row-gap: 8px; + } + + .wf-freq-center-input { + width: 96px; + } +} diff --git a/static/icons/icon.svg b/static/icons/icon.svg new file mode 100644 index 0000000..86d733a --- /dev/null +++ b/static/icons/icon.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/static/js/core/cheat-sheets.js b/static/js/core/cheat-sheets.js new file mode 100644 index 0000000..8106fe1 --- /dev/null +++ b/static/js/core/cheat-sheets.js @@ -0,0 +1,77 @@ +/* INTERCEPT Per-Mode Cheat Sheets */ +const CheatSheets = (function () { + 'use strict'; + + const CONTENT = { + pager: { title: 'Pager Decoder', icon: '📟', hardware: 'RTL-SDR dongle', description: 'Decodes POCSAG and FLEX pager protocols via rtl_fm + multimon-ng.', whatToExpect: 'Numeric and alphanumeric pager messages with address codes.', tips: ['Try frequencies 152.240, 157.450, 462.9625 MHz', 'Gain 38–45 dB works well for most dongles', 'POCSAG 512/1200/2400 baud are common'] }, + sensor: { title: '433MHz Sensors', icon: '🌡️', hardware: 'RTL-SDR dongle', description: 'Decodes 433MHz IoT sensors via rtl_433.', whatToExpect: 'JSON events from weather stations, door sensors, car key fobs.', tips: ['Leave gain on AUTO', 'Walk around to discover hidden sensors', 'Protocol filter narrows false positives'] }, + wifi: { title: 'WiFi Scanner', icon: '📡', hardware: 'WiFi adapter (monitor mode)', description: 'Scans WiFi networks and clients via airodump-ng or nmcli.', whatToExpect: 'SSIDs, BSSIDs, channel, signal strength, encryption type.', tips: ['Run airmon-ng check kill before monitoring', 'Proximity radar shows signal strength', 'TSCM baseline detects rogue APs'] }, + bluetooth: { title: 'Bluetooth Scanner', icon: '🔵', hardware: 'Built-in or USB Bluetooth adapter', description: 'Scans BLE and classic Bluetooth devices. Identifies trackers.', whatToExpect: 'Device names, MACs, RSSI, manufacturer, tracker type.', tips: ['Proximity radar shows device distance', 'Known tracker DB has 47K+ fingerprints', 'Use BT Locate to physically find a tracker'] }, + bt_locate: { title: 'BT Locate (SAR)', icon: '🎯', hardware: 'Bluetooth adapter + optional GPS', description: 'SAR Bluetooth locator. Tracks RSSI over time to triangulate position.', whatToExpect: 'RSSI chart, proximity band (IMMEDIATE/NEAR/FAR), GPS trail.', tips: ['Handoff from Bluetooth mode to lock onto a device', 'Indoor n=3.0 gives better distance estimates', 'Follow the heat trail toward stronger signal'] }, + meshtastic: { title: 'Meshtastic', icon: '🕸️', hardware: 'Meshtastic LoRa node (USB)', description: 'Monitors Meshtastic LoRa mesh network messages and positions.', whatToExpect: 'Text messages, node map, telemetry.', tips: ['Default channel must match your mesh', 'Long-Fast has best range', 'GPS nodes appear on map automatically'] }, + adsb: { title: 'ADS-B Aircraft', icon: '✈️', hardware: 'RTL-SDR + 1090MHz antenna', description: 'Tracks aircraft via ADS-B Mode S transponders using dump1090.', whatToExpect: 'Flight numbers, positions, altitude, speed, squawk codes.', tips: ['1090MHz — use a dedicated antenna', 'Emergency squawks: 7500 hijack, 7600 radio fail, 7700 emergency', 'Full Dashboard shows map view'] }, + ais: { title: 'AIS Vessels', icon: '🚢', hardware: 'RTL-SDR + VHF antenna (162 MHz)', description: 'Tracks marine vessels via AIS using AIS-catcher.', whatToExpect: 'MMSI, vessel names, positions, speed, heading, cargo type.', tips: ['VHF antenna centered at 162MHz works best', 'DSC distress alerts appear in red', 'Coastline range ~40 nautical miles'] }, + aprs: { title: 'APRS', icon: '📻', hardware: 'RTL-SDR + VHF + direwolf', description: 'Decodes APRS amateur packet radio via direwolf TNC modem.', whatToExpect: 'Station positions, weather reports, messages, telemetry.', tips: ['Primary APRS frequency: 144.390 MHz (North America)', 'direwolf must be running', 'Positions appear on the map'] }, + satellite: { title: 'Satellite Tracker', icon: '🛰️', hardware: 'None (pass prediction only)', description: 'Predicts satellite pass times using TLE data from CelesTrak.', whatToExpect: 'Pass windows with AOS/LOS times, max elevation, bearing.', tips: ['Set observer location in Settings', 'Plan ISS SSTV using pass times', 'TLEs auto-update every 24 hours'] }, + sstv: { title: 'ISS SSTV', icon: '🖼️', hardware: 'RTL-SDR + 145MHz antenna', description: 'Receives ISS SSTV images via slowrx.', whatToExpect: 'Color images during ISS SSTV events (PD180 mode).', tips: ['ISS SSTV: 145.800 MHz', 'Check ARISS for active event dates', 'ISS must be overhead — check pass times'] }, + weathersat: { title: 'Weather Satellites', icon: '🌤️', hardware: 'RTL-SDR + 137MHz turnstile/QFH antenna', description: 'Decodes NOAA APT and Meteor LRPT weather imagery via SatDump.', whatToExpect: 'Infrared/visible cloud imagery.', tips: ['NOAA 15/18/19: 137.1–137.9 MHz APT', 'Meteor M2-3: 137.9 MHz LRPT', 'Use circular polarized antenna (QFH or turnstile)'] }, + sstv_general:{ title: 'HF SSTV', icon: '📷', hardware: 'RTL-SDR + HF upconverter', description: 'Receives HF SSTV transmissions.', whatToExpect: 'Amateur radio images on 14.230 MHz (USB mode).', tips: ['14.230 MHz USB is primary HF SSTV frequency', 'Scottie 1 and Martin 1 most common', 'Best during daylight hours'] }, + gps: { title: 'GPS Receiver', icon: '🗺️', hardware: 'USB GPS receiver (NMEA)', description: 'Streams GPS position and feeds location to other modes.', whatToExpect: 'Lat/lon, altitude, speed, heading, satellite count.', tips: ['GPS feeds into RF Heatmap', 'BT Locate uses GPS for trail logging', 'Set observer location for satellite prediction'] }, + spaceweather:{ title: 'Space Weather', icon: '☀️', hardware: 'None (NOAA/SpaceWeatherLive data)', description: 'Monitors solar activity and geomagnetic storm indices.', whatToExpect: 'Kp index, solar flux, X-ray flare alerts, CME tracking.', tips: ['High Kp (≥5) = geomagnetic storm', 'X-class flares cause HF radio blackouts', 'Check before HF or satellite operations'] }, + listening: { title: 'Listening Post', icon: '🎧', hardware: 'RTL-SDR dongle', description: 'Wideband scanner and audio receiver for AM/FM/USB/LSB/CW.', whatToExpect: 'Audio from any frequency, spectrum waterfall, squelch.', tips: ['VHF air band: 118–136 MHz AM', 'Marine VHF: 156–174 MHz FM', 'HF requires upconverter or direct-sampling SDR'] }, + tscm: { title: 'TSCM Counter-Surveillance', icon: '🔍', hardware: 'WiFi + Bluetooth adapters', description: 'Technical Surveillance Countermeasures — detects hidden devices.', whatToExpect: 'RF baseline comparison, rogue device alerts, tracker detection.', tips: ['Take baseline in a known-clean environment', 'New strong signals = potential bug', 'Correlate WiFi + Bluetooth observations'] }, + spystations: { title: 'Spy Stations', icon: '🕵️', hardware: 'RTL-SDR + HF antenna', description: 'Database of known number stations, military, and diplomatic HF signals.', whatToExpect: 'Scheduled broadcasts, frequency database, tune-to links.', tips: ['Numbers stations often broadcast on the hour', 'Use Listening Post to tune directly', 'STANAG and HF mil signals are common'] }, + websdr: { title: 'WebSDR', icon: '🌐', hardware: 'None (uses remote SDR servers)', description: 'Access remote WebSDR receivers worldwide for HF shortwave listening.', whatToExpect: 'Live audio from global HF receivers, waterfall display.', tips: ['websdr.org lists available servers', 'Good for HF when local antenna is lacking', 'Use in-app player for seamless experience'] }, + subghz: { title: 'SubGHz Transceiver', icon: '📡', hardware: 'HackRF One', description: 'Transmit and receive sub-GHz RF signals for IoT and industrial protocols.', whatToExpect: 'Raw signal capture, replay, and protocol analysis.', tips: ['Only use on licensed frequencies', 'Capture mode records raw IQ for replay', 'Common: garage doors, keyfobs, 315/433/868/915 MHz'] }, + rtlamr: { title: 'Utility Meter Reader', icon: '⚡', hardware: 'RTL-SDR dongle', description: 'Reads AMI/AMR smart utility meter broadcasts via rtlamr.', whatToExpect: 'Meter IDs, consumption readings, interval data.', tips: ['Most meters broadcast on 915 MHz', 'MSG types 5, 7, 13, 21 most common', 'Consumption data is read-only public broadcast'] }, + waterfall: { title: 'Spectrum Waterfall', icon: '🌊', hardware: 'RTL-SDR or HackRF (WebSocket)', description: 'Full-screen real-time FFT spectrum waterfall display.', whatToExpect: 'Color-coded signal intensity scrolling over time.', tips: ['Turbo palette has best contrast for weak signals', 'Peak hold shows max power in red', 'Hover over waterfall to see frequency'] }, + rfheatmap: { title: 'RF Heatmap', icon: '🗺️', hardware: 'GPS receiver + WiFi/BT/SDR', description: 'GPS-tagged signal strength heatmap. Walk to build coverage maps.', whatToExpect: 'Leaflet map with heat overlay showing signal by location.', tips: ['Connect GPS first, wait for fix', 'Set min sample distance to avoid duplicates', 'Export GeoJSON for use in QGIS'] }, + fingerprint: { title: 'RF Fingerprinting', icon: '🔬', hardware: 'RTL-SDR + Listening Post scanner', description: 'Records RF baselines and detects anomalies via statistical comparison.', whatToExpect: 'Band-by-band power comparison, z-score anomaly detection.', tips: ['Take baseline in a clean RF environment', 'Z-score ≥3 = statistically significant anomaly', 'New bands highlighted in purple'] }, + }; + + function show(mode) { + const data = CONTENT[mode]; + const modal = document.getElementById('cheatSheetModal'); + const content = document.getElementById('cheatSheetContent'); + if (!modal || !content) return; + + if (!data) { + content.innerHTML = `

No cheat sheet for: ${mode}

`; + } else { + content.innerHTML = ` +
+
${data.icon}
+

${data.title}

+
+ Hardware: ${data.hardware} +
+

${data.description}

+
+
What to expect
+

${data.whatToExpect}

+
+
+
Tips
+
    + ${data.tips.map(t => `
  • ${t}
  • `).join('')} +
+
+
`; + } + modal.style.display = 'flex'; + } + + function hide() { + const modal = document.getElementById('cheatSheetModal'); + if (modal) modal.style.display = 'none'; + } + + function showForCurrentMode() { + const mode = document.body.getAttribute('data-mode'); + if (mode) show(mode); + } + + return { show, hide, showForCurrentMode }; +})(); + +window.CheatSheets = CheatSheets; diff --git a/static/js/core/command-palette.js b/static/js/core/command-palette.js index 9502553..316efce 100644 --- a/static/js/core/command-palette.js +++ b/static/js/core/command-palette.js @@ -25,7 +25,6 @@ const CommandPalette = (function() { { mode: 'gps', label: 'GPS' }, { mode: 'meshtastic', label: 'Meshtastic' }, { mode: 'websdr', label: 'WebSDR' }, - { mode: 'analytics', label: 'Analytics' }, { mode: 'spaceweather', label: 'Space Weather' }, ]; diff --git a/static/js/core/first-run-setup.js b/static/js/core/first-run-setup.js index 8ed64d9..c903b41 100644 --- a/static/js/core/first-run-setup.js +++ b/static/js/core/first-run-setup.js @@ -139,7 +139,6 @@ const FirstRunSetup = (function() { ['sstv', 'ISS SSTV'], ['weathersat', 'Weather Sat'], ['sstv_general', 'HF SSTV'], - ['analytics', 'Analytics'], ]; for (const [value, label] of modes) { const opt = document.createElement('option'); diff --git a/static/js/core/keyboard-shortcuts.js b/static/js/core/keyboard-shortcuts.js new file mode 100644 index 0000000..273566c --- /dev/null +++ b/static/js/core/keyboard-shortcuts.js @@ -0,0 +1,74 @@ +/* INTERCEPT Keyboard Shortcuts — global hotkey handler + help modal */ +const KeyboardShortcuts = (function () { + 'use strict'; + + const GUARD_SELECTOR = 'input, textarea, select, [contenteditable], .CodeMirror *'; + let _handler = null; + + function _handle(e) { + if (e.target.matches(GUARD_SELECTOR)) return; + + if (e.altKey) { + switch (e.key.toLowerCase()) { + case 'w': e.preventDefault(); window.switchMode && switchMode('waterfall'); break; + case 'h': e.preventDefault(); window.switchMode && switchMode('rfheatmap'); break; + case 'n': e.preventDefault(); window.switchMode && switchMode('fingerprint'); break; + case 'm': e.preventDefault(); window.VoiceAlerts && VoiceAlerts.toggleMute(); break; + case 's': e.preventDefault(); _toggleSidebar(); break; + case 'k': e.preventDefault(); showHelp(); break; + case 'c': e.preventDefault(); window.CheatSheets && CheatSheets.showForCurrentMode(); break; + default: + if (e.key >= '1' && e.key <= '9') { + e.preventDefault(); + _switchToNthMode(parseInt(e.key) - 1); + } + } + } else if (!e.ctrlKey && !e.metaKey) { + if (e.key === '?') { showHelp(); } + if (e.key === 'Escape') { + const kbModal = document.getElementById('kbShortcutsModal'); + if (kbModal && kbModal.style.display !== 'none') { hideHelp(); return; } + const csModal = document.getElementById('cheatSheetModal'); + if (csModal && csModal.style.display !== 'none') { + window.CheatSheets && CheatSheets.hide(); return; + } + } + } + } + + function _toggleSidebar() { + const mc = document.querySelector('.main-content'); + if (mc) mc.classList.toggle('sidebar-collapsed'); + } + + function _switchToNthMode(n) { + if (!window.interceptModeCatalog) return; + const mode = document.body.getAttribute('data-mode'); + if (!mode) return; + const catalog = window.interceptModeCatalog; + const entry = catalog[mode]; + if (!entry) return; + const groupModes = Object.keys(catalog).filter(k => catalog[k].group === entry.group); + if (groupModes[n]) window.switchMode && switchMode(groupModes[n]); + } + + function showHelp() { + const modal = document.getElementById('kbShortcutsModal'); + if (modal) modal.style.display = 'flex'; + } + + function hideHelp() { + const modal = document.getElementById('kbShortcutsModal'); + if (modal) modal.style.display = 'none'; + } + + function init() { + if (_handler) document.removeEventListener('keydown', _handler); + _handler = _handle; + document.addEventListener('keydown', _handler); + } + + return { init, showHelp, hideHelp }; +})(); + +window.KeyboardShortcuts = KeyboardShortcuts; diff --git a/static/js/core/recordings.js b/static/js/core/recordings.js index 8aba475..8f6fa65 100644 --- a/static/js/core/recordings.js +++ b/static/js/core/recordings.js @@ -114,13 +114,7 @@ const RecordingUI = (function() { function openReplay(sessionId) { if (!sessionId) return; - localStorage.setItem('analyticsReplaySession', sessionId); - if (typeof hideSettings === 'function') hideSettings(); - if (typeof switchMode === 'function') { - switchMode('analytics', { updateUrl: true }); - return; - } - window.location.href = '/?mode=analytics'; + window.open(`/recordings/${sessionId}/download`, '_blank'); } function escapeHtml(str) { diff --git a/static/js/core/settings-manager.js b/static/js/core/settings-manager.js index cbbabf7..f883130 100644 --- a/static/js/core/settings-manager.js +++ b/static/js/core/settings-manager.js @@ -1265,6 +1265,7 @@ function switchSettingsTab(tabName) { } else if (tabName === 'location') { loadObserverLocation(); } else if (tabName === 'alerts') { + loadVoiceAlertConfig(); if (typeof AlertCenter !== 'undefined') { AlertCenter.loadFeed(); } @@ -1277,6 +1278,61 @@ function switchSettingsTab(tabName) { } } +/** + * Load voice alert configuration into Settings > Alerts tab + */ +function loadVoiceAlertConfig() { + if (typeof VoiceAlerts === 'undefined') return; + const cfg = VoiceAlerts.getConfig(); + + const pager = document.getElementById('voiceCfgPager'); + const tscm = document.getElementById('voiceCfgTscm'); + const tracker = document.getElementById('voiceCfgTracker'); + const squawk = document.getElementById('voiceCfgSquawk'); + const rate = document.getElementById('voiceCfgRate'); + const pitch = document.getElementById('voiceCfgPitch'); + const rateVal = document.getElementById('voiceCfgRateVal'); + const pitchVal = document.getElementById('voiceCfgPitchVal'); + + if (pager) pager.checked = cfg.streams.pager !== false; + if (tscm) tscm.checked = cfg.streams.tscm !== false; + if (tracker) tracker.checked = cfg.streams.bluetooth !== false; + if (squawk) squawk.checked = cfg.streams.squawks !== false; + if (rate) rate.value = cfg.rate; + if (pitch) pitch.value = cfg.pitch; + if (rateVal) rateVal.textContent = cfg.rate; + if (pitchVal) pitchVal.textContent = cfg.pitch; + + // Populate voice dropdown + VoiceAlerts.getAvailableVoices().then(function (voices) { + var sel = document.getElementById('voiceCfgVoice'); + if (!sel) return; + sel.innerHTML = '' + + voices.filter(function (v) { return v.lang.startsWith('en'); }).map(function (v) { + return ''; + }).join(''); + }); +} + +function saveVoiceAlertConfig() { + if (typeof VoiceAlerts === 'undefined') return; + VoiceAlerts.setConfig({ + rate: parseFloat(document.getElementById('voiceCfgRate')?.value) || 1.1, + pitch: parseFloat(document.getElementById('voiceCfgPitch')?.value) || 0.9, + voiceName: document.getElementById('voiceCfgVoice')?.value || '', + streams: { + pager: !!document.getElementById('voiceCfgPager')?.checked, + tscm: !!document.getElementById('voiceCfgTscm')?.checked, + bluetooth: !!document.getElementById('voiceCfgTracker')?.checked, + squawks: !!document.getElementById('voiceCfgSquawk')?.checked, + }, + }); +} + +function testVoiceAlert() { + if (typeof VoiceAlerts !== 'undefined') VoiceAlerts.testVoice(); +} + /** * Load API key status into the API Keys settings tab */ diff --git a/static/js/core/voice-alerts.js b/static/js/core/voice-alerts.js new file mode 100644 index 0000000..00e0aeb --- /dev/null +++ b/static/js/core/voice-alerts.js @@ -0,0 +1,200 @@ +/* INTERCEPT Voice Alerts — Web Speech API queue with priority system */ +const VoiceAlerts = (function () { + 'use strict'; + + const PRIORITY = { LOW: 0, MEDIUM: 1, HIGH: 2 }; + let _enabled = true; + let _muted = false; + let _queue = []; + let _speaking = false; + let _sources = {}; + const STORAGE_KEY = 'intercept-voice-muted'; + const CONFIG_KEY = 'intercept-voice-config'; + + // Default config + let _config = { + rate: 1.1, + pitch: 0.9, + voiceName: '', + streams: { pager: true, tscm: true, bluetooth: true }, + }; + + function _loadConfig() { + _muted = localStorage.getItem(STORAGE_KEY) === 'true'; + try { + const stored = localStorage.getItem(CONFIG_KEY); + if (stored) { + const parsed = JSON.parse(stored); + _config.rate = parsed.rate ?? _config.rate; + _config.pitch = parsed.pitch ?? _config.pitch; + _config.voiceName = parsed.voiceName ?? _config.voiceName; + if (parsed.streams) { + Object.assign(_config.streams, parsed.streams); + } + } + } catch (_) {} + _updateMuteButton(); + } + + function _updateMuteButton() { + const btn = document.getElementById('voiceMuteBtn'); + if (!btn) return; + btn.classList.toggle('voice-muted', _muted); + btn.title = _muted ? 'Unmute voice alerts' : 'Mute voice alerts'; + btn.style.opacity = _muted ? '0.4' : '1'; + } + + function _getVoice() { + if (!_config.voiceName) return null; + const voices = window.speechSynthesis ? speechSynthesis.getVoices() : []; + return voices.find(v => v.name === _config.voiceName) || null; + } + + function speak(text, priority) { + if (priority === undefined) priority = PRIORITY.MEDIUM; + if (!_enabled || _muted) return; + if (!window.speechSynthesis) return; + if (priority === PRIORITY.LOW && _speaking) return; + if (priority === PRIORITY.HIGH && _speaking) { + window.speechSynthesis.cancel(); + _queue = []; + _speaking = false; + } + _queue.push({ text, priority }); + if (!_speaking) _dequeue(); + } + + function _dequeue() { + if (_queue.length === 0) { _speaking = false; return; } + _speaking = true; + const item = _queue.shift(); + const utt = new SpeechSynthesisUtterance(item.text); + utt.rate = _config.rate; + utt.pitch = _config.pitch; + const voice = _getVoice(); + if (voice) utt.voice = voice; + utt.onend = () => { _speaking = false; _dequeue(); }; + utt.onerror = () => { _speaking = false; _dequeue(); }; + window.speechSynthesis.speak(utt); + } + + function toggleMute() { + _muted = !_muted; + localStorage.setItem(STORAGE_KEY, _muted ? 'true' : 'false'); + _updateMuteButton(); + if (_muted && window.speechSynthesis) window.speechSynthesis.cancel(); + } + + function _openStream(url, handler, key) { + if (_sources[key]) return; + const es = new EventSource(url); + es.onmessage = handler; + es.onerror = () => { es.close(); delete _sources[key]; }; + _sources[key] = es; + } + + function _startStreams() { + if (!_enabled) return; + + // Pager stream + if (_config.streams.pager) { + _openStream('/stream', (ev) => { + try { + const d = JSON.parse(ev.data); + if (d.address && d.message) { + speak(`Pager message to ${d.address}: ${String(d.message).slice(0, 60)}`, PRIORITY.MEDIUM); + } + } catch (_) {} + }, 'pager'); + } + + // TSCM stream + if (_config.streams.tscm) { + _openStream('/tscm/sweep/stream', (ev) => { + try { + const d = JSON.parse(ev.data); + if (d.threat_level && d.description) { + speak(`TSCM alert: ${d.threat_level} — ${d.description}`, PRIORITY.HIGH); + } + } catch (_) {} + }, 'tscm'); + } + + // Bluetooth stream — tracker detection only + if (_config.streams.bluetooth) { + _openStream('/api/bluetooth/stream', (ev) => { + try { + const d = JSON.parse(ev.data); + if (d.service_data && d.service_data.tracker_type) { + speak(`Tracker detected: ${d.service_data.tracker_type}`, PRIORITY.HIGH); + } + } catch (_) {} + }, 'bluetooth'); + } + + } + + function _stopStreams() { + Object.values(_sources).forEach(es => { try { es.close(); } catch (_) {} }); + _sources = {}; + } + + function init() { + _loadConfig(); + _startStreams(); + } + + function setEnabled(val) { + _enabled = val; + if (!val) { + _stopStreams(); + if (window.speechSynthesis) window.speechSynthesis.cancel(); + } else { + _startStreams(); + } + } + + // ── Config API (used by Ops Center voice config panel) ───────────── + + function getConfig() { + return JSON.parse(JSON.stringify(_config)); + } + + function setConfig(cfg) { + if (cfg.rate !== undefined) _config.rate = cfg.rate; + if (cfg.pitch !== undefined) _config.pitch = cfg.pitch; + if (cfg.voiceName !== undefined) _config.voiceName = cfg.voiceName; + if (cfg.streams) Object.assign(_config.streams, cfg.streams); + localStorage.setItem(CONFIG_KEY, JSON.stringify(_config)); + // Restart streams to apply per-stream toggle changes + _stopStreams(); + _startStreams(); + } + + function getAvailableVoices() { + return new Promise(resolve => { + if (!window.speechSynthesis) { resolve([]); return; } + let voices = speechSynthesis.getVoices(); + if (voices.length > 0) { resolve(voices); return; } + speechSynthesis.onvoiceschanged = () => { + resolve(speechSynthesis.getVoices()); + }; + // Timeout fallback + setTimeout(() => resolve(speechSynthesis.getVoices()), 500); + }); + } + + function testVoice(text) { + if (!window.speechSynthesis) return; + const utt = new SpeechSynthesisUtterance(text || 'Voice alert test. All systems nominal.'); + utt.rate = _config.rate; + utt.pitch = _config.pitch; + const voice = _getVoice(); + if (voice) utt.voice = voice; + speechSynthesis.speak(utt); + } + + return { init, speak, toggleMute, setEnabled, getConfig, setConfig, getAvailableVoices, testVoice, PRIORITY }; +})(); + +window.VoiceAlerts = VoiceAlerts; diff --git a/static/js/modes/analytics.js b/static/js/modes/analytics.js deleted file mode 100644 index 11586fd..0000000 --- a/static/js/modes/analytics.js +++ /dev/null @@ -1,549 +0,0 @@ -/** - * Analytics Dashboard Module - * Cross-mode summary, sparklines, alerts, correlations, target view, and replay. - */ -const Analytics = (function () { - 'use strict'; - - let refreshTimer = null; - let replayTimer = null; - let replaySessions = []; - let replayEvents = []; - let replayIndex = 0; - - function init() { - refresh(); - loadReplaySessions(); - if (!refreshTimer) { - refreshTimer = setInterval(refresh, 5000); - } - } - - function destroy() { - if (refreshTimer) { - clearInterval(refreshTimer); - refreshTimer = null; - } - pauseReplay(); - } - - function refresh() { - Promise.all([ - fetch('/analytics/summary').then(r => r.json()).catch(() => null), - fetch('/analytics/activity').then(r => r.json()).catch(() => null), - fetch('/analytics/insights').then(r => r.json()).catch(() => null), - fetch('/analytics/patterns').then(r => r.json()).catch(() => null), - fetch('/alerts/events?limit=20').then(r => r.json()).catch(() => null), - fetch('/correlation').then(r => r.json()).catch(() => null), - fetch('/analytics/geofences').then(r => r.json()).catch(() => null), - ]).then(([summary, activity, insights, patterns, alerts, correlations, geofences]) => { - if (summary) renderSummary(summary); - if (activity) renderSparklines(activity.sparklines || {}); - if (insights) renderInsights(insights); - if (patterns) renderPatterns(patterns.patterns || []); - if (alerts) renderAlerts(alerts.events || []); - if (correlations) renderCorrelations(correlations); - if (geofences) renderGeofences(geofences.zones || []); - }); - } - - function renderSummary(data) { - const counts = data.counts || {}; - _setText('analyticsCountAdsb', counts.adsb || 0); - _setText('analyticsCountAis', counts.ais || 0); - _setText('analyticsCountWifi', counts.wifi || 0); - _setText('analyticsCountBt', counts.bluetooth || 0); - _setText('analyticsCountDsc', counts.dsc || 0); - _setText('analyticsCountAcars', counts.acars || 0); - _setText('analyticsCountVdl2', counts.vdl2 || 0); - _setText('analyticsCountAprs', counts.aprs || 0); - _setText('analyticsCountMesh', counts.meshtastic || 0); - - const health = data.health || {}; - const container = document.getElementById('analyticsHealth'); - if (container) { - let html = ''; - const modeLabels = { - pager: 'Pager', sensor: '433MHz', adsb: 'ADS-B', ais: 'AIS', - acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', wifi: 'WiFi', - bluetooth: 'BT', dsc: 'DSC', meshtastic: 'Mesh' - }; - for (const [mode, info] of Object.entries(health)) { - if (mode === 'sdr_devices') continue; - const running = info && info.running; - const label = modeLabels[mode] || mode; - html += '
' + _esc(label) + '
'; - } - container.innerHTML = html; - } - - const squawks = data.squawks || []; - const sqSection = document.getElementById('analyticsSquawkSection'); - const sqList = document.getElementById('analyticsSquawkList'); - if (sqSection && sqList) { - if (squawks.length > 0) { - sqSection.style.display = ''; - sqList.innerHTML = squawks.map(s => - '
' + _esc(s.squawk) + ' ' + - _esc(s.meaning) + ' - ' + _esc(s.callsign || s.icao) + '
' - ).join(''); - } else { - sqSection.style.display = 'none'; - } - } - } - - function renderSparklines(sparklines) { - const map = { - adsb: 'analyticsSparkAdsb', - ais: 'analyticsSparkAis', - wifi: 'analyticsSparkWifi', - bluetooth: 'analyticsSparkBt', - dsc: 'analyticsSparkDsc', - acars: 'analyticsSparkAcars', - vdl2: 'analyticsSparkVdl2', - aprs: 'analyticsSparkAprs', - meshtastic: 'analyticsSparkMesh', - }; - - for (const [mode, elId] of Object.entries(map)) { - const el = document.getElementById(elId); - if (!el) continue; - const data = sparklines[mode] || []; - if (data.length < 2) { - el.innerHTML = ''; - continue; - } - const max = Math.max(...data, 1); - const w = 100; - const h = 24; - const step = w / (data.length - 1); - const points = data.map((v, i) => - (i * step).toFixed(1) + ',' + (h - (v / max) * (h - 2)).toFixed(1) - ).join(' '); - el.innerHTML = ''; - } - } - - function renderInsights(data) { - const cards = data.cards || []; - const topChanges = data.top_changes || []; - const cardsEl = document.getElementById('analyticsInsights'); - const changesEl = document.getElementById('analyticsTopChanges'); - - if (cardsEl) { - if (!cards.length) { - cardsEl.innerHTML = '
No insight data available
'; - } else { - cardsEl.innerHTML = cards.map(c => { - const sev = _esc(c.severity || 'low'); - const title = _esc(c.title || 'Insight'); - const value = _esc(c.value || '--'); - const label = _esc(c.label || ''); - const detail = _esc(c.detail || ''); - return '
' + - '
' + title + '
' + - '
' + value + '
' + - '
' + label + '
' + - '
' + detail + '
' + - '
'; - }).join(''); - } - } - - if (changesEl) { - if (!topChanges.length) { - changesEl.innerHTML = '
No change signals yet
'; - } else { - changesEl.innerHTML = topChanges.map(item => { - const mode = _esc(item.mode_label || item.mode || ''); - const deltaRaw = Number(item.delta || 0); - const trendClass = deltaRaw > 0 ? 'up' : (deltaRaw < 0 ? 'down' : 'flat'); - const delta = _esc(item.signed_delta || String(deltaRaw)); - const recentAvg = _esc(item.recent_avg); - const prevAvg = _esc(item.previous_avg); - return '
' + - '' + mode + '' + - '' + delta + '' + - 'avg ' + recentAvg + ' vs ' + prevAvg + '' + - '
'; - }).join(''); - } - } - } - - function renderPatterns(patterns) { - const container = document.getElementById('analyticsPatternList'); - if (!container) return; - if (!patterns || patterns.length === 0) { - container.innerHTML = '
No recurring patterns detected
'; - return; - } - - const modeLabels = { - adsb: 'ADS-B', ais: 'AIS', wifi: 'WiFi', bluetooth: 'Bluetooth', - dsc: 'DSC', acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', meshtastic: 'Meshtastic', - }; - - const sorted = patterns - .slice() - .sort((a, b) => (b.confidence || 0) - (a.confidence || 0)) - .slice(0, 20); - - container.innerHTML = sorted.map(p => { - const confidencePct = Math.round((Number(p.confidence || 0)) * 100); - const mode = modeLabels[p.mode] || (p.mode || '--').toUpperCase(); - const period = _humanPeriod(Number(p.period_seconds || 0)); - const occurrences = Number(p.occurrences || 0); - const deviceId = _shortId(p.device_id || '--'); - return '
' + - '
' + - '' + _esc(mode) + '' + - '' + _esc(deviceId) + '' + - '
' + - '
' + - 'Period: ' + _esc(period) + '' + - 'Hits: ' + _esc(occurrences) + '' + - '' + _esc(confidencePct) + '%' + - '
' + - '
'; - }).join(''); - } - - function renderAlerts(events) { - const container = document.getElementById('analyticsAlertFeed'); - if (!container) return; - if (!events || events.length === 0) { - container.innerHTML = '
No recent alerts
'; - return; - } - container.innerHTML = events.slice(0, 20).map(e => { - const sev = e.severity || 'medium'; - const title = e.title || e.event_type || 'Alert'; - const time = e.created_at ? new Date(e.created_at).toLocaleTimeString() : ''; - return '
' + - '' + _esc(sev) + '' + - '' + _esc(title) + '' + - '' + _esc(time) + '' + - '
'; - }).join(''); - } - - function renderCorrelations(data) { - const container = document.getElementById('analyticsCorrelations'); - if (!container) return; - const pairs = (data && data.correlations) || []; - if (pairs.length === 0) { - container.innerHTML = '
No correlations detected
'; - return; - } - container.innerHTML = pairs.slice(0, 20).map(p => { - const conf = Math.round((p.confidence || 0) * 100); - return '
' + - '' + _esc(p.wifi_mac || '') + '' + - '' + - '' + _esc(p.bt_mac || '') + '' + - '
' + - '' + conf + '%' + - '
'; - }).join(''); - } - - function renderGeofences(zones) { - const container = document.getElementById('analyticsGeofenceList'); - if (!container) return; - if (!zones || zones.length === 0) { - container.innerHTML = '
No geofence zones defined
'; - return; - } - container.innerHTML = zones.map(z => - '
' + - '' + _esc(z.name) + '' + - '' + z.radius_m + 'm' + - '' + - '
' - ).join(''); - } - - function addGeofence() { - const name = prompt('Zone name:'); - if (!name) return; - const lat = parseFloat(prompt('Latitude:', '0')); - const lon = parseFloat(prompt('Longitude:', '0')); - const radius = parseFloat(prompt('Radius (meters):', '1000')); - if (isNaN(lat) || isNaN(lon) || isNaN(radius)) { - alert('Invalid input'); - return; - } - fetch('/analytics/geofences', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, lat, lon, radius_m: radius }), - }) - .then(r => r.json()) - .then(() => refresh()); - } - - function deleteGeofence(id) { - if (!confirm('Delete this geofence zone?')) return; - fetch('/analytics/geofences/' + id, { method: 'DELETE' }) - .then(r => r.json()) - .then(() => refresh()); - } - - function exportData(mode) { - const m = mode || (document.getElementById('exportMode') || {}).value || 'adsb'; - const f = (document.getElementById('exportFormat') || {}).value || 'json'; - window.open('/analytics/export/' + encodeURIComponent(m) + '?format=' + encodeURIComponent(f), '_blank'); - } - - function searchTarget() { - const input = document.getElementById('analyticsTargetQuery'); - const summaryEl = document.getElementById('analyticsTargetSummary'); - const q = (input && input.value || '').trim(); - if (!q) { - if (summaryEl) summaryEl.textContent = 'Enter a search value to correlate entities'; - renderTargetResults([]); - return; - } - - fetch('/analytics/target?q=' + encodeURIComponent(q) + '&limit=120') - .then((r) => r.json()) - .then((data) => { - const results = data.results || []; - if (summaryEl) { - const modeCounts = data.mode_counts || {}; - const bits = Object.entries(modeCounts).map(([mode, count]) => `${mode}: ${count}`).join(' | '); - summaryEl.textContent = `${results.length} results${bits ? ' | ' + bits : ''}`; - } - renderTargetResults(results); - }) - .catch((err) => { - if (summaryEl) summaryEl.textContent = 'Search failed'; - if (typeof reportActionableError === 'function') { - reportActionableError('Target View Search', err, { onRetry: searchTarget }); - } - }); - } - - function renderTargetResults(results) { - const container = document.getElementById('analyticsTargetResults'); - if (!container) return; - - if (!results || !results.length) { - container.innerHTML = '
No matching entities
'; - return; - } - - container.innerHTML = results.map((item) => { - const title = _esc(item.title || item.id || 'Entity'); - const subtitle = _esc(item.subtitle || ''); - const mode = _esc(item.mode || 'unknown'); - const confidence = item.confidence != null ? `Confidence ${_esc(Math.round(Number(item.confidence) * 100))}%` : ''; - const lastSeen = _esc(item.last_seen || ''); - return '
' + - '
' + mode + '' + title + '
' + - '
' + subtitle + '' + - (lastSeen ? 'Last seen ' + lastSeen + '' : '') + - (confidence ? '' + confidence + '' : '') + - '
' + - '
'; - }).join(''); - } - - function loadReplaySessions() { - const select = document.getElementById('analyticsReplaySelect'); - if (!select) return; - - fetch('/recordings?limit=60') - .then((r) => r.json()) - .then((data) => { - replaySessions = (data.recordings || []).filter((rec) => Number(rec.event_count || 0) > 0); - - if (!replaySessions.length) { - select.innerHTML = ''; - return; - } - - select.innerHTML = replaySessions.map((rec) => { - const label = `${rec.mode} | ${(rec.label || 'session')} | ${new Date(rec.started_at).toLocaleString()}`; - return ``; - }).join(''); - - const pendingReplay = localStorage.getItem('analyticsReplaySession'); - if (pendingReplay && replaySessions.some((rec) => rec.id === pendingReplay)) { - select.value = pendingReplay; - localStorage.removeItem('analyticsReplaySession'); - loadReplay(); - } - }) - .catch((err) => { - if (typeof reportActionableError === 'function') { - reportActionableError('Load Replay Sessions', err, { onRetry: loadReplaySessions }); - } - }); - } - - function loadReplay() { - pauseReplay(); - replayEvents = []; - replayIndex = 0; - - const select = document.getElementById('analyticsReplaySelect'); - const meta = document.getElementById('analyticsReplayMeta'); - const timeline = document.getElementById('analyticsReplayTimeline'); - if (!select || !meta || !timeline) return; - - const id = select.value; - if (!id) { - meta.textContent = 'Select a recording'; - timeline.innerHTML = '
No recording selected
'; - return; - } - - meta.textContent = 'Loading replay events...'; - - fetch('/recordings/' + encodeURIComponent(id) + '/events?limit=600') - .then((r) => r.json()) - .then((data) => { - replayEvents = data.events || []; - replayIndex = 0; - if (!replayEvents.length) { - meta.textContent = 'No events found in selected recording'; - timeline.innerHTML = '
No events to replay
'; - return; - } - - const rec = replaySessions.find((s) => s.id === id); - const mode = rec ? rec.mode : (data.recording && data.recording.mode) || 'unknown'; - meta.textContent = `${replayEvents.length} events loaded | mode ${mode}`; - renderReplayWindow(); - }) - .catch((err) => { - meta.textContent = 'Replay load failed'; - if (typeof reportActionableError === 'function') { - reportActionableError('Load Replay', err, { onRetry: loadReplay }); - } - }); - } - - function playReplay() { - if (!replayEvents.length) { - loadReplay(); - return; - } - - if (replayTimer) return; - - replayTimer = setInterval(() => { - if (replayIndex >= replayEvents.length - 1) { - pauseReplay(); - return; - } - replayIndex += 1; - renderReplayWindow(); - }, 260); - } - - function pauseReplay() { - if (replayTimer) { - clearInterval(replayTimer); - replayTimer = null; - } - } - - function stepReplay() { - if (!replayEvents.length) { - loadReplay(); - return; - } - - pauseReplay(); - replayIndex = Math.min(replayIndex + 1, replayEvents.length - 1); - renderReplayWindow(); - } - - function renderReplayWindow() { - const timeline = document.getElementById('analyticsReplayTimeline'); - const meta = document.getElementById('analyticsReplayMeta'); - if (!timeline || !meta) return; - - const total = replayEvents.length; - if (!total) { - timeline.innerHTML = '
No events to replay
'; - return; - } - - const start = Math.max(0, replayIndex - 15); - const end = Math.min(total, replayIndex + 20); - const windowed = replayEvents.slice(start, end); - - timeline.innerHTML = windowed.map((row, i) => { - const absolute = start + i; - const active = absolute === replayIndex; - const eventType = _esc(row.event_type || 'event'); - const mode = _esc(row.mode || '--'); - const ts = _esc(row.timestamp ? new Date(row.timestamp).toLocaleTimeString() : '--'); - const detail = summarizeReplayEvent(row.event || {}); - return '
' + - '
' + mode + '' + eventType + '
' + - '
' + ts + '' + _esc(detail) + '
' + - '
'; - }).join(''); - - meta.textContent = `Event ${replayIndex + 1}/${total}`; - } - - function summarizeReplayEvent(event) { - if (!event || typeof event !== 'object') return 'No details'; - if (event.callsign) return `Callsign ${event.callsign}`; - if (event.icao) return `ICAO ${event.icao}`; - if (event.ssid) return `SSID ${event.ssid}`; - if (event.bssid) return `BSSID ${event.bssid}`; - if (event.address) return `Address ${event.address}`; - if (event.name) return `Name ${event.name}`; - const keys = Object.keys(event); - if (!keys.length) return 'No fields'; - return `${keys[0]}=${String(event[keys[0]]).slice(0, 40)}`; - } - - function _setText(id, val) { - const el = document.getElementById(id); - if (el) el.textContent = val; - } - - function _esc(s) { - if (typeof s !== 'string') s = String(s == null ? '' : s); - return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); - } - - function _shortId(value) { - const text = String(value || ''); - if (text.length <= 18) return text; - return text.slice(0, 8) + '...' + text.slice(-6); - } - - function _humanPeriod(seconds) { - if (!isFinite(seconds) || seconds <= 0) return '--'; - if (seconds < 60) return Math.round(seconds) + 's'; - const mins = seconds / 60; - if (mins < 60) return mins.toFixed(mins < 10 ? 1 : 0) + 'm'; - const hours = mins / 60; - return hours.toFixed(hours < 10 ? 1 : 0) + 'h'; - } - - return { - init, - destroy, - refresh, - addGeofence, - deleteGeofence, - exportData, - searchTarget, - loadReplay, - playReplay, - pauseReplay, - stepReplay, - loadReplaySessions, - }; -})(); diff --git a/static/js/modes/bt_locate.js b/static/js/modes/bt_locate.js index a295730..59246be 100644 --- a/static/js/modes/bt_locate.js +++ b/static/js/modes/bt_locate.js @@ -1,27 +1,27 @@ -/** - * BT Locate — Bluetooth SAR Device Location Mode - * GPS-tagged signal trail mapping with proximity audio alerts. - */ -const BtLocate = (function() { - 'use strict'; - +/** + * BT Locate — Bluetooth SAR Device Location Mode + * GPS-tagged signal trail mapping with proximity audio alerts. + */ +const BtLocate = (function() { + 'use strict'; + let eventSource = null; let map = null; let mapMarkers = []; let trailPoints = []; let trailLine = null; let rssiHistory = []; - const MAX_RSSI_POINTS = 60; - let chartCanvas = null; - let chartCtx = null; - let currentEnvironment = 'OUTDOOR'; - let audioCtx = null; - let audioEnabled = false; - let beepTimer = null; - let initialized = false; - let handoffData = null; - let pollTimer = null; - let durationTimer = null; + const MAX_RSSI_POINTS = 60; + let chartCanvas = null; + let chartCtx = null; + let currentEnvironment = 'OUTDOOR'; + let audioCtx = null; + let audioEnabled = false; + let beepTimer = null; + let initialized = false; + let handoffData = null; + let pollTimer = null; + let durationTimer = null; let sessionStartedAt = null; let lastDetectionCount = 0; let gpsLocked = false; @@ -193,8 +193,8 @@ const BtLocate = (function() { checkStatus(); return; } - - // Init map + + // Init map const mapEl = document.getElementById('btLocateMap'); if (mapEl && typeof L !== 'undefined') { map = L.map('btLocateMap', { @@ -226,23 +226,23 @@ const BtLocate = (function() { map.on('resize moveend zoomend', () => { flushPendingHeatSync(); }); - setTimeout(() => { + requestAnimationFrame(() => { safeInvalidateMap(); flushPendingHeatSync(); - }, 100); - scheduleMapStabilization(); + scheduleMapStabilization(); + }); } - - // Init RSSI chart canvas - chartCanvas = document.getElementById('btLocateRssiChart'); - if (chartCanvas) { - chartCtx = chartCanvas.getContext('2d'); - } - - checkStatus(); - initialized = true; - } - + + // Init RSSI chart canvas + chartCanvas = document.getElementById('btLocateRssiChart'); + if (chartCanvas) { + chartCtx = chartCanvas.getContext('2d'); + } + + checkStatus(); + initialized = true; + } + function checkStatus() { fetch(statusUrl()) .then(r => r.json()) @@ -275,11 +275,11 @@ const BtLocate = (function() { const mac = normalizeMacInput(document.getElementById('btLocateMac')?.value); const namePattern = document.getElementById('btLocateNamePattern')?.value.trim(); const irk = document.getElementById('btLocateIrk')?.value.trim(); - - const body = { environment: currentEnvironment }; - if (mac) body.mac_address = mac; - if (namePattern) body.name_pattern = namePattern; - if (irk) body.irk_hex = irk; + + const body = { environment: currentEnvironment }; + if (mac) body.mac_address = mac; + if (namePattern) body.name_pattern = namePattern; + if (irk) body.irk_hex = irk; if (handoffData?.device_id) body.device_id = handoffData.device_id; if (handoffData?.device_key) body.device_key = handoffData.device_key; if (handoffData?.fingerprint_id) body.fingerprint_id = handoffData.fingerprint_id; @@ -293,9 +293,9 @@ const BtLocate = (function() { body.fallback_lat = fallbackLocation.lat; body.fallback_lon = fallbackLocation.lon; } - + debugLog('[BtLocate] Starting with body:', body); - + if (!body.mac_address && !body.name_pattern && !body.irk_hex && !body.device_id && !body.device_key && !body.fingerprint_id) { alert('Please provide at least one target identifier or use hand-off from Bluetooth mode.'); @@ -347,7 +347,7 @@ const BtLocate = (function() { setStartButtonBusy(false); }); } - + function stop() { fetch('/bt_locate/stop', { method: 'POST' }) .then(r => r.json()) @@ -363,17 +363,17 @@ const BtLocate = (function() { stopAudio(); }) .catch(err => console.error('[BtLocate] Stop error:', err)); - } - + } + function showActiveUI() { setStartButtonBusy(false); const startBtn = document.getElementById('btLocateStartBtn'); const stopBtn = document.getElementById('btLocateStopBtn'); if (startBtn) startBtn.style.display = 'none'; - if (stopBtn) stopBtn.style.display = 'inline-block'; - show('btLocateHud'); - } - + if (stopBtn) stopBtn.style.display = 'inline-block'; + show('btLocateHud'); + } + function showIdleUI() { startRequestInFlight = false; setStartButtonBusy(false); @@ -388,43 +388,43 @@ const BtLocate = (function() { if (startBtn) startBtn.style.display = 'inline-block'; if (stopBtn) stopBtn.style.display = 'none'; hide('btLocateHud'); - hide('btLocateScanStatus'); - } - - function updateScanStatus(statusData) { - const el = document.getElementById('btLocateScanStatus'); - const dot = document.getElementById('btLocateScanDot'); - const text = document.getElementById('btLocateScanText'); - if (!el) return; - - el.style.display = ''; - if (statusData && statusData.scanner_running) { - if (dot) dot.style.background = '#22c55e'; - if (text) text.textContent = 'BT scanner active'; - } else { - if (dot) dot.style.background = '#f97316'; - if (text) text.textContent = 'BT scanner not running — waiting...'; - } - } - - function show(id) { const el = document.getElementById(id); if (el) el.style.display = ''; } - function hide(id) { const el = document.getElementById(id); if (el) el.style.display = 'none'; } - - function connectSSE() { - if (eventSource) eventSource.close(); + hide('btLocateScanStatus'); + } + + function updateScanStatus(statusData) { + const el = document.getElementById('btLocateScanStatus'); + const dot = document.getElementById('btLocateScanDot'); + const text = document.getElementById('btLocateScanText'); + if (!el) return; + + el.style.display = ''; + if (statusData && statusData.scanner_running) { + if (dot) dot.style.background = '#22c55e'; + if (text) text.textContent = 'BT scanner active'; + } else { + if (dot) dot.style.background = '#f97316'; + if (text) text.textContent = 'BT scanner not running — waiting...'; + } + } + + function show(id) { const el = document.getElementById(id); if (el) el.style.display = ''; } + function hide(id) { const el = document.getElementById(id); if (el) el.style.display = 'none'; } + + function connectSSE() { + if (eventSource) eventSource.close(); debugLog('[BtLocate] Connecting SSE stream'); - eventSource = new EventSource('/bt_locate/stream'); - - eventSource.addEventListener('detection', function(e) { - try { - const event = JSON.parse(e.data); + eventSource = new EventSource('/bt_locate/stream'); + + eventSource.addEventListener('detection', function(e) { + try { + const event = JSON.parse(e.data); debugLog('[BtLocate] Detection event:', event); - handleDetection(event); - } catch (err) { - console.error('[BtLocate] Parse error:', err); - } - }); - + handleDetection(event); + } catch (err) { + console.error('[BtLocate] Parse error:', err); + } + }); + eventSource.addEventListener('session_ended', function() { showIdleUI(); disconnectSSE(); @@ -436,66 +436,66 @@ const BtLocate = (function() { eventSource = null; } }; - + // Start polling fallback (catches data even if SSE fails) startPolling(); pollStatus(); } - - function disconnectSSE() { - if (eventSource) { - eventSource.close(); - eventSource = null; - } - stopPolling(); - } - - function startPolling() { - stopPolling(); - lastDetectionCount = 0; - pollTimer = setInterval(pollStatus, 3000); - startDurationTimer(); - } - - function stopPolling() { - if (pollTimer) { - clearInterval(pollTimer); - pollTimer = null; - } - stopDurationTimer(); - } - - function startDurationTimer() { - stopDurationTimer(); - durationTimer = setInterval(updateDuration, 1000); - } - - function stopDurationTimer() { - if (durationTimer) { - clearInterval(durationTimer); - durationTimer = null; - } - } - - function updateDuration() { - if (!sessionStartedAt) return; - const elapsed = Math.round((Date.now() - sessionStartedAt) / 1000); - const mins = Math.floor(elapsed / 60); - const secs = elapsed % 60; - const timeEl = document.getElementById('btLocateSessionTime'); - if (timeEl) timeEl.textContent = mins + ':' + String(secs).padStart(2, '0'); - } - + + function disconnectSSE() { + if (eventSource) { + eventSource.close(); + eventSource = null; + } + stopPolling(); + } + + function startPolling() { + stopPolling(); + lastDetectionCount = 0; + pollTimer = setInterval(pollStatus, 3000); + startDurationTimer(); + } + + function stopPolling() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + stopDurationTimer(); + } + + function startDurationTimer() { + stopDurationTimer(); + durationTimer = setInterval(updateDuration, 1000); + } + + function stopDurationTimer() { + if (durationTimer) { + clearInterval(durationTimer); + durationTimer = null; + } + } + + function updateDuration() { + if (!sessionStartedAt) return; + const elapsed = Math.round((Date.now() - sessionStartedAt) / 1000); + const mins = Math.floor(elapsed / 60); + const secs = elapsed % 60; + const timeEl = document.getElementById('btLocateSessionTime'); + if (timeEl) timeEl.textContent = mins + ':' + String(secs).padStart(2, '0'); + } + function pollStatus() { fetch(statusUrl()) .then(r => r.json()) .then(data => { - if (!data.active) { - showIdleUI(); - disconnectSSE(); - return; - } - + if (!data.active) { + showIdleUI(); + disconnectSSE(); + return; + } + updateScanStatus(data); updateHudInfo(data); @@ -506,24 +506,24 @@ const BtLocate = (function() { // Show diagnostics const diagEl = document.getElementById('btLocateDiag'); - if (diagEl) { - let diag = 'Polls: ' + (data.poll_count || 0) + - (data.poll_thread_alive === false ? ' DEAD' : '') + - ' | Scan: ' + (data.scanner_running ? 'Y' : 'N') + - ' | Devices: ' + (data.scanner_device_count || 0) + - ' | Det: ' + (data.detection_count || 0); - // Show debug device sample if no detections - if (data.detection_count === 0 && data.debug_devices && data.debug_devices.length > 0) { - const matched = data.debug_devices.filter(d => d.match); - const sample = data.debug_devices.slice(0, 3).map(d => - (d.name || '?') + '|' + (d.id || '').substring(0, 12) + ':' + (d.match ? 'Y' : 'N') - ).join(', '); - diag += ' | Match:' + matched.length + '/' + data.debug_devices.length + ' [' + sample + ']'; - } - diagEl.textContent = diag; - } - - // If detection count increased, fetch new trail points + if (diagEl) { + let diag = 'Polls: ' + (data.poll_count || 0) + + (data.poll_thread_alive === false ? ' DEAD' : '') + + ' | Scan: ' + (data.scanner_running ? 'Y' : 'N') + + ' | Devices: ' + (data.scanner_device_count || 0) + + ' | Det: ' + (data.detection_count || 0); + // Show debug device sample if no detections + if (data.detection_count === 0 && data.debug_devices && data.debug_devices.length > 0) { + const matched = data.debug_devices.filter(d => d.match); + const sample = data.debug_devices.slice(0, 3).map(d => + (d.name || '?') + '|' + (d.id || '').substring(0, 12) + ':' + (d.match ? 'Y' : 'N') + ).join(', '); + diag += ' | Match:' + matched.length + '/' + data.debug_devices.length + ' [' + sample + ']'; + } + diagEl.textContent = diag; + } + + // If detection count increased, fetch new trail points if (data.detection_count > lastDetectionCount) { lastDetectionCount = data.detection_count; fetch('/bt_locate/trail') @@ -537,53 +537,53 @@ const BtLocate = (function() { }); } }) - .catch(() => {}); - } - - function updateHudInfo(data) { - // Target info - const targetEl = document.getElementById('btLocateTargetInfo'); - if (targetEl && data.target) { - const t = data.target; - const name = t.known_name || t.name_pattern || ''; - const addr = t.mac_address || t.device_id || ''; - const addrDisplay = formatAddr(addr); - targetEl.textContent = name ? (name + (addrDisplay ? ' (' + addrDisplay + ')' : '')) : addrDisplay || '--'; - } - - // Environment info - const envEl = document.getElementById('btLocateEnvInfo'); - if (envEl) { - const envNames = { FREE_SPACE: 'Open Field', OUTDOOR: 'Outdoor', INDOOR: 'Indoor', CUSTOM: 'Custom' }; - envEl.textContent = (envNames[data.environment] || data.environment) + ' n=' + (data.path_loss_exponent || '?'); - } - - // GPS status - const gpsEl = document.getElementById('btLocateGpsStatus'); - if (gpsEl) { - const src = data.gps_source || 'none'; - if (src === 'live') gpsEl.textContent = 'GPS: Live'; - else if (src === 'manual') gpsEl.textContent = 'GPS: Manual'; - else gpsEl.textContent = 'GPS: None'; - } - - // Last seen - const lastEl = document.getElementById('btLocateLastSeen'); - if (lastEl) { - if (data.last_detection) { - const ago = Math.round((Date.now() - new Date(data.last_detection).getTime()) / 1000); - lastEl.textContent = 'Last: ' + (ago < 60 ? ago + 's ago' : Math.floor(ago / 60) + 'm ago'); - } else { - lastEl.textContent = 'Last: --'; - } - } - - // Session start time (duration handled by 1s timer) - if (data.started_at && !sessionStartedAt) { - sessionStartedAt = new Date(data.started_at).getTime(); - } - } - + .catch(() => {}); + } + + function updateHudInfo(data) { + // Target info + const targetEl = document.getElementById('btLocateTargetInfo'); + if (targetEl && data.target) { + const t = data.target; + const name = t.known_name || t.name_pattern || ''; + const addr = t.mac_address || t.device_id || ''; + const addrDisplay = formatAddr(addr); + targetEl.textContent = name ? (name + (addrDisplay ? ' (' + addrDisplay + ')' : '')) : addrDisplay || '--'; + } + + // Environment info + const envEl = document.getElementById('btLocateEnvInfo'); + if (envEl) { + const envNames = { FREE_SPACE: 'Open Field', OUTDOOR: 'Outdoor', INDOOR: 'Indoor', CUSTOM: 'Custom' }; + envEl.textContent = (envNames[data.environment] || data.environment) + ' n=' + (data.path_loss_exponent || '?'); + } + + // GPS status + const gpsEl = document.getElementById('btLocateGpsStatus'); + if (gpsEl) { + const src = data.gps_source || 'none'; + if (src === 'live') gpsEl.textContent = 'GPS: Live'; + else if (src === 'manual') gpsEl.textContent = 'GPS: Manual'; + else gpsEl.textContent = 'GPS: None'; + } + + // Last seen + const lastEl = document.getElementById('btLocateLastSeen'); + if (lastEl) { + if (data.last_detection) { + const ago = Math.round((Date.now() - new Date(data.last_detection).getTime()) / 1000); + lastEl.textContent = 'Last: ' + (ago < 60 ? ago + 's ago' : Math.floor(ago / 60) + 'm ago'); + } else { + lastEl.textContent = 'Last: --'; + } + } + + // Session start time (duration handled by 1s timer) + if (data.started_at && !sessionStartedAt) { + sessionStartedAt = new Date(data.started_at).getTime(); + } + } + function flushQueuedDetection() { if (!queuedDetection) return; const event = queuedDetection; @@ -695,14 +695,14 @@ const BtLocate = (function() { } } } - - function updateStats(detections, gpsPoints) { - const detCountEl = document.getElementById('btLocateDetectionCount'); - const gpsCountEl = document.getElementById('btLocateGpsCount'); - if (detCountEl) detCountEl.textContent = detections || 0; - if (gpsCountEl) gpsCountEl.textContent = gpsPoints || 0; - } - + + function updateStats(detections, gpsPoints) { + const detCountEl = document.getElementById('btLocateDetectionCount'); + const gpsCountEl = document.getElementById('btLocateGpsCount'); + if (detCountEl) detCountEl.textContent = detections || 0; + if (gpsCountEl) gpsCountEl.textContent = gpsPoints || 0; + } + function addMapMarker(point, options = {}) { if (!map || point.lat == null || point.lon == null) return false; const lat = Number(point.lat); @@ -1604,242 +1604,242 @@ const BtLocate = (function() { debugLog('[BtLocate] ' + title + ': ' + message); } } - - function drawRssiChart() { - if (!chartCtx || !chartCanvas) return; - - const w = chartCanvas.width = chartCanvas.parentElement.clientWidth - 16; - const h = chartCanvas.height = chartCanvas.parentElement.clientHeight - 24; - chartCtx.clearRect(0, 0, w, h); - - if (rssiHistory.length < 2) return; - - // RSSI range: -100 to -20 - const minR = -100, maxR = -20; - const range = maxR - minR; - - // Grid lines - chartCtx.strokeStyle = 'rgba(255,255,255,0.05)'; - chartCtx.lineWidth = 1; - [-30, -50, -70, -90].forEach(v => { - const y = h - ((v - minR) / range) * h; - chartCtx.beginPath(); - chartCtx.moveTo(0, y); - chartCtx.lineTo(w, y); - chartCtx.stroke(); - }); - - // Draw RSSI line - const step = w / (MAX_RSSI_POINTS - 1); - chartCtx.beginPath(); - chartCtx.strokeStyle = '#00ff88'; - chartCtx.lineWidth = 2; - - rssiHistory.forEach((rssi, i) => { - const x = i * step; - const y = h - ((rssi - minR) / range) * h; - if (i === 0) chartCtx.moveTo(x, y); - else chartCtx.lineTo(x, y); - }); - chartCtx.stroke(); - - // Fill under - const lastIdx = rssiHistory.length - 1; - chartCtx.lineTo(lastIdx * step, h); - chartCtx.lineTo(0, h); - chartCtx.closePath(); - chartCtx.fillStyle = 'rgba(0,255,136,0.08)'; - chartCtx.fill(); - } - - // Audio proximity tone (Web Audio API) - function playTone(freq, duration) { - if (!audioCtx || audioCtx.state !== 'running') return; - const osc = audioCtx.createOscillator(); - const gain = audioCtx.createGain(); - osc.connect(gain); - gain.connect(audioCtx.destination); - osc.frequency.value = freq; - osc.type = 'sine'; - gain.gain.value = 0.2; - gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration); - osc.start(); - osc.stop(audioCtx.currentTime + duration); - } - - function playProximityTone(rssi) { - if (!audioCtx || audioCtx.state !== 'running') return; - // Stronger signal = higher pitch and shorter beep - const strength = Math.max(0, Math.min(1, (rssi + 100) / 70)); - const freq = 400 + strength * 800; // 400-1200 Hz - const duration = 0.06 + (1 - strength) * 0.12; - playTone(freq, duration); - } - - function toggleAudio() { - const cb = document.getElementById('btLocateAudioEnable'); - audioEnabled = cb?.checked || false; - if (audioEnabled) { - // Create AudioContext on user gesture (required by browser policy) - if (!audioCtx) { - try { - audioCtx = new (window.AudioContext || window.webkitAudioContext)(); - } catch (e) { - console.error('[BtLocate] AudioContext creation failed:', e); - return; - } - } - // Resume must happen within a user gesture handler - const ctx = audioCtx; - ctx.resume().then(() => { + + function drawRssiChart() { + if (!chartCtx || !chartCanvas) return; + + const w = chartCanvas.width = chartCanvas.parentElement.clientWidth - 16; + const h = chartCanvas.height = chartCanvas.parentElement.clientHeight - 24; + chartCtx.clearRect(0, 0, w, h); + + if (rssiHistory.length < 2) return; + + // RSSI range: -100 to -20 + const minR = -100, maxR = -20; + const range = maxR - minR; + + // Grid lines + chartCtx.strokeStyle = 'rgba(255,255,255,0.05)'; + chartCtx.lineWidth = 1; + [-30, -50, -70, -90].forEach(v => { + const y = h - ((v - minR) / range) * h; + chartCtx.beginPath(); + chartCtx.moveTo(0, y); + chartCtx.lineTo(w, y); + chartCtx.stroke(); + }); + + // Draw RSSI line + const step = w / (MAX_RSSI_POINTS - 1); + chartCtx.beginPath(); + chartCtx.strokeStyle = '#00ff88'; + chartCtx.lineWidth = 2; + + rssiHistory.forEach((rssi, i) => { + const x = i * step; + const y = h - ((rssi - minR) / range) * h; + if (i === 0) chartCtx.moveTo(x, y); + else chartCtx.lineTo(x, y); + }); + chartCtx.stroke(); + + // Fill under + const lastIdx = rssiHistory.length - 1; + chartCtx.lineTo(lastIdx * step, h); + chartCtx.lineTo(0, h); + chartCtx.closePath(); + chartCtx.fillStyle = 'rgba(0,255,136,0.08)'; + chartCtx.fill(); + } + + // Audio proximity tone (Web Audio API) + function playTone(freq, duration) { + if (!audioCtx || audioCtx.state !== 'running') return; + const osc = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + osc.connect(gain); + gain.connect(audioCtx.destination); + osc.frequency.value = freq; + osc.type = 'sine'; + gain.gain.value = 0.2; + gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration); + osc.start(); + osc.stop(audioCtx.currentTime + duration); + } + + function playProximityTone(rssi) { + if (!audioCtx || audioCtx.state !== 'running') return; + // Stronger signal = higher pitch and shorter beep + const strength = Math.max(0, Math.min(1, (rssi + 100) / 70)); + const freq = 400 + strength * 800; // 400-1200 Hz + const duration = 0.06 + (1 - strength) * 0.12; + playTone(freq, duration); + } + + function toggleAudio() { + const cb = document.getElementById('btLocateAudioEnable'); + audioEnabled = cb?.checked || false; + if (audioEnabled) { + // Create AudioContext on user gesture (required by browser policy) + if (!audioCtx) { + try { + audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + } catch (e) { + console.error('[BtLocate] AudioContext creation failed:', e); + return; + } + } + // Resume must happen within a user gesture handler + const ctx = audioCtx; + ctx.resume().then(() => { debugLog('[BtLocate] AudioContext state:', ctx.state); - // Confirmation beep so user knows audio is working - playTone(600, 0.08); - }); - } else { - stopAudio(); - } - } - - function stopAudio() { - audioEnabled = false; - const cb = document.getElementById('btLocateAudioEnable'); - if (cb) cb.checked = false; - } - - function setEnvironment(env) { - currentEnvironment = env; - document.querySelectorAll('.btl-env-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.env === env); - }); - // Push to running session if active + // Confirmation beep so user knows audio is working + playTone(600, 0.08); + }); + } else { + stopAudio(); + } + } + + function stopAudio() { + audioEnabled = false; + const cb = document.getElementById('btLocateAudioEnable'); + if (cb) cb.checked = false; + } + + function setEnvironment(env) { + currentEnvironment = env; + document.querySelectorAll('.btl-env-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.env === env); + }); + // Push to running session if active fetch(statusUrl()).then(r => r.json()).then(data => { if (data.active) { fetch('/bt_locate/environment', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ environment: env }), - }).then(r => r.json()).then(res => { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ environment: env }), + }).then(r => r.json()).then(res => { debugLog('[BtLocate] Environment updated:', res); - }); - } - }).catch(() => {}); - } - - function isUuid(addr) { - return addr && /^[0-9A-F]{8}-[0-9A-F]{4}-/i.test(addr); - } - - function formatAddr(addr) { - if (!addr) return ''; - if (isUuid(addr)) return addr.substring(0, 8) + '-...' + addr.slice(-4); - return addr; - } - - function handoff(deviceInfo) { + }); + } + }).catch(() => {}); + } + + function isUuid(addr) { + return addr && /^[0-9A-F]{8}-[0-9A-F]{4}-/i.test(addr); + } + + function formatAddr(addr) { + if (!addr) return ''; + if (isUuid(addr)) return addr.substring(0, 8) + '-...' + addr.slice(-4); + return addr; + } + + function handoff(deviceInfo) { debugLog('[BtLocate] Handoff received:', deviceInfo); - handoffData = deviceInfo; - - // Populate fields - if (deviceInfo.mac_address) { - const macInput = document.getElementById('btLocateMac'); - if (macInput) macInput.value = deviceInfo.mac_address; - } - - // Show handoff card - const card = document.getElementById('btLocateHandoffCard'); - const nameEl = document.getElementById('btLocateHandoffName'); - const metaEl = document.getElementById('btLocateHandoffMeta'); - if (card) card.style.display = ''; - if (nameEl) nameEl.textContent = deviceInfo.known_name || formatAddr(deviceInfo.mac_address) || 'Unknown'; - if (metaEl) { - const parts = []; - if (deviceInfo.mac_address) parts.push(formatAddr(deviceInfo.mac_address)); - if (deviceInfo.known_manufacturer) parts.push(deviceInfo.known_manufacturer); - if (deviceInfo.last_known_rssi != null) parts.push(deviceInfo.last_known_rssi + ' dBm'); - metaEl.textContent = parts.join(' \u00b7 '); - } - - // Auto-fill IRK if available from scanner - if (deviceInfo.irk_hex) { - const irkInput = document.getElementById('btLocateIrk'); - if (irkInput) irkInput.value = deviceInfo.irk_hex; - } - - // Switch to bt_locate mode - if (typeof switchMode === 'function') { - switchMode('bt_locate'); - } - } - - function clearHandoff() { - handoffData = null; - const card = document.getElementById('btLocateHandoffCard'); - if (card) card.style.display = 'none'; - } - - function fetchPairedIrks() { - const picker = document.getElementById('btLocateIrkPicker'); - const status = document.getElementById('btLocateIrkPickerStatus'); - const list = document.getElementById('btLocateIrkPickerList'); - const btn = document.getElementById('btLocateDetectIrkBtn'); - if (!picker || !status || !list) return; - - // Toggle off if already visible - if (picker.style.display !== 'none') { - picker.style.display = 'none'; - return; - } - - picker.style.display = ''; - list.innerHTML = ''; - status.textContent = 'Scanning paired devices...'; - status.style.display = ''; - if (btn) btn.disabled = true; - - fetch('/bt_locate/paired_irks') - .then(r => r.json()) - .then(data => { - if (btn) btn.disabled = false; - const devices = data.devices || []; - - if (devices.length === 0) { - status.textContent = 'No paired devices with IRKs found'; - return; - } - - status.style.display = 'none'; - list.innerHTML = ''; - - devices.forEach(dev => { - const item = document.createElement('div'); - item.className = 'btl-irk-picker-item'; - item.innerHTML = - '
' + (dev.name || 'Unknown Device') + '
' + - '
' + dev.address + ' \u00b7 ' + (dev.address_type || '') + '
'; - item.addEventListener('click', function() { - selectPairedIrk(dev); - }); - list.appendChild(item); - }); - }) - .catch(err => { - if (btn) btn.disabled = false; - console.error('[BtLocate] Failed to fetch paired IRKs:', err); - status.textContent = 'Failed to read paired devices'; - }); - } - - function selectPairedIrk(dev) { - const irkInput = document.getElementById('btLocateIrk'); - const nameInput = document.getElementById('btLocateNamePattern'); - const picker = document.getElementById('btLocateIrkPicker'); - - if (irkInput) irkInput.value = dev.irk_hex; - if (nameInput && dev.name && !nameInput.value) nameInput.value = dev.name; - if (picker) picker.style.display = 'none'; - } - + handoffData = deviceInfo; + + // Populate fields + if (deviceInfo.mac_address) { + const macInput = document.getElementById('btLocateMac'); + if (macInput) macInput.value = deviceInfo.mac_address; + } + + // Show handoff card + const card = document.getElementById('btLocateHandoffCard'); + const nameEl = document.getElementById('btLocateHandoffName'); + const metaEl = document.getElementById('btLocateHandoffMeta'); + if (card) card.style.display = ''; + if (nameEl) nameEl.textContent = deviceInfo.known_name || formatAddr(deviceInfo.mac_address) || 'Unknown'; + if (metaEl) { + const parts = []; + if (deviceInfo.mac_address) parts.push(formatAddr(deviceInfo.mac_address)); + if (deviceInfo.known_manufacturer) parts.push(deviceInfo.known_manufacturer); + if (deviceInfo.last_known_rssi != null) parts.push(deviceInfo.last_known_rssi + ' dBm'); + metaEl.textContent = parts.join(' \u00b7 '); + } + + // Auto-fill IRK if available from scanner + if (deviceInfo.irk_hex) { + const irkInput = document.getElementById('btLocateIrk'); + if (irkInput) irkInput.value = deviceInfo.irk_hex; + } + + // Switch to bt_locate mode + if (typeof switchMode === 'function') { + switchMode('bt_locate'); + } + } + + function clearHandoff() { + handoffData = null; + const card = document.getElementById('btLocateHandoffCard'); + if (card) card.style.display = 'none'; + } + + function fetchPairedIrks() { + const picker = document.getElementById('btLocateIrkPicker'); + const status = document.getElementById('btLocateIrkPickerStatus'); + const list = document.getElementById('btLocateIrkPickerList'); + const btn = document.getElementById('btLocateDetectIrkBtn'); + if (!picker || !status || !list) return; + + // Toggle off if already visible + if (picker.style.display !== 'none') { + picker.style.display = 'none'; + return; + } + + picker.style.display = ''; + list.innerHTML = ''; + status.textContent = 'Scanning paired devices...'; + status.style.display = ''; + if (btn) btn.disabled = true; + + fetch('/bt_locate/paired_irks') + .then(r => r.json()) + .then(data => { + if (btn) btn.disabled = false; + const devices = data.devices || []; + + if (devices.length === 0) { + status.textContent = 'No paired devices with IRKs found'; + return; + } + + status.style.display = 'none'; + list.innerHTML = ''; + + devices.forEach(dev => { + const item = document.createElement('div'); + item.className = 'btl-irk-picker-item'; + item.innerHTML = + '
' + (dev.name || 'Unknown Device') + '
' + + '
' + dev.address + ' \u00b7 ' + (dev.address_type || '') + '
'; + item.addEventListener('click', function() { + selectPairedIrk(dev); + }); + list.appendChild(item); + }); + }) + .catch(err => { + if (btn) btn.disabled = false; + console.error('[BtLocate] Failed to fetch paired IRKs:', err); + status.textContent = 'Failed to read paired devices'; + }); + } + + function selectPairedIrk(dev) { + const irkInput = document.getElementById('btLocateIrk'); + const nameInput = document.getElementById('btLocateNamePattern'); + const picker = document.getElementById('btLocateIrkPicker'); + + if (irkInput) irkInput.value = dev.irk_hex; + if (nameInput && dev.name && !nameInput.value) nameInput.value = dev.name; + if (picker) picker.style.display = 'none'; + } + function clearTrail() { fetch('/bt_locate/clear_trail', { method: 'POST' }) .then(r => r.json()) @@ -1853,7 +1853,7 @@ const BtLocate = (function() { }) .catch(err => console.error('[BtLocate] Clear trail error:', err)); } - + function invalidateMap() { if (safeInvalidateMap()) { flushPendingHeatSync(); @@ -1863,15 +1863,15 @@ const BtLocate = (function() { } scheduleMapStabilization(8); } - + return { init, setActiveMode, start, stop, - handoff, - clearHandoff, - setEnvironment, + handoff, + clearHandoff, + setEnvironment, toggleAudio, toggleHeatmap, toggleMovement, @@ -1879,10 +1879,10 @@ const BtLocate = (function() { toggleSmoothing, exportTrail, clearTrail, - handleDetection, - invalidateMap, - fetchPairedIrks, - }; + handleDetection, + invalidateMap, + fetchPairedIrks, + }; })(); window.BtLocate = BtLocate; diff --git a/static/js/modes/fingerprint.js b/static/js/modes/fingerprint.js new file mode 100644 index 0000000..ea7446f --- /dev/null +++ b/static/js/modes/fingerprint.js @@ -0,0 +1,404 @@ +/* Signal Fingerprinting — RF baseline recorder + anomaly comparator */ +const Fingerprint = (function () { + 'use strict'; + + let _active = false; + let _recording = false; + let _scannerSource = null; + let _pendingObs = []; + let _flushTimer = null; + let _currentTab = 'record'; + let _chartInstance = null; + let _ownedScanner = false; + let _obsCount = 0; + + function _flushObservations() { + if (!_recording || _pendingObs.length === 0) return; + const batch = _pendingObs.splice(0); + fetch('/fingerprint/observation', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ observations: batch }), + }).catch(() => {}); + } + + function _startScannerStream() { + if (_scannerSource) { _scannerSource.close(); _scannerSource = null; } + _scannerSource = new EventSource('/listening/scanner/stream'); + _scannerSource.onmessage = (ev) => { + try { + const d = JSON.parse(ev.data); + // Only collect meaningful signal events (signal_found has SNR) + if (d.type && d.type !== 'signal_found' && d.type !== 'scan_update') return; + + const freq = d.frequency ?? d.freq_mhz ?? null; + if (freq === null) return; + + // Prefer SNR (dB) from signal_found events; fall back to level for scan_update + let power = null; + if (d.snr !== undefined && d.snr !== null) { + power = d.snr; + } else if (d.level !== undefined && d.level !== null) { + // level is RMS audio — skip scan_update noise floor readings + if (d.type === 'signal_found') { + power = d.level; + } else { + return; // scan_update with no SNR — skip + } + } else if (d.power_dbm !== undefined) { + power = d.power_dbm; + } + + if (power === null) return; + + if (_recording) { + _pendingObs.push({ freq_mhz: parseFloat(freq), power_dbm: parseFloat(power) }); + _obsCount++; + _updateObsCounter(); + } + } catch (_) {} + }; + } + + function _updateObsCounter() { + const el = document.getElementById('fpObsCount'); + if (el) el.textContent = _obsCount; + } + + function _setStatus(msg) { + const el = document.getElementById('fpRecordStatus'); + if (el) el.textContent = msg; + } + + // ── Scanner lifecycle (standalone control) ───────────────────────── + + async function _checkScannerStatus() { + try { + const r = await fetch('/listening/scanner/status'); + if (r.ok) { + const d = await r.json(); + return !!d.running; + } + } catch (_) {} + return false; + } + + async function _updateScannerStatusUI() { + const running = await _checkScannerStatus(); + const dotEl = document.getElementById('fpScannerDot'); + const textEl = document.getElementById('fpScannerStatusText'); + const startB = document.getElementById('fpScannerStartBtn'); + const stopB = document.getElementById('fpScannerStopBtn'); + + if (dotEl) dotEl.style.background = running ? 'var(--accent-green, #00ff88)' : 'rgba(255,255,255,0.2)'; + if (textEl) textEl.textContent = running ? 'Scanner running' : 'Scanner not running'; + if (startB) startB.style.display = running ? 'none' : ''; + if (stopB) stopB.style.display = (running && _ownedScanner) ? '' : 'none'; + + // Auto-connect to stream if scanner is running + if (running && !_scannerSource) _startScannerStream(); + } + + async function startScanner() { + const deviceVal = document.getElementById('fpDevice')?.value || 'rtlsdr:0'; + const [sdrType, idxStr] = deviceVal.includes(':') ? deviceVal.split(':') : ['rtlsdr', '0']; + const startB = document.getElementById('fpScannerStartBtn'); + if (startB) { startB.disabled = true; startB.textContent = 'Starting…'; } + + try { + const res = await fetch('/listening/scanner/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ start_freq: 24, end_freq: 1700, sdr_type: sdrType, device: parseInt(idxStr) || 0 }), + }); + if (res.ok) { + _ownedScanner = true; + _startScannerStream(); + } + } catch (_) {} + + if (startB) { startB.disabled = false; startB.textContent = 'Start Scanner'; } + await _updateScannerStatusUI(); + } + + async function stopScanner() { + if (!_ownedScanner) return; + try { + await fetch('/listening/scanner/stop', { method: 'POST' }); + } catch (_) {} + _ownedScanner = false; + if (_scannerSource) { _scannerSource.close(); _scannerSource = null; } + await _updateScannerStatusUI(); + } + + // ── Recording ────────────────────────────────────────────────────── + + async function startRecording() { + // Check scanner is running first + const running = await _checkScannerStatus(); + if (!running) { + _setStatus('Scanner not running — start it first (Step 2)'); + return; + } + + const name = document.getElementById('fpSessionName')?.value.trim() || 'Session ' + new Date().toLocaleString(); + const location = document.getElementById('fpSessionLocation')?.value.trim() || null; + try { + const res = await fetch('/fingerprint/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, location }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Start failed'); + _recording = true; + _pendingObs = []; + _obsCount = 0; + _updateObsCounter(); + _flushTimer = setInterval(_flushObservations, 5000); + if (!_scannerSource) _startScannerStream(); + const startBtn = document.getElementById('fpStartBtn'); + const stopBtn = document.getElementById('fpStopBtn'); + if (startBtn) startBtn.style.display = 'none'; + if (stopBtn) stopBtn.style.display = ''; + _setStatus('Recording… session #' + data.session_id); + } catch (e) { + _setStatus('Error: ' + e.message); + } + } + + async function stopRecording() { + _recording = false; + _flushObservations(); + if (_flushTimer) { clearInterval(_flushTimer); _flushTimer = null; } + if (_scannerSource) { _scannerSource.close(); _scannerSource = null; } + try { + const res = await fetch('/fingerprint/stop', { method: 'POST' }); + const data = await res.json(); + _setStatus(`Saved: ${data.bands_recorded} bands recorded (${_obsCount} observations)`); + } catch (e) { + _setStatus('Error saving: ' + e.message); + } + const startBtn = document.getElementById('fpStartBtn'); + const stopBtn = document.getElementById('fpStopBtn'); + if (startBtn) startBtn.style.display = ''; + if (stopBtn) stopBtn.style.display = 'none'; + _loadSessions(); + } + + async function _loadSessions() { + try { + const res = await fetch('/fingerprint/list'); + const data = await res.json(); + const sel = document.getElementById('fpBaselineSelect'); + if (!sel) return; + const sessions = (data.sessions || []).filter(s => s.finalized_at); + sel.innerHTML = sessions.length + ? sessions.map(s => ``).join('') + : ''; + } catch (_) {} + } + + // ── Compare ──────────────────────────────────────────────────────── + + async function compareNow() { + const baselineId = document.getElementById('fpBaselineSelect')?.value; + if (!baselineId) return; + + // Check scanner is running + const running = await _checkScannerStatus(); + if (!running) { + const statusEl = document.getElementById('fpCompareStatus'); + if (statusEl) statusEl.textContent = 'Scanner not running — start it first'; + return; + } + + const statusEl = document.getElementById('fpCompareStatus'); + const compareBtn = document.querySelector('#fpComparePanel .run-btn'); + if (statusEl) statusEl.textContent = 'Collecting observations…'; + if (compareBtn) { compareBtn.disabled = true; compareBtn.textContent = 'Scanning…'; } + + // Collect live observations for ~3 seconds + const obs = []; + const tmpSrc = new EventSource('/listening/scanner/stream'); + const deadline = Date.now() + 3000; + + await new Promise(resolve => { + tmpSrc.onmessage = (ev) => { + if (Date.now() > deadline) { tmpSrc.close(); resolve(); return; } + try { + const d = JSON.parse(ev.data); + if (d.type && d.type !== 'signal_found' && d.type !== 'scan_update') return; + const freq = d.frequency ?? d.freq_mhz ?? null; + let power = null; + if (d.snr !== undefined && d.snr !== null) power = d.snr; + else if (d.type === 'signal_found' && d.level !== undefined) power = d.level; + else if (d.power_dbm !== undefined) power = d.power_dbm; + if (freq !== null && power !== null) obs.push({ freq_mhz: parseFloat(freq), power_dbm: parseFloat(power) }); + if (statusEl) statusEl.textContent = `Collecting… ${obs.length} observations`; + } catch (_) {} + }; + tmpSrc.onerror = () => { tmpSrc.close(); resolve(); }; + setTimeout(() => { tmpSrc.close(); resolve(); }, 3500); + }); + + if (statusEl) statusEl.textContent = `Comparing ${obs.length} observations against baseline…`; + + try { + const res = await fetch('/fingerprint/compare', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ baseline_id: parseInt(baselineId), observations: obs }), + }); + const data = await res.json(); + _renderAnomalies(data.anomalies || []); + _renderChart(data.baseline_bands || [], data.anomalies || []); + if (statusEl) statusEl.textContent = `Done — ${obs.length} observations, ${(data.anomalies || []).length} anomalies`; + } catch (e) { + console.error('Compare failed:', e); + if (statusEl) statusEl.textContent = 'Compare failed: ' + e.message; + } + + if (compareBtn) { compareBtn.disabled = false; compareBtn.textContent = 'Compare Now'; } + } + + function _renderAnomalies(anomalies) { + const panel = document.getElementById('fpAnomalyList'); + const items = document.getElementById('fpAnomalyItems'); + if (!panel || !items) return; + + if (anomalies.length === 0) { + items.innerHTML = '
No significant anomalies detected.
'; + panel.style.display = 'block'; + return; + } + + items.innerHTML = anomalies.map(a => { + const z = a.z_score !== null ? Math.abs(a.z_score) : 999; + let cls = 'severity-warn', badge = 'POWER'; + if (a.anomaly_type === 'new') { cls = 'severity-new'; badge = 'NEW'; } + else if (a.anomaly_type === 'missing') { cls = 'severity-warn'; badge = 'MISSING'; } + else if (z >= 3) { cls = 'severity-alert'; } + + const zText = a.z_score !== null ? `z=${a.z_score.toFixed(1)}` : ''; + const powerText = a.current_power !== null ? `${a.current_power.toFixed(1)} dBm` : 'absent'; + const baseText = a.baseline_mean !== null ? `baseline: ${a.baseline_mean.toFixed(1)} dBm` : ''; + + return `
+
+ ${a.band_label} + ${badge} + ${z >= 3 ? 'ALERT' : ''} +
+
${powerText} ${baseText} ${zText}
+
`; + }).join(''); + panel.style.display = 'block'; + + // Voice alert for high-severity anomalies + const highZ = anomalies.find(a => (a.z_score !== null && Math.abs(a.z_score) >= 3) || a.anomaly_type === 'new'); + if (highZ && window.VoiceAlerts) { + VoiceAlerts.speak(`RF anomaly detected: ${highZ.band_label} — ${highZ.anomaly_type}`, 2); + } + } + + function _renderChart(baselineBands, anomalies) { + const canvas = document.getElementById('fpChartCanvas'); + if (!canvas || typeof Chart === 'undefined') return; + + const anomalyMap = {}; + anomalies.forEach(a => { anomalyMap[a.band_center_mhz] = a; }); + + const bands = baselineBands.slice(0, 40); + const labels = bands.map(b => b.band_center_mhz.toFixed(1)); + const means = bands.map(b => b.mean_dbm); + const currentPowers = bands.map(b => { + const a = anomalyMap[b.band_center_mhz]; + return a ? a.current_power : b.mean_dbm; + }); + const barColors = bands.map(b => { + const a = anomalyMap[b.band_center_mhz]; + if (!a) return 'rgba(74,163,255,0.6)'; + if (a.anomaly_type === 'new') return 'rgba(168,85,247,0.8)'; + if (a.z_score !== null && Math.abs(a.z_score) >= 3) return 'rgba(239,68,68,0.8)'; + return 'rgba(251,191,36,0.7)'; + }); + + if (_chartInstance) { _chartInstance.destroy(); _chartInstance = null; } + + _chartInstance = new Chart(canvas, { + type: 'bar', + data: { + labels, + datasets: [ + { label: 'Baseline Mean', data: means, backgroundColor: 'rgba(74,163,255,0.3)', borderColor: 'rgba(74,163,255,0.8)', borderWidth: 1 }, + { label: 'Current', data: currentPowers, backgroundColor: barColors, borderColor: barColors, borderWidth: 1 }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { labels: { color: '#aaa', font: { size: 10 } } } }, + scales: { + x: { ticks: { color: '#666', font: { size: 9 }, maxRotation: 90 }, grid: { color: 'rgba(255,255,255,0.05)' } }, + y: { ticks: { color: '#666', font: { size: 10 } }, grid: { color: 'rgba(255,255,255,0.05)' }, title: { display: true, text: 'Power (dBm)', color: '#666' } }, + }, + }, + }); + } + + function showTab(tab) { + _currentTab = tab; + const recordPanel = document.getElementById('fpRecordPanel'); + const comparePanel = document.getElementById('fpComparePanel'); + if (recordPanel) recordPanel.style.display = tab === 'record' ? '' : 'none'; + if (comparePanel) comparePanel.style.display = tab === 'compare' ? '' : 'none'; + document.querySelectorAll('.fp-tab-btn').forEach(b => b.classList.remove('active')); + const activeBtn = tab === 'record' + ? document.getElementById('fpTabRecord') + : document.getElementById('fpTabCompare'); + if (activeBtn) activeBtn.classList.add('active'); + const hintEl = document.getElementById('fpTabHint'); + if (hintEl) hintEl.innerHTML = TAB_HINTS[tab] || ''; + if (tab === 'compare') _loadSessions(); + } + + function _loadDevices() { + const sel = document.getElementById('fpDevice'); + if (!sel) return; + fetch('/devices').then(r => r.json()).then(devices => { + if (!devices || devices.length === 0) { + sel.innerHTML = ''; + return; + } + sel.innerHTML = devices.map(d => { + const label = d.serial ? `${d.name} [${d.serial}]` : d.name; + return ``; + }).join(''); + }).catch(() => { sel.innerHTML = ''; }); + } + + const TAB_HINTS = { + record: 'Record a baseline in a known-clean RF environment, then use Compare later to detect new or anomalous signals.', + compare: 'Select a saved baseline and click Compare Now to scan for deviations. Anomalies are flagged by statistical z-score.', + }; + + function init() { + _active = true; + _loadDevices(); + _loadSessions(); + _updateScannerStatusUI(); + } + + function destroy() { + _active = false; + if (_recording) stopRecording(); + if (_scannerSource) { _scannerSource.close(); _scannerSource = null; } + if (_chartInstance) { _chartInstance.destroy(); _chartInstance = null; } + if (_ownedScanner) stopScanner(); + } + + return { init, destroy, showTab, startRecording, stopRecording, compareNow, startScanner, stopScanner }; +})(); + +window.Fingerprint = Fingerprint; diff --git a/static/js/modes/rfheatmap.js b/static/js/modes/rfheatmap.js new file mode 100644 index 0000000..4956dc5 --- /dev/null +++ b/static/js/modes/rfheatmap.js @@ -0,0 +1,456 @@ +/* RF Heatmap — GPS + signal strength Leaflet heatmap */ +const RFHeatmap = (function () { + 'use strict'; + + let _map = null; + let _heatLayer = null; + let _gpsSource = null; + let _sigSource = null; + let _heatPoints = []; + let _isRecording = false; + let _lastLat = null, _lastLng = null; + let _minDist = 5; + let _source = 'wifi'; + let _gpsPos = null; + let _lastSignal = null; + let _active = false; + let _ownedSource = false; // true if heatmap started the source itself + + const RSSI_RANGES = { + wifi: { min: -90, max: -30 }, + bluetooth: { min: -100, max: -40 }, + scanner: { min: -120, max: -20 }, + }; + + function _norm(val, src) { + const r = RSSI_RANGES[src] || RSSI_RANGES.wifi; + return Math.max(0, Math.min(1, (val - r.min) / (r.max - r.min))); + } + + function _haversineM(lat1, lng1, lat2, lng2) { + const R = 6371000; + const dLat = (lat2 - lat1) * Math.PI / 180; + const dLng = (lng2 - lng1) * Math.PI / 180; + const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + } + + function _ensureLeafletHeat(cb) { + if (window.L && L.heatLayer) { cb(); return; } + const s = document.createElement('script'); + s.src = '/static/js/vendor/leaflet-heat.js'; + s.onload = cb; + s.onerror = () => console.warn('RF Heatmap: leaflet-heat.js failed to load'); + document.head.appendChild(s); + } + + function _initMap() { + if (_map) return; + const el = document.getElementById('rfheatmapMapEl'); + if (!el) return; + + // Defer map creation until container has non-zero dimensions (prevents leaflet-heat IndexSizeError) + if (el.offsetWidth === 0 || el.offsetHeight === 0) { + setTimeout(_initMap, 200); + return; + } + + const fallback = _getFallbackPos(); + const lat = _gpsPos ? _gpsPos.lat : (fallback ? fallback.lat : 37.7749); + const lng = _gpsPos ? _gpsPos.lng : (fallback ? fallback.lng : -122.4194); + + _map = L.map(el, { zoomControl: true }).setView([lat, lng], 16); + L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + attribution: '© OpenStreetMap contributors © CARTO', + subdomains: 'abcd', + maxZoom: 20, + }).addTo(_map); + + _heatLayer = L.heatLayer([], { radius: 25, blur: 15, maxZoom: 17 }).addTo(_map); + } + + function _startGPS() { + if (_gpsSource) { _gpsSource.close(); _gpsSource = null; } + _gpsSource = new EventSource('/gps/stream'); + _gpsSource.onmessage = (ev) => { + try { + const d = JSON.parse(ev.data); + if (d.lat && d.lng && d.fix) { + _gpsPos = { lat: parseFloat(d.lat), lng: parseFloat(d.lng) }; + _updateGpsPill(true, _gpsPos.lat, _gpsPos.lng); + if (_map) _map.setView([_gpsPos.lat, _gpsPos.lng], _map.getZoom(), { animate: false }); + } else { + _updateGpsPill(false); + } + } catch (_) {} + }; + _gpsSource.onerror = () => _updateGpsPill(false); + } + + function _updateGpsPill(fix, lat, lng) { + const pill = document.getElementById('rfhmGpsPill'); + if (!pill) return; + if (fix && lat !== undefined) { + pill.textContent = `${lat.toFixed(5)}, ${lng.toFixed(5)}`; + pill.style.color = 'var(--accent-green, #00ff88)'; + } else { + const fallback = _getFallbackPos(); + pill.textContent = fallback ? 'No Fix (using fallback)' : 'No Fix'; + pill.style.color = fallback ? 'var(--accent-yellow, #f59e0b)' : 'var(--text-dim, #555)'; + } + } + + function _startSignalStream() { + if (_sigSource) { _sigSource.close(); _sigSource = null; } + let url; + if (_source === 'wifi') url = '/wifi/stream'; + else if (_source === 'bluetooth') url = '/api/bluetooth/stream'; + else url = '/listening/scanner/stream'; + + _sigSource = new EventSource(url); + _sigSource.onmessage = (ev) => { + try { + const d = JSON.parse(ev.data); + let rssi = null; + if (_source === 'wifi') rssi = d.signal_level ?? d.signal ?? null; + else if (_source === 'bluetooth') rssi = d.rssi ?? null; + else rssi = d.power_level ?? d.power ?? null; + if (rssi !== null) { + _lastSignal = parseFloat(rssi); + _updateSignalDisplay(_lastSignal); + } + _maybeSample(); + } catch (_) {} + }; + } + + function _maybeSample() { + if (!_isRecording || _lastSignal === null) return; + if (!_gpsPos) { + const fb = _getFallbackPos(); + if (fb) _gpsPos = fb; + else return; + } + + const { lat, lng } = _gpsPos; + if (_lastLat !== null) { + const dist = _haversineM(_lastLat, _lastLng, lat, lng); + if (dist < _minDist) return; + } + + const intensity = _norm(_lastSignal, _source); + _heatPoints.push([lat, lng, intensity]); + _lastLat = lat; + _lastLng = lng; + + if (_heatLayer) { + const el = document.getElementById('rfheatmapMapEl'); + if (el && el.offsetWidth > 0 && el.offsetHeight > 0) _heatLayer.setLatLngs(_heatPoints); + } + _updateCount(); + } + + function _updateCount() { + const el = document.getElementById('rfhmPointCount'); + if (el) el.textContent = _heatPoints.length; + } + + function _updateSignalDisplay(rssi) { + const valEl = document.getElementById('rfhmLiveSignal'); + const barEl = document.getElementById('rfhmSignalBar'); + const statusEl = document.getElementById('rfhmSignalStatus'); + if (!valEl) return; + + valEl.textContent = rssi !== null ? `${rssi.toFixed(1)} dBm` : '— dBm'; + + if (rssi !== null) { + // Normalise to 0–100% for the bar + const pct = Math.round(_norm(rssi, _source) * 100); + if (barEl) barEl.style.width = pct + '%'; + + // Colour the value by strength + let color, label; + if (pct >= 66) { color = 'var(--accent-green, #00ff88)'; label = 'Strong'; } + else if (pct >= 33) { color = 'var(--accent-cyan, #4aa3ff)'; label = 'Moderate'; } + else { color = '#f59e0b'; label = 'Weak'; } + valEl.style.color = color; + if (barEl) barEl.style.background = color; + + if (statusEl) { + statusEl.textContent = _isRecording + ? `${label} — recording point every ${_minDist}m` + : `${label} — press Start Recording to begin`; + } + } else { + if (barEl) barEl.style.width = '0%'; + valEl.style.color = 'var(--text-dim)'; + if (statusEl) statusEl.textContent = 'No signal data received yet'; + } + } + + function setSource(src) { + _source = src; + if (_active) _startSignalStream(); + } + + function setMinDist(m) { + _minDist = m; + } + + function startRecording() { + _isRecording = true; + _lastLat = null; _lastLng = null; + const startBtn = document.getElementById('rfhmRecordBtn'); + const stopBtn = document.getElementById('rfhmStopBtn'); + if (startBtn) startBtn.style.display = 'none'; + if (stopBtn) { stopBtn.style.display = ''; stopBtn.classList.add('rfhm-recording-pulse'); } + } + + function stopRecording() { + _isRecording = false; + const startBtn = document.getElementById('rfhmRecordBtn'); + const stopBtn = document.getElementById('rfhmStopBtn'); + if (startBtn) startBtn.style.display = ''; + if (stopBtn) { stopBtn.style.display = 'none'; stopBtn.classList.remove('rfhm-recording-pulse'); } + } + + function clearPoints() { + _heatPoints = []; + if (_heatLayer) { + const el = document.getElementById('rfheatmapMapEl'); + if (el && el.offsetWidth > 0 && el.offsetHeight > 0) _heatLayer.setLatLngs([]); + } + _updateCount(); + } + + function exportGeoJSON() { + const features = _heatPoints.map(([lat, lng, intensity]) => ({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [lng, lat] }, + properties: { intensity, source: _source }, + })); + const geojson = { type: 'FeatureCollection', features }; + const blob = new Blob([JSON.stringify(geojson, null, 2)], { type: 'application/json' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `rf_heatmap_${Date.now()}.geojson`; + a.click(); + } + + function invalidateMap() { + if (!_map) return; + const el = document.getElementById('rfheatmapMapEl'); + if (el && el.offsetWidth > 0 && el.offsetHeight > 0) { + _map.invalidateSize(); + } + } + + // ── Source lifecycle (start / stop / status) ────────────────────── + + async function _checkSourceStatus() { + const src = _source; + let running = false; + let detail = null; + try { + if (src === 'wifi') { + const r = await fetch('/wifi/v2/scan/status'); + if (r.ok) { const d = await r.json(); running = !!d.is_scanning; detail = d.interface || null; } + } else if (src === 'bluetooth') { + const r = await fetch('/api/bluetooth/scan/status'); + if (r.ok) { const d = await r.json(); running = !!d.is_scanning; } + } else if (src === 'scanner') { + const r = await fetch('/listening/scanner/status'); + if (r.ok) { const d = await r.json(); running = !!d.running; } + } + } catch (_) {} + return { running, detail }; + } + + async function startSource() { + const src = _source; + const btn = document.getElementById('rfhmSourceStartBtn'); + const status = document.getElementById('rfhmSourceStatus'); + if (btn) { btn.disabled = true; btn.textContent = 'Starting…'; } + + try { + let res; + if (src === 'wifi') { + // Try to find a monitor interface from the WiFi status first + let iface = null; + try { + const st = await fetch('/wifi/v2/scan/status'); + if (st.ok) { const d = await st.json(); iface = d.interface || null; } + } catch (_) {} + if (!iface) { + // Ask the user to enter an interface name + const entered = prompt('Enter your monitor-mode WiFi interface name (e.g. wlan0mon):'); + if (!entered) { _updateSourceStatusUI(); return; } + iface = entered.trim(); + } + res = await fetch('/wifi/v2/scan/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ interface: iface }) }); + } else if (src === 'bluetooth') { + res = await fetch('/api/bluetooth/scan/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'auto' }) }); + } else if (src === 'scanner') { + const deviceVal = document.getElementById('rfhmDevice')?.value || 'rtlsdr:0'; + const [sdrType, idxStr] = deviceVal.includes(':') ? deviceVal.split(':') : ['rtlsdr', '0']; + res = await fetch('/listening/scanner/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ start_freq: 88, end_freq: 108, sdr_type: sdrType, device: parseInt(idxStr) || 0 }) }); + } + if (res && res.ok) { + _ownedSource = true; + _startSignalStream(); + } + } catch (_) {} + + await _updateSourceStatusUI(); + } + + async function stopSource() { + if (!_ownedSource) return; + try { + if (_source === 'wifi') await fetch('/wifi/v2/scan/stop', { method: 'POST' }); + else if (_source === 'bluetooth') await fetch('/api/bluetooth/scan/stop', { method: 'POST' }); + else if (_source === 'scanner') await fetch('/listening/scanner/stop', { method: 'POST' }); + } catch (_) {} + _ownedSource = false; + await _updateSourceStatusUI(); + } + + async function _updateSourceStatusUI() { + const { running, detail } = await _checkSourceStatus(); + const row = document.getElementById('rfhmSourceStatusRow'); + const dotEl = document.getElementById('rfhmSourceDot'); + const textEl = document.getElementById('rfhmSourceStatusText'); + const startB = document.getElementById('rfhmSourceStartBtn'); + const stopB = document.getElementById('rfhmSourceStopBtn'); + if (!row) return; + + const SOURCE_NAMES = { wifi: 'WiFi Scanner', bluetooth: 'Bluetooth Scanner', scanner: 'SDR Scanner' }; + const name = SOURCE_NAMES[_source] || _source; + + if (dotEl) dotEl.style.background = running ? 'var(--accent-green)' : 'rgba(255,255,255,0.2)'; + if (textEl) textEl.textContent = running + ? `${name} running${detail ? ' · ' + detail : ''}` + : `${name} not running`; + if (startB) { startB.style.display = running ? 'none' : ''; startB.disabled = false; startB.textContent = `Start ${name}`; } + if (stopB) stopB.style.display = (running && _ownedSource) ? '' : 'none'; + + // Auto-subscribe to stream if source just became running + if (running && !_sigSource) _startSignalStream(); + } + + const SOURCE_HINTS = { + wifi: 'Walk with your device — stronger WiFi signals are plotted brighter on the map.', + bluetooth: 'Walk near Bluetooth devices — signal strength is mapped by RSSI.', + scanner: 'SDR scanner power levels are mapped by GPS position. Start the Listening Post scanner first.', + }; + + function onSourceChange() { + const src = document.getElementById('rfhmSource')?.value || 'wifi'; + const hint = document.getElementById('rfhmSourceHint'); + const dg = document.getElementById('rfhmDeviceGroup'); + if (hint) hint.textContent = SOURCE_HINTS[src] || ''; + if (dg) dg.style.display = src === 'scanner' ? '' : 'none'; + _lastSignal = null; + _ownedSource = false; + _updateSignalDisplay(null); + _updateSourceStatusUI(); + // Re-subscribe to correct stream + if (_sigSource) { _sigSource.close(); _sigSource = null; } + _startSignalStream(); + } + + function _loadDevices() { + const sel = document.getElementById('rfhmDevice'); + if (!sel) return; + fetch('/devices').then(r => r.json()).then(devices => { + if (!devices || devices.length === 0) { + sel.innerHTML = ''; + return; + } + sel.innerHTML = devices.map(d => { + const label = d.serial ? `${d.name} [${d.serial}]` : d.name; + return ``; + }).join(''); + }).catch(() => { sel.innerHTML = ''; }); + } + + function _getFallbackPos() { + // Try observer location from localStorage (shared across all map modes) + try { + const stored = localStorage.getItem('observerLocation'); + if (stored) { + const p = JSON.parse(stored); + if (p && typeof p.lat === 'number' && typeof p.lon === 'number') { + return { lat: p.lat, lng: p.lon }; + } + } + } catch (_) {} + // Try manual coord inputs + const lat = parseFloat(document.getElementById('rfhmManualLat')?.value); + const lng = parseFloat(document.getElementById('rfhmManualLon')?.value); + if (!isNaN(lat) && !isNaN(lng)) return { lat, lng }; + return null; + } + + function setManualCoords() { + const lat = parseFloat(document.getElementById('rfhmManualLat')?.value); + const lng = parseFloat(document.getElementById('rfhmManualLon')?.value); + if (!isNaN(lat) && !isNaN(lng) && !_gpsPos && _map) { + _map.setView([lat, lng], _map.getZoom(), { animate: false }); + } + } + + function useObserverLocation() { + try { + const stored = localStorage.getItem('observerLocation'); + if (stored) { + const p = JSON.parse(stored); + if (p && typeof p.lat === 'number' && typeof p.lon === 'number') { + const latEl = document.getElementById('rfhmManualLat'); + const lonEl = document.getElementById('rfhmManualLon'); + if (latEl) latEl.value = p.lat.toFixed(5); + if (lonEl) lonEl.value = p.lon.toFixed(5); + if (_map) _map.setView([p.lat, p.lon], _map.getZoom(), { animate: true }); + return; + } + } + } catch (_) {} + } + + function init() { + _active = true; + _loadDevices(); + onSourceChange(); + + // Pre-fill manual coords from observer location if available + const fallback = _getFallbackPos(); + if (fallback) { + const latEl = document.getElementById('rfhmManualLat'); + const lonEl = document.getElementById('rfhmManualLon'); + if (latEl && !latEl.value) latEl.value = fallback.lat.toFixed(5); + if (lonEl && !lonEl.value) lonEl.value = fallback.lng.toFixed(5); + } + + _updateSignalDisplay(null); + _updateSourceStatusUI(); + _ensureLeafletHeat(() => { + setTimeout(() => { + _initMap(); + _startGPS(); + _startSignalStream(); + }, 50); + }); + } + + function destroy() { + _active = false; + if (_isRecording) stopRecording(); + if (_ownedSource) stopSource(); + if (_gpsSource) { _gpsSource.close(); _gpsSource = null; } + if (_sigSource) { _sigSource.close(); _sigSource = null; } + } + + return { init, destroy, setSource, setMinDist, startRecording, stopRecording, clearPoints, exportGeoJSON, invalidateMap, onSourceChange, setManualCoords, useObserverLocation, startSource, stopSource }; +})(); + +window.RFHeatmap = RFHeatmap; diff --git a/static/js/modes/waterfall.js b/static/js/modes/waterfall.js new file mode 100644 index 0000000..39bce4b --- /dev/null +++ b/static/js/modes/waterfall.js @@ -0,0 +1,2134 @@ +/* + * Spectrum Waterfall Mode + * Real-time SDR waterfall with click-to-tune and integrated monitor audio. + */ +const Waterfall = (function () { + 'use strict'; + + let _ws = null; + let _es = null; + let _transport = 'ws'; + let _wsOpened = false; + let _wsFallbackTimer = null; + let _sseStartPromise = null; + let _sseStartConfigKey = ''; + let _active = false; + let _running = false; + let _listenersAttached = false; + let _controlListenersAttached = false; + + let _retuneTimer = null; + let _monitorRetuneTimer = null; + + let _peakHold = false; + let _showAnnotations = true; + let _autoRange = true; + let _dbMin = -100; + let _dbMax = -20; + let _palette = 'turbo'; + + let _specCanvas = null; + let _specCtx = null; + let _wfCanvas = null; + let _wfCtx = null; + let _peakLine = null; + + let _startMhz = 98.8; + let _endMhz = 101.2; + let _monitorFreqMhz = 100.0; + + let _monitoring = false; + let _monitorMuted = false; + let _resumeWaterfallAfterMonitor = false; + let _startingMonitor = false; + let _monitorSource = 'process'; + let _pendingSharedMonitorRearm = false; + let _audioConnectNonce = 0; + let _audioAnalyser = null; + let _audioContext = null; + let _audioSourceNode = null; + let _smeterRaf = null; + let _audioUnlockRequired = false; + + let _devices = []; + + const PALETTES = {}; + + const RF_BANDS = [ + [0.535, 1.705, 'AM', 'rgba(255,200,50,0.15)'], + [87.5, 108.0, 'FM', 'rgba(255,100,100,0.15)'], + [108.0, 137.0, 'Aviation', 'rgba(100,220,100,0.12)'], + [137.5, 137.9125, 'NOAA APT', 'rgba(50,200,255,0.25)'], + [144.0, 148.0, '2m Ham', 'rgba(255,165,0,0.20)'], + [156.0, 174.0, 'Marine', 'rgba(50,150,255,0.15)'], + [162.4, 162.55, 'Wx Radio', 'rgba(50,255,200,0.35)'], + [420.0, 450.0, '70cm Ham', 'rgba(255,165,0,0.18)'], + [433.05, 434.79, 'ISM 433', 'rgba(255,80,255,0.25)'], + [446.0, 446.2, 'PMR446', 'rgba(180,80,255,0.30)'], + [868.0, 868.6, 'ISM 868', 'rgba(255,80,255,0.22)'], + [902.0, 928.0, 'ISM 915', 'rgba(255,80,255,0.18)'], + [1089.95, 1090.05, 'ADS-B', 'rgba(50,255,80,0.45)'], + [2400.0, 2500.0, '2.4G WiFi', 'rgba(255,165,0,0.12)'], + [5725.0, 5875.0, '5.8G WiFi', 'rgba(255,165,0,0.12)'], + ]; + + const PRESETS = { + fm: { center: 98.0, span: 20.0, mode: 'wfm', step: 0.1 }, + air: { center: 124.5, span: 8.0, mode: 'am', step: 0.025 }, + marine: { center: 161.0, span: 4.0, mode: 'fm', step: 0.025 }, + ham2m: { center: 146.0, span: 4.0, mode: 'fm', step: 0.0125 }, + }; + const WS_OPEN_FALLBACK_MS = 6500; + + function _setStatus(text) { + const el = document.getElementById('wfStatus'); + if (el) { + el.textContent = text || ''; + } + } + + function _setVisualStatus(text) { + const el = document.getElementById('wfVisualStatus'); + if (el) { + el.textContent = text || 'IDLE'; + } + } + + function _setMonitorState(text) { + const el = document.getElementById('wfMonitorState'); + if (el) { + el.textContent = text || 'No audio monitor'; + } + } + + function _buildPalettes() { + function lerp(a, b, t) { + return a + (b - a) * t; + } + function lerpRGB(c1, c2, t) { + return [lerp(c1[0], c2[0], t), lerp(c1[1], c2[1], t), lerp(c1[2], c2[2], t)]; + } + function buildLUT(stops) { + const lut = new Uint8Array(256 * 3); + for (let i = 0; i < 256; i += 1) { + const t = i / 255; + let s = 0; + while (s < stops.length - 2 && t > stops[s + 1][0]) s += 1; + const t0 = stops[s][0]; + const t1 = stops[s + 1][0]; + const local = t0 === t1 ? 0 : (t - t0) / (t1 - t0); + const rgb = lerpRGB(stops[s][1], stops[s + 1][1], local); + lut[i * 3] = Math.round(rgb[0]); + lut[i * 3 + 1] = Math.round(rgb[1]); + lut[i * 3 + 2] = Math.round(rgb[2]); + } + return lut; + } + PALETTES.turbo = buildLUT([ + [0, [48, 18, 59]], + [0.25, [65, 182, 196]], + [0.5, [253, 231, 37]], + [0.75, [246, 114, 48]], + [1, [178, 24, 43]], + ]); + PALETTES.plasma = buildLUT([ + [0, [13, 8, 135]], + [0.33, [126, 3, 168]], + [0.66, [249, 124, 1]], + [1, [240, 249, 33]], + ]); + PALETTES.inferno = buildLUT([ + [0, [0, 0, 4]], + [0.33, [65, 1, 88]], + [0.66, [253, 163, 23]], + [1, [252, 255, 164]], + ]); + PALETTES.viridis = buildLUT([ + [0, [68, 1, 84]], + [0.33, [59, 82, 139]], + [0.66, [33, 145, 140]], + [1, [253, 231, 37]], + ]); + } + + function _colorize(val, lut) { + const idx = Math.max(0, Math.min(255, Math.round(val * 255))); + return [lut[idx * 3], lut[idx * 3 + 1], lut[idx * 3 + 2]]; + } + + function _parseFrame(buf) { + if (!buf || buf.byteLength < 11) return null; + const view = new DataView(buf); + if (view.getUint8(0) !== 0x01) return null; + const startMhz = view.getFloat32(1, true); + const endMhz = view.getFloat32(5, true); + const numBins = view.getUint16(9, true); + if (buf.byteLength < 11 + numBins) return null; + const bins = new Uint8Array(buf, 11, numBins); + return { numBins, bins, startMhz, endMhz }; + } + + function _getNumber(id, fallback) { + const el = document.getElementById(id); + if (!el) return fallback; + const value = parseFloat(el.value); + return Number.isFinite(value) ? value : fallback; + } + + function _clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); + } + + function _wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + function _ctx2d(canvas, options) { + if (!canvas) return null; + try { + return canvas.getContext('2d', options); + } catch (_) { + return canvas.getContext('2d'); + } + } + + function _ssePayloadKey(payload) { + return JSON.stringify([ + payload.start_freq, + payload.end_freq, + payload.bin_size, + payload.gain, + payload.device, + payload.interval, + payload.max_bins, + ]); + } + + function _isWaterfallAlreadyRunningConflict(response, body) { + if (body?.already_running === true) return true; + if (!response || response.status !== 409) return false; + const msg = String(body?.message || '').toLowerCase(); + return msg.includes('already running'); + } + + function _isWaterfallDeviceBusy(response, body) { + return !!response && response.status === 409 && body?.error_type === 'DEVICE_BUSY'; + } + + function _clearWsFallbackTimer() { + if (_wsFallbackTimer) { + clearTimeout(_wsFallbackTimer); + _wsFallbackTimer = null; + } + } + + function _closeSseStream() { + if (_es) { + try { + _es.close(); + } catch (_) { + // Ignore EventSource close failures. + } + _es = null; + } + } + + function _normalizeSweepBins(rawBins) { + if (!Array.isArray(rawBins) || rawBins.length === 0) return null; + const bins = rawBins.map((v) => Number(v)); + if (!bins.some((v) => Number.isFinite(v))) return null; + + let min = _autoRange ? Infinity : _dbMin; + let max = _autoRange ? -Infinity : _dbMax; + if (_autoRange) { + for (let i = 0; i < bins.length; i += 1) { + const value = bins[i]; + if (!Number.isFinite(value)) continue; + if (value < min) min = value; + if (value > max) max = value; + } + if (!Number.isFinite(min) || !Number.isFinite(max)) return null; + const pad = Math.max(8, (max - min) * 0.08); + min -= pad; + max += pad; + } + + if (max <= min) max = min + 1; + const out = new Uint8Array(bins.length); + const span = max - min; + for (let i = 0; i < bins.length; i += 1) { + const value = Number.isFinite(bins[i]) ? bins[i] : min; + const norm = _clamp((value - min) / span, 0, 1); + out[i] = Math.round(norm * 255); + } + return out; + } + + function _setUnlockVisible(show) { + const btn = document.getElementById('wfAudioUnlockBtn'); + if (btn) btn.style.display = show ? '' : 'none'; + } + + function _isAutoplayError(err) { + if (!err) return false; + const name = String(err.name || '').toLowerCase(); + const msg = String(err.message || '').toLowerCase(); + return name === 'notallowederror' + || msg.includes('notallowed') + || msg.includes('gesture') + || msg.includes('user didn\'t interact'); + } + + function _waitForPlayback(player, timeoutMs) { + return new Promise((resolve) => { + let done = false; + let timer = null; + + const finish = (ok) => { + if (done) return; + done = true; + if (timer) clearTimeout(timer); + events.forEach((evt) => player.removeEventListener(evt, onReady)); + failEvents.forEach((evt) => player.removeEventListener(evt, onFail)); + resolve(ok); + }; + + const onReady = () => finish(true); + const onFail = () => finish(false); + const events = ['playing', 'timeupdate', 'canplay', 'loadeddata']; + const failEvents = ['error', 'abort', 'stalled', 'ended']; + + events.forEach((evt) => player.addEventListener(evt, onReady)); + failEvents.forEach((evt) => player.addEventListener(evt, onFail)); + + timer = setTimeout(() => { + finish(!player.paused && (player.currentTime > 0 || player.readyState >= 2)); + }, timeoutMs); + + if (!player.paused && (player.currentTime > 0 || player.readyState >= 2)) { + finish(true); + } + }); + } + + function _readStepLabel() { + const stepEl = document.getElementById('wfStepSize'); + if (!stepEl) return 'STEP 100 kHz'; + const option = stepEl.options[stepEl.selectedIndex]; + if (option && option.textContent) return `STEP ${option.textContent.trim()}`; + const value = parseFloat(stepEl.value); + if (!Number.isFinite(value)) return 'STEP --'; + return value >= 1 ? `STEP ${value.toFixed(0)} MHz` : `STEP ${(value * 1000).toFixed(0)} kHz`; + } + + function _getMonitorMode() { + return document.getElementById('wfMonitorMode')?.value || 'wfm'; + } + + function _setModeButtons(mode) { + document.querySelectorAll('.wf-mode-btn').forEach((btn) => { + btn.classList.toggle('is-active', btn.dataset.mode === mode); + }); + } + + function _setMonitorMode(mode) { + const safeMode = ['wfm', 'fm', 'am', 'usb', 'lsb'].includes(mode) ? mode : 'wfm'; + const select = document.getElementById('wfMonitorMode'); + if (select) { + select.value = safeMode; + } + _setModeButtons(safeMode); + const modeReadout = document.getElementById('wfRxModeReadout'); + if (modeReadout) modeReadout.textContent = safeMode.toUpperCase(); + } + + function _setSmeter(levelPct, text) { + const bar = document.getElementById('wfSmeterBar'); + const label = document.getElementById('wfSmeterText'); + if (bar) bar.style.width = `${_clamp(levelPct, 0, 100).toFixed(1)}%`; + if (label) label.textContent = text || 'S0'; + } + + function _stopSmeter() { + if (_smeterRaf) { + cancelAnimationFrame(_smeterRaf); + _smeterRaf = null; + } + _setSmeter(0, 'S0'); + } + + function _startSmeter(player) { + if (!player) return; + try { + if (!_audioContext) { + _audioContext = new (window.AudioContext || window.webkitAudioContext)(); + } + + if (_audioContext.state === 'suspended') { + _audioContext.resume().catch(() => {}); + } + + if (!_audioSourceNode) { + _audioSourceNode = _audioContext.createMediaElementSource(player); + } + + if (!_audioAnalyser) { + _audioAnalyser = _audioContext.createAnalyser(); + _audioAnalyser.fftSize = 2048; + _audioAnalyser.smoothingTimeConstant = 0.7; + _audioSourceNode.connect(_audioAnalyser); + _audioAnalyser.connect(_audioContext.destination); + } + } catch (_) { + return; + } + + const samples = new Uint8Array(_audioAnalyser.frequencyBinCount); + const render = () => { + if (!_monitoring || !_audioAnalyser) { + _setSmeter(0, 'S0'); + return; + } + _audioAnalyser.getByteFrequencyData(samples); + let sum = 0; + for (let i = 0; i < samples.length; i += 1) sum += samples[i]; + const avg = sum / (samples.length || 1); + const pct = _clamp((avg / 180) * 100, 0, 100); + let sText = 'S0'; + const sUnit = Math.round((pct / 100) * 9); + if (sUnit >= 9) { + const over = Math.max(0, Math.round((pct - 88) * 1.8)); + sText = over > 0 ? `S9+${over}` : 'S9'; + } else { + sText = `S${Math.max(0, sUnit)}`; + } + _setSmeter(pct, sText); + _smeterRaf = requestAnimationFrame(render); + }; + + _stopSmeter(); + _smeterRaf = requestAnimationFrame(render); + } + + function _currentCenter() { + return _getNumber('wfCenterFreq', 100.0); + } + + function _currentSpan() { + return _getNumber('wfSpanMhz', 2.4); + } + + function _updateRunButtons() { + const startBtn = document.getElementById('wfStartBtn'); + const stopBtn = document.getElementById('wfStopBtn'); + if (startBtn) startBtn.style.display = _running ? 'none' : ''; + if (stopBtn) stopBtn.style.display = _running ? '' : 'none'; + } + + function _updateTuneLine() { + const span = _endMhz - _startMhz; + const pct = span > 0 ? (_monitorFreqMhz - _startMhz) / span : 0.5; + const visible = Number.isFinite(pct) && pct >= 0 && pct <= 1; + + ['wfTuneLineSpec', 'wfTuneLineWf'].forEach((id) => { + const line = document.getElementById(id); + if (!line) return; + if (visible) { + line.style.left = `${(pct * 100).toFixed(4)}%`; + line.classList.add('is-visible'); + } else { + line.classList.remove('is-visible'); + } + }); + } + + function _updateFreqDisplay() { + const center = _currentCenter(); + const span = _currentSpan(); + + const hiddenCenter = document.getElementById('wfCenterFreq'); + if (hiddenCenter) hiddenCenter.value = center.toFixed(4); + + const centerDisplay = document.getElementById('wfFreqCenterDisplay'); + if (centerDisplay && document.activeElement !== centerDisplay) { + centerDisplay.value = center.toFixed(4); + } + + const spanEl = document.getElementById('wfSpanDisplay'); + if (spanEl) { + spanEl.textContent = span >= 1 + ? `${span.toFixed(3)} MHz` + : `${(span * 1000).toFixed(1)} kHz`; + } + + const rangeEl = document.getElementById('wfRangeDisplay'); + if (rangeEl) { + rangeEl.textContent = `${_startMhz.toFixed(4)} - ${_endMhz.toFixed(4)} MHz`; + } + + const tuneEl = document.getElementById('wfTuneDisplay'); + if (tuneEl) { + tuneEl.textContent = `Tune ${_monitorFreqMhz.toFixed(4)} MHz`; + } + + const rxReadout = document.getElementById('wfRxFreqReadout'); + if (rxReadout) rxReadout.textContent = center.toFixed(4); + + const stepReadout = document.getElementById('wfRxStepReadout'); + if (stepReadout) stepReadout.textContent = _readStepLabel(); + + const modeReadout = document.getElementById('wfRxModeReadout'); + if (modeReadout) modeReadout.textContent = _getMonitorMode().toUpperCase(); + + _updateTuneLine(); + } + + function _drawBandAnnotations(width, height) { + const span = _endMhz - _startMhz; + if (span <= 0) return; + + _specCtx.save(); + _specCtx.font = '9px var(--font-mono, monospace)'; + _specCtx.textBaseline = 'top'; + _specCtx.textAlign = 'center'; + + for (const [bStart, bEnd, bLabel, bColor] of RF_BANDS) { + if (bEnd < _startMhz || bStart > _endMhz) continue; + const x0 = Math.max(0, ((bStart - _startMhz) / span) * width); + const x1 = Math.min(width, ((bEnd - _startMhz) / span) * width); + const bw = x1 - x0; + + _specCtx.fillStyle = bColor; + _specCtx.fillRect(x0, 0, bw, height); + + if (bw > 25) { + _specCtx.fillStyle = 'rgba(255,255,255,0.75)'; + _specCtx.fillText(bLabel, x0 + bw / 2, 3); + } + } + + _specCtx.restore(); + } + + function _drawDbScale(width, height) { + if (_autoRange) return; + const range = _dbMax - _dbMin; + if (range <= 0) return; + + _specCtx.save(); + _specCtx.font = '9px var(--font-mono, monospace)'; + _specCtx.textBaseline = 'middle'; + _specCtx.textAlign = 'left'; + + for (let i = 0; i <= 5; i += 1) { + const t = i / 5; + const db = _dbMax - t * range; + const y = t * height; + _specCtx.strokeStyle = 'rgba(255,255,255,0.07)'; + _specCtx.lineWidth = 1; + _specCtx.beginPath(); + _specCtx.moveTo(0, y); + _specCtx.lineTo(width, y); + _specCtx.stroke(); + _specCtx.fillStyle = 'rgba(255,255,255,0.48)'; + _specCtx.fillText(`${Math.round(db)} dB`, 3, Math.max(6, Math.min(height - 6, y))); + } + + _specCtx.restore(); + } + + function _drawCenterLine(width, height) { + _specCtx.save(); + _specCtx.strokeStyle = 'rgba(255,215,0,0.45)'; + _specCtx.lineWidth = 1; + _specCtx.setLineDash([4, 4]); + _specCtx.beginPath(); + _specCtx.moveTo(width / 2, 0); + _specCtx.lineTo(width / 2, height); + _specCtx.stroke(); + _specCtx.restore(); + } + + function _drawSpectrum(bins) { + if (!_specCtx || !_specCanvas || !bins || bins.length === 0) return; + + const width = _specCanvas.width; + const height = _specCanvas.height; + _specCtx.clearRect(0, 0, width, height); + _specCtx.fillStyle = '#000'; + _specCtx.fillRect(0, 0, width, height); + + if (_showAnnotations) _drawBandAnnotations(width, height); + _drawDbScale(width, height); + + const n = bins.length; + + _specCtx.beginPath(); + _specCtx.moveTo(0, height); + for (let i = 0; i < n; i += 1) { + const x = (i / (n - 1)) * width; + const y = height - (bins[i] / 255) * height; + _specCtx.lineTo(x, y); + } + _specCtx.lineTo(width, height); + _specCtx.closePath(); + _specCtx.fillStyle = 'rgba(74,163,255,0.16)'; + _specCtx.fill(); + + _specCtx.beginPath(); + for (let i = 0; i < n; i += 1) { + const x = (i / (n - 1)) * width; + const y = height - (bins[i] / 255) * height; + if (i === 0) _specCtx.moveTo(x, y); + else _specCtx.lineTo(x, y); + } + _specCtx.strokeStyle = 'rgba(110,188,255,0.85)'; + _specCtx.lineWidth = 1; + _specCtx.stroke(); + + if (_peakHold) { + if (!_peakLine || _peakLine.length !== n) _peakLine = new Uint8Array(n); + for (let i = 0; i < n; i += 1) { + if (bins[i] > _peakLine[i]) _peakLine[i] = bins[i]; + } + + _specCtx.beginPath(); + for (let i = 0; i < n; i += 1) { + const x = (i / (n - 1)) * width; + const y = height - (_peakLine[i] / 255) * height; + if (i === 0) _specCtx.moveTo(x, y); + else _specCtx.lineTo(x, y); + } + _specCtx.strokeStyle = 'rgba(255,98,98,0.75)'; + _specCtx.lineWidth = 1; + _specCtx.stroke(); + } + + _drawCenterLine(width, height); + } + + function _scrollWaterfall(bins) { + if (!_wfCtx || !_wfCanvas || !bins || bins.length === 0) return; + + const width = _wfCanvas.width; + const height = _wfCanvas.height; + if (width === 0 || height === 0) return; + + // Shift existing image down by 1px using GPU copy (avoids expensive readback). + _wfCtx.drawImage(_wfCanvas, 0, 0, width, height - 1, 0, 1, width, height - 1); + + const lut = PALETTES[_palette] || PALETTES.turbo; + const row = _wfCtx.createImageData(width, 1); + const data = row.data; + const n = bins.length; + for (let x = 0; x < width; x += 1) { + const idx = Math.round((x / (width - 1)) * (n - 1)); + const val = bins[idx] / 255; + const [r, g, b] = _colorize(val, lut); + const off = x * 4; + data[off] = r; + data[off + 1] = g; + data[off + 2] = b; + data[off + 3] = 255; + } + _wfCtx.putImageData(row, 0, 0); + } + + function _drawFreqAxis() { + const axis = document.getElementById('wfFreqAxis'); + if (!axis) return; + axis.innerHTML = ''; + const ticks = 8; + for (let i = 0; i <= ticks; i += 1) { + const frac = i / ticks; + const freq = _startMhz + frac * (_endMhz - _startMhz); + const tick = document.createElement('div'); + tick.className = 'wf-freq-tick'; + tick.style.left = `${frac * 100}%`; + tick.textContent = freq.toFixed(2); + axis.appendChild(tick); + } + _updateFreqDisplay(); + } + + function _resizeCanvases() { + const sc = document.getElementById('wfSpectrumCanvas'); + const wc = document.getElementById('wfWaterfallCanvas'); + + if (sc) { + sc.width = sc.parentElement ? sc.parentElement.offsetWidth : 800; + sc.height = sc.parentElement ? sc.parentElement.offsetHeight : 110; + } + + if (wc) { + wc.width = wc.parentElement ? wc.parentElement.offsetWidth : 800; + wc.height = wc.parentElement ? wc.parentElement.offsetHeight : 450; + } + + _drawFreqAxis(); + } + + function _freqAtX(canvas, clientX) { + const rect = canvas.getBoundingClientRect(); + const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + return _startMhz + frac * (_endMhz - _startMhz); + } + + function _showTooltip(canvas, event) { + const tooltip = document.getElementById('wfTooltip'); + if (!tooltip) return; + + const freq = _freqAtX(canvas, event.clientX); + const wrap = document.querySelector('.wf-waterfall-canvas-wrap'); + if (wrap) { + const rect = wrap.getBoundingClientRect(); + tooltip.style.left = `${event.clientX - rect.left}px`; + tooltip.style.transform = 'translateX(-50%)'; + tooltip.style.top = '4px'; + } + tooltip.textContent = `${freq.toFixed(4)} MHz`; + tooltip.style.display = 'block'; + } + + function _hideTooltip() { + const tooltip = document.getElementById('wfTooltip'); + if (tooltip) tooltip.style.display = 'none'; + } + + function _queueRetune(delayMs, action = 'start') { + clearTimeout(_retuneTimer); + _retuneTimer = setTimeout(() => { + if ((_ws && _ws.readyState === WebSocket.OPEN) || _transport === 'sse') { + if (action === 'tune' && _transport === 'ws') { + _sendWsTuneCmd(); + } else { + _sendStartCmd(); + } + } + }, delayMs); + } + + function _queueMonitorRetune(delayMs) { + if (!_monitoring) return; + clearTimeout(_monitorRetuneTimer); + _monitorRetuneTimer = setTimeout(() => { + _startMonitorInternal({ wasRunningWaterfall: false, retuneOnly: true }).catch(() => {}); + }, delayMs); + } + + function _isSharedMonitorActive() { + return ( + _monitoring + && _monitorSource === 'waterfall' + && _transport === 'ws' + && _running + && _ws + && _ws.readyState === WebSocket.OPEN + ); + } + + function _queueMonitorAdjust(delayMs, { allowSharedTune = true } = {}) { + if (!_monitoring) return; + if (allowSharedTune && _isSharedMonitorActive()) { + _queueRetune(delayMs, 'tune'); + return; + } + _queueMonitorRetune(delayMs); + } + + function _setAndTune(freqMhz, immediate = false) { + const clamped = _clamp(freqMhz, 0.001, 6000.0); + + const input = document.getElementById('wfCenterFreq'); + if (input) input.value = clamped.toFixed(4); + + _monitorFreqMhz = clamped; + const currentSpan = _endMhz - _startMhz; + const configuredSpan = _clamp(_currentSpan(), 0.05, 30.0); + const activeSpan = Number.isFinite(currentSpan) && currentSpan > 0 ? currentSpan : configuredSpan; + const edgeMargin = activeSpan * 0.08; + const withinCapture = clamped >= (_startMhz + edgeMargin) && clamped <= (_endMhz - edgeMargin); + const needsRetune = !withinCapture; + + if (needsRetune) { + _startMhz = clamped - configuredSpan / 2; + _endMhz = clamped + configuredSpan / 2; + _drawFreqAxis(); + } else { + _updateFreqDisplay(); + } + + const sharedMonitor = _isSharedMonitorActive(); + if (_monitoring) { + if (!sharedMonitor) { + _queueMonitorRetune(immediate ? 35 : 140); + } else if (needsRetune) { + // Capture restart can clear shared monitor state; re-arm on 'started'. + _pendingSharedMonitorRearm = true; + } + } + + if (!((_ws && _ws.readyState === WebSocket.OPEN) || _transport === 'sse')) { + return; + } + + if (_transport === 'ws') { + if (needsRetune) { + if (immediate) _sendStartCmd(); + else _queueRetune(160, 'start'); + } else { + if (immediate) _sendWsTuneCmd(); + else _queueRetune(70, 'tune'); + } + return; + } + + if (immediate) _sendStartCmd(); + else _queueRetune(220, 'start'); + } + + function _recenterAndRestart() { + _startMhz = _currentCenter() - _currentSpan() / 2; + _endMhz = _currentCenter() + _currentSpan() / 2; + _drawFreqAxis(); + _sendStartCmd(); + } + + function _onRetuneRequired(msg) { + if (!msg || msg.status !== 'retune_required') return false; + _setStatus(msg.message || 'Retuning SDR capture...'); + if (Number.isFinite(msg.vfo_freq_mhz)) { + const input = document.getElementById('wfCenterFreq'); + if (input) input.value = Number(msg.vfo_freq_mhz).toFixed(4); + } + _recenterAndRestart(); + return true; + } + + function _handleCanvasWheel(event) { + event.preventDefault(); + + if (event.ctrlKey || event.metaKey) { + const spanEl = document.getElementById('wfSpanMhz'); + const current = _currentSpan(); + const factor = event.deltaY < 0 ? 1 / 1.2 : 1.2; + const next = _clamp(current * factor, 0.05, 30.0); + if (spanEl) spanEl.value = next.toFixed(3); + _startMhz = _currentCenter() - next / 2; + _endMhz = _currentCenter() + next / 2; + _drawFreqAxis(); + + if (_monitoring) { + _queueMonitorAdjust(260, { allowSharedTune: false }); + } else if (_running) { + _queueRetune(260); + } + return; + } + + const step = _getNumber('wfStepSize', 0.1); + const dir = event.deltaY < 0 ? 1 : -1; + const center = _currentCenter(); + _setAndTune(center + dir * step, true); + } + + function _clickTune(canvas, event) { + const target = _freqAtX(canvas, event.clientX); + _setAndTune(target, true); + } + + function _setupCanvasInteraction() { + if (_listenersAttached) return; + _listenersAttached = true; + + const bindCanvas = (canvas) => { + if (!canvas) return; + canvas.style.cursor = 'crosshair'; + canvas.addEventListener('mousemove', (e) => _showTooltip(canvas, e)); + canvas.addEventListener('mouseleave', _hideTooltip); + canvas.addEventListener('click', (e) => _clickTune(canvas, e)); + canvas.addEventListener('wheel', _handleCanvasWheel, { passive: false }); + }; + + bindCanvas(_wfCanvas); + bindCanvas(_specCanvas); + } + + function _setupResizeHandle() { + const handle = document.getElementById('wfResizeHandle'); + if (!handle || handle.dataset.rdy) return; + handle.dataset.rdy = '1'; + + let startY = 0; + let startH = 0; + + const onMove = (event) => { + const delta = event.clientY - startY; + const next = _clamp(startH + delta, 55, 300); + const wrap = document.querySelector('.wf-spectrum-canvas-wrap'); + if (wrap) wrap.style.height = `${next}px`; + _resizeCanvases(); + if (_wfCtx && _wfCanvas) _wfCtx.clearRect(0, 0, _wfCanvas.width, _wfCanvas.height); + }; + + const onUp = () => { + handle.classList.remove('dragging'); + document.body.style.userSelect = ''; + document.body.style.cursor = ''; + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + + handle.addEventListener('mousedown', (event) => { + const wrap = document.querySelector('.wf-spectrum-canvas-wrap'); + startY = event.clientY; + startH = wrap ? wrap.offsetHeight : 108; + handle.classList.add('dragging'); + document.body.style.userSelect = 'none'; + document.body.style.cursor = 'ns-resize'; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + event.preventDefault(); + }); + } + + function _setupFrequencyBarInteraction() { + const display = document.getElementById('wfFreqCenterDisplay'); + if (!display || display.dataset.rdy) return; + display.dataset.rdy = '1'; + + display.addEventListener('focus', () => display.select()); + + display.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + const value = parseFloat(display.value); + if (Number.isFinite(value) && value > 0) _setAndTune(value, true); + display.blur(); + } else if (event.key === 'Escape') { + _updateFreqDisplay(); + display.blur(); + } else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { + event.preventDefault(); + const step = _getNumber('wfStepSize', 0.1); + const dir = event.key === 'ArrowUp' ? 1 : -1; + const cur = parseFloat(display.value) || _currentCenter(); + _setAndTune(cur + dir * step, true); + } + }); + + display.addEventListener('blur', () => { + const value = parseFloat(display.value); + if (Number.isFinite(value) && value > 0) _setAndTune(value, true); + }); + + display.addEventListener('wheel', (event) => { + event.preventDefault(); + const step = _getNumber('wfStepSize', 0.1); + const dir = event.deltaY < 0 ? 1 : -1; + _setAndTune(_currentCenter() + dir * step, true); + }, { passive: false }); + } + + function _setupControlListeners() { + if (_controlListenersAttached) return; + _controlListenersAttached = true; + + const centerEl = document.getElementById('wfCenterFreq'); + if (centerEl) { + centerEl.addEventListener('change', () => { + const value = parseFloat(centerEl.value); + if (Number.isFinite(value) && value > 0) _setAndTune(value, true); + }); + } + + const spanEl = document.getElementById('wfSpanMhz'); + if (spanEl) { + spanEl.addEventListener('change', () => { + const span = _clamp(_currentSpan(), 0.05, 30.0); + spanEl.value = span.toFixed(3); + _startMhz = _currentCenter() - span / 2; + _endMhz = _currentCenter() + span / 2; + _drawFreqAxis(); + + if (_monitoring) _queueMonitorAdjust(250, { allowSharedTune: false }); + if (_running) _queueRetune(250); + }); + } + + const stepEl = document.getElementById('wfStepSize'); + if (stepEl) { + stepEl.addEventListener('change', () => _updateFreqDisplay()); + } + + ['wfFftSize', 'wfFps', 'wfAvgCount', 'wfGain', 'wfPpm', 'wfBiasT', 'wfDbMin', 'wfDbMax'].forEach((id) => { + const el = document.getElementById(id); + if (!el) return; + const evt = el.tagName === 'INPUT' && el.type === 'text' ? 'blur' : 'change'; + el.addEventListener(evt, () => { + if (_monitoring && (id === 'wfGain' || id === 'wfBiasT')) { + _queueMonitorAdjust(280, { allowSharedTune: false }); + } + if (_running) _queueRetune(180); + }); + }); + + const monitorMode = document.getElementById('wfMonitorMode'); + if (monitorMode) { + monitorMode.addEventListener('change', () => { + _setMonitorMode(monitorMode.value); + if (_monitoring) _queueMonitorAdjust(140); + }); + } + + document.querySelectorAll('.wf-mode-btn').forEach((btn) => { + btn.addEventListener('click', () => { + const mode = btn.dataset.mode || 'wfm'; + _setMonitorMode(mode); + if (_monitoring) _queueMonitorAdjust(140); + _updateFreqDisplay(); + }); + }); + + const sq = document.getElementById('wfMonitorSquelch'); + const sqValue = document.getElementById('wfMonitorSquelchValue'); + if (sq) { + sq.addEventListener('input', () => { + if (sqValue) sqValue.textContent = String(parseInt(sq.value, 10) || 0); + }); + sq.addEventListener('change', () => { + if (_monitoring) _queueMonitorAdjust(180); + }); + } + + const gain = document.getElementById('wfMonitorGain'); + const gainValue = document.getElementById('wfMonitorGainValue'); + if (gain) { + gain.addEventListener('input', () => { + const g = parseInt(gain.value, 10) || 0; + if (gainValue) gainValue.textContent = String(g); + }); + gain.addEventListener('change', () => { + if (_monitoring) _queueMonitorAdjust(180, { allowSharedTune: false }); + }); + } + + const vol = document.getElementById('wfMonitorVolume'); + const volValue = document.getElementById('wfMonitorVolumeValue'); + if (vol) { + vol.addEventListener('input', () => { + const v = parseInt(vol.value, 10) || 0; + if (volValue) volValue.textContent = String(v); + const player = document.getElementById('wfAudioPlayer'); + if (player) player.volume = v / 100; + }); + } + + window.addEventListener('resize', _resizeCanvases); + } + + function _selectedDevice() { + const raw = document.getElementById('wfDevice')?.value || 'rtlsdr:0'; + const parts = raw.includes(':') ? raw.split(':') : ['rtlsdr', '0']; + return { + sdrType: parts[0] || 'rtlsdr', + deviceIndex: parseInt(parts[1], 10) || 0, + }; + } + + function _waterfallRequestConfig() { + const centerMhz = _currentCenter(); + const spanMhz = _clamp(_currentSpan(), 0.05, 30.0); + _startMhz = centerMhz - spanMhz / 2; + _endMhz = centerMhz + spanMhz / 2; + _monitorFreqMhz = centerMhz; + _peakLine = null; + _drawFreqAxis(); + + const gainRaw = String(document.getElementById('wfGain')?.value || 'AUTO').trim(); + const gain = gainRaw.toUpperCase() === 'AUTO' ? 'auto' : parseFloat(gainRaw); + const device = _selectedDevice(); + const fftSize = parseInt(document.getElementById('wfFftSize')?.value, 10) || 1024; + const fps = parseInt(document.getElementById('wfFps')?.value, 10) || 20; + const avgCount = parseInt(document.getElementById('wfAvgCount')?.value, 10) || 4; + const ppm = parseInt(document.getElementById('wfPpm')?.value, 10) || 0; + const biasT = !!document.getElementById('wfBiasT')?.checked; + + return { + centerMhz, + spanMhz, + gain, + device, + fftSize, + fps, + avgCount, + ppm, + biasT, + }; + } + + function _sendWsStartCmd() { + if (!_ws || _ws.readyState !== WebSocket.OPEN) return; + const cfg = _waterfallRequestConfig(); + + const payload = { + cmd: 'start', + center_freq_mhz: cfg.centerMhz, + center_freq: cfg.centerMhz, + span_mhz: cfg.spanMhz, + gain: cfg.gain, + sdr_type: cfg.device.sdrType, + device: cfg.device.deviceIndex, + fft_size: cfg.fftSize, + fps: cfg.fps, + avg_count: cfg.avgCount, + ppm: cfg.ppm, + bias_t: cfg.biasT, + }; + + if (!_autoRange) { + _dbMin = parseFloat(document.getElementById('wfDbMin')?.value) || -100; + _dbMax = parseFloat(document.getElementById('wfDbMax')?.value) || -20; + payload.db_min = _dbMin; + payload.db_max = _dbMax; + } + + try { + _ws.send(JSON.stringify(payload)); + _setStatus(`Tuning ${cfg.centerMhz.toFixed(4)} MHz...`); + _setVisualStatus('TUNING'); + } catch (err) { + _setStatus(`Failed to send tune command: ${err}`); + _setVisualStatus('ERROR'); + } + } + + function _sendWsTuneCmd() { + if (!_ws || _ws.readyState !== WebSocket.OPEN) return; + + const squelch = parseInt(document.getElementById('wfMonitorSquelch')?.value, 10) || 0; + const mode = _getMonitorMode(); + const payload = { + cmd: 'tune', + vfo_freq_mhz: _monitorFreqMhz, + modulation: mode, + squelch, + }; + + try { + _ws.send(JSON.stringify(payload)); + _setStatus(`Tuned ${_monitorFreqMhz.toFixed(4)} MHz`); + if (!_monitoring) _setVisualStatus('RUNNING'); + } catch (err) { + _setStatus(`Tune command failed: ${err}`); + _setVisualStatus('ERROR'); + } + } + + async function _sendSseStartCmd({ forceRestart = false } = {}) { + const cfg = _waterfallRequestConfig(); + const spanHz = Math.max(1000, Math.round(cfg.spanMhz * 1e6)); + const targetBins = _clamp(cfg.fftSize, 128, 4096); + const binSize = Math.max(1000, Math.round(spanHz / targetBins)); + const interval = _clamp(1 / Math.max(1, cfg.fps), 0.1, 2.0); + const gain = Number.isFinite(cfg.gain) ? cfg.gain : 40; + + const payload = { + start_freq: _startMhz, + end_freq: _endMhz, + bin_size: binSize, + gain: Math.round(gain), + device: cfg.device.deviceIndex, + interval, + max_bins: targetBins, + }; + const payloadKey = _ssePayloadKey(payload); + + const startOnce = async () => { + const response = await fetch('/listening/waterfall/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + let body = {}; + try { + body = await response.json(); + } catch (_) { + body = {}; + } + return { response, body }; + }; + + if (_sseStartPromise) { + await _sseStartPromise.catch(() => {}); + if (!_active) return; + if (!forceRestart && _running && _sseStartConfigKey === payloadKey) return; + } + + const runStart = (async () => { + const shouldRestart = forceRestart || (_running && _sseStartConfigKey && _sseStartConfigKey !== payloadKey); + if (shouldRestart) { + await fetch('/listening/waterfall/stop', { method: 'POST' }).catch(() => {}); + _running = false; + _updateRunButtons(); + await _wait(140); + } + + let { response, body } = await startOnce(); + + if (_isWaterfallDeviceBusy(response, body)) { + throw new Error(body.message || 'SDR device is busy'); + } + + // If we attached to an existing backend worker after a page refresh, + // restart once so requested center/span is definitely applied. + if (_isWaterfallAlreadyRunningConflict(response, body) && !_sseStartConfigKey) { + await fetch('/listening/waterfall/stop', { method: 'POST' }).catch(() => {}); + await _wait(140); + ({ response, body } = await startOnce()); + if (_isWaterfallDeviceBusy(response, body)) { + throw new Error(body.message || 'SDR device is busy'); + } + } + + if (_isWaterfallAlreadyRunningConflict(response, body)) { + body = { status: 'started', message: body.message || 'Waterfall already running' }; + } else if (!response.ok || (body.status && body.status !== 'started')) { + throw new Error(body.message || `Waterfall start failed (${response.status})`); + } + + _sseStartConfigKey = payloadKey; + _running = true; + _updateRunButtons(); + _setStatus(`Streaming ${_startMhz.toFixed(4)} - ${_endMhz.toFixed(4)} MHz`); + _setVisualStatus('RUNNING'); + })(); + _sseStartPromise = runStart; + + try { + await runStart; + } finally { + if (_sseStartPromise === runStart) { + _sseStartPromise = null; + } + } + } + + function _sendStartCmd() { + if (_transport === 'sse') { + _sendSseStartCmd().catch((err) => { + _setStatus(`Waterfall start failed: ${err}`); + _setVisualStatus('ERROR'); + }); + return; + } + _sendWsStartCmd(); + } + + function _handleSseMessage(msg) { + if (!msg || typeof msg !== 'object') return; + if (msg.type === 'keepalive') return; + if (msg.type === 'waterfall_error') { + const text = msg.message || 'Waterfall source error'; + _setStatus(text); + if (!_monitoring) _setVisualStatus('ERROR'); + return; + } + if (msg.type !== 'waterfall_sweep') return; + + const startFreq = Number(msg.start_freq); + const endFreq = Number(msg.end_freq); + if (Number.isFinite(startFreq) && Number.isFinite(endFreq) && endFreq > startFreq) { + _startMhz = startFreq; + _endMhz = endFreq; + _drawFreqAxis(); + } + + const bins = _normalizeSweepBins(msg.bins); + if (!bins || bins.length === 0) return; + _drawSpectrum(bins); + _scrollWaterfall(bins); + } + + function _openSseStream() { + if (_es) return; + const source = new EventSource(`/listening/waterfall/stream?t=${Date.now()}`); + _es = source; + source.onmessage = (event) => { + let msg = null; + try { + msg = JSON.parse(event.data); + } catch (_) { + return; + } + _running = true; + _updateRunButtons(); + if (!_monitoring) _setVisualStatus('RUNNING'); + _handleSseMessage(msg); + }; + source.onerror = () => { + if (!_active) return; + _setStatus('Waterfall SSE stream interrupted; retrying...'); + if (!_monitoring) _setVisualStatus('DISCONNECTED'); + }; + } + + async function _activateSseFallback(reason = '') { + _clearWsFallbackTimer(); + + if (_ws) { + try { + _ws.close(); + } catch (_) { + // Ignore close errors during fallback. + } + _ws = null; + } + + _transport = 'sse'; + _openSseStream(); + if (reason) _setStatus(reason); + await _sendSseStartCmd(); + } + + async function _handleBinary(data) { + let buf = null; + if (data instanceof ArrayBuffer) { + buf = data; + } else if (data && typeof data.arrayBuffer === 'function') { + buf = await data.arrayBuffer(); + } + + if (!buf) return; + const frame = _parseFrame(buf); + if (!frame) return; + + if (frame.startMhz > 0 && frame.endMhz > frame.startMhz) { + _startMhz = frame.startMhz; + _endMhz = frame.endMhz; + _drawFreqAxis(); + } + + _drawSpectrum(frame.bins); + _scrollWaterfall(frame.bins); + } + + function _onMessage(event) { + if (typeof event.data === 'string') { + try { + const msg = JSON.parse(event.data); + if (msg.status === 'started') { + _running = true; + _updateRunButtons(); + if (Number.isFinite(msg.vfo_freq_mhz)) { + _monitorFreqMhz = Number(msg.vfo_freq_mhz); + } + if (Number.isFinite(msg.start_freq) && Number.isFinite(msg.end_freq)) { + _startMhz = msg.start_freq; + _endMhz = msg.end_freq; + _drawFreqAxis(); + } + _setStatus(`Streaming ${_startMhz.toFixed(4)} - ${_endMhz.toFixed(4)} MHz`); + _setVisualStatus('RUNNING'); + if (_pendingSharedMonitorRearm && _monitoring && _monitorSource === 'waterfall') { + _pendingSharedMonitorRearm = false; + _queueMonitorRetune(120); + } + } else if (msg.status === 'tuned') { + if (_onRetuneRequired(msg)) return; + if (Number.isFinite(msg.vfo_freq_mhz)) { + _monitorFreqMhz = Number(msg.vfo_freq_mhz); + } + _updateFreqDisplay(); + _setStatus(`Tuned ${_monitorFreqMhz.toFixed(4)} MHz`); + if (!_monitoring) _setVisualStatus('RUNNING'); + } else if (_onRetuneRequired(msg)) { + return; + } else if (msg.status === 'stopped') { + _running = false; + _updateRunButtons(); + _setStatus('Waterfall stopped'); + _setVisualStatus('STOPPED'); + } else if (msg.status === 'error') { + _running = false; + _updateRunButtons(); + _setStatus(msg.message || 'Waterfall error'); + _setVisualStatus('ERROR'); + } else if (msg.status) { + _setStatus(msg.status); + } + } catch (_) { + // Ignore malformed status payloads + } + return; + } + + _handleBinary(event.data).catch(() => {}); + } + + async function _pauseMonitorAudioElement() { + const player = document.getElementById('wfAudioPlayer'); + if (!player) return; + try { + player.pause(); + } catch (_) { + // Ignore pause errors + } + player.removeAttribute('src'); + player.load(); + } + + async function _attachMonitorAudio(nonce) { + const player = document.getElementById('wfAudioPlayer'); + if (!player) { + return { ok: false, reason: 'player_missing', message: 'Audio player is unavailable.' }; + } + + player.autoplay = true; + player.preload = 'auto'; + player.muted = _monitorMuted; + const vol = parseInt(document.getElementById('wfMonitorVolume')?.value, 10) || 82; + player.volume = vol / 100; + + const maxAttempts = 4; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + if (nonce !== _audioConnectNonce) { + return { ok: false, reason: 'stale' }; + } + + await _pauseMonitorAudioElement(); + player.src = `/listening/audio/stream?fresh=1&t=${Date.now()}-${attempt}`; + player.load(); + + try { + const playPromise = player.play(); + if (playPromise && typeof playPromise.then === 'function') { + await playPromise; + } + } catch (err) { + if (_isAutoplayError(err)) { + _audioUnlockRequired = true; + _setUnlockVisible(true); + return { + ok: false, + reason: 'autoplay_blocked', + message: 'Browser blocked audio playback. Click Unlock Audio.', + }; + } + + if (attempt < maxAttempts) { + await _wait(180 * attempt); + continue; + } + + return { + ok: false, + reason: 'play_failed', + message: `Audio playback failed: ${err && err.message ? err.message : 'unknown error'}`, + }; + } + + const active = await _waitForPlayback(player, 3500); + if (nonce !== _audioConnectNonce) { + return { ok: false, reason: 'stale' }; + } + + if (active) { + _audioUnlockRequired = false; + _setUnlockVisible(false); + return { ok: true, player }; + } + + if (attempt < maxAttempts) { + await _wait(220 * attempt); + continue; + } + } + + return { + ok: false, + reason: 'stream_timeout', + message: 'No audio data reached the browser stream.', + }; + } + + function _deviceKey(device) { + if (!device) return ''; + return `${device.sdrType || ''}:${device.deviceIndex || 0}`; + } + + function _findAlternateDevice(currentDevice) { + const currentKey = _deviceKey(currentDevice); + for (const d of _devices) { + const candidate = { + sdrType: String(d.sdr_type || 'rtlsdr'), + deviceIndex: parseInt(d.index, 10) || 0, + }; + if (_deviceKey(candidate) !== currentKey) { + return candidate; + } + } + return null; + } + + async function _requestAudioStart({ + frequency, + modulation, + squelch, + gain, + device, + biasT, + }) { + const response = await fetch('/listening/audio/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + frequency, + modulation, + squelch, + gain, + device: device.deviceIndex, + sdr_type: device.sdrType, + bias_t: biasT, + }), + }); + + let payload = {}; + try { + payload = await response.json(); + } catch (_) { + payload = {}; + } + return { response, payload }; + } + + function _syncMonitorButtons() { + const monitorBtn = document.getElementById('wfMonitorBtn'); + const muteBtn = document.getElementById('wfMuteBtn'); + + if (monitorBtn) { + monitorBtn.textContent = _monitoring ? 'Stop Monitor' : 'Monitor'; + monitorBtn.classList.toggle('is-active', _monitoring); + monitorBtn.disabled = _startingMonitor; + } + + if (muteBtn) { + muteBtn.textContent = _monitorMuted ? 'Unmute' : 'Mute'; + muteBtn.disabled = !_monitoring; + } + } + + async function _startMonitorInternal({ wasRunningWaterfall = false, retuneOnly = false } = {}) { + if (_startingMonitor) return; + _startingMonitor = true; + _syncMonitorButtons(); + const nonce = ++_audioConnectNonce; + + try { + if (!retuneOnly) { + _resumeWaterfallAfterMonitor = !!wasRunningWaterfall; + } + + const centerMhz = _currentCenter(); + const mode = document.getElementById('wfMonitorMode')?.value || 'wfm'; + const squelch = parseInt(document.getElementById('wfMonitorSquelch')?.value, 10) || 0; + const sliderGain = parseInt(document.getElementById('wfMonitorGain')?.value, 10); + const fallbackGain = parseFloat(String(document.getElementById('wfGain')?.value || '40')); + const gain = Number.isFinite(sliderGain) + ? sliderGain + : (Number.isFinite(fallbackGain) ? Math.round(fallbackGain) : 40); + const selectedDevice = _selectedDevice(); + const altDevice = _running ? _findAlternateDevice(selectedDevice) : null; + let monitorDevice = altDevice || selectedDevice; + const biasT = !!document.getElementById('wfBiasT')?.checked; + const usingSecondaryDevice = !!altDevice; + + _monitorFreqMhz = centerMhz; + _drawFreqAxis(); + _stopSmeter(); + _setUnlockVisible(false); + _audioUnlockRequired = false; + + if (usingSecondaryDevice) { + _setMonitorState( + `Starting ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()} on ` + + `${monitorDevice.sdrType.toUpperCase()} #${monitorDevice.deviceIndex}...` + ); + } else { + _setMonitorState(`Starting ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()}...`); + } + + let { response, payload } = await _requestAudioStart({ + frequency: centerMhz, + modulation: mode, + squelch, + gain, + device: monitorDevice, + biasT, + }); + if (nonce !== _audioConnectNonce) return; + + const busy = payload?.error_type === 'DEVICE_BUSY' || response.status === 409; + if ( + busy + && _running + && !usingSecondaryDevice + && !retuneOnly + ) { + _setMonitorState('Audio device busy, pausing waterfall and retrying monitor...'); + await stop({ keepStatus: true }); + _resumeWaterfallAfterMonitor = true; + await _wait(220); + monitorDevice = selectedDevice; + ({ response, payload } = await _requestAudioStart({ + frequency: centerMhz, + modulation: mode, + squelch, + gain, + device: monitorDevice, + biasT, + })); + if (nonce !== _audioConnectNonce) return; + } + + if (!response.ok || payload.status !== 'started') { + const msg = payload.message || `Monitor start failed (${response.status})`; + _monitoring = false; + _monitorSource = 'process'; + _pendingSharedMonitorRearm = false; + _stopSmeter(); + _setMonitorState(msg); + _setStatus(msg); + _setVisualStatus('ERROR'); + _syncMonitorButtons(); + if (!retuneOnly && _resumeWaterfallAfterMonitor && _active) { + await start(); + } + return; + } + + const attach = await _attachMonitorAudio(nonce); + if (nonce !== _audioConnectNonce) return; + _monitorSource = payload?.source === 'waterfall' ? 'waterfall' : 'process'; + + if (!attach.ok) { + if (attach.reason === 'autoplay_blocked') { + _monitoring = true; + _syncMonitorButtons(); + _setMonitorState(`Monitoring ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()} (audio locked)`); + _setStatus('Monitor started but browser blocked playback. Click Unlock Audio.'); + _setVisualStatus('MONITOR'); + return; + } + + _monitoring = false; + _monitorSource = 'process'; + _pendingSharedMonitorRearm = false; + _stopSmeter(); + _setUnlockVisible(false); + _setMonitorState(attach.message || 'Audio stream failed to start.'); + _setStatus(attach.message || 'Audio stream failed to start.'); + _setVisualStatus('ERROR'); + _syncMonitorButtons(); + try { + await fetch('/listening/audio/stop', { method: 'POST' }); + } catch (_) { + // Ignore cleanup stop failures + } + if (!retuneOnly && _resumeWaterfallAfterMonitor && _active) { + await start(); + } + return; + } + + _monitoring = true; + _syncMonitorButtons(); + _startSmeter(attach.player); + if (_monitorSource === 'waterfall') { + _setMonitorState( + `Monitoring ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()} via shared IQ` + ); + } else if (usingSecondaryDevice) { + _setMonitorState( + `Monitoring ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()} ` + + `via ${monitorDevice.sdrType.toUpperCase()} #${monitorDevice.deviceIndex}` + ); + } else { + _setMonitorState(`Monitoring ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()}`); + } + _setStatus(`Audio monitor active on ${centerMhz.toFixed(4)} MHz (${mode.toUpperCase()})`); + _setVisualStatus('MONITOR'); + } catch (err) { + if (nonce !== _audioConnectNonce) return; + _monitoring = false; + _monitorSource = 'process'; + _pendingSharedMonitorRearm = false; + _stopSmeter(); + _setUnlockVisible(false); + _syncMonitorButtons(); + _setMonitorState(`Monitor error: ${err}`); + _setStatus(`Monitor error: ${err}`); + _setVisualStatus('ERROR'); + if (!retuneOnly && _resumeWaterfallAfterMonitor && _active) { + await start(); + } + } finally { + _startingMonitor = false; + _syncMonitorButtons(); + } + } + + async function stopMonitor({ resumeWaterfall = false } = {}) { + clearTimeout(_monitorRetuneTimer); + _audioConnectNonce += 1; + + try { + await fetch('/listening/audio/stop', { method: 'POST' }); + } catch (_) { + // Ignore backend stop errors + } + + _stopSmeter(); + _setUnlockVisible(false); + _audioUnlockRequired = false; + await _pauseMonitorAudioElement(); + + _monitoring = false; + _monitorSource = 'process'; + _pendingSharedMonitorRearm = false; + _syncMonitorButtons(); + _setMonitorState('No audio monitor'); + + if (_running) { + _setVisualStatus('RUNNING'); + } else { + _setVisualStatus('READY'); + } + + if (resumeWaterfall && _active) { + _resumeWaterfallAfterMonitor = false; + await start(); + } + } + + function _syncMonitorModeWithPreset(mode) { + _setMonitorMode(mode); + } + + function applyPreset(name) { + const preset = PRESETS[name]; + if (!preset) return; + + const centerEl = document.getElementById('wfCenterFreq'); + const spanEl = document.getElementById('wfSpanMhz'); + const stepEl = document.getElementById('wfStepSize'); + + if (centerEl) centerEl.value = preset.center.toFixed(4); + if (spanEl) spanEl.value = preset.span.toFixed(3); + if (stepEl) stepEl.value = String(preset.step); + + _syncMonitorModeWithPreset(preset.mode); + _setAndTune(preset.center, true); + _setStatus(`Preset applied: ${name.toUpperCase()}`); + } + + async function toggleMonitor() { + if (_monitoring) { + await stopMonitor({ resumeWaterfall: _resumeWaterfallAfterMonitor }); + return; + } + + await _startMonitorInternal({ wasRunningWaterfall: _running, retuneOnly: false }); + } + + function toggleMute() { + _monitorMuted = !_monitorMuted; + const player = document.getElementById('wfAudioPlayer'); + if (player) player.muted = _monitorMuted; + _syncMonitorButtons(); + } + + async function unlockAudio() { + if (!_monitoring || !_audioUnlockRequired) return; + const player = document.getElementById('wfAudioPlayer'); + if (!player) return; + + try { + if (_audioContext && _audioContext.state === 'suspended') { + await _audioContext.resume(); + } + } catch (_) { + // Ignore context resume errors. + } + + try { + const playPromise = player.play(); + if (playPromise && typeof playPromise.then === 'function') { + await playPromise; + } + _audioUnlockRequired = false; + _setUnlockVisible(false); + _startSmeter(player); + _setMonitorState(`Monitoring ${_monitorFreqMhz.toFixed(4)} MHz ${_getMonitorMode().toUpperCase()}`); + _setStatus('Audio monitor unlocked'); + } catch (_) { + _audioUnlockRequired = true; + _setUnlockVisible(true); + _setMonitorState('Audio is still blocked by browser policy. Click Unlock Audio again.'); + } + } + + async function start() { + if (_monitoring) { + await stopMonitor({ resumeWaterfall: false }); + } + + if (_ws && _ws.readyState === WebSocket.OPEN) { + _sendStartCmd(); + return; + } + + if (_ws && _ws.readyState === WebSocket.CONNECTING) return; + + _specCanvas = document.getElementById('wfSpectrumCanvas'); + _wfCanvas = document.getElementById('wfWaterfallCanvas'); + _specCtx = _ctx2d(_specCanvas); + _wfCtx = _ctx2d(_wfCanvas, { willReadFrequently: false }); + + _resizeCanvases(); + _setupCanvasInteraction(); + + const center = _currentCenter(); + const span = _currentSpan(); + _startMhz = center - span / 2; + _endMhz = center + span / 2; + _monitorFreqMhz = center; + _drawFreqAxis(); + + if (typeof WebSocket === 'undefined') { + await _activateSseFallback('WebSocket unavailable. Using fallback waterfall stream.'); + return; + } + + _transport = 'ws'; + _wsOpened = false; + _clearWsFallbackTimer(); + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + let ws = null; + try { + ws = new WebSocket(`${proto}//${location.host}/ws/waterfall`); + } catch (_) { + await _activateSseFallback('WebSocket initialization failed. Using fallback waterfall stream.'); + return; + } + _ws = ws; + _ws.binaryType = 'arraybuffer'; + _wsFallbackTimer = setTimeout(() => { + if (!_wsOpened && _active && _transport === 'ws') { + _activateSseFallback('WebSocket endpoint unavailable. Using fallback waterfall stream.').catch((err) => { + _setStatus(`Waterfall fallback failed: ${err}`); + _setVisualStatus('ERROR'); + }); + } + }, WS_OPEN_FALLBACK_MS); + + _ws.onopen = () => { + _wsOpened = true; + _clearWsFallbackTimer(); + _sendStartCmd(); + _setStatus('Connected to waterfall stream'); + }; + + _ws.onmessage = _onMessage; + + _ws.onerror = () => { + if (!_wsOpened && _active) { + // Let the open-timeout fallback decide; transient errors can recover. + _setStatus('WebSocket handshake hiccup. Retrying...'); + return; + } + _setStatus('Waterfall connection error'); + if (!_monitoring) _setVisualStatus('ERROR'); + }; + + _ws.onclose = () => { + if (!_wsOpened && _active) { + // Wait for timeout-based fallback; avoid flapping to SSE on brief close/retry. + _setStatus('WebSocket closed before ready. Waiting to retry/fallback...'); + return; + } + _clearWsFallbackTimer(); + _running = false; + _updateRunButtons(); + if (_active) { + _setStatus('Waterfall disconnected'); + if (!_monitoring) { + _setVisualStatus('DISCONNECTED'); + } + } + }; + } + + async function stop({ keepStatus = false } = {}) { + clearTimeout(_retuneTimer); + _clearWsFallbackTimer(); + _wsOpened = false; + _pendingSharedMonitorRearm = false; + + if (_ws) { + try { + _ws.send(JSON.stringify({ cmd: 'stop' })); + } catch (_) { + // Ignore command send failures during shutdown. + } + try { + _ws.close(); + } catch (_) { + // Ignore close errors. + } + _ws = null; + } + + if (_es) { + _closeSseStream(); + try { + await fetch('/listening/waterfall/stop', { method: 'POST' }); + } catch (_) { + // Ignore fallback stop errors. + } + } + + _sseStartConfigKey = ''; + _running = false; + _updateRunButtons(); + if (!keepStatus) { + _setStatus('Waterfall stopped'); + if (!_monitoring) _setVisualStatus('STOPPED'); + } + } + + function setPalette(name) { + _palette = name; + } + + function togglePeakHold(value) { + _peakHold = !!value; + if (!_peakHold) _peakLine = null; + } + + function toggleAnnotations(value) { + _showAnnotations = !!value; + } + + function toggleAutoRange(value) { + _autoRange = !!value; + const dbMinEl = document.getElementById('wfDbMin'); + const dbMaxEl = document.getElementById('wfDbMax'); + if (dbMinEl) dbMinEl.disabled = _autoRange; + if (dbMaxEl) dbMaxEl.disabled = _autoRange; + + if (_running) { + _queueRetune(50); + } + } + + function stepFreq(multiplier) { + const step = _getNumber('wfStepSize', 0.1); + _setAndTune(_currentCenter() + multiplier * step, true); + } + + function _renderDeviceOptions(devices) { + const sel = document.getElementById('wfDevice'); + if (!sel) return; + + if (!Array.isArray(devices) || devices.length === 0) { + sel.innerHTML = ''; + return; + } + + const previous = sel.value; + sel.innerHTML = devices.map((d) => { + const label = d.serial ? `${d.name} [${d.serial}]` : d.name; + return ``; + }).join(''); + + if (previous && [...sel.options].some((opt) => opt.value === previous)) { + sel.value = previous; + } + + _updateDeviceInfo(); + } + + function _formatSampleRate(samples) { + if (!Array.isArray(samples) || samples.length === 0) return '--'; + const max = Math.max(...samples.map((v) => parseInt(v, 10)).filter((v) => Number.isFinite(v))); + if (!Number.isFinite(max) || max <= 0) return '--'; + return max >= 1e6 ? `${(max / 1e6).toFixed(2)} Msps` : `${Math.round(max / 1000)} ksps`; + } + + function _updateDeviceInfo() { + const sel = document.getElementById('wfDevice'); + const panel = document.getElementById('wfDeviceInfo'); + if (!sel || !panel) return; + + const value = sel.value; + if (!value) { + panel.style.display = 'none'; + return; + } + + const [sdrType, idx] = value.split(':'); + const device = _devices.find((d) => d.sdr_type === sdrType && String(d.index) === idx); + if (!device) { + panel.style.display = 'none'; + return; + } + + const caps = device.capabilities || {}; + const typeEl = document.getElementById('wfDeviceType'); + const rangeEl = document.getElementById('wfDeviceRange'); + const bwEl = document.getElementById('wfDeviceBw'); + + if (typeEl) typeEl.textContent = String(device.sdr_type || '--').toUpperCase(); + if (rangeEl) { + rangeEl.textContent = Number.isFinite(caps.freq_min_mhz) && Number.isFinite(caps.freq_max_mhz) + ? `${caps.freq_min_mhz}-${caps.freq_max_mhz} MHz` + : '--'; + } + if (bwEl) bwEl.textContent = _formatSampleRate(caps.sample_rates); + + panel.style.display = 'block'; + } + + function onDeviceChange() { + _updateDeviceInfo(); + if (_monitoring) _queueMonitorRetune(120); + if (_running) _queueRetune(120); + } + + function _loadDevices() { + fetch('/devices') + .then((r) => r.json()) + .then((devices) => { + _devices = Array.isArray(devices) ? devices : []; + _renderDeviceOptions(_devices); + }) + .catch(() => { + const sel = document.getElementById('wfDevice'); + if (sel) sel.innerHTML = ''; + }); + } + + function init() { + if (_active) { + if (!_running && !_sseStartPromise) { + _setVisualStatus('CONNECTING'); + _setStatus('Connecting waterfall stream...'); + Promise.resolve(start()).catch((err) => { + _setStatus(`Waterfall start failed: ${err}`); + _setVisualStatus('ERROR'); + }); + } + return; + } + _active = true; + _buildPalettes(); + _peakLine = null; + + _specCanvas = document.getElementById('wfSpectrumCanvas'); + _wfCanvas = document.getElementById('wfWaterfallCanvas'); + _specCtx = _ctx2d(_specCanvas); + _wfCtx = _ctx2d(_wfCanvas, { willReadFrequently: false }); + + _setupCanvasInteraction(); + _setupResizeHandle(); + _setupFrequencyBarInteraction(); + _setupControlListeners(); + + _loadDevices(); + + const center = _currentCenter(); + const span = _currentSpan(); + _monitorFreqMhz = center; + _startMhz = center - span / 2; + _endMhz = center + span / 2; + + const vol = document.getElementById('wfMonitorVolume'); + const volValue = document.getElementById('wfMonitorVolumeValue'); + if (vol && volValue) volValue.textContent = String(parseInt(vol.value, 10) || 0); + + const sq = document.getElementById('wfMonitorSquelch'); + const sqValue = document.getElementById('wfMonitorSquelchValue'); + if (sq && sqValue) sqValue.textContent = String(parseInt(sq.value, 10) || 0); + + const gain = document.getElementById('wfMonitorGain'); + const gainValue = document.getElementById('wfMonitorGainValue'); + if (gain && gainValue) gainValue.textContent = String(parseInt(gain.value, 10) || 0); + + const dbMinEl = document.getElementById('wfDbMin'); + const dbMaxEl = document.getElementById('wfDbMax'); + if (dbMinEl) dbMinEl.disabled = true; + if (dbMaxEl) dbMaxEl.disabled = true; + + _setMonitorMode(_getMonitorMode()); + _setUnlockVisible(false); + _setSmeter(0, 'S0'); + _syncMonitorButtons(); + _updateRunButtons(); + _setVisualStatus('CONNECTING'); + _setStatus('Connecting waterfall stream...'); + + setTimeout(_resizeCanvases, 60); + _drawFreqAxis(); + Promise.resolve(start()).catch((err) => { + _setStatus(`Waterfall start failed: ${err}`); + _setVisualStatus('ERROR'); + }); + } + + async function destroy() { + _active = false; + clearTimeout(_retuneTimer); + clearTimeout(_monitorRetuneTimer); + + if (_monitoring) { + await stopMonitor({ resumeWaterfall: false }); + } + + await stop({ keepStatus: true }); + + if (_specCtx && _specCanvas) _specCtx.clearRect(0, 0, _specCanvas.width, _specCanvas.height); + if (_wfCtx && _wfCanvas) _wfCtx.clearRect(0, 0, _wfCanvas.width, _wfCanvas.height); + + _specCanvas = null; + _wfCanvas = null; + _specCtx = null; + _wfCtx = null; + + _stopSmeter(); + _setUnlockVisible(false); + _audioUnlockRequired = false; + _pendingSharedMonitorRearm = false; + _sseStartConfigKey = ''; + _sseStartPromise = null; + } + + return { + init, + destroy, + start, + stop, + stepFreq, + setPalette, + togglePeakHold, + toggleAnnotations, + toggleAutoRange, + onDeviceChange, + toggleMonitor, + toggleMute, + unlockAudio, + applyPreset, + stopMonitor, + }; +})(); + +window.Waterfall = Waterfall; diff --git a/static/js/vendor/leaflet-heat.js b/static/js/vendor/leaflet-heat.js new file mode 100644 index 0000000..f29b3d1 --- /dev/null +++ b/static/js/vendor/leaflet-heat.js @@ -0,0 +1,297 @@ +/* + * Leaflet.heat — a tiny, fast Leaflet heatmap plugin + * https://github.com/Leaflet/Leaflet.heat + * (c) 2014, Vladimir Agafonkin + * MIT License + * + * Bundled local copy for INTERCEPT — avoids CDN dependency. + * Includes simpleheat (https://github.com/mourner/simpleheat), MIT License. + */ + +// ---- simpleheat ---- +(function (global, factory) { + typeof define === 'function' && define.amd ? define(factory) : + typeof exports !== 'undefined' ? module.exports = factory() : + global.simpleheat = factory(); +}(this, function () { + 'use strict'; + + function simpleheat(canvas) { + if (!(this instanceof simpleheat)) return new simpleheat(canvas); + this._canvas = canvas = typeof canvas === 'string' ? document.getElementById(canvas) : canvas; + this._ctx = canvas.getContext('2d'); + this._width = canvas.width; + this._height = canvas.height; + this._max = 1; + this._data = []; + } + + simpleheat.prototype = { + defaultRadius: 25, + defaultGradient: { + 0.4: 'blue', + 0.6: 'cyan', + 0.7: 'lime', + 0.8: 'yellow', + 1.0: 'red' + }, + + data: function (data) { + this._data = data; + return this; + }, + + max: function (max) { + this._max = max; + return this; + }, + + add: function (point) { + this._data.push(point); + return this; + }, + + clear: function () { + this._data = []; + return this; + }, + + radius: function (r, blur) { + blur = blur === undefined ? 15 : blur; + var circle = this._circle = this._createCanvas(), + ctx = circle.getContext('2d'), + r2 = this._r = r + blur; + circle.width = circle.height = r2 * 2; + ctx.shadowOffsetX = ctx.shadowOffsetY = r2 * 2; + ctx.shadowBlur = blur; + ctx.shadowColor = 'black'; + ctx.beginPath(); + ctx.arc(-r2, -r2, r, 0, Math.PI * 2, true); + ctx.closePath(); + ctx.fill(); + return this; + }, + + resize: function () { + this._width = this._canvas.width; + this._height = this._canvas.height; + }, + + gradient: function (grad) { + var canvas = this._createCanvas(), + ctx = canvas.getContext('2d'), + gradient = ctx.createLinearGradient(0, 0, 0, 256); + canvas.width = 1; + canvas.height = 256; + for (var i in grad) { + gradient.addColorStop(+i, grad[i]); + } + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, 1, 256); + this._grad = ctx.getImageData(0, 0, 1, 256).data; + return this; + }, + + draw: function (minOpacity) { + if (!this._circle) this.radius(this.defaultRadius); + if (!this._grad) this.gradient(this.defaultGradient); + + var ctx = this._ctx; + ctx.clearRect(0, 0, this._width, this._height); + + for (var i = 0, len = this._data.length, p; i < len; i++) { + p = this._data[i]; + ctx.globalAlpha = Math.min(Math.max(p[2] / this._max, minOpacity === undefined ? 0.05 : minOpacity), 1); + ctx.drawImage(this._circle, p[0] - this._r, p[1] - this._r); + } + + var colored = ctx.getImageData(0, 0, this._width, this._height); + this._colorize(colored.data, this._grad); + ctx.putImageData(colored, 0, 0); + + return this; + }, + + _colorize: function (pixels, gradient) { + for (var i = 3, len = pixels.length, j; i < len; i += 4) { + j = pixels[i] * 4; + if (j) { + pixels[i - 3] = gradient[j]; + pixels[i - 2] = gradient[j + 1]; + pixels[i - 1] = gradient[j + 2]; + } + } + }, + + _createCanvas: function () { + if (typeof document !== 'undefined') { + return document.createElement('canvas'); + } + return { getContext: function () {} }; + } + }; + + return simpleheat; +})); + +// ---- Leaflet.heat plugin ---- +(function () { + if (typeof L === 'undefined') return; + + L.HeatLayer = (L.Layer ? L.Layer : L.Class).extend({ + initialize: function (latlngs, options) { + this._latlngs = latlngs; + L.setOptions(this, options); + }, + + setLatLngs: function (latlngs) { + this._latlngs = latlngs; + return this.redraw(); + }, + + addLatLng: function (latlng) { + this._latlngs.push(latlng); + return this.redraw(); + }, + + setOptions: function (options) { + L.setOptions(this, options); + if (this._heat) this._updateOptions(); + return this.redraw(); + }, + + redraw: function () { + if (this._heat && !this._frame && this._map && !this._map._animating) { + this._frame = L.Util.requestAnimFrame(this._redraw, this); + } + return this; + }, + + onAdd: function (map) { + this._map = map; + if (!this._canvas) this._initCanvas(); + if (this.options.pane) this.getPane().appendChild(this._canvas); + else map._panes.overlayPane.appendChild(this._canvas); + map.on('moveend', this._reset, this); + if (map.options.zoomAnimation && L.Browser.any3d) { + map.on('zoomanim', this._animateZoom, this); + } + this._reset(); + }, + + onRemove: function (map) { + if (this.options.pane) this.getPane().removeChild(this._canvas); + else map.getPanes().overlayPane.removeChild(this._canvas); + map.off('moveend', this._reset, this); + if (map.options.zoomAnimation) { + map.off('zoomanim', this._animateZoom, this); + } + }, + + addTo: function (map) { + map.addLayer(this); + return this; + }, + + _initCanvas: function () { + var canvas = this._canvas = L.DomUtil.create('canvas', 'leaflet-heatmap-layer leaflet-layer'); + var originProp = L.DomUtil.testProp(['transformOrigin', 'WebkitTransformOrigin', 'msTransformOrigin']); + canvas.style[originProp] = '50% 50%'; + var size = this._map.getSize(); + canvas.width = size.x; + canvas.height = size.y; + var animated = this._map.options.zoomAnimation && L.Browser.any3d; + L.DomUtil.addClass(canvas, 'leaflet-zoom-' + (animated ? 'animated' : 'hide')); + this._heat = simpleheat(canvas); + this._updateOptions(); + }, + + _updateOptions: function () { + this._heat.radius(this.options.radius || this._heat.defaultRadius, this.options.blur); + if (this.options.gradient) this._heat.gradient(this.options.gradient); + if (this.options.minOpacity) this._heat.minOpacity = this.options.minOpacity; + }, + + _reset: function () { + var topLeft = this._map.containerPointToLayerPoint([0, 0]); + L.DomUtil.setPosition(this._canvas, topLeft); + var size = this._map.getSize(); + if (this._heat._width !== size.x) { + this._canvas.width = this._heat._width = size.x; + } + if (this._heat._height !== size.y) { + this._canvas.height = this._heat._height = size.y; + } + this._redraw(); + }, + + _redraw: function () { + this._frame = null; + if (!this._map) return; + var data = [], + r = this._heat._r, + size = this._map.getSize(), + bounds = new L.Bounds(L.point([-r, -r]), size.add([r, r])), + max = this.options.max === undefined ? 1 : this.options.max, + maxZoom = this.options.maxZoom === undefined ? this._map.getMaxZoom() : this.options.maxZoom, + v = 1 / Math.pow(2, Math.max(0, Math.min(maxZoom - this._map.getZoom(), 12))), + cellSize = r / 2, + grid = [], + panePos = this._map._getMapPanePos(), + offsetX = panePos.x % cellSize, + offsetY = panePos.y % cellSize, + i, len, p, cell, x, y, j, len2, k; + + for (i = 0, len = this._latlngs.length; i < len; i++) { + p = this._map.latLngToContainerPoint(this._latlngs[i]); + if (bounds.contains(p)) { + x = Math.floor((p.x - offsetX) / cellSize) + 2; + y = Math.floor((p.y - offsetY) / cellSize) + 2; + var alt = this._latlngs[i].alt !== undefined ? this._latlngs[i].alt : + this._latlngs[i][2] !== undefined ? +this._latlngs[i][2] : 1; + k = alt * v; + grid[y] = grid[y] || []; + cell = grid[y][x]; + if (!cell) { + grid[y][x] = [p.x, p.y, k]; + } else { + cell[0] = (cell[0] * cell[2] + p.x * k) / (cell[2] + k); + cell[1] = (cell[1] * cell[2] + p.y * k) / (cell[2] + k); + cell[2] += k; + } + } + } + + for (i = 0, len = grid.length; i < len; i++) { + if (grid[i]) { + for (j = 0, len2 = grid[i].length; j < len2; j++) { + cell = grid[i][j]; + if (cell) { + data.push([ + Math.round(cell[0]), + Math.round(cell[1]), + Math.min(cell[2], max) + ]); + } + } + } + } + + this._heat.data(data).draw(this.options.minOpacity); + }, + + _animateZoom: function (e) { + var scale = this._map.getZoomScale(e.zoom), + offset = this._map._getCenterOffset(e.center)._multiplyBy(-scale).subtract(this._map._getMapPanePos()); + if (L.DomUtil.setTransform) { + L.DomUtil.setTransform(this._canvas, offset, scale); + } else { + this._canvas.style[L.DomUtil.TRANSFORM] = L.DomUtil.getTranslateString(offset) + ' scale(' + scale + ')'; + } + } + }); + + L.heatLayer = function (latlngs, options) { + return new L.HeatLayer(latlngs, options); + }; +}()); diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000..4a2eb81 --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,16 @@ +{ + "name": "INTERCEPT Signal Intelligence", + "short_name": "INTERCEPT", + "description": "Unified SIGINT platform for software-defined radio analysis", + "start_url": "/", + "display": "standalone", + "background_color": "#0b1118", + "theme_color": "#0b1118", + "icons": [ + { + "src": "/static/icons/icon.svg", + "sizes": "any", + "type": "image/svg+xml" + } + ] +} diff --git a/static/sw.js b/static/sw.js new file mode 100644 index 0000000..a1e30ea --- /dev/null +++ b/static/sw.js @@ -0,0 +1,85 @@ +/* INTERCEPT Service Worker — cache-first static, network-only for API/SSE/WS */ +const CACHE_NAME = 'intercept-v1'; + +const NETWORK_ONLY_PREFIXES = [ + '/stream', '/ws/', '/api/', '/gps/', '/wifi/', '/bluetooth/', + '/adsb/', '/ais/', '/acars/', '/aprs/', '/tscm/', '/satellite/', + '/meshtastic/', '/bt_locate/', '/listening/', '/sensor/', '/pager/', + '/sstv/', '/weather-sat/', '/subghz/', '/rtlamr/', '/dsc/', '/vdl2/', + '/spy/', '/space-weather/', '/websdr/', '/analytics/', '/correlation/', + '/recordings/', '/controller/', '/fingerprint/', '/ops/', +]; + +const STATIC_PREFIXES = [ + '/static/css/', + '/static/js/', + '/static/icons/', + '/static/fonts/', +]; + +const CACHE_EXACT = ['/manifest.json']; + +function isNetworkOnly(req) { + if (req.method !== 'GET') return true; + const accept = req.headers.get('Accept') || ''; + if (accept.includes('text/event-stream')) return true; + const url = new URL(req.url); + return NETWORK_ONLY_PREFIXES.some(p => url.pathname.startsWith(p)); +} + +function isStaticAsset(req) { + const url = new URL(req.url); + if (CACHE_EXACT.includes(url.pathname)) return true; + return STATIC_PREFIXES.some(p => url.pathname.startsWith(p)); +} + +self.addEventListener('install', (e) => { + self.skipWaiting(); +}); + +self.addEventListener('activate', (e) => { + e.waitUntil( + caches.keys().then(keys => + Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))) + ).then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (e) => { + const req = e.request; + + // Always bypass service worker for non-GET and streaming routes + if (isNetworkOnly(req)) { + e.respondWith(fetch(req)); + return; + } + + // Cache-first for static assets + if (isStaticAsset(req)) { + e.respondWith( + caches.open(CACHE_NAME).then(cache => + cache.match(req).then(cached => { + if (cached) { + // Revalidate in background + fetch(req).then(res => { + if (res && res.status === 200) cache.put(req, res.clone()); + }).catch(() => {}); + return cached; + } + return fetch(req).then(res => { + if (res && res.status === 200) cache.put(req, res.clone()); + return res; + }); + }) + ) + ); + return; + } + + // Network-first for HTML pages + e.respondWith( + fetch(req).catch(() => + caches.match(req).then(cached => cached || new Response('Offline', { status: 503 })) + ) + ); +}); diff --git a/templates/index.html b/templates/index.html index a72830c..65025b3 100644 --- a/templates/index.html +++ b/templates/index.html @@ -6,6 +6,11 @@ iNTERCEPT // See the Invisible + + + + + - + + + + + + + + + + + + + + + diff --git a/templates/partials/modes/analytics.html b/templates/partials/modes/analytics.html deleted file mode 100644 index 1775b00..0000000 --- a/templates/partials/modes/analytics.html +++ /dev/null @@ -1,211 +0,0 @@ - -
-{# Analytics Dashboard Sidebar Panel #} - -
-

- Summary - -

-
-
-
-
0
-
Aircraft
-
-
-
-
0
-
Vessels
-
-
-
-
0
-
WiFi
-
-
-
-
0
-
Bluetooth
-
-
-
-
0
-
DSC
-
-
-
-
0
-
ACARS
-
-
-
-
0
-
VDL2
-
-
-
-
0
-
APRS
-
-
-
-
0
-
Mesh
-
-
-
-
-
- -
-

- Operational Insights - -

-
-
-
Insights loading...
-
-
-
Top Changes
-
-
No change signals yet
-
-
-
-
- -
-

- Mode Health - -

-
-
-
-
- - - -
-

- Temporal Patterns - -

-
-
-
No recurring patterns detected
-
-
-
- -
-

- Recent Alerts - -

-
-
-
No recent alerts
-
-
-
- -
-

- Correlations - -

-
-
-
No correlations detected
-
-
-
- -
-

- Geofences - -

-
-
- -
-
- -
-

- Target View - -

-
-
- - -
-
Search to correlate entities across modes
-
-
No target selected
-
-
-
- -
-

- Session Replay - -

-
-
- - - - - -
-
No replay loaded
-
-
Select a recording to replay key events
-
-
-
- -
-

- Export Data - -

-
-
- - - -
-
-
- -
diff --git a/templates/partials/modes/fingerprint.html b/templates/partials/modes/fingerprint.html new file mode 100644 index 0000000..9c53b9a --- /dev/null +++ b/templates/partials/modes/fingerprint.html @@ -0,0 +1,115 @@ + +
+ + +
+
+ RF Fingerprinting captures the baseline radio environment at a location. + Record a baseline when the environment is "clean", then compare later to + detect new transmitters, surveillance devices, or signal anomalies. +
+
+ + +
+

Workflow

+
+ + +
+
+ Record a baseline in a known-clean RF environment, then use Compare later to detect new or anomalous signals. +
+
+ + +
+
+

Step 1 — Select Device

+
+ + +
+
+ +
+

Step 2 — Scanner Status

+
+ + Checking… +
+
+ + +
+
+ +
+

Step 3 — Record Baseline

+
+ + +
+
+ + +
+
+ Observations + 0 +
+
+ + +
+
+ + + + +
diff --git a/templates/partials/modes/rfheatmap.html b/templates/partials/modes/rfheatmap.html new file mode 100644 index 0000000..2550cd5 --- /dev/null +++ b/templates/partials/modes/rfheatmap.html @@ -0,0 +1,126 @@ + +
+ + +
+

RF Heatmap

+
+ Walk around with INTERCEPT running. Your GPS position and the current signal strength are saved as a point on the map every few metres. The result is a coverage heatmap — bright areas have strong signal, dark areas are weak or absent. +
+
+ + +
+

1What to Map

+
+ + +
+ + + + +
+ Walk near WiFi access points — their signal strength at each location is recorded. +
+ + +
+
+ + Checking… +
+ + +
+
+ + +
+

2Your Location

+ +
+ GPS + No Fix +
+ +
+
+ No GPS? Enter a fixed location to map signals from a stationary point. +
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+

3Live Signal

+
+
+ Current + — dBm +
+ +
+
+
+
Waiting for signal data…
+
+
+ + +
+

4Record

+ +
+ +
+ + 5m +
+
A new point is added after you move this distance.
+
+ +
+ Points Captured + 0 +
+ + + +
+ + +
+

Map

+
+ + +
+
+ +
diff --git a/templates/partials/modes/waterfall.html b/templates/partials/modes/waterfall.html new file mode 100644 index 0000000..cb09777 --- /dev/null +++ b/templates/partials/modes/waterfall.html @@ -0,0 +1,142 @@ + +
+
+

Spectrum Waterfall

+
+ Click spectrum or waterfall to tune. Scroll to step-tune. Ctrl/Cmd + scroll to zoom span. +
+
+ +
+

Device

+
+ + +
+ +
+ +
+

Tuning

+
+ + +
+
+ + +
+
+ + + + +
+
+ +
+

Capture

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+

Display

+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ +
+ + +
+
+ Tune with click. Use Monitor in the top strip for audio listen. +
+
+
diff --git a/templates/partials/nav.html b/templates/partials/nav.html index 0e5d3f5..5896208 100644 --- a/templates/partials/nav.html +++ b/templates/partials/nav.html @@ -67,6 +67,7 @@ {{ mode_item('rtlamr', 'Meters', '') }} {{ mode_item('listening', 'Listening Post', '') }} {{ mode_item('subghz', 'SubGHz', '') }} + {{ mode_item('waterfall', 'Waterfall', '') }} @@ -133,9 +134,10 @@
{{ mode_item('tscm', 'TSCM', '') }} - {{ mode_item('analytics', 'Analytics', '') }} {{ mode_item('spystations', 'Spy Stations', '') }} {{ mode_item('websdr', 'WebSDR', '') }} + {{ mode_item('rfheatmap', 'RF Heatmap', '') }} + {{ mode_item('fingerprint', 'RF Fingerprint', '') }}
@@ -177,6 +179,12 @@ + + + + +
Alert Feed
@@ -316,7 +403,6 @@ -
@@ -392,7 +478,6 @@ - diff --git a/tests/test_analytics.py b/tests/test_analytics.py deleted file mode 100644 index d52f148..0000000 --- a/tests/test_analytics.py +++ /dev/null @@ -1,202 +0,0 @@ -"""Tests for analytics endpoints, export, and squawk detection.""" - -import json -import tempfile -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - - -@pytest.fixture(scope='session') -def app(): - """Create application for testing.""" - import app as app_module - import utils.database as db_mod - from routes import register_blueprints - - app_module.app.config['TESTING'] = True - - # Use temp directory for test database - tmp_dir = Path(tempfile.mkdtemp()) - db_mod.DB_DIR = tmp_dir - db_mod.DB_PATH = tmp_dir / 'test_intercept.db' - # Reset thread-local connection so it picks up new path - if hasattr(db_mod._local, 'connection') and db_mod._local.connection: - db_mod._local.connection.close() - db_mod._local.connection = None - - db_mod.init_db() - - if 'pager' not in app_module.app.blueprints: - register_blueprints(app_module.app) - - return app_module.app - - -@pytest.fixture -def client(app): - client = app.test_client() - # Set session login to bypass require_login before_request hook - with client.session_transaction() as sess: - sess['logged_in'] = True - return client - - -class TestAnalyticsSummary: - """Tests for /analytics/summary endpoint.""" - - def test_summary_returns_json(self, client): - response = client.get('/analytics/summary') - assert response.status_code == 200 - data = json.loads(response.data) - assert data['status'] == 'success' - assert 'counts' in data - assert 'health' in data - assert 'squawks' in data - - def test_summary_counts_structure(self, client): - response = client.get('/analytics/summary') - data = json.loads(response.data) - counts = data['counts'] - assert 'adsb' in counts - assert 'ais' in counts - assert 'wifi' in counts - assert 'bluetooth' in counts - assert 'dsc' in counts - # All should be integers - for val in counts.values(): - assert isinstance(val, int) - - def test_summary_health_structure(self, client): - response = client.get('/analytics/summary') - data = json.loads(response.data) - health = data['health'] - # Should have process statuses - assert 'pager' in health - assert 'sensor' in health - assert 'adsb' in health - # Each should have a running flag - for mode_info in health.values(): - if isinstance(mode_info, dict) and 'running' in mode_info: - assert isinstance(mode_info['running'], bool) - - -class TestAnalyticsExport: - """Tests for /analytics/export/ endpoint.""" - - def test_export_adsb_json(self, client): - response = client.get('/analytics/export/adsb?format=json') - assert response.status_code == 200 - data = json.loads(response.data) - assert data['status'] == 'success' - assert data['mode'] == 'adsb' - assert 'data' in data - assert isinstance(data['data'], list) - - def test_export_adsb_csv(self, client): - response = client.get('/analytics/export/adsb?format=csv') - assert response.status_code == 200 - assert response.content_type.startswith('text/csv') - assert 'Content-Disposition' in response.headers - - def test_export_invalid_mode(self, client): - response = client.get('/analytics/export/invalid_mode') - assert response.status_code == 400 - data = json.loads(response.data) - assert data['status'] == 'error' - - def test_export_wifi_json(self, client): - response = client.get('/analytics/export/wifi?format=json') - assert response.status_code == 200 - data = json.loads(response.data) - assert data['status'] == 'success' - assert data['mode'] == 'wifi' - - -class TestAnalyticsSquawks: - """Tests for squawk detection.""" - - def test_squawks_endpoint(self, client): - response = client.get('/analytics/squawks') - assert response.status_code == 200 - data = json.loads(response.data) - assert data['status'] == 'success' - assert isinstance(data['squawks'], list) - - def test_get_emergency_squawks_detects_7700(self): - from utils.analytics import get_emergency_squawks - - # Mock the adsb_aircraft DataStore - mock_store = MagicMock() - mock_store.items.return_value = [ - ('ABC123', {'squawk': '7700', 'callsign': 'TEST01', 'altitude': 35000}), - ('DEF456', {'squawk': '1200', 'callsign': 'TEST02'}), - ] - - with patch('utils.analytics.app_module') as mock_app: - mock_app.adsb_aircraft = mock_store - squawks = get_emergency_squawks() - - assert len(squawks) == 1 - assert squawks[0]['squawk'] == '7700' - assert squawks[0]['meaning'] == 'General Emergency' - assert squawks[0]['icao'] == 'ABC123' - - -class TestGeofenceCRUD: - """Tests for geofence CRUD endpoints.""" - - def test_list_geofences(self, client): - response = client.get('/analytics/geofences') - assert response.status_code == 200 - data = json.loads(response.data) - assert data['status'] == 'success' - assert isinstance(data['zones'], list) - - def test_create_geofence(self, client): - response = client.post('/analytics/geofences', - data=json.dumps({ - 'name': 'Test Zone', - 'lat': 51.5074, - 'lon': -0.1278, - 'radius_m': 500, - }), - content_type='application/json') - assert response.status_code == 200 - data = json.loads(response.data) - assert data['status'] == 'success' - assert 'zone_id' in data - - def test_create_geofence_missing_fields(self, client): - response = client.post('/analytics/geofences', - data=json.dumps({'name': 'No coords'}), - content_type='application/json') - assert response.status_code == 400 - - def test_create_geofence_invalid_coords(self, client): - response = client.post('/analytics/geofences', - data=json.dumps({ - 'name': 'Bad', - 'lat': 100, - 'lon': 0, - 'radius_m': 100, - }), - content_type='application/json') - assert response.status_code == 400 - - def test_delete_geofence_not_found(self, client): - response = client.delete('/analytics/geofences/99999') - assert response.status_code == 404 - - -class TestAnalyticsActivity: - """Tests for /analytics/activity endpoint.""" - - def test_activity_returns_sparklines(self, client): - response = client.get('/analytics/activity') - assert response.status_code == 200 - data = json.loads(response.data) - assert data['status'] == 'success' - assert 'sparklines' in data - assert isinstance(data['sparklines'], dict) diff --git a/tests/test_sdr_detection.py b/tests/test_sdr_detection.py new file mode 100644 index 0000000..0c6864c --- /dev/null +++ b/tests/test_sdr_detection.py @@ -0,0 +1,46 @@ +"""Tests for RTL-SDR detection parsing.""" + +from unittest.mock import MagicMock, patch + +from utils.sdr.base import SDRType +from utils.sdr.detection import detect_rtlsdr_devices + + +@patch('utils.sdr.detection._check_tool', return_value=True) +@patch('utils.sdr.detection.subprocess.run') +def test_detect_rtlsdr_devices_filters_empty_serial_entries(mock_run, _mock_check_tool): + """Ignore malformed rtl_test rows that have an empty SN field.""" + mock_result = MagicMock() + mock_result.stdout = "" + mock_result.stderr = ( + "Found 3 device(s):\n" + " 0: ??C?, , SN:\n" + " 1: ??C?, , SN:\n" + " 2: RTLSDRBlog, Blog V4, SN: 1\n" + ) + mock_run.return_value = mock_result + + devices = detect_rtlsdr_devices() + + assert len(devices) == 1 + assert devices[0].sdr_type == SDRType.RTL_SDR + assert devices[0].index == 2 + assert devices[0].name == "RTLSDRBlog, Blog V4" + assert devices[0].serial == "1" + + +@patch('utils.sdr.detection._check_tool', return_value=True) +@patch('utils.sdr.detection.subprocess.run') +def test_detect_rtlsdr_devices_uses_replace_decode_mode(mock_run, _mock_check_tool): + """Run rtl_test with tolerant decoding for malformed output bytes.""" + mock_result = MagicMock() + mock_result.stdout = "" + mock_result.stderr = "Found 0 device(s):" + mock_run.return_value = mock_result + + detect_rtlsdr_devices() + + _, kwargs = mock_run.call_args + assert kwargs["text"] is True + assert kwargs["encoding"] == "utf-8" + assert kwargs["errors"] == "replace" diff --git a/tests/test_waterfall_websocket.py b/tests/test_waterfall_websocket.py new file mode 100644 index 0000000..2814e44 --- /dev/null +++ b/tests/test_waterfall_websocket.py @@ -0,0 +1,54 @@ +"""Tests for waterfall WebSocket configuration helpers.""" + +from routes.waterfall_websocket import ( + _parse_center_freq_mhz, + _parse_span_mhz, + _pick_sample_rate, +) +from utils.sdr import SDRType +from utils.sdr.base import SDRCapabilities + + +def _caps(sample_rates): + return SDRCapabilities( + sdr_type=SDRType.RTL_SDR, + freq_min_mhz=24.0, + freq_max_mhz=1766.0, + gain_min=0.0, + gain_max=49.6, + sample_rates=sample_rates, + supports_bias_t=True, + supports_ppm=True, + tx_capable=False, + ) + + +def test_parse_center_prefers_center_freq_mhz(): + assert _parse_center_freq_mhz({'center_freq_mhz': 162.55, 'center_freq': 144000000}) == 162.55 + + +def test_parse_center_supports_center_freq_hz(): + assert _parse_center_freq_mhz({'center_freq_hz': 915000000}) == 915.0 + + +def test_parse_center_supports_legacy_hz_payload(): + assert _parse_center_freq_mhz({'center_freq': 109000000}) == 109.0 + + +def test_parse_center_supports_legacy_mhz_payload(): + assert _parse_center_freq_mhz({'center_freq': 433.92}) == 433.92 + + +def test_parse_span_from_hz_and_mhz(): + assert _parse_span_mhz({'span_hz': 2400000}) == 2.4 + assert _parse_span_mhz({'span_mhz': 10.0}) == 10.0 + + +def test_pick_sample_rate_chooses_nearest_declared_rate(): + caps = _caps([250000, 1024000, 1800000, 2048000, 2400000]) + assert _pick_sample_rate(700000, caps, SDRType.RTL_SDR) == 1024000 + + +def test_pick_sample_rate_falls_back_to_max_bandwidth(): + caps = _caps([]) + assert _pick_sample_rate(10_000_000, caps, SDRType.RTL_SDR) == 2_400_000 diff --git a/utils/analytics.py b/utils/analytics.py deleted file mode 100644 index 205e0e3..0000000 --- a/utils/analytics.py +++ /dev/null @@ -1,230 +0,0 @@ -"""Cross-mode analytics: activity tracking, summaries, and emergency squawk detection.""" - -from __future__ import annotations - -import contextlib -import time -from collections import deque -from typing import Any - -import app as app_module - - -class ModeActivityTracker: - """Track device counts per mode in time-bucketed ring buffer for sparklines.""" - - def __init__(self, max_buckets: int = 60, bucket_interval: float = 5.0): - self._max_buckets = max_buckets - self._bucket_interval = bucket_interval - self._history: dict[str, deque] = {} - self._last_record_time = 0.0 - - def record(self) -> None: - """Snapshot current counts for all modes.""" - now = time.time() - if now - self._last_record_time < self._bucket_interval: - return - self._last_record_time = now - - counts = _get_mode_counts() - for mode, count in counts.items(): - if mode not in self._history: - self._history[mode] = deque(maxlen=self._max_buckets) - self._history[mode].append(count) - - def get_sparkline(self, mode: str) -> list[int]: - """Return sparkline array for a mode.""" - self.record() - return list(self._history.get(mode, [])) - - def get_all_sparklines(self) -> dict[str, list[int]]: - """Return sparkline arrays for all tracked modes.""" - self.record() - return {mode: list(values) for mode, values in self._history.items()} - - -# Singleton -_tracker: ModeActivityTracker | None = None - - -def get_activity_tracker() -> ModeActivityTracker: - global _tracker - if _tracker is None: - _tracker = ModeActivityTracker() - return _tracker - - -def _safe_len(attr_name: str) -> int: - """Safely get len() of an app_module attribute.""" - try: - return len(getattr(app_module, attr_name)) - except Exception: - return 0 - - -def _safe_route_attr(module_path: str, attr_name: str, default: int = 0) -> int: - """Safely read a module-level counter from a route file.""" - try: - import importlib - mod = importlib.import_module(module_path) - return int(getattr(mod, attr_name, default)) - except Exception: - return default - - -def _get_mode_counts() -> dict[str, int]: - """Read current entity counts from all available data sources.""" - counts: dict[str, int] = {} - - # ADS-B aircraft (DataStore) - counts['adsb'] = _safe_len('adsb_aircraft') - - # AIS vessels (DataStore) - counts['ais'] = _safe_len('ais_vessels') - - # WiFi: prefer v2 scanner, fall back to legacy DataStore - wifi_count = 0 - try: - from utils.wifi.scanner import _scanner_instance as wifi_scanner - if wifi_scanner is not None: - wifi_count = len(wifi_scanner.access_points) - except Exception: - pass - if wifi_count == 0: - wifi_count = _safe_len('wifi_networks') - counts['wifi'] = wifi_count - - # Bluetooth: prefer v2 scanner, fall back to legacy DataStore - bt_count = 0 - try: - from utils.bluetooth.scanner import _scanner_instance as bt_scanner - if bt_scanner is not None: - bt_count = len(bt_scanner.get_devices()) - except Exception: - pass - if bt_count == 0: - bt_count = _safe_len('bt_devices') - counts['bluetooth'] = bt_count - - # DSC messages (DataStore) - counts['dsc'] = _safe_len('dsc_messages') - - # ACARS message count (route-level counter) - counts['acars'] = _safe_route_attr('routes.acars', 'acars_message_count') - - # VDL2 message count (route-level counter) - counts['vdl2'] = _safe_route_attr('routes.vdl2', 'vdl2_message_count') - - # APRS stations (route-level dict) - try: - import routes.aprs as aprs_mod - counts['aprs'] = len(getattr(aprs_mod, 'aprs_stations', {})) - except Exception: - counts['aprs'] = 0 - - # Meshtastic recent messages (route-level list) - try: - import routes.meshtastic as mesh_route - counts['meshtastic'] = len(getattr(mesh_route, '_recent_messages', [])) - except Exception: - counts['meshtastic'] = 0 - - return counts - - -def get_cross_mode_summary() -> dict[str, Any]: - """Return counts dict for all available data sources.""" - counts = _get_mode_counts() - wifi_clients_count = 0 - try: - from utils.wifi.scanner import _scanner_instance as wifi_scanner - if wifi_scanner is not None: - wifi_clients_count = len(wifi_scanner.clients) - except Exception: - pass - if wifi_clients_count == 0: - wifi_clients_count = _safe_len('wifi_clients') - counts['wifi_clients'] = wifi_clients_count - return counts - - -def get_mode_health() -> dict[str, dict]: - """Check process refs and SDR status for each mode.""" - health: dict[str, dict] = {} - - process_map = { - 'pager': 'current_process', - 'sensor': 'sensor_process', - 'adsb': 'adsb_process', - 'ais': 'ais_process', - 'acars': 'acars_process', - 'vdl2': 'vdl2_process', - 'aprs': 'aprs_process', - 'wifi': 'wifi_process', - 'bluetooth': 'bt_process', - 'dsc': 'dsc_process', - 'rtlamr': 'rtlamr_process', - } - - for mode, attr in process_map.items(): - proc = getattr(app_module, attr, None) - running = proc is not None and (proc.poll() is None if proc else False) - health[mode] = {'running': running} - - # Override WiFi/BT health with v2 scanner status if available - try: - from utils.wifi.scanner import _scanner_instance as wifi_scanner - if wifi_scanner is not None and wifi_scanner.is_scanning: - health['wifi'] = {'running': True} - except Exception: - pass - try: - from utils.bluetooth.scanner import _scanner_instance as bt_scanner - if bt_scanner is not None and bt_scanner.is_scanning: - health['bluetooth'] = {'running': True} - except Exception: - pass - - # Meshtastic: check client connection status - try: - from utils.meshtastic import get_meshtastic_client - client = get_meshtastic_client() - health['meshtastic'] = {'running': client._interface is not None} - except Exception: - health['meshtastic'] = {'running': False} - - try: - sdr_status = app_module.get_sdr_device_status() - health['sdr_devices'] = {str(k): v for k, v in sdr_status.items()} - except Exception: - health['sdr_devices'] = {} - - return health - - -EMERGENCY_SQUAWKS = { - '7700': 'General Emergency', - '7600': 'Comms Failure', - '7500': 'Hijack', -} - - -def get_emergency_squawks() -> list[dict]: - """Iterate adsb_aircraft DataStore for emergency squawk codes.""" - emergencies: list[dict] = [] - try: - for icao, aircraft in app_module.adsb_aircraft.items(): - sq = str(aircraft.get('squawk', '')).strip() - if sq in EMERGENCY_SQUAWKS: - emergencies.append({ - 'icao': icao, - 'callsign': aircraft.get('callsign', ''), - 'squawk': sq, - 'meaning': EMERGENCY_SQUAWKS[sq], - 'altitude': aircraft.get('altitude'), - 'lat': aircraft.get('lat'), - 'lon': aircraft.get('lon'), - }) - except Exception: - pass - return emergencies diff --git a/utils/rf_fingerprint.py b/utils/rf_fingerprint.py new file mode 100644 index 0000000..25435f9 --- /dev/null +++ b/utils/rf_fingerprint.py @@ -0,0 +1,210 @@ +"""RF Fingerprinting engine using Welford online algorithm for statistics.""" + +from __future__ import annotations + +import sqlite3 +import threading +import math +from typing import Optional + + +class RFFingerprinter: + BAND_RESOLUTION_MHZ = 0.1 # 100 kHz buckets + + def __init__(self, db_path: str): + self._lock = threading.Lock() + self.db = sqlite3.connect(db_path, check_same_thread=False) + self.db.row_factory = sqlite3.Row + self._init_schema() + + def _init_schema(self): + with self._lock: + self.db.executescript(""" + CREATE TABLE IF NOT EXISTS rf_fingerprints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + location TEXT, + created_at TEXT DEFAULT (datetime('now')), + finalized_at TEXT + ); + CREATE TABLE IF NOT EXISTS rf_observations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fp_id INTEGER NOT NULL REFERENCES rf_fingerprints(id) ON DELETE CASCADE, + band_center_mhz REAL NOT NULL, + power_dbm REAL NOT NULL, + recorded_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS rf_baselines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fp_id INTEGER NOT NULL REFERENCES rf_fingerprints(id) ON DELETE CASCADE, + band_center_mhz REAL NOT NULL, + mean_dbm REAL NOT NULL, + std_dbm REAL NOT NULL, + sample_count INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_obs_fp_id ON rf_observations(fp_id); + CREATE INDEX IF NOT EXISTS idx_baseline_fp_id ON rf_baselines(fp_id); + """) + self.db.commit() + + def _snap_to_band(self, freq_mhz: float) -> float: + """Snap frequency to nearest band center (100 kHz resolution).""" + return round(round(freq_mhz / self.BAND_RESOLUTION_MHZ) * self.BAND_RESOLUTION_MHZ, 3) + + def start_session(self, name: str, location: Optional[str] = None) -> int: + with self._lock: + cur = self.db.execute( + "INSERT INTO rf_fingerprints (name, location) VALUES (?, ?)", + (name, location), + ) + self.db.commit() + return cur.lastrowid + + def add_observation(self, session_id: int, freq_mhz: float, power_dbm: float): + band = self._snap_to_band(freq_mhz) + with self._lock: + self.db.execute( + "INSERT INTO rf_observations (fp_id, band_center_mhz, power_dbm) VALUES (?, ?, ?)", + (session_id, band, power_dbm), + ) + self.db.commit() + + def add_observations_batch(self, session_id: int, observations: list[dict]): + rows = [ + (session_id, self._snap_to_band(o["freq_mhz"]), o["power_dbm"]) + for o in observations + ] + with self._lock: + self.db.executemany( + "INSERT INTO rf_observations (fp_id, band_center_mhz, power_dbm) VALUES (?, ?, ?)", + rows, + ) + self.db.commit() + + def finalize(self, session_id: int) -> dict: + """Compute statistics per band and store baselines.""" + with self._lock: + rows = self.db.execute( + "SELECT band_center_mhz, power_dbm FROM rf_observations WHERE fp_id = ? ORDER BY band_center_mhz", + (session_id,), + ).fetchall() + + # Group by band + bands: dict[float, list[float]] = {} + for row in rows: + b = row["band_center_mhz"] + bands.setdefault(b, []).append(row["power_dbm"]) + + baselines = [] + for band_mhz, powers in bands.items(): + n = len(powers) + mean = sum(powers) / n + if n > 1: + variance = sum((p - mean) ** 2 for p in powers) / (n - 1) + std = math.sqrt(variance) + else: + std = 0.0 + baselines.append((session_id, band_mhz, mean, std, n)) + + with self._lock: + self.db.executemany( + "INSERT INTO rf_baselines (fp_id, band_center_mhz, mean_dbm, std_dbm, sample_count) VALUES (?, ?, ?, ?, ?)", + baselines, + ) + self.db.execute( + "UPDATE rf_fingerprints SET finalized_at = datetime('now') WHERE id = ?", + (session_id,), + ) + self.db.commit() + + return {"session_id": session_id, "bands_recorded": len(baselines)} + + def compare(self, baseline_id: int, observations: list[dict]) -> list[dict]: + """Compare observations against a stored baseline. Returns anomaly list.""" + with self._lock: + baseline_rows = self.db.execute( + "SELECT band_center_mhz, mean_dbm, std_dbm, sample_count FROM rf_baselines WHERE fp_id = ?", + (baseline_id,), + ).fetchall() + + baseline_map: dict[float, dict] = { + row["band_center_mhz"]: dict(row) for row in baseline_rows + } + + # Build current band map (average power per band) + current_bands: dict[float, list[float]] = {} + for obs in observations: + b = self._snap_to_band(obs["freq_mhz"]) + current_bands.setdefault(b, []).append(obs["power_dbm"]) + current_map = {b: sum(ps) / len(ps) for b, ps in current_bands.items()} + + anomalies = [] + + # Check each baseline band + for band_mhz, bl in baseline_map.items(): + if band_mhz in current_map: + current_power = current_map[band_mhz] + delta = current_power - bl["mean_dbm"] + std = bl["std_dbm"] if bl["std_dbm"] > 0 else 1.0 + z_score = delta / std + if abs(z_score) >= 2.0: + anomalies.append({ + "band_center_mhz": band_mhz, + "band_label": f"{band_mhz:.1f} MHz", + "baseline_mean": bl["mean_dbm"], + "baseline_std": bl["std_dbm"], + "current_power": current_power, + "z_score": z_score, + "anomaly_type": "power", + }) + else: + anomalies.append({ + "band_center_mhz": band_mhz, + "band_label": f"{band_mhz:.1f} MHz", + "baseline_mean": bl["mean_dbm"], + "baseline_std": bl["std_dbm"], + "current_power": None, + "z_score": None, + "anomaly_type": "missing", + }) + + # Check for new bands not in baseline + for band_mhz, current_power in current_map.items(): + if band_mhz not in baseline_map: + anomalies.append({ + "band_center_mhz": band_mhz, + "band_label": f"{band_mhz:.1f} MHz", + "baseline_mean": None, + "baseline_std": None, + "current_power": current_power, + "z_score": None, + "anomaly_type": "new", + }) + + anomalies.sort( + key=lambda a: abs(a["z_score"]) if a["z_score"] is not None else 0, + reverse=True, + ) + return anomalies + + def list_sessions(self) -> list[dict]: + with self._lock: + rows = self.db.execute( + """SELECT id, name, location, created_at, finalized_at, + (SELECT COUNT(*) FROM rf_baselines WHERE fp_id = rf_fingerprints.id) AS band_count + FROM rf_fingerprints ORDER BY created_at DESC""" + ).fetchall() + return [dict(row) for row in rows] + + def delete_session(self, session_id: int): + with self._lock: + self.db.execute("DELETE FROM rf_fingerprints WHERE id = ?", (session_id,)) + self.db.commit() + + def get_baseline_bands(self, baseline_id: int) -> list[dict]: + with self._lock: + rows = self.db.execute( + "SELECT band_center_mhz, mean_dbm, std_dbm, sample_count FROM rf_baselines WHERE fp_id = ? ORDER BY band_center_mhz", + (baseline_id,), + ).fetchall() + return [dict(row) for row in rows] diff --git a/utils/sdr/detection.py b/utils/sdr/detection.py index e97c6b8..cec3fc1 100644 --- a/utils/sdr/detection.py +++ b/utils/sdr/detection.py @@ -112,18 +112,21 @@ def detect_rtlsdr_devices() -> list[SDRDevice]: lib_paths = ['/usr/local/lib', '/opt/homebrew/lib'] current_ld = env.get('DYLD_LIBRARY_PATH', '') env['DYLD_LIBRARY_PATH'] = ':'.join(lib_paths + [current_ld] if current_ld else lib_paths) - result = subprocess.run( - ['rtl_test', '-t'], - capture_output=True, - text=True, - timeout=5, - env=env - ) - output = result.stderr + result.stdout - - # Parse device info from rtl_test output - # Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001" - device_pattern = r'(\d+):\s+(.+?)(?:,\s*SN:\s*(\S+))?$' + result = subprocess.run( + ['rtl_test', '-t'], + capture_output=True, + text=True, + encoding='utf-8', + errors='replace', + timeout=5, + env=env + ) + output = result.stderr + result.stdout + + # Parse device info from rtl_test output + # Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001" + # Require a non-empty serial to avoid matching malformed lines like "SN:". + device_pattern = r'(\d+):\s+(.+?),\s*SN:\s*(\S+)\s*$' from .rtlsdr import RTLSDRCommandBuilder @@ -131,14 +134,14 @@ def detect_rtlsdr_devices() -> list[SDRDevice]: line = line.strip() match = re.match(device_pattern, line) if match: - devices.append(SDRDevice( - sdr_type=SDRType.RTL_SDR, - index=int(match.group(1)), - name=match.group(2).strip().rstrip(','), - serial=match.group(3) or 'N/A', - driver='rtlsdr', - capabilities=RTLSDRCommandBuilder.CAPABILITIES - )) + devices.append(SDRDevice( + sdr_type=SDRType.RTL_SDR, + index=int(match.group(1)), + name=match.group(2).strip().rstrip(','), + serial=match.group(3), + driver='rtlsdr', + capabilities=RTLSDRCommandBuilder.CAPABILITIES + )) # Fallback: if we found devices but couldn't parse details if not devices: