diff --git a/CHANGELOG.md b/CHANGELOG.md index 253ee03..dded8fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,36 +1,96 @@ # Changelog -All notable changes to iNTERCEPT will be documented in this file. - -## [2.21.1] - 2026-02-20 - -### Fixed -- BT Locate map first-load rendering race that could cause blank/late map initialization -- BT Locate mode switch timing so Leaflet invalidation runs after panel visibility settles -- BT Locate trail restore startup latency by batching historical GPS point rendering - ---- - -## [2.21.0] - 2026-02-20 - -### Added -- Analytics panels for operational insights and temporal pattern analysis - -### Changed -- Global map theme refresh with improved contrast and cross-dashboard consistency -- Cross-app UX refinements for accessibility, mode consistency, and render performance -- BT Locate enhancements including improved continuity, smoothing, and confidence reporting - -### Fixed -- Weather satellite auto-scheduler and Mercator tracking reliability issues -- Bluetooth/WiFi runtime health issues affecting scanner continuity -- ADS-B SSE multi-client fanout stability and remote VDL2 streaming reliability - ---- - -## [2.15.0] - 2026-02-09 - -### Added +All notable changes to iNTERCEPT will be documented in this file. + +## [2.22.3] - 2026-02-23 + +### Fixed +- Waterfall control panel rendered as unstyled text for up to 20 seconds on first visit — CSS is now loaded eagerly with the rest of the page assets +- WebSDR globe failed to render on first page load — initialization now waits for a layout frame before mounting the WebGL renderer, ensuring the container has non-zero dimensions +- Waterfall monitor audio took minutes to start — `_waitForPlayback` now only reports success on actual audio playback (`playing`/`timeupdate`), not from the WAV header alone (`loadeddata`/`canplay`) +- Waterfall monitor could not be stopped — `stopMonitor()` now pauses audio and updates the UI immediately instead of waiting for the backend stop request (which blocked for 1+ seconds during SDR process cleanup) +- Stopping the waterfall no longer shows a stale "WebSocket closed before ready" message — the `onclose` handler now detects intentional closes + +--- + +## [2.22.1] - 2026-02-23 + +### Fixed +- PWA install prompt not appearing — manifest now includes required PNG icons (192×192, 512×512) +- Apple touch icon updated to PNG for iOS Safari compatibility +- Service worker cache bumped to bust stale cached assets + +--- + +## [2.22.0] - 2026-02-23 + +### Added +- **Waterfall Receiver Overhaul** - WebSocket-based I/Q streaming with server-side FFT, click-to-tune, zoom controls, and auto-scaling +- **Voice Alerts** - Configurable text-to-speech event notifications across modes +- **Signal Fingerprinting** - RF device identification and pattern analysis mode +- **SignalID** - Automatic signal classification via SigIDWiki API integration +- **PWA Support** - Installable web app with service worker caching and manifest +- **Real-time Signal Scope** - Live signal visualization for pager, sensor, and SSTV modes +- **ADS-B MSG2 Surface Parsing** - Ground vehicle movement tracking from MSG2 frames +- **Cheat Sheets** - Quick reference overlays for keyboard shortcuts and mode controls +- App icon (SVG) for PWA and browser tab + +### Changed +- **WebSDR overhaul** - Improved receiver management, audio streaming, and UI +- **Mode stop responsiveness** - Faster timeout handling and improved WiFi/Bluetooth scanner shutdown +- **Mode transitions** - Smoother navigation with performance instrumentation +- **BT Locate** - Refactored JS engine with improved trail management and signal smoothing +- **Listening Post** - Refactored with cross-module frequency routing +- **SSTV decoder** - State machine improvements and partial image streaming +- Analytics mode removed; per-mode analytics panels integrated into existing dashboards + +### Fixed +- ADS-B SSE multi-client fanout stability and update flush timing +- WiFi scanner robustness and monitor mode teardown reliability +- Agent client reliability improvements for remote sensor nodes +- SSTV VIS detector state reporting in signal monitor diagnostics + +### Documentation +- Complete documentation audit across README, FEATURES, USAGE, help modal, and GitHub Pages +- Fixed license badge (MIT → Apache 2.0) to match actual LICENSE file +- Fixed tool name `rtl_amr` → `rtlamr` throughout all docs +- Fixed incorrect entry point examples (`python app.py` → `sudo -E venv/bin/python intercept.py`) +- Removed duplicate AIS Vessel Tracking section from FEATURES.md +- Updated SSTV requirements: pure Python decoder, no external `slowrx` needed +- Added ACARS and VDL2 mode descriptions to in-app help modal +- GitHub Pages site: corrected Docker command, license, and tool name references + +--- + +## [2.21.1] - 2026-02-20 + +### Fixed +- BT Locate map first-load rendering race that could cause blank/late map initialization +- BT Locate mode switch timing so Leaflet invalidation runs after panel visibility settles +- BT Locate trail restore startup latency by batching historical GPS point rendering + +--- + +## [2.21.0] - 2026-02-20 + +### Added +- Analytics panels for operational insights and temporal pattern analysis + +### Changed +- Global map theme refresh with improved contrast and cross-dashboard consistency +- Cross-app UX refinements for accessibility, mode consistency, and render performance +- BT Locate enhancements including improved continuity, smoothing, and confidence reporting + +### Fixed +- Weather satellite auto-scheduler and Mercator tracking reliability issues +- Bluetooth/WiFi runtime health issues affecting scanner continuity +- ADS-B SSE multi-client fanout stability and remote VDL2 streaming reliability + +--- + +## [2.15.0] - 2026-02-09 + +### Added - **Real-time WebSocket Waterfall** - I/Q capture with server-side FFT - Click-to-tune, zoom controls, and auto-scaling quantization - Shared waterfall UI across SDR modes with function bar controls diff --git a/Dockerfile b/Dockerfile index fb48ca8..b5c8904 100644 --- a/Dockerfile +++ b/Dockerfile @@ -57,7 +57,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ soapysdr-module-airspy \ airspy \ limesuite \ - hackrf \ # Utilities curl \ procps \ @@ -190,6 +189,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ fi \ && cd /tmp \ && rm -rf /tmp/SatDump \ + # Build hackrf CLI tools from source — avoids libhackrf0 version conflict + # between the 'hackrf' apt package and soapysdr-module-hackrf's newer libhackrf0 + && cd /tmp \ + && git clone --depth 1 https://github.com/greatscottgadgets/hackrf.git \ + && cd hackrf/host \ + && mkdir build && cd build \ + && cmake .. \ + && make \ + && make install \ + && ldconfig \ + && rm -rf /tmp/hackrf \ # Build rtlamr (utility meter decoder - requires Go) && cd /tmp \ && curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \ @@ -240,6 +250,7 @@ RUN mkdir -p /app/data /app/data/weather_sat # Expose web interface port EXPOSE 5050 +EXPOSE 5443 # Environment variables with defaults ENV INTERCEPT_HOST=0.0.0.0 \ diff --git a/README.md b/README.md index ed677d9..b4d27b6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@

Python 3.9+ - MIT License + Apache 2.0 License Platform

@@ -40,7 +40,7 @@ Support the developer of this open-source project - **HF SSTV** - Terrestrial SSTV on shortwave frequencies (80m-10m, VHF, UHF) - **APRS** - Amateur packet radio position reports and telemetry via direwolf - **Satellite Tracking** - Pass prediction with polar plot and ground track map -- **Utility Meters** - Electric, gas, and water meter reading via rtl_amr +- **Utility Meters** - Electric, gas, and water meter reading via rtlamr - **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional) - **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng - **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support) @@ -57,8 +57,6 @@ Support the developer of this open-source project ## Installation / Debian / Ubuntu / MacOS -``` - **1. Clone and run:** ```bash git clone https://github.com/smittix/intercept.git @@ -150,7 +148,7 @@ Set these as environment variables for either local installs or Docker: ```bash INTERCEPT_ADSB_AUTO_START=true \ INTERCEPT_SHARED_OBSERVER_LOCATION=false \ -python app.py +sudo -E venv/bin/python intercept.py ``` **Docker example (.env)** @@ -172,7 +170,7 @@ Then open **/adsb/history** for the reporting dashboard. After starting, open **http://localhost:5050** in your browser. The username and password is admin:admin -The credentials can be change in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py +The credentials can be changed in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py --- @@ -245,7 +243,7 @@ Created by **smittix** - [GitHub](https://github.com/smittix) [AIS-catcher](https://github.com/jvde-github/AIS-catcher) | [acarsdec](https://github.com/TLeconte/acarsdec) | [direwolf](https://github.com/wb2osz/direwolf) | -[rtl_amr](https://github.com/bemasher/rtlamr) | +[rtlamr](https://github.com/bemasher/rtlamr) | [dumpvdl2](https://github.com/szpajder/dumpvdl2) | [aircrack-ng](https://www.aircrack-ng.org/) | [Leaflet.js](https://leafletjs.com/) | diff --git a/app.py b/app.py index da0dc8e..0754fff 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,20 +855,50 @@ 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}) +def _ensure_self_signed_cert(cert_dir: str) -> tuple: + """Generate a self-signed certificate if one doesn't already exist. + + Returns (cert_path, key_path) tuple. + """ + cert_path = os.path.join(cert_dir, 'intercept.crt') + key_path = os.path.join(cert_dir, 'intercept.key') + + if os.path.exists(cert_path) and os.path.exists(key_path): + print(f"Using existing SSL certificate: {cert_path}") + return cert_path, key_path + + os.makedirs(cert_dir, exist_ok=True) + print("Generating self-signed SSL certificate...") + + import subprocess + result = subprocess.run([ + 'openssl', 'req', '-x509', '-newkey', 'rsa:2048', + '-keyout', key_path, '-out', cert_path, + '-days', '365', '-nodes', + '-subj', '/CN=intercept/O=INTERCEPT/C=US', + ], capture_output=True, text=True) + + if result.returncode != 0: + raise RuntimeError(f"Failed to generate SSL certificate: {result.stderr}") + + print(f"SSL certificate generated: {cert_path}") + return cert_path, key_path + + def main() -> None: """Main entry point.""" import argparse @@ -883,6 +925,12 @@ def main() -> None: default=config.DEBUG, help='Enable debug mode' ) + parser.add_argument( + '--https', + action='store_true', + default=config.HTTPS, + help='Enable HTTPS with self-signed certificate' + ) parser.add_argument( '--check-deps', action='store_true', @@ -1008,7 +1056,18 @@ def main() -> None: except ImportError as e: print(f"WebSocket waterfall disabled: {e}") - print(f"Open http://localhost:{args.port} in your browser") + # Configure SSL if HTTPS is enabled + ssl_context = None + if args.https: + cert_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data', 'certs') + if config.SSL_CERT and config.SSL_KEY: + ssl_context = (config.SSL_CERT, config.SSL_KEY) + print(f"Using provided SSL certificate: {config.SSL_CERT}") + else: + ssl_context = _ensure_self_signed_cert(cert_dir) + + protocol = 'https' if ssl_context else 'http' + print(f"Open {protocol}://localhost:{args.port} in your browser") print() print("Press Ctrl+C to stop") print() @@ -1020,4 +1079,5 @@ def main() -> None: debug=args.debug, threaded=True, load_dotenv=False, + ssl_context=ssl_context, ) diff --git a/config.py b/config.py index 6b94432..89d383b 100644 --- a/config.py +++ b/config.py @@ -6,35 +6,64 @@ import logging import os import sys -# Application version -VERSION = "2.21.1" - -# Changelog - latest release notes (shown on welcome screen) -CHANGELOG = [ - { - "version": "2.21.1", - "date": "February 2026", - "highlights": [ - "BT Locate map first-load fix with render stabilization retries during initial mode open", - "BT Locate trail restore optimization for faster startup when historical GPS points exist", - "BT Locate mode-switch map invalidation timing fix to prevent delayed/blank map render", - ] - }, - { - "version": "2.21.0", - "date": "February 2026", - "highlights": [ - "Global map theme refresh with improved contrast and cross-dashboard consistency", - "Cross-app UX updates for accessibility, mode consistency, and render performance", - "Weather satellite reliability fixes for auto-scheduler and Mercator pass tracking", - "Bluetooth/WiFi runtime health fixes with BT Locate continuity and confidence improvements", - "ADS-B/VDL2 streaming reliability upgrades for multi-client SSE fanout and remote decoding", - "Analytics enhancements with operational insights and temporal pattern panels", - ] - }, - { - "version": "2.20.0", - "date": "February 2026", +# Application version +VERSION = "2.22.3" + +# Changelog - latest release notes (shown on welcome screen) +CHANGELOG = [ + { + "version": "2.22.3", + "date": "February 2026", + "highlights": [ + "Waterfall control panel no longer shows as unstyled text on first visit", + "WebSDR globe renders correctly on first page load without requiring a refresh", + "Waterfall monitor audio no longer takes minutes to start — playback detection now waits for real audio data instead of just the WAV header", + "Waterfall monitor stop is now instant — audio pauses and UI updates immediately instead of waiting for backend cleanup", + "Stopping the waterfall no longer shows a stale 'WebSocket closed before ready' message", + ] + }, + { + "version": "2.22.1", + "date": "February 2026", + "highlights": [ + "Waterfall receiver overhaul: WebSocket I/Q streaming with server-side FFT, click-to-tune, and zoom controls", + "Voice alerts for configurable event notifications across modes", + "Signal fingerprinting mode for RF device identification and pattern analysis", + "SignalID integration via SigIDWiki API for automatic signal classification", + "PWA support: installable web app with service worker and manifest", + "Mode stop responsiveness improvements with faster timeout handling", + "Navigation performance instrumentation and smoother mode transitions", + "Pager, sensor, and SSTV real-time signal scope visualization", + "ADS-B MSG2 surface movement parsing for ground vehicle tracking", + "WebSDR major overhaul with improved receiver management and audio streaming", + "Documentation audit: fixed license, tool names, entry points, and SSTV decoder references", + "Help modal updated with ACARS and VDL2 mode descriptions", + ] + }, + { + "version": "2.21.1", + "date": "February 2026", + "highlights": [ + "BT Locate map first-load fix with render stabilization retries during initial mode open", + "BT Locate trail restore optimization for faster startup when historical GPS points exist", + "BT Locate mode-switch map invalidation timing fix to prevent delayed/blank map render", + ] + }, + { + "version": "2.21.0", + "date": "February 2026", + "highlights": [ + "Global map theme refresh with improved contrast and cross-dashboard consistency", + "Cross-app UX updates for accessibility, mode consistency, and render performance", + "Weather satellite reliability fixes for auto-scheduler and Mercator pass tracking", + "Bluetooth/WiFi runtime health fixes with BT Locate continuity and confidence improvements", + "ADS-B/VDL2 streaming reliability upgrades for multi-client SSE fanout and remote decoding", + "Analytics enhancements with operational insights and temporal pattern panels", + ] + }, + { + "version": "2.20.0", + "date": "February 2026", "highlights": [ "Space Weather mode: real-time solar and geomagnetic monitoring from NOAA SWPC, NASA SDO, and HamQSL", "Kp index, solar wind, X-ray flux charts with Chart.js visualization", @@ -99,14 +128,14 @@ CHANGELOG = [ "Pure Python SSTV decoder replacing broken slowrx dependency", "Real-time signal scope for pager, sensor, and SSTV modes", "USB-level device probe to prevent cryptic rtl_fm crashes", - "SDR device lock-up fix from unreleased device registry on crash", + "SDR device lock-up fix from unreleased device registry on crash", ] }, { "version": "2.14.0", "date": "February 2026", "highlights": [ - "HF SSTV general mode with predefined shortwave frequencies", + "HF SSTV general mode with predefined shortwave frequencies", "WebSDR integration for remote HF/shortwave listening", "Listening Post signal scanner and audio pipeline improvements", "TSCM sweep resilience, WiFi detection, and correlation fixes", @@ -251,6 +280,11 @@ PORT = _get_env_int('PORT', 5050) DEBUG = _get_env_bool('DEBUG', False) THREADED = _get_env_bool('THREADED', True) +# HTTPS / SSL settings +HTTPS = _get_env_bool('HTTPS', False) +SSL_CERT = _get_env('SSL_CERT', '') +SSL_KEY = _get_env('SSL_KEY', '') + # Default RTL-SDR settings DEFAULT_GAIN = _get_env('DEFAULT_GAIN', '40') DEFAULT_DEVICE = _get_env('DEFAULT_DEVICE', '0') diff --git a/docker-compose.yml b/docker-compose.yml index 23f0982..b0daa37 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,8 @@ services: - basic ports: - "5050:5050" + # Uncomment for HTTPS support (set INTERCEPT_HTTPS=true below) + # - "5443:5443" # Privileged mode required for USB SDR device access privileged: true # USB device mapping for all USB devices @@ -35,6 +37,9 @@ services: - INTERCEPT_HOST=0.0.0.0 - INTERCEPT_PORT=5050 - INTERCEPT_LOG_LEVEL=INFO + # HTTPS support (auto-generates self-signed cert) + # - INTERCEPT_HTTPS=true + # - INTERCEPT_PORT=5443 # ADS-B history is disabled by default # To enable, use: docker compose --profile history up -d # - INTERCEPT_ADSB_HISTORY_ENABLED=true @@ -73,6 +78,8 @@ services: - adsb_db ports: - "5050:5050" + # Uncomment for HTTPS support (set INTERCEPT_HTTPS=true below) + # - "5443:5443" # Privileged mode required for USB SDR device access privileged: true # USB device mapping for all USB devices @@ -85,6 +92,9 @@ services: - INTERCEPT_HOST=0.0.0.0 - INTERCEPT_PORT=5050 - INTERCEPT_LOG_LEVEL=INFO + # HTTPS support (auto-generates self-signed cert) + # - INTERCEPT_HTTPS=true + # - INTERCEPT_PORT=5443 - INTERCEPT_ADSB_HISTORY_ENABLED=true - INTERCEPT_ADSB_DB_HOST=adsb_db - INTERCEPT_ADSB_DB_PORT=5432 diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 8d57ef9..73a2909 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -24,17 +24,6 @@ Complete feature list for all modules. - **Wideband spectrum analysis** with real-time visualization - **I/Q capture** - record raw samples for offline analysis -## AIS Vessel Tracking - -- **Real-time vessel tracking** via AIS-catcher on 161.975/162.025 MHz -- **Full-screen dashboard** - dedicated popout with interactive map -- **Interactive Leaflet map** with OpenStreetMap tiles (dark-themed) -- **Vessel details popup** - name, MMSI, callsign, destination, ETA -- **Navigation data** - speed, course, heading, rate of turn -- **Ship type classification** - cargo, tanker, passenger, fishing, etc. -- **Vessel dimensions** - length, width, draught -- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay - ## Spy Stations (Number Stations) - **Comprehensive database** of active number stations and diplomatic networks diff --git a/docs/USAGE.md b/docs/USAGE.md index 8f15fd7..37774ad 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -172,7 +172,7 @@ Set the following environment variables (Docker recommended): ```bash INTERCEPT_ADSB_AUTO_START=true \ INTERCEPT_SHARED_OBSERVER_LOCATION=false \ -python app.py +sudo -E venv/bin/python intercept.py ``` **Docker example (.env)** diff --git a/docs/index.html b/docs/index.html index 7e0a7ac..a2bbc56 100644 --- a/docs/index.html +++ b/docs/index.html @@ -110,7 +110,7 @@

Utility Meters

-

Smart meter monitoring via rtl_amr. Receive electric, gas, and water meter broadcasts in real time.

+

Smart meter monitoring via rtlamr. Receive electric, gas, and water meter broadcasts in real time.

@@ -321,7 +321,7 @@ sudo -E venv/bin/python intercept.py
git clone https://github.com/smittix/intercept.git
 cd intercept
-docker compose up -d
+docker compose --profile basic up -d --build

Requires privileged mode for USB SDR access

@@ -422,7 +422,7 @@ docker compose up -d diff --git a/pyproject.toml b/pyproject.toml index e1229b3..0a0de12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "intercept" -version = "2.21.1" +version = "2.22.3" description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth" readme = "README.md" requires-python = ">=3.9" diff --git a/routes/__init__.py b/routes/__init__.py index 844a117..9f2075f 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -2,40 +2,40 @@ def register_blueprints(app): """Register all route blueprints with the Flask app.""" - from .pager import pager_bp - from .sensor import sensor_bp - from .rtlamr import rtlamr_bp - from .wifi import wifi_bp - from .wifi_v2 import wifi_v2_bp - from .bluetooth import bluetooth_bp - from .bluetooth_v2 import bluetooth_v2_bp + from .acars import acars_bp from .adsb import adsb_bp from .ais import ais_bp - from .dsc import dsc_bp - from .acars import acars_bp - from .vdl2 import vdl2_bp - from .aprs import aprs_bp - from .satellite import satellite_bp - from .gps import gps_bp - from .settings import settings_bp - from .correlation import correlation_bp - from .listening_post import listening_post_bp - from .meshtastic import meshtastic_bp - from .tscm import tscm_bp, init_tscm_state - from .spy_stations import spy_stations_bp - from .controller import controller_bp - from .offline import offline_bp - from .updater import updater_bp - from .sstv import sstv_bp - from .weather_sat import weather_sat_bp - from .sstv_general import sstv_general_bp - from .websdr import websdr_bp from .alerts import alerts_bp + from .aprs import aprs_bp + from .bluetooth import bluetooth_bp + from .bluetooth_v2 import bluetooth_v2_bp + from .bt_locate import bt_locate_bp + from .controller import controller_bp + from .correlation import correlation_bp + from .dsc import dsc_bp + from .gps import gps_bp + from .listening_post import receiver_bp + from .meshtastic import meshtastic_bp + from .offline import offline_bp + from .pager import pager_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 .rtlamr import rtlamr_bp + from .satellite import satellite_bp + from .sensor import sensor_bp + from .settings import settings_bp + from .signalid import signalid_bp + from .space_weather import space_weather_bp + from .spy_stations import spy_stations_bp + from .sstv import sstv_bp + from .sstv_general import sstv_general_bp + from .subghz import subghz_bp + from .tscm import init_tscm_state, tscm_bp + from .updater import updater_bp + from .vdl2 import vdl2_bp + from .weather_sat import weather_sat_bp + from .websdr import websdr_bp + from .wifi import wifi_bp + from .wifi_v2 import wifi_v2_bp app.register_blueprint(pager_bp) app.register_blueprint(sensor_bp) @@ -54,7 +54,7 @@ def register_blueprints(app): app.register_blueprint(gps_bp) app.register_blueprint(settings_bp) app.register_blueprint(correlation_bp) - app.register_blueprint(listening_post_bp) + app.register_blueprint(receiver_bp) app.register_blueprint(meshtastic_bp) app.register_blueprint(tscm_bp) app.register_blueprint(spy_stations_bp) @@ -69,10 +69,10 @@ def register_blueprints(app): 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(signalid_bp) # External signal ID enrichment + + # 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/adsb.py b/routes/adsb.py index 4ad6610..d938bc5 100644 --- a/routes/adsb.py +++ b/routes/adsb.py @@ -379,10 +379,62 @@ def parse_sbs_stream(service_addr): adsb_bytes_received = 0 adsb_lines_received = 0 + def flush_pending_updates(force: bool = False) -> None: + nonlocal last_update + if not pending_updates: + return + + now = time.time() + if not force and now - last_update < ADSB_UPDATE_INTERVAL: + return + + captured_at = datetime.now(timezone.utc) + for update_icao in tuple(pending_updates): + if update_icao in app_module.adsb_aircraft: + snapshot = app_module.adsb_aircraft[update_icao] + _broadcast_adsb_update({ + 'type': 'aircraft', + **snapshot + }) + adsb_snapshot_writer.enqueue({ + 'captured_at': captured_at, + 'icao': update_icao, + 'callsign': snapshot.get('callsign'), + 'registration': snapshot.get('registration'), + 'type_code': snapshot.get('type_code'), + 'type_desc': snapshot.get('type_desc'), + 'altitude': snapshot.get('altitude'), + 'speed': snapshot.get('speed'), + 'heading': snapshot.get('heading'), + 'vertical_rate': snapshot.get('vertical_rate'), + 'lat': snapshot.get('lat'), + 'lon': snapshot.get('lon'), + 'squawk': snapshot.get('squawk'), + 'source_host': service_addr, + 'snapshot': snapshot, + }) + # Geofence check + _gf_lat = snapshot.get('lat') + _gf_lon = snapshot.get('lon') + if _gf_lat is not None and _gf_lon is not None: + try: + from utils.geofence import get_geofence_manager + for _gf_evt in get_geofence_manager().check_position( + update_icao, 'aircraft', _gf_lat, _gf_lon, + {'callsign': snapshot.get('callsign'), 'altitude': snapshot.get('altitude')} + ): + process_event('adsb', _gf_evt, 'geofence') + except Exception: + pass + + pending_updates.clear() + last_update = now + while adsb_using_service: try: data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore') if not data: + flush_pending_updates(force=True) logger.warning("SBS connection closed (no data)") break adsb_bytes_received += len(data) @@ -501,56 +553,40 @@ def parse_sbs_stream(service_addr): 'squawk': sq, 'meaning': _EMERGENCY_SQUAWKS[sq], }, 'squawk_emergency') + elif msg_type == '2' and len(parts) > 15: + if parts[11]: + try: + aircraft['altitude'] = int(float(parts[11])) + except (ValueError, TypeError): + pass + if parts[12]: + try: + aircraft['speed'] = int(float(parts[12])) + except (ValueError, TypeError): + pass + if parts[13]: + try: + aircraft['heading'] = int(float(parts[13])) + except (ValueError, TypeError): + pass + if parts[14] and parts[15]: + try: + aircraft['lat'] = float(parts[14]) + aircraft['lon'] = float(parts[15]) + except (ValueError, TypeError): + pass + app_module.adsb_aircraft.set(icao, aircraft) pending_updates.add(icao) adsb_messages_received += 1 adsb_last_message_time = time.time() - - now = time.time() - if now - last_update >= ADSB_UPDATE_INTERVAL: - for update_icao in pending_updates: - if update_icao in app_module.adsb_aircraft: - snapshot = app_module.adsb_aircraft[update_icao] - _broadcast_adsb_update({ - 'type': 'aircraft', - **snapshot - }) - adsb_snapshot_writer.enqueue({ - 'captured_at': datetime.now(timezone.utc), - 'icao': update_icao, - 'callsign': snapshot.get('callsign'), - 'registration': snapshot.get('registration'), - 'type_code': snapshot.get('type_code'), - 'type_desc': snapshot.get('type_desc'), - 'altitude': snapshot.get('altitude'), - 'speed': snapshot.get('speed'), - 'heading': snapshot.get('heading'), - 'vertical_rate': snapshot.get('vertical_rate'), - 'lat': snapshot.get('lat'), - 'lon': snapshot.get('lon'), - 'squawk': snapshot.get('squawk'), - 'source_host': service_addr, - 'snapshot': snapshot, - }) - # Geofence check - _gf_lat = snapshot.get('lat') - _gf_lon = snapshot.get('lon') - if _gf_lat is not None and _gf_lon is not None: - try: - from utils.geofence import get_geofence_manager - for _gf_evt in get_geofence_manager().check_position( - update_icao, 'aircraft', _gf_lat, _gf_lon, - {'callsign': snapshot.get('callsign'), 'altitude': snapshot.get('altitude')} - ): - process_event('adsb', _gf_evt, 'geofence') - except Exception: - pass - pending_updates.clear() - last_update = now + flush_pending_updates() except socket.timeout: + flush_pending_updates() continue + flush_pending_updates(force=True) sock.close() adsb_connected = False except OSError as e: 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/listening_post.py b/routes/listening_post.py index b09772e..17cdd22 100644 --- a/routes/listening_post.py +++ b/routes/listening_post.py @@ -1,4 +1,4 @@ -"""Listening Post routes for radio monitoring and frequency scanning.""" +"""Receiver routes for radio monitoring and frequency scanning.""" from __future__ import annotations @@ -9,17 +9,18 @@ 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 import app as app_module from utils.logging import get_logger -from utils.sse import sse_stream_fanout +from utils.sse import sse_stream_fanout from utils.event_pipeline import process_event from utils.constants import ( SSE_QUEUE_TIMEOUT, @@ -28,21 +29,24 @@ from utils.constants import ( ) from utils.sdr import SDRFactory, SDRType -logger = get_logger('intercept.listening_post') +logger = get_logger('intercept.receiver') -listening_post_bp = Blueprint('listening_post', __name__, url_prefix='/listening') +receiver_bp = Blueprint('receiver', __name__, url_prefix='/receiver') # ============================================ # GLOBAL STATE # ============================================ # Audio demodulation state -audio_process = None -audio_rtl_process = None -audio_lock = threading.Lock() -audio_running = False -audio_frequency = 0.0 -audio_modulation = 'fm' +audio_process = None +audio_rtl_process = None +audio_lock = threading.Lock() +audio_start_lock = threading.Lock() +audio_running = False +audio_frequency = 0.0 +audio_modulation = 'fm' +audio_source = 'process' +audio_start_token = 0 # Scanner state scanner_thread: Optional[threading.Thread] = None @@ -51,7 +55,7 @@ scanner_lock = threading.Lock() scanner_paused = False scanner_current_freq = 0.0 scanner_active_device: Optional[int] = None -listening_active_device: Optional[int] = None +receiver_active_device: Optional[int] = None scanner_power_process: Optional[subprocess.Popen] = None scanner_config = { 'start_freq': 88.0, @@ -102,21 +106,37 @@ def find_ffmpeg() -> str | None: return shutil.which('ffmpeg') -VALID_MODULATIONS = ['fm', 'wfm', 'am', 'usb', 'lsb'] - - -def normalize_modulation(value: str) -> str: - """Normalize and validate modulation string.""" - mod = str(value or '').lower().strip() - if mod not in VALID_MODULATIONS: - raise ValueError(f'Invalid modulation. Use: {", ".join(VALID_MODULATIONS)}') - return mod - - -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 +VALID_MODULATIONS = ['fm', 'wfm', 'am', 'usb', 'lsb'] + + +def normalize_modulation(value: str) -> str: + """Normalize and validate modulation string.""" + mod = str(value or '').lower().strip() + if mod not in VALID_MODULATIONS: + raise ValueError(f'Invalid modulation. Use: {", ".join(VALID_MODULATIONS)}') + return mod + + +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: """Check for required tools.""" rtl_fm = find_rtl_fm() @@ -939,10 +971,10 @@ def check_tools() -> Response: }) -@listening_post_bp.route('/scanner/start', methods=['POST']) +@receiver_bp.route('/scanner/start', methods=['POST']) def start_scanner() -> Response: """Start the frequency scanner.""" - global scanner_thread, scanner_running, scanner_config, scanner_active_device, listening_active_device + global scanner_thread, scanner_running, scanner_config, scanner_active_device, receiver_active_device with scanner_lock: if scanner_running: @@ -1008,9 +1040,9 @@ def start_scanner() -> Response: 'message': 'rtl_power not found. Install rtl-sdr tools.' }), 503 # Release listening device if active - if listening_active_device is not None: - app_module.release_sdr_device(listening_active_device) - listening_active_device = None + if receiver_active_device is not None: + app_module.release_sdr_device(receiver_active_device) + receiver_active_device = None # Claim device for scanner error = app_module.claim_sdr_device(scanner_config['device'], 'scanner') if error: @@ -1036,9 +1068,9 @@ def start_scanner() -> Response: 'status': 'error', 'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.' }), 503 - if listening_active_device is not None: - app_module.release_sdr_device(listening_active_device) - listening_active_device = None + if receiver_active_device is not None: + app_module.release_sdr_device(receiver_active_device) + receiver_active_device = None error = app_module.claim_sdr_device(scanner_config['device'], 'scanner') if error: return jsonify({ @@ -1058,7 +1090,7 @@ def start_scanner() -> Response: }) -@listening_post_bp.route('/scanner/stop', methods=['POST']) +@receiver_bp.route('/scanner/stop', methods=['POST']) def stop_scanner() -> Response: """Stop the frequency scanner.""" global scanner_running, scanner_active_device, scanner_power_process @@ -1082,7 +1114,7 @@ def stop_scanner() -> Response: return jsonify({'status': 'stopped'}) -@listening_post_bp.route('/scanner/pause', methods=['POST']) +@receiver_bp.route('/scanner/pause', methods=['POST']) def pause_scanner() -> Response: """Pause/resume the scanner.""" global scanner_paused @@ -1104,7 +1136,7 @@ def pause_scanner() -> Response: scanner_skip_signal = False -@listening_post_bp.route('/scanner/skip', methods=['POST']) +@receiver_bp.route('/scanner/skip', methods=['POST']) def skip_signal() -> Response: """Skip current signal and continue scanning.""" global scanner_skip_signal @@ -1124,7 +1156,7 @@ def skip_signal() -> Response: }) -@listening_post_bp.route('/scanner/config', methods=['POST']) +@receiver_bp.route('/scanner/config', methods=['POST']) def update_scanner_config() -> Response: """Update scanner config while running (step, squelch, gain, dwell).""" data = request.json or {} @@ -1166,7 +1198,7 @@ def update_scanner_config() -> Response: }) -@listening_post_bp.route('/scanner/status') +@receiver_bp.route('/scanner/status') def scanner_status() -> Response: """Get scanner status.""" return jsonify({ @@ -1179,28 +1211,28 @@ def scanner_status() -> Response: }) -@listening_post_bp.route('/scanner/stream') -def stream_scanner_events() -> Response: - """SSE stream for scanner events.""" - def _on_msg(msg: dict[str, Any]) -> None: - process_event('listening_scanner', msg, msg.get('type')) - - response = Response( - sse_stream_fanout( - source_queue=scanner_queue, - channel_key='listening_scanner', - timeout=SSE_QUEUE_TIMEOUT, - keepalive_interval=SSE_KEEPALIVE_INTERVAL, - on_message=_on_msg, - ), - mimetype='text/event-stream', - ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - return response +@receiver_bp.route('/scanner/stream') +def stream_scanner_events() -> Response: + """SSE stream for scanner events.""" + def _on_msg(msg: dict[str, Any]) -> None: + process_event('receiver_scanner', msg, msg.get('type')) + + response = Response( + sse_stream_fanout( + source_queue=scanner_queue, + channel_key='receiver_scanner', + timeout=SSE_QUEUE_TIMEOUT, + keepalive_interval=SSE_KEEPALIVE_INTERVAL, + on_message=_on_msg, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + return response -@listening_post_bp.route('/scanner/log') +@receiver_bp.route('/scanner/log') def get_activity_log() -> Response: """Get activity log.""" limit = request.args.get('limit', 100, type=int) @@ -1211,7 +1243,7 @@ def get_activity_log() -> Response: }) -@listening_post_bp.route('/scanner/log/clear', methods=['POST']) +@receiver_bp.route('/scanner/log/clear', methods=['POST']) def clear_activity_log() -> Response: """Clear activity log.""" with activity_log_lock: @@ -1219,7 +1251,7 @@ def clear_activity_log() -> Response: return jsonify({'status': 'cleared'}) -@listening_post_bp.route('/presets') +@receiver_bp.route('/presets') def get_presets() -> Response: """Get scanner presets.""" presets = [ @@ -1239,151 +1271,237 @@ def get_presets() -> Response: # MANUAL AUDIO ENDPOINTS (for direct listening) # ============================================ -@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 - - # Stop scanner if running - if scanner_running: - scanner_running = False - if scanner_active_device is not None: - app_module.release_sdr_device(scanner_active_device) - scanner_active_device = None - if scanner_thread and scanner_thread.is_alive(): - try: - scanner_thread.join(timeout=2.0) - except Exception: - pass - if scanner_power_process and scanner_power_process.poll() is None: - try: - scanner_power_process.terminate() - scanner_power_process.wait(timeout=1) - except Exception: - try: - scanner_power_process.kill() - except Exception: - pass - scanner_power_process = None - try: - subprocess.run(['pkill', '-9', 'rtl_power'], capture_output=True, timeout=0.5) - except Exception: - pass - time.sleep(0.5) - - 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 - - if frequency <= 0: - return jsonify({ - 'status': 'error', - 'message': 'frequency is required' - }), 400 - - valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay'] - if sdr_type not in valid_sdr_types: - return jsonify({ - 'status': 'error', - 'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}' - }), 400 - - # 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) - - # Claim device for listening audio. The WebSocket waterfall handler - # may still be tearing down its IQ capture process (thread join + - # safe_terminate can take several seconds), so we retry with back-off - # to give the USB device time to be fully released. - if listening_active_device is None or listening_active_device != device: - if listening_active_device is not None: - 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} " - f"failed, retrying in 0.5s: {error}" - ) - time.sleep(0.5) - - if error: - return jsonify({ - 'status': 'error', - 'error_type': 'DEVICE_BUSY', - 'message': error - }), 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 +@receiver_bp.route('/audio/start', methods=['POST']) +def start_audio() -> Response: + """Start audio at specific frequency (manual mode).""" + global scanner_running, scanner_active_device, receiver_active_device, scanner_power_process, scanner_thread + global audio_running, audio_frequency, audio_modulation, audio_source, audio_start_token + + 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() + request_token_raw = data.get('request_token') + request_token = int(request_token_raw) if request_token_raw is not None else None + 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({ + 'status': 'error', + 'message': 'frequency is required' + }), 400 + + valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay'] + if sdr_type not in valid_sdr_types: + return jsonify({ + 'status': 'error', + 'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}' + }), 400 + + with audio_start_lock: + if request_token is not None: + if request_token < audio_start_token: + return jsonify({ + 'status': 'stale', + 'message': 'Superseded audio start request', + 'source': audio_source, + 'superseded': True, + }), 409 + audio_start_token = request_token + else: + audio_start_token += 1 + request_token = audio_start_token + + # Stop scanner if running + if scanner_running: + scanner_running = False + if scanner_active_device is not None: + app_module.release_sdr_device(scanner_active_device) + scanner_active_device = None + if scanner_thread and scanner_thread.is_alive(): + try: + scanner_thread.join(timeout=2.0) + except Exception: + pass + if scanner_power_process and scanner_power_process.poll() is None: + try: + scanner_power_process.terminate() + scanner_power_process.wait(timeout=1) + except Exception: + try: + scanner_power_process.kill() + except Exception: + pass + scanner_power_process = None + try: + subprocess.run(['pkill', '-9', 'rtl_power'], capture_output=True, timeout=0.5) + except Exception: + pass + time.sleep(0.5) + + # Update config for audio + scanner_config['squelch'] = squelch + 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 receiver_active_device is not None: + app_module.release_sdr_device(receiver_active_device) + receiver_active_device = None + return jsonify({ + 'status': 'started', + 'frequency': frequency, + 'modulation': modulation, + 'source': 'waterfall', + 'request_token': request_token, + }) + 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 + + # safe_terminate can take several seconds), so we retry with back-off + # to give the USB device time to be fully released. + if receiver_active_device is None or receiver_active_device != device: + if receiver_active_device is not None: + app_module.release_sdr_device(receiver_active_device) + receiver_active_device = None + + error = None + max_claim_attempts = 6 + for attempt in range(max_claim_attempts): + error = app_module.claim_sdr_device(device, 'receiver') + 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) + + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error + }), 409 + receiver_active_device = device + + _start_audio_stream(frequency, modulation) + + if audio_running: + audio_source = 'process' + return jsonify({ + 'status': 'started', + 'frequency': audio_frequency, + 'modulation': audio_modulation, + 'source': 'process', + 'request_token': request_token, + }) + + # Avoid leaving a stale device claim after startup failure. + if receiver_active_device is not None: + app_module.release_sdr_device(receiver_active_device) + receiver_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']) +@receiver_bp.route('/audio/stop', methods=['POST']) def stop_audio() -> Response: """Stop audio.""" - global listening_active_device + global receiver_active_device _stop_audio_stream() - if listening_active_device is not None: - app_module.release_sdr_device(listening_active_device) - listening_active_device = None + if receiver_active_device is not None: + app_module.release_sdr_device(receiver_active_device) + receiver_active_device = None return jsonify({'status': 'stopped'}) -@listening_post_bp.route('/audio/status') +@receiver_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': audio_running, + 'running': running, 'frequency': audio_frequency, - 'modulation': audio_modulation + 'modulation': audio_modulation, + 'source': audio_source, }) -@listening_post_bp.route('/audio/debug') +@receiver_bp.route('/audio/debug') def audio_debug() -> Response: """Get audio debug status and recent stderr logs.""" rtl_log_path = '/tmp/rtl_fm_stderr.log' @@ -1397,26 +1515,51 @@ def audio_debug() -> Response: except Exception: return '' + 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') +@receiver_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 @@ -1438,17 +1581,71 @@ def audio_probe() -> Response: return jsonify({'status': 'ok', 'bytes': size}) -@listening_post_bp.route('/audio/stream') +@receiver_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) + 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) + inactive_since: float | None = None + + while audio_running and audio_source == 'waterfall': + chunk = read_shared_monitor_audio_chunk(timeout=1.0) + if chunk: + inactive_since = None + yield chunk + continue + shared = get_shared_capture_status() + if shared.get('running') and shared.get('monitor_enabled'): + inactive_since = None + continue + if inactive_since is None: + inactive_since = time.monotonic() + continue + if (time.monotonic() - inactive_since) < 4.0: + continue + 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/mpeg', status=204) + return Response(b'', mimetype='audio/wav', status=204) def generate(): # Capture local reference to avoid race condition with stop @@ -1474,21 +1671,25 @@ def stream_audio() -> Response: yield header_chunk # Stream real-time audio - first_chunk_deadline = time.time() + 3.0 + 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: - # If no data arrives shortly after start, exit so caller can retry + # Keep connection open while demodulator settles. if time.time() > first_chunk_deadline: - logger.warning("Audio stream timed out waiting for first chunk") - break + 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 @@ -1513,7 +1714,7 @@ def stream_audio() -> Response: # SIGNAL IDENTIFICATION ENDPOINT # ============================================ -@listening_post_bp.route('/signal/guess', methods=['POST']) +@receiver_bp.route('/signal/guess', methods=['POST']) def guess_signal() -> Response: """Identify a signal based on frequency, modulation, and other parameters.""" data = request.json or {} @@ -1621,9 +1822,20 @@ 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 @@ -1646,17 +1858,33 @@ def _waterfall_loop(): waterfall_process = subprocess.Popen( cmd, stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, + 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: @@ -1666,6 +1894,7 @@ def _waterfall_loop(): 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 @@ -1723,8 +1952,12 @@ def _waterfall_loop(): 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: @@ -1761,14 +1994,19 @@ def _stop_waterfall_internal() -> None: waterfall_active_device = None -@listening_post_bp.route('/waterfall/start', methods=['POST']) +@receiver_bp.route('/waterfall/start', methods=['POST']) 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 + 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 @@ -1817,7 +2055,7 @@ def start_waterfall() -> Response: return jsonify({'status': 'started', 'config': waterfall_config}) -@listening_post_bp.route('/waterfall/stop', methods=['POST']) +@receiver_bp.route('/waterfall/stop', methods=['POST']) def stop_waterfall() -> Response: """Stop the waterfall display.""" _stop_waterfall_internal() @@ -1825,25 +2063,25 @@ def stop_waterfall() -> Response: return jsonify({'status': 'stopped'}) -@listening_post_bp.route('/waterfall/stream') -def stream_waterfall() -> Response: - """SSE stream for waterfall data.""" - def _on_msg(msg: dict[str, Any]) -> None: - process_event('waterfall', msg, msg.get('type')) - - response = Response( - sse_stream_fanout( - source_queue=waterfall_queue, - channel_key='listening_waterfall', - timeout=SSE_QUEUE_TIMEOUT, - keepalive_interval=SSE_KEEPALIVE_INTERVAL, - on_message=_on_msg, - ), - mimetype='text/event-stream', - ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - return response +@receiver_bp.route('/waterfall/stream') +def stream_waterfall() -> Response: + """SSE stream for waterfall data.""" + def _on_msg(msg: dict[str, Any]) -> None: + process_event('waterfall', msg, msg.get('type')) + + response = Response( + sse_stream_fanout( + source_queue=waterfall_queue, + channel_key='receiver_waterfall', + timeout=SSE_QUEUE_TIMEOUT, + keepalive_interval=SSE_KEEPALIVE_INTERVAL, + on_message=_on_msg, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + return response def _downsample_bins(values: list[float], target: int) -> list[float]: """Downsample bins to a target length using simple averaging.""" if target <= 0 or len(values) <= target: diff --git a/routes/pager.py b/routes/pager.py index 8d6f59a..6dfcc3b 100644 --- a/routes/pager.py +++ b/routes/pager.py @@ -96,7 +96,7 @@ def parse_multimon_output(line: str) -> dict[str, str] | None: return None -def log_message(msg: dict[str, Any]) -> None: +def log_message(msg: dict[str, Any]) -> None: """Log a message to file if logging is enabled.""" if not app_module.logging_enabled: return @@ -104,25 +104,39 @@ def log_message(msg: dict[str, Any]) -> None: with open(app_module.log_file_path, 'a') as f: timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') f.write(f"{timestamp} | {msg.get('protocol', 'UNKNOWN')} | {msg.get('address', '')} | {msg.get('message', '')}\n") - except Exception as e: - logger.error(f"Failed to log message: {e}") - - -def audio_relay_thread( - rtl_stdout, - multimon_stdin, - output_queue: queue.Queue, - stop_event: threading.Event, -) -> None: - """Relay audio from rtl_fm to multimon-ng while computing signal levels. - - Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight - through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope - event onto *output_queue*. - """ - CHUNK = 4096 # bytes – 2048 samples at 16-bit mono - INTERVAL = 0.1 # seconds between scope updates - last_scope = time.monotonic() + except Exception as e: + logger.error(f"Failed to log message: {e}") + + +def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]: + """Compress recent PCM samples into a signed 8-bit waveform for SSE.""" + if not samples: + return [] + + window = samples[-window_size:] if len(samples) > window_size else samples + waveform: list[int] = [] + for sample in window: + # Convert int16 PCM to int8 range for lightweight transport. + packed = int(round(sample / 256)) + waveform.append(max(-127, min(127, packed))) + return waveform + + +def audio_relay_thread( + rtl_stdout, + multimon_stdin, + output_queue: queue.Queue, + stop_event: threading.Event, +) -> None: + """Relay audio from rtl_fm to multimon-ng while computing signal levels. + + Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight + through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope + event plus a compact waveform sample onto *output_queue*. + """ + CHUNK = 4096 # bytes – 2048 samples at 16-bit mono + INTERVAL = 0.1 # seconds between scope updates + last_scope = time.monotonic() try: while not stop_event.is_set(): @@ -146,15 +160,16 @@ def audio_relay_thread( if n_samples == 0: continue samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2]) - peak = max(abs(s) for s in samples) - rms = int(math.sqrt(sum(s * s for s in samples) / n_samples)) - output_queue.put_nowait({ - 'type': 'scope', - 'rms': rms, - 'peak': peak, - }) - except (struct.error, ValueError, queue.Full): - pass + peak = max(abs(s) for s in samples) + rms = int(math.sqrt(sum(s * s for s in samples) / n_samples)) + output_queue.put_nowait({ + 'type': 'scope', + 'rms': rms, + 'peak': peak, + 'waveform': _encode_scope_waveform(samples), + }) + except (struct.error, ValueError, queue.Full): + pass except Exception as e: logger.debug(f"Audio relay error: {e}") finally: diff --git a/routes/sensor.py b/routes/sensor.py index 9faf8e9..ab34c8e 100644 --- a/routes/sensor.py +++ b/routes/sensor.py @@ -1,14 +1,15 @@ """RTL_433 sensor monitoring routes.""" -from __future__ import annotations - -import json -import queue -import subprocess -import threading -import time -from datetime import datetime -from typing import Generator +from __future__ import annotations + +import json +import math +import queue +import subprocess +import threading +import time +from datetime import datetime +from typing import Any, Generator from flask import Blueprint, jsonify, request, Response @@ -28,12 +29,42 @@ sensor_bp = Blueprint('sensor', __name__) # Track which device is being used sensor_active_device: int | None = None -# RSSI history per device (model_id -> list of (timestamp, rssi)) -sensor_rssi_history: dict[str, list[tuple[float, float]]] = {} -_MAX_RSSI_HISTORY = 60 - - -def stream_sensor_output(process: subprocess.Popen[bytes]) -> None: +# RSSI history per device (model_id -> list of (timestamp, rssi)) +sensor_rssi_history: dict[str, list[tuple[float, float]]] = {} +_MAX_RSSI_HISTORY = 60 + + +def _build_scope_waveform(rssi: float, snr: float, noise: float, points: int = 256) -> list[int]: + """Synthesize a compact waveform from rtl_433 level metrics.""" + points = max(32, min(points, 512)) + + # rssi is usually negative; stronger signals are closer to 0 dBm. + rssi_norm = min(max(abs(rssi) / 40.0, 0.0), 1.0) + snr_norm = min(max((snr + 5.0) / 35.0, 0.0), 1.0) + noise_norm = min(max(abs(noise) / 40.0, 0.0), 1.0) + + amplitude = max(0.06, min(1.0, (0.6 * rssi_norm + 0.4 * snr_norm) - (0.22 * noise_norm))) + cycles = 3.0 + (snr_norm * 8.0) + harmonic = 0.25 + (0.35 * snr_norm) + hiss = 0.08 + (0.18 * noise_norm) + phase = (time.monotonic() * (1.4 + (snr_norm * 2.2))) % (2.0 * math.pi) + + waveform: list[int] = [] + for i in range(points): + t = i / (points - 1) + base = math.sin((2.0 * math.pi * cycles * t) + phase) + overtone = math.sin((2.0 * math.pi * (cycles * 2.4) * t) + (phase * 0.7)) + noise_wobble = math.sin((2.0 * math.pi * (cycles * 7.0) * t) + (phase * 2.1)) + + sample = amplitude * (base + (harmonic * overtone) + (hiss * noise_wobble)) + sample /= (1.0 + harmonic + hiss) + packed = int(round(max(-1.0, min(1.0, sample)) * 127.0)) + waveform.append(max(-127, min(127, packed))) + + return waveform + + +def stream_sensor_output(process: subprocess.Popen[bytes]) -> None: """Stream rtl_433 JSON output to queue.""" try: app_module.sensor_queue.put({'type': 'status', 'text': 'started'}) @@ -64,16 +95,24 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None: rssi = data.get('rssi') snr = data.get('snr') noise = data.get('noise') - if rssi is not None or snr is not None: - try: - app_module.sensor_queue.put_nowait({ - 'type': 'scope', - 'rssi': rssi if rssi is not None else 0, - 'snr': snr if snr is not None else 0, - 'noise': noise if noise is not None else 0, - }) - except queue.Full: - pass + if rssi is not None or snr is not None: + try: + rssi_value = float(rssi) if rssi is not None else 0.0 + snr_value = float(snr) if snr is not None else 0.0 + noise_value = float(noise) if noise is not None else 0.0 + app_module.sensor_queue.put_nowait({ + 'type': 'scope', + 'rssi': rssi_value, + 'snr': snr_value, + 'noise': noise_value, + 'waveform': _build_scope_waveform( + rssi=rssi_value, + snr=snr_value, + noise=noise_value, + ), + }) + except (TypeError, ValueError, queue.Full): + pass # Log if enabled if app_module.logging_enabled: diff --git a/routes/signalid.py b/routes/signalid.py new file mode 100644 index 0000000..5935dab --- /dev/null +++ b/routes/signalid.py @@ -0,0 +1,352 @@ +"""Signal identification enrichment routes (SigID Wiki proxy lookup).""" + +from __future__ import annotations + +import json +import time +import urllib.parse +import urllib.request +from typing import Any + +from flask import Blueprint, Response, jsonify, request + +from utils.logging import get_logger + +logger = get_logger('intercept.signalid') + +signalid_bp = Blueprint('signalid', __name__, url_prefix='/signalid') + +SIGID_API_URL = 'https://www.sigidwiki.com/api.php' +SIGID_USER_AGENT = 'INTERCEPT-SignalID/1.0' +SIGID_TIMEOUT_SECONDS = 12 +SIGID_CACHE_TTL_SECONDS = 600 + +_cache: dict[str, dict[str, Any]] = {} + + +def _cache_get(key: str) -> Any | None: + entry = _cache.get(key) + if not entry: + return None + if time.time() >= entry['expires']: + _cache.pop(key, None) + return None + return entry['data'] + + +def _cache_set(key: str, data: Any, ttl_seconds: int = SIGID_CACHE_TTL_SECONDS) -> None: + _cache[key] = { + 'data': data, + 'expires': time.time() + ttl_seconds, + } + + +def _fetch_api_json(params: dict[str, str]) -> dict[str, Any] | None: + query = urllib.parse.urlencode(params, doseq=True) + url = f'{SIGID_API_URL}?{query}' + req = urllib.request.Request(url, headers={'User-Agent': SIGID_USER_AGENT}) + try: + with urllib.request.urlopen(req, timeout=SIGID_TIMEOUT_SECONDS) as resp: + payload = resp.read().decode('utf-8', errors='replace') + data = json.loads(payload) + except Exception as exc: + logger.warning('SigID API request failed: %s', exc) + return None + if isinstance(data, dict) and data.get('error'): + logger.warning('SigID API returned error: %s', data.get('error')) + return None + return data if isinstance(data, dict) else None + + +def _ask_query(query: str) -> dict[str, Any] | None: + return _fetch_api_json({ + 'action': 'ask', + 'query': query, + 'format': 'json', + }) + + +def _search_query(search_text: str, limit: int) -> dict[str, Any] | None: + return _fetch_api_json({ + 'action': 'query', + 'list': 'search', + 'srsearch': search_text, + 'srlimit': str(limit), + 'format': 'json', + }) + + +def _to_float_list(values: Any) -> list[float]: + if not isinstance(values, list): + return [] + out: list[float] = [] + for value in values: + try: + out.append(float(value)) + except (TypeError, ValueError): + continue + return out + + +def _to_text_list(values: Any) -> list[str]: + if not isinstance(values, list): + return [] + out: list[str] = [] + for value in values: + text = str(value or '').strip() + if text: + out.append(text) + return out + + +def _normalize_modes(values: list[str]) -> list[str]: + out: list[str] = [] + for value in values: + for token in str(value).replace('/', ',').split(','): + mode = token.strip().upper() + if mode and mode not in out: + out.append(mode) + return out + + +def _extract_matches_from_ask(data: dict[str, Any]) -> list[dict[str, Any]]: + results = data.get('query', {}).get('results', {}) + if not isinstance(results, dict): + return [] + + matches: list[dict[str, Any]] = [] + for title, entry in results.items(): + if not isinstance(entry, dict): + continue + + printouts = entry.get('printouts', {}) + if not isinstance(printouts, dict): + printouts = {} + + frequencies_hz = _to_float_list(printouts.get('Frequencies')) + frequencies_mhz = [round(v / 1e6, 6) for v in frequencies_hz if v > 0] + + modes = _normalize_modes(_to_text_list(printouts.get('Mode'))) + modulations = _normalize_modes(_to_text_list(printouts.get('Modulation'))) + + match = { + 'title': str(entry.get('fulltext') or title), + 'url': str(entry.get('fullurl') or ''), + 'frequencies_mhz': frequencies_mhz, + 'modes': modes, + 'modulations': modulations, + 'source': 'SigID Wiki', + } + matches.append(match) + + return matches + + +def _dedupe_matches(matches: list[dict[str, Any]]) -> list[dict[str, Any]]: + deduped: dict[str, dict[str, Any]] = {} + for match in matches: + key = f"{match.get('title', '')}|{match.get('url', '')}" + if key not in deduped: + deduped[key] = match + continue + + # Merge frequencies/modes/modulations from duplicates. + existing = deduped[key] + for field in ('frequencies_mhz', 'modes', 'modulations'): + base = existing.get(field, []) + extra = match.get(field, []) + if not isinstance(base, list): + base = [] + if not isinstance(extra, list): + extra = [] + merged = list(base) + for item in extra: + if item not in merged: + merged.append(item) + existing[field] = merged + return list(deduped.values()) + + +def _rank_matches( + matches: list[dict[str, Any]], + *, + frequency_mhz: float, + modulation: str, +) -> list[dict[str, Any]]: + target_hz = frequency_mhz * 1e6 + wanted_mod = str(modulation or '').strip().upper() + + def score(match: dict[str, Any]) -> tuple[int, float, str]: + score_value = 0 + freqs_mhz = match.get('frequencies_mhz') or [] + distances_hz: list[float] = [] + for f_mhz in freqs_mhz: + try: + distances_hz.append(abs((float(f_mhz) * 1e6) - target_hz)) + except (TypeError, ValueError): + continue + min_distance_hz = min(distances_hz) if distances_hz else 1e12 + + if min_distance_hz <= 100: + score_value += 120 + elif min_distance_hz <= 1_000: + score_value += 90 + elif min_distance_hz <= 10_000: + score_value += 70 + elif min_distance_hz <= 100_000: + score_value += 40 + + if wanted_mod: + modes = [str(v).upper() for v in (match.get('modes') or [])] + modulations = [str(v).upper() for v in (match.get('modulations') or [])] + if wanted_mod in modes: + score_value += 25 + if wanted_mod in modulations: + score_value += 25 + + title = str(match.get('title') or '') + title_lower = title.lower() + if 'unidentified' in title_lower or 'unknown' in title_lower: + score_value -= 10 + + return (score_value, min_distance_hz, title.lower()) + + ranked = sorted(matches, key=score, reverse=True) + for match in ranked: + try: + nearest = min(abs((float(f) * 1e6) - target_hz) for f in (match.get('frequencies_mhz') or [])) + match['distance_hz'] = int(round(nearest)) + except Exception: + match['distance_hz'] = None + return ranked + + +def _format_freq_variants_mhz(freq_mhz: float) -> list[str]: + variants = [ + f'{freq_mhz:.6f}'.rstrip('0').rstrip('.'), + f'{freq_mhz:.4f}'.rstrip('0').rstrip('.'), + f'{freq_mhz:.3f}'.rstrip('0').rstrip('.'), + ] + out: list[str] = [] + for value in variants: + if value and value not in out: + out.append(value) + return out + + +def _lookup_sigidwiki_matches(frequency_mhz: float, modulation: str, limit: int) -> dict[str, Any]: + all_matches: list[dict[str, Any]] = [] + exact_queries: list[str] = [] + + for freq_token in _format_freq_variants_mhz(frequency_mhz): + query = ( + f'[[Category:Signal]][[Frequencies::{freq_token} MHz]]' + f'|?Frequencies|?Mode|?Modulation|limit={max(10, limit * 2)}' + ) + exact_queries.append(query) + data = _ask_query(query) + if data: + all_matches.extend(_extract_matches_from_ask(data)) + if all_matches: + break + + search_used = False + if not all_matches: + search_used = True + search_terms = [f'{frequency_mhz:.4f} MHz'] + if modulation: + search_terms.insert(0, f'{frequency_mhz:.4f} MHz {modulation.upper()}') + + seen_titles: set[str] = set() + for term in search_terms: + search_data = _search_query(term, max(5, min(limit * 2, 10))) + search_results = search_data.get('query', {}).get('search', []) if isinstance(search_data, dict) else [] + if not isinstance(search_results, list) or not search_results: + continue + + for item in search_results: + title = str(item.get('title') or '').strip() + if not title or title in seen_titles: + continue + seen_titles.add(title) + page_query = f'[[{title}]]|?Frequencies|?Mode|?Modulation|limit=1' + page_data = _ask_query(page_query) + if page_data: + all_matches.extend(_extract_matches_from_ask(page_data)) + if len(all_matches) >= max(limit * 3, 12): + break + if all_matches: + break + + deduped = _dedupe_matches(all_matches) + ranked = _rank_matches(deduped, frequency_mhz=frequency_mhz, modulation=modulation) + return { + 'matches': ranked[:limit], + 'search_used': search_used, + 'exact_queries': exact_queries, + } + + +@signalid_bp.route('/sigidwiki', methods=['POST']) +def sigidwiki_lookup() -> Response: + """Lookup likely signal types from SigID Wiki by tuned frequency.""" + payload = request.get_json(silent=True) or {} + + freq_raw = payload.get('frequency_mhz') + if freq_raw is None: + return jsonify({'status': 'error', 'message': 'frequency_mhz is required'}), 400 + + try: + frequency_mhz = float(freq_raw) + except (TypeError, ValueError): + return jsonify({'status': 'error', 'message': 'Invalid frequency_mhz'}), 400 + + if frequency_mhz <= 0: + return jsonify({'status': 'error', 'message': 'frequency_mhz must be positive'}), 400 + + modulation = str(payload.get('modulation') or '').strip().upper() + if modulation and len(modulation) > 16: + modulation = modulation[:16] + + limit_raw = payload.get('limit', 8) + try: + limit = int(limit_raw) + except (TypeError, ValueError): + limit = 8 + limit = max(1, min(limit, 20)) + + cache_key = f'{round(frequency_mhz, 6)}|{modulation}|{limit}' + cached = _cache_get(cache_key) + if cached is not None: + return jsonify({ + 'status': 'ok', + 'source': 'sigidwiki', + 'frequency_mhz': round(frequency_mhz, 6), + 'modulation': modulation or None, + 'cached': True, + **cached, + }) + + try: + lookup = _lookup_sigidwiki_matches(frequency_mhz, modulation, limit) + except Exception as exc: + logger.error('SigID lookup failed: %s', exc) + return jsonify({'status': 'error', 'message': 'SigID lookup failed'}), 502 + + response_payload = { + 'matches': lookup.get('matches', []), + 'match_count': len(lookup.get('matches', [])), + 'search_used': bool(lookup.get('search_used')), + 'exact_queries': lookup.get('exact_queries', []), + } + _cache_set(cache_key, response_payload) + + return jsonify({ + 'status': 'ok', + 'source': 'sigidwiki', + 'frequency_mhz': round(frequency_mhz, 6), + 'modulation': modulation or None, + 'cached': False, + **response_payload, + }) + diff --git a/routes/waterfall_websocket.py b/routes/waterfall_websocket.py index 5512d6f..15d14c9 100644 --- a/routes/waterfall_websocket.py +++ b/routes/waterfall_websocket.py @@ -1,386 +1,821 @@ -"""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 - -try: - from flask_sock import Sock - WEBSOCKET_AVAILABLE = True -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 - -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.""" - mapping = { - 'rtlsdr': SDRType.RTL_SDR, - 'rtl_sdr': SDRType.RTL_SDR, - 'hackrf': SDRType.HACKRF, - 'limesdr': SDRType.LIME_SDR, - 'lime_sdr': SDRType.LIME_SDR, - 'airspy': SDRType.AIRSPY, - 'sdrplay': SDRType.SDRPLAY, - } - return mapping.get(sdr_type_str.lower(), SDRType.RTL_SDR) - - -def _build_dummy_device(device_index: int, sdr_type: SDRType) -> SDRDevice: - """Build a minimal SDRDevice for command building.""" - builder = SDRFactory.get_builder(sdr_type) - caps = builder.get_capabilities() - return SDRDevice( - sdr_type=sdr_type, - index=device_index, - name=f'{sdr_type.value}-{device_index}', - serial='N/A', - driver=sdr_type.value, - capabilities=caps, - ) - - -def init_waterfall_websocket(app: Flask): - """Initialize WebSocket waterfall streaming.""" - if not WEBSOCKET_AVAILABLE: - logger.warning("flask-sock not installed, WebSocket waterfall disabled") - return - - sock = Sock(app) - - @sock.route('/ws/waterfall') - def waterfall_stream(ws): - """WebSocket endpoint for real-time waterfall streaming.""" - logger.info("WebSocket waterfall client connected") - - # 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) - - try: - while True: - # Drain send queue first (non-blocking) - while True: - try: - outgoing = send_queue.get_nowait() - except queue.Empty: - break - try: - ws.send(outgoing) - except Exception: - stop_event.set() - break - - try: - msg = ws.receive(timeout=0.1) - except Exception as e: - err = str(e).lower() - if "closed" in err: - break - if "timed out" not in err: - logger.error(f"WebSocket receive error: {e}") - continue - - if msg is None: - # simple-websocket returns None on timeout AND on - # close; check ws.connected to tell them apart. - if not ws.connected: - break - if stop_event.is_set(): - break - continue - - try: - data = json.loads(msg) - except (json.JSONDecodeError, TypeError): - continue - - 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: - 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'})) - - 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) - 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 - # 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") +"""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 + WEBSOCKET_AVAILABLE = True +except ImportError: + WEBSOCKET_AVAILABLE = False + Sock = None + +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=20) +_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, +} +# Generation counter to prevent stale WebSocket handlers from clobbering +# shared state set by a newer handler (e.g. old handler's finally block +# running after a new connection has already started capture). +_capture_generation: int = 0 + +# 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, + generation: int | None = None, +) -> int: + """Update shared capture state. + + Returns the current generation counter. When *running* is True and + *generation* is None the counter is bumped; callers should capture + the returned value and pass it back when setting running=False so + that stale handlers cannot clobber a newer session. + """ + global _capture_generation + with _shared_state_lock: + if not running and generation is not None: + # Only allow the matching generation to clear the state. + if generation != _capture_generation: + return _capture_generation + if running and generation is None: + _capture_generation += 1 + _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 + gen = _capture_generation + if not running: + _clear_shared_audio_queue() + return gen + + +def _set_shared_monitor( + *, + enabled: bool, + frequency_mhz: float | None = None, + modulation: str | None = None, + squelch: int | None = None, +) -> None: + was_enabled = False + freq_changed = 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: + old_freq = float(_shared_state.get('monitor_freq_mhz', 0.0) or 0.0) + _shared_state['monitor_freq_mhz'] = float(frequency_mhz) + if abs(float(frequency_mhz) - old_freq) > 1e-6: + freq_changed = True + 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) or (enabled and freq_changed): + _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, + rotator_phase: float = 0.0, +) -> tuple[bytes | None, float]: + if samples.size < 32 or sample_rate <= 0: + return None, float(rotator_phase) + + 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, float(rotator_phase) + + phase_inc = (2.0 * np.pi * freq_offset_hz) / fs + n = np.arange(samples.size, dtype=np.float64) + rotator = np.exp(-1j * (float(rotator_phase) + phase_inc * n)).astype(np.complex64) + next_phase = float((float(rotator_phase) + phase_inc * samples.size) % (2.0 * np.pi)) + 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, next_phase + shifted = shifted[:usable].reshape(-1, pre_decim).mean(axis=1) + fs1 = fs / pre_decim + if shifted.size < 16: + return None, next_phase + + 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, next_phase + + 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, next_phase + 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(), next_phase + + +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, + 'hackrf': SDRType.HACKRF, + 'limesdr': SDRType.LIME_SDR, + 'lime_sdr': SDRType.LIME_SDR, + 'airspy': SDRType.AIRSPY, + 'sdrplay': SDRType.SDRPLAY, + } + return mapping.get(sdr_type_str.lower(), SDRType.RTL_SDR) + + +def _build_dummy_device(device_index: int, sdr_type: SDRType) -> SDRDevice: + """Build a minimal SDRDevice for command building.""" + builder = SDRFactory.get_builder(sdr_type) + caps = builder.get_capabilities() + return SDRDevice( + sdr_type=sdr_type, + index=device_index, + name=f'{sdr_type.value}-{device_index}', + serial='N/A', + driver=sdr_type.value, + capabilities=caps, + ) + + +def init_waterfall_websocket(app: Flask): + """Initialize WebSocket waterfall streaming.""" + if not WEBSOCKET_AVAILABLE: + logger.warning("flask-sock not installed, WebSocket waterfall disabled") + return + + sock = Sock(app) + + @sock.route('/ws/waterfall') + def waterfall_stream(ws): + """WebSocket endpoint for real-time waterfall streaming.""" + logger.info("WebSocket waterfall client connected") + + # Import app module for device claiming + import app as app_module + + iq_process = None + reader_thread = None + stop_event = threading.Event() + claimed_device = None + my_generation = None # tracks which capture generation this handler owns + 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: + # Drain send queue first (non-blocking) + while True: + try: + outgoing = send_queue.get_nowait() + except queue.Empty: + break + try: + ws.send(outgoing) + except Exception: + stop_event.set() + break + + try: + msg = ws.receive(timeout=0.01) + except Exception as e: + err = str(e).lower() + if "closed" in err: + break + if "timed out" not in err: + logger.error(f"WebSocket receive error: {e}") + continue + + if msg is None: + # simple-websocket returns None on timeout AND on + # close; check ws.connected to tell them apart. + if not ws.connected: + break + if stop_event.is_set(): + break + continue + + try: + data = json.loads(msg) + except (json.JSONDecodeError, TypeError): + continue + + cmd = data.get('cmd') + + if cmd == 'start': + shared_before = get_shared_capture_status() + keep_monitor_enabled = bool(shared_before.get('monitor_enabled')) + keep_monitor_modulation = str(shared_before.get('monitor_modulation', 'wfm')) + keep_monitor_squelch = int(shared_before.get('monitor_squelch', 0) or 0) + # 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, generation=my_generation) + my_generation = 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 + try: + center_freq_mhz = _parse_center_freq_mhz(data) + requested_vfo_mhz = float( + data.get( + 'vfo_freq_mhz', + data.get('frequency_mhz', center_freq_mhz), + ) + ) + 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 + target_vfo_mhz = requested_vfo_mhz + if not (start_freq <= target_vfo_mhz <= end_freq): + target_vfo_mhz = center_freq_mhz + + # Claim the device (retry when restarting to allow + # the kernel time to release the USB handle). + max_claim_attempts = 4 if was_restarting else 1 + claim_err = None + for _claim_attempt in range(max_claim_attempts): + claim_err = app_module.claim_sdr_device(device_index, 'waterfall') + if not claim_err: + break + if _claim_attempt < max_claim_attempts - 1: + time.sleep(0.4) + 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 + + my_generation = _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=keep_monitor_enabled, + frequency_mhz=target_vfo_mhz, + modulation=keep_monitor_modulation, + squelch=keep_monitor_squelch, + ) + + # 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': target_vfo_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 + monitor_rotator_phase = 0.0 + last_monitor_offset_hz = None + + 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: + center_mhz_cfg = float(monitor_cfg.get('center_mhz', _center_mhz)) + monitor_mhz_cfg = float(monitor_cfg.get('monitor_freq_mhz', _center_mhz)) + offset_hz = (monitor_mhz_cfg - center_mhz_cfg) * 1e6 + if ( + last_monitor_offset_hz is None + or abs(offset_hz - last_monitor_offset_hz) > 1.0 + ): + monitor_rotator_phase = 0.0 + last_monitor_offset_hz = offset_hz + + audio_chunk, monitor_rotator_phase = _demodulate_monitor_audio( + samples=samples, + sample_rate=_sample_rate, + center_mhz=center_mhz_cfg, + monitor_freq_mhz=monitor_mhz_cfg, + modulation=monitor_cfg.get('modulation', 'wfm'), + squelch=int(monitor_cfg.get('squelch', 0)), + rotator_phase=monitor_rotator_phase, + ) + if audio_chunk: + _push_shared_audio_chunk(audio_chunk) + else: + monitor_rotator_phase = 0.0 + last_monitor_offset_hz = None + + # 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 + _set_shared_capture_state(running=False, generation=my_generation) + my_generation = None + stop_event.clear() + ws.send(json.dumps({'status': 'stopped'})) + + except Exception as e: + logger.info(f"WebSocket waterfall closed: {e}") + finally: + # Cleanup — use generation guard so a stale handler cannot + # clobber shared state owned by a newer WS connection. + 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) + _set_shared_capture_state(running=False, generation=my_generation) + # 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"). + 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/adsb_dashboard.css b/static/css/adsb_dashboard.css index 6cedd7f..b196714 100644 --- a/static/css/adsb_dashboard.css +++ b/static/css/adsb_dashboard.css @@ -893,6 +893,92 @@ body { display: block; } +.map-crosshair-overlay { + position: absolute; + inset: 0; + pointer-events: none; + overflow: hidden; + z-index: 1200; + --crosshair-x-start: 100%; + --crosshair-y-start: 100%; + --crosshair-x-end: 50%; + --crosshair-y-end: 50%; + --crosshair-duration: 1500ms; +} + +.map-crosshair-line { + position: absolute; + opacity: 0; + background: var(--accent-cyan); + box-shadow: none; + will-change: transform, opacity; +} + +.map-crosshair-vertical { + top: 0; + bottom: 0; + width: 1px; + left: 0; + transform: translateX(var(--crosshair-x-start)); +} + +.map-crosshair-horizontal { + left: 0; + right: 0; + height: 1px; + top: 0; + transform: translateY(var(--crosshair-y-start)); +} + +.map-crosshair-overlay.active .map-crosshair-vertical { + animation: mapCrosshairSweepX var(--crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards; +} + +.map-crosshair-overlay.active .map-crosshair-horizontal { + animation: mapCrosshairSweepY var(--crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards; +} + +@keyframes mapCrosshairSweepX { + 0% { + transform: translateX(var(--crosshair-x-start)); + opacity: 0; + } + 12% { + opacity: 1; + } + 85% { + opacity: 1; + } + 100% { + transform: translateX(var(--crosshair-x-end)); + opacity: 0; + } +} + +@keyframes mapCrosshairSweepY { + 0% { + transform: translateY(var(--crosshair-y-start)); + opacity: 0; + } + 12% { + opacity: 1; + } + 85% { + opacity: 1; + } + 100% { + transform: translateY(var(--crosshair-y-end)); + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .map-crosshair-overlay.active .map-crosshair-vertical, + .map-crosshair-overlay.active .map-crosshair-horizontal { + animation-duration: 220ms; + } +} + /* Right sidebar - Mobile first */ .sidebar { display: flex; 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..236cca6 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,99 @@ 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; + } +} + +/* ── Crosshair sweep animation ───────────────────────────────────── */ +.btl-crosshair-overlay { + position: absolute; + inset: 0; + pointer-events: none; + overflow: hidden; + z-index: 1200; + --btl-crosshair-x-start: 100%; + --btl-crosshair-y-start: 100%; + --btl-crosshair-x-end: 50%; + --btl-crosshair-y-end: 50%; + --btl-crosshair-duration: 1500ms; +} + +.btl-crosshair-line { + position: absolute; + opacity: 0; + background: var(--accent-cyan); + will-change: transform, opacity; +} + +.btl-crosshair-vertical { + top: 0; + bottom: 0; + width: 1px; + left: 0; + transform: translateX(var(--btl-crosshair-x-start)); +} + +.btl-crosshair-horizontal { + left: 0; + right: 0; + height: 1px; + top: 0; + transform: translateY(var(--btl-crosshair-y-start)); +} + +.btl-crosshair-overlay.active .btl-crosshair-vertical { + animation: btlCrosshairSweepX var(--btl-crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards; +} + +.btl-crosshair-overlay.active .btl-crosshair-horizontal { + animation: btlCrosshairSweepY var(--btl-crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards; +} + +@keyframes btlCrosshairSweepX { + 0% { transform: translateX(var(--btl-crosshair-x-start)); opacity: 0; } + 12% { opacity: 1; } + 85% { opacity: 1; } + 100% { transform: translateX(var(--btl-crosshair-x-end)); opacity: 0; } +} + +@keyframes btlCrosshairSweepY { + 0% { transform: translateY(var(--btl-crosshair-y-start)); opacity: 0; } + 12% { opacity: 1; } + 85% { opacity: 1; } + 100% { transform: translateY(var(--btl-crosshair-y-end)); opacity: 0; } +} + +@media (prefers-reduced-motion: reduce) { + .btl-crosshair-overlay.active .btl-crosshair-vertical, + .btl-crosshair-overlay.active .btl-crosshair-horizontal { + animation-duration: 220ms; + } +} diff --git a/static/css/modes/gps.css b/static/css/modes/gps.css index 0c25ef2..22f40d9 100644 --- a/static/css/modes/gps.css +++ b/static/css/modes/gps.css @@ -139,16 +139,67 @@ letter-spacing: 0.5px; } -.gps-skyview-canvas-wrap { - display: flex; - justify-content: center; - align-items: center; -} - -#gpsSkyCanvas { - max-width: 100%; - height: auto; -} +.gps-skyview-canvas-wrap { + position: relative; + display: block; + width: min(100%, 430px); + aspect-ratio: 1 / 1; + margin: 0 auto; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-primary); + overflow: hidden; +} + +#gpsSkyCanvas { + display: block; + width: 100%; + height: 100%; + cursor: grab; + touch-action: none; +} + +#gpsSkyCanvas:active { + cursor: grabbing; +} + +.gps-sky-overlay { + position: absolute; + inset: 0; + pointer-events: none; + font-family: var(--font-mono); +} + +.gps-sky-label { + position: absolute; + transform: translate(-50%, -50%); + font-size: 9px; + letter-spacing: 0.2px; + text-shadow: 0 0 6px rgba(0, 0, 0, 0.9); + white-space: nowrap; +} + +.gps-sky-label-cardinal { + font-weight: 700; + color: var(--text-secondary); + opacity: 0.85; +} + +.gps-sky-label-sat { + font-weight: 600; +} + +.gps-sky-label-sat.unused { + opacity: 0.75; +} + +.gps-sky-hint { + margin-top: 8px; + font-size: 9px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.4px; +} /* Position info panel */ .gps-position-panel { diff --git a/static/css/modes/waterfall.css b/static/css/modes/waterfall.css new file mode 100644 index 0000000..d1ab8fb --- /dev/null +++ b/static/css/modes/waterfall.css @@ -0,0 +1,1182 @@ +/* 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-zoom-btn { + font-size: 15px; + font-weight: 700; +} + +.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; +} + +/* Band strip below spectrum */ + +.wf-band-strip { + height: 40px; + flex-shrink: 0; + position: relative; + overflow: hidden; + background: linear-gradient(180deg, rgba(9, 14, 26, 0.96) 0%, rgba(5, 10, 18, 0.98) 100%); + border-bottom: 1px solid rgba(255, 255, 255, 0.07); +} + +.wf-band-block { + position: absolute; + top: 5px; + bottom: 5px; + border: 1px solid rgba(150, 203, 255, 0.46); + border-radius: 4px; + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 6px; + padding: 0 5px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12), 0 0 8px rgba(3, 10, 25, 0.5); + color: rgba(236, 247, 255, 0.96); +} + +.wf-band-name { + font-family: var(--font-mono, monospace); + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.08em; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.65); +} + +.wf-band-edge { + font-family: var(--font-mono, monospace); + font-size: 9px; + font-variant-numeric: tabular-nums; + color: rgba(209, 230, 255, 0.95); + white-space: nowrap; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.65); +} + +.wf-band-block.is-tight { + grid-template-columns: 1fr; +} + +.wf-band-block.is-tight .wf-band-edge { + display: none; +} + +.wf-band-block.is-tight .wf-band-name { + display: block; +} + +.wf-band-block.is-mini { + grid-template-columns: 1fr; +} + +.wf-band-block.is-mini .wf-band-edge { + display: none; +} + +.wf-band-block.is-mini .wf-band-name { + display: block; +} + +.wf-band-marker { + position: absolute; + top: 3px; + bottom: 3px; + width: 1px; + transform: translateX(-50%); +} + +.wf-band-marker::before { + content: ''; + position: absolute; + inset: 0; + width: 1px; + background: rgba(166, 216, 255, 0.62); + box-shadow: 0 0 5px rgba(71, 175, 255, 0.34); +} + +.wf-band-marker-label { + position: absolute; + left: 50%; + transform: translateX(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + max-width: 84px; + height: 12px; + padding: 0 5px; + border-radius: 3px; + border: 1px solid rgba(152, 210, 255, 0.52); + background: rgba(11, 24, 44, 0.95); + color: rgba(220, 240, 255, 0.95); + font-family: var(--font-mono, monospace); + font-size: 8px; + letter-spacing: 0.06em; + text-transform: uppercase; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.wf-band-marker.lane-0 .wf-band-marker-label { + top: 1px; +} + +.wf-band-marker.lane-1 .wf-band-marker-label { + top: 19px; +} + +.wf-band-marker.is-overlap .wf-band-marker-label { + display: none; +} + +.wf-band-strip-empty { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-mono, monospace); + font-size: 10px; + letter-spacing: 0.06em; + color: var(--text-muted); + text-transform: uppercase; +} + +/* 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; + } +} + +/* Sidebar controls */ + +.wf-side .section.wf-side-hero { + background: linear-gradient(180deg, rgba(16, 26, 40, 0.95) 0%, rgba(9, 15, 24, 0.97) 100%); + border-color: rgba(96, 171, 255, 0.34); + box-shadow: 0 8px 24px rgba(0, 10, 26, 0.34), inset 0 0 0 1px rgba(255, 255, 255, 0.03); +} + +.wf-side-hero-title-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.wf-side-hero-title { + font-family: var(--font-mono, monospace); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-primary); +} + +.wf-side-chip { + font-family: var(--font-mono, monospace); + font-size: 9px; + color: #9fd0ff; + border: 1px solid rgba(88, 175, 255, 0.36); + border-radius: 999px; + background: rgba(33, 73, 124, 0.32); + padding: 2px 8px; + text-transform: uppercase; + letter-spacing: 0.07em; + white-space: nowrap; +} + +.wf-side-hero-subtext { + margin-top: 8px; + font-size: 11px; + color: var(--text-secondary); + line-height: 1.45; +} + +.wf-side-hero-stats { + margin-top: 10px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; +} + +.wf-side-stat { + border: 1px solid rgba(92, 163, 255, 0.22); + border-radius: 6px; + background: rgba(0, 0, 0, 0.26); + padding: 6px 7px; + min-width: 0; +} + +.wf-side-stat-label { + color: var(--text-muted); + font-family: var(--font-mono, monospace); + font-size: 9px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.wf-side-stat-value { + margin-top: 4px; + color: var(--text-primary); + font-family: var(--font-mono, monospace); + font-size: 12px; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.wf-side-hero-actions { + margin-top: 10px; +} + +.wf-side-status-line { + margin-top: 8px; + font-size: 11px; + color: var(--text-dim); + line-height: 1.35; +} + +.wf-side-help { + font-size: 11px; + color: var(--text-secondary); + line-height: 1.45; + margin-bottom: 8px; +} + +.wf-side-box { + margin-top: 8px; + padding: 8px; + border: 1px solid rgba(74, 163, 255, 0.2); + border-radius: 6px; + background: rgba(0, 0, 0, 0.25); +} + +.wf-side-box-muted { + border-color: rgba(74, 163, 255, 0.14); + background: rgba(0, 0, 0, 0.2); +} + +.wf-side-kv { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.wf-side-kv:last-child { + margin-bottom: 0; +} + +.wf-side-kv-label { + color: var(--text-muted); + text-transform: uppercase; + font-size: 10px; + letter-spacing: 0.05em; +} + +.wf-side-kv-value { + color: var(--text-secondary); + font-family: var(--font-mono, monospace); + text-align: right; +} + +.wf-side-grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; +} + +.wf-side-grid-2.wf-side-grid-gap-top { + margin-top: 8px; +} + +.wf-side-divider { + margin: 9px 0; + height: 1px; + background: rgba(255, 255, 255, 0.08); +} + +.wf-bookmark-row { + display: grid; + grid-template-columns: 1.1fr 0.9fr; + gap: 6px; + margin-bottom: 8px; +} + +.wf-bookmark-row input, +.wf-bookmark-row select { + width: 100%; +} + +.wf-bookmark-list, +.wf-recent-list { + margin-top: 8px; + max-height: 160px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 4px; +} + +.wf-bookmark-item, +.wf-recent-item { + display: grid; + grid-template-columns: 1fr auto auto; + align-items: center; + gap: 6px; + background: rgba(0, 0, 0, 0.24); + border: 1px solid rgba(74, 163, 255, 0.16); + border-radius: 5px; + padding: 5px 7px; + min-width: 0; +} + +.wf-recent-item { + grid-template-columns: 1fr auto; +} + +.wf-bookmark-link, +.wf-recent-link { + border: none; + padding: 0; + margin: 0; + background: transparent; + color: var(--accent-cyan); + font-family: var(--font-mono, monospace); + font-size: 11px; + cursor: pointer; + text-align: left; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.wf-bookmark-link:hover, +.wf-recent-link:hover { + color: #bce1ff; +} + +.wf-bookmark-mode { + color: var(--text-muted); + font-family: var(--font-mono, monospace); + font-size: 9px; +} + +.wf-bookmark-remove { + border: 1px solid rgba(255, 126, 126, 0.35); + border-radius: 4px; + background: rgba(90, 16, 16, 0.45); + color: #ffb3b3; + font-size: 10px; + cursor: pointer; + width: 22px; + height: 20px; + line-height: 1; + padding: 0; +} + +.wf-side-inline-label { + margin-top: 8px; + color: var(--text-muted); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.wf-inline-value { + color: var(--text-dim); + font-weight: 400; +} + +.wf-empty { + color: var(--text-muted); + text-align: center; + font-size: 10px; + padding: 8px 4px; +} + +.wf-scan-checkboxes { + margin-top: 8px; +} + +.wf-scan-metric-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; +} + +.wf-scan-metric-card { + background: rgba(0, 0, 0, 0.24); + border: 1px solid rgba(74, 163, 255, 0.18); + border-radius: 6px; + padding: 7px 6px; + text-align: center; +} + +.wf-scan-metric-label { + color: var(--text-muted); + font-size: 9px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.wf-scan-metric-value { + margin-top: 4px; + color: var(--accent-cyan); + font-family: var(--font-mono, monospace); + font-size: 15px; + font-weight: 700; +} + +.wf-hit-table-wrap { + margin-top: 8px; + max-height: 145px; + overflow: auto; + border: 1px solid rgba(74, 163, 255, 0.16); + border-radius: 6px; +} + +.wf-hit-table { + width: 100%; + border-collapse: collapse; + font-size: 10px; +} + +.wf-hit-table th { + position: sticky; + top: 0; + background: rgba(15, 25, 38, 0.94); + color: var(--text-muted); + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 5px 4px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + text-align: left; +} + +.wf-hit-table td { + padding: 4px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + color: var(--text-secondary); + white-space: nowrap; +} + +.wf-hit-table td:last-child { + white-space: normal; +} + +.wf-hit-action { + border: 1px solid rgba(93, 182, 255, 0.34); + border-radius: 4px; + background: rgba(21, 54, 95, 0.72); + color: #b8e1ff; + padding: 2px 6px; + cursor: pointer; + font-size: 9px; + font-family: var(--font-mono, monospace); + text-transform: uppercase; +} + +.wf-hit-action:hover { + background: rgba(29, 73, 128, 0.82); +} + +.wf-activity-log { + margin-top: 8px; + max-height: 130px; + overflow-y: auto; + border: 1px solid rgba(74, 163, 255, 0.16); + border-radius: 6px; + background: rgba(0, 0, 0, 0.2); + padding: 6px; +} + +.wf-log-entry { + margin-bottom: 5px; + padding: 4px 6px; + border-left: 2px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.02); + border-radius: 3px; + font-size: 10px; + line-height: 1.35; +} + +.wf-log-entry:last-child { + margin-bottom: 0; +} + +.wf-log-entry.is-signal { + border-left-color: rgba(67, 232, 145, 0.75); +} + +.wf-log-entry.is-error { + border-left-color: rgba(255, 111, 111, 0.75); +} + +.wf-log-time { + color: var(--text-muted); + margin-right: 6px; + font-family: var(--font-mono, monospace); + font-size: 9px; +} diff --git a/static/icons/apple-touch-icon.png b/static/icons/apple-touch-icon.png new file mode 100644 index 0000000..7ea2792 Binary files /dev/null and b/static/icons/apple-touch-icon.png differ diff --git a/static/icons/favicon-32.png b/static/icons/favicon-32.png new file mode 100644 index 0000000..ce06d0a Binary files /dev/null and b/static/icons/favicon-32.png differ diff --git a/static/icons/icon-192.png b/static/icons/icon-192.png new file mode 100644 index 0000000..6c89828 Binary files /dev/null and b/static/icons/icon-192.png differ diff --git a/static/icons/icon-512.png b/static/icons/icon-512.png new file mode 100644 index 0000000..34df10d Binary files /dev/null and b/static/icons/icon-512.png differ diff --git a/static/icons/icon.svg b/static/icons/icon.svg new file mode 100644 index 0000000..3280665 --- /dev/null +++ b/static/icons/icon.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/static/images/globe/earth-dark.jpg b/static/images/globe/earth-dark.jpg new file mode 100644 index 0000000..222bd93 Binary files /dev/null and b/static/images/globe/earth-dark.jpg differ diff --git a/static/js/components/activity-timeline.js b/static/js/components/activity-timeline.js index 234b104..b6d7e7a 100644 --- a/static/js/components/activity-timeline.js +++ b/static/js/components/activity-timeline.js @@ -1,7 +1,7 @@ /** * Activity Timeline Component * Reusable, configuration-driven timeline visualization for time-based metadata - * Supports multiple modes: TSCM, Listening Post, Bluetooth, WiFi, Monitoring + * Supports multiple modes: TSCM, RF Receiver, Bluetooth, WiFi, Monitoring */ const ActivityTimeline = (function() { @@ -176,7 +176,7 @@ const ActivityTimeline = (function() { */ function categorizeById(id, mode) { // RF frequency categorization - if (mode === 'rf' || mode === 'tscm' || mode === 'listening-post') { + if (mode === 'rf' || mode === 'tscm' || mode === 'waterfall') { const f = parseFloat(id); if (!isNaN(f)) { if (f >= 2400 && f <= 2500) return '2.4 GHz wireless band'; diff --git a/static/js/components/timeline-adapters/rf-adapter.js b/static/js/components/timeline-adapters/rf-adapter.js index 972d16c..cdba8d4 100644 --- a/static/js/components/timeline-adapters/rf-adapter.js +++ b/static/js/components/timeline-adapters/rf-adapter.js @@ -1,8 +1,8 @@ -/** - * RF Signal Timeline Adapter - * Normalizes RF signal data for the Activity Timeline component - * Used by: Listening Post, TSCM - */ +/** + * RF Signal Timeline Adapter + * Normalizes RF signal data for the Activity Timeline component + * Used by: Spectrum Waterfall, TSCM + */ const RFTimelineAdapter = (function() { 'use strict'; @@ -157,16 +157,16 @@ const RFTimelineAdapter = (function() { return signals.map(normalizer); } - /** - * Create timeline configuration for Listening Post mode - */ - function getListeningPostConfig() { - return { - title: 'Signal Activity', - mode: 'listening-post', - visualMode: 'enriched', - collapsed: false, - showAnnotations: true, + /** + * Create timeline configuration for spectrum waterfall mode. + */ + function getWaterfallConfig() { + return { + title: 'Spectrum Activity', + mode: 'waterfall', + visualMode: 'enriched', + collapsed: false, + showAnnotations: true, showLegend: true, defaultWindow: '15m', availableWindows: ['5m', '15m', '30m', '1h'], @@ -184,9 +184,14 @@ const RFTimelineAdapter = (function() { } ], maxItems: 50, - maxDisplayedLanes: 12 - }; - } + maxDisplayedLanes: 12 + }; + } + + // Backward compatibility alias for legacy callers. + function getListeningPostConfig() { + return getWaterfallConfig(); + } /** * Create timeline configuration for TSCM mode @@ -224,8 +229,9 @@ const RFTimelineAdapter = (function() { categorizeFrequency: categorizeFrequency, // Configuration presets - getListeningPostConfig: getListeningPostConfig, - getTscmConfig: getTscmConfig, + getWaterfallConfig: getWaterfallConfig, + getListeningPostConfig: getListeningPostConfig, + getTscmConfig: getTscmConfig, // Constants RSSI_THRESHOLDS: RSSI_THRESHOLDS, diff --git a/static/js/core/app.js b/static/js/core/app.js index f6a7d49..41b029f 100644 --- a/static/js/core/app.js +++ b/static/js/core/app.js @@ -98,7 +98,7 @@ function switchMode(mode) { const modeMap = { 'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft', 'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth', - 'listening': 'listening', 'meshtastic': 'meshtastic' + 'meshtastic': 'meshtastic' }; document.querySelectorAll('.mode-nav-btn').forEach(btn => { const label = btn.querySelector('.nav-label'); @@ -114,7 +114,6 @@ function switchMode(mode) { document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite'); document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi'); document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth'); - document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening'); document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs'); document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm'); document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr'); @@ -143,7 +142,6 @@ function switchMode(mode) { 'satellite': 'SATELLITE', 'wifi': 'WIFI', 'bluetooth': 'BLUETOOTH', - 'listening': 'LISTENING POST', 'tscm': 'TSCM', 'aprs': 'APRS', 'meshtastic': 'MESHTASTIC' @@ -166,7 +164,6 @@ function switchMode(mode) { const showRadar = document.getElementById('adsbEnableMap')?.checked; document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none'; document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none'; - document.getElementById('listeningPostVisuals').style.display = mode === 'listening' ? 'grid' : 'none'; // Update output panel title based on mode const titles = { @@ -176,7 +173,6 @@ function switchMode(mode) { 'satellite': 'Satellite Monitor', 'wifi': 'WiFi Scanner', 'bluetooth': 'Bluetooth Scanner', - 'listening': 'Listening Post', 'meshtastic': 'Meshtastic Mesh Monitor' }; document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor'; @@ -184,7 +180,7 @@ function switchMode(mode) { // Show/hide Device Intelligence for modes that use it const reconBtn = document.getElementById('reconBtn'); const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]'); - if (mode === 'satellite' || mode === 'aircraft' || mode === 'listening') { + if (mode === 'satellite' || mode === 'aircraft') { document.getElementById('reconPanel').style.display = 'none'; if (reconBtn) reconBtn.style.display = 'none'; if (intelBtn) intelBtn.style.display = 'none'; @@ -198,7 +194,7 @@ function switchMode(mode) { // Show RTL-SDR device section for modes that use it document.getElementById('rtlDeviceSection').style.display = - (mode === 'pager' || mode === 'sensor' || mode === 'aircraft' || mode === 'listening') ? 'block' : 'none'; + (mode === 'pager' || mode === 'sensor' || mode === 'aircraft') ? 'block' : 'none'; // Toggle mode-specific tool status displays document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none'; @@ -207,7 +203,7 @@ function switchMode(mode) { // Hide waterfall and output console for modes with their own visualizations document.querySelector('.waterfall-container').style.display = - (mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block'; + (mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block'; document.getElementById('output').style.display = (mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block'; document.querySelector('.status-bar').style.display = (mode === 'satellite' || mode === 'tscm' || mode === 'meshtastic' || mode === 'aprs' || mode === 'spystations') ? 'none' : 'flex'; @@ -226,11 +222,6 @@ function switchMode(mode) { } else if (mode === 'satellite') { if (typeof initPolarPlot === 'function') initPolarPlot(); if (typeof initSatelliteList === 'function') initSatelliteList(); - } else if (mode === 'listening') { - if (typeof checkScannerTools === 'function') checkScannerTools(); - if (typeof checkAudioTools === 'function') checkAudioTools(); - if (typeof populateScannerDeviceSelect === 'function') populateScannerDeviceSelect(); - if (typeof populateAudioDeviceSelect === 'function') populateAudioDeviceSelect(); } else if (mode === 'meshtastic') { if (typeof Meshtastic !== 'undefined' && Meshtastic.init) Meshtastic.init(); } diff --git a/static/js/core/cheat-sheets.js b/static/js/core/cheat-sheets.js new file mode 100644 index 0000000..9bfc56a --- /dev/null +++ b/static/js/core/cheat-sheets.js @@ -0,0 +1,74 @@ +/* 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: ['BT Locate uses GPS for trail logging', 'Set observer location for satellite prediction', 'Verify a 3D fix before relying on altitude'] }, + 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'] }, + 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 Spectrum Waterfall 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'] }, + }; + + 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..b128f36 100644 --- a/static/js/core/command-palette.js +++ b/static/js/core/command-palette.js @@ -12,8 +12,8 @@ const CommandPalette = (function() { { mode: 'pager', label: 'Pager' }, { mode: 'sensor', label: '433MHz Sensors' }, { mode: 'rtlamr', label: 'Meters' }, - { mode: 'listening', label: 'Listening Post' }, { mode: 'subghz', label: 'SubGHz' }, + { mode: 'waterfall', label: 'Spectrum Waterfall' }, { mode: 'aprs', label: 'APRS' }, { mode: 'wifi', label: 'WiFi Scanner' }, { mode: 'bluetooth', label: 'Bluetooth Scanner' }, @@ -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' }, ]; @@ -188,13 +187,39 @@ const CommandPalette = (function() { title: 'View Aircraft Dashboard', description: 'Open dedicated ADS-B dashboard page', keyword: 'aircraft adsb dashboard', - run: () => { window.location.href = '/adsb/dashboard'; } + run: () => { + if (window.InterceptNavPerf && typeof window.InterceptNavPerf.markStart === 'function') { + window.InterceptNavPerf.markStart({ + targetPath: '/adsb/dashboard', + trigger: 'command-palette', + sourceMode: (typeof currentMode === 'string' && currentMode) ? currentMode : null, + activeScans: (typeof getActiveScanSummary === 'function') ? getActiveScanSummary() : null, + }); + } + if (typeof stopActiveLocalScansForNavigation === 'function') { + stopActiveLocalScansForNavigation(); + } + window.location.href = '/adsb/dashboard'; + } }, { title: 'View Vessel Dashboard', description: 'Open dedicated AIS dashboard page', keyword: 'vessel ais dashboard', - run: () => { window.location.href = '/ais/dashboard'; } + run: () => { + if (window.InterceptNavPerf && typeof window.InterceptNavPerf.markStart === 'function') { + window.InterceptNavPerf.markStart({ + targetPath: '/ais/dashboard', + trigger: 'command-palette', + sourceMode: (typeof currentMode === 'string' && currentMode) ? currentMode : null, + activeScans: (typeof getActiveScanSummary === 'function') ? getActiveScanSummary() : null, + }); + } + if (typeof stopActiveLocalScansForNavigation === 'function') { + stopActiveLocalScansForNavigation(); + } + window.location.href = '/ais/dashboard'; + } }, { title: 'Kill All Running Processes', diff --git a/static/js/core/first-run-setup.js b/static/js/core/first-run-setup.js index 8ed64d9..9f313b8 100644 --- a/static/js/core/first-run-setup.js +++ b/static/js/core/first-run-setup.js @@ -130,7 +130,7 @@ const FirstRunSetup = (function() { ['pager', 'Pager'], ['sensor', '433MHz'], ['rtlamr', 'Meters'], - ['listening', 'Listening Post'], + ['waterfall', 'Waterfall'], ['wifi', 'WiFi'], ['bluetooth', 'Bluetooth'], ['bt_locate', 'BT Locate'], @@ -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'); @@ -150,7 +149,11 @@ const FirstRunSetup = (function() { const savedDefaultMode = localStorage.getItem(DEFAULT_MODE_KEY); if (savedDefaultMode) { - modeSelectEl.value = savedDefaultMode; + const normalizedMode = savedDefaultMode === 'listening' ? 'waterfall' : savedDefaultMode; + modeSelectEl.value = normalizedMode; + if (normalizedMode !== savedDefaultMode) { + localStorage.setItem(DEFAULT_MODE_KEY, normalizedMode); + } } actionsEl.appendChild(modeSelectEl); diff --git a/static/js/core/global-nav.js b/static/js/core/global-nav.js index 280df7a..34fe83e 100644 --- a/static/js/core/global-nav.js +++ b/static/js/core/global-nav.js @@ -18,6 +18,18 @@ if (menuLink) { event.preventDefault(); event.stopPropagation(); + try { + const target = new URL(menuLink.href, window.location.href); + if (window.InterceptNavPerf && typeof window.InterceptNavPerf.markStart === 'function') { + window.InterceptNavPerf.markStart({ + targetPath: target.pathname, + trigger: 'global-nav', + sourceMode: document.body?.getAttribute('data-mode') || null, + }); + } + } catch (_) { + // Ignore malformed link targets. + } window.location.href = menuLink.href; return; } diff --git a/static/js/core/keyboard-shortcuts.js b/static/js/core/keyboard-shortcuts.js new file mode 100644 index 0000000..6bebdd2 --- /dev/null +++ b/static/js/core/keyboard-shortcuts.js @@ -0,0 +1,72 @@ +/* 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.code) { + case 'KeyW': e.preventDefault(); window.switchMode && switchMode('waterfall'); break; + case 'KeyM': e.preventDefault(); window.VoiceAlerts && VoiceAlerts.toggleMute(); break; + case 'KeyS': e.preventDefault(); _toggleSidebar(); break; + case 'KeyK': e.preventDefault(); showHelp(); break; + case 'KeyC': e.preventDefault(); window.CheatSheets && CheatSheets.showForCurrentMode(); break; + default: + if (e.code >= 'Digit1' && e.code <= 'Digit9') { + e.preventDefault(); + _switchToNthMode(parseInt(e.code.replace('Digit', '')) - 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..48edca2 100644 --- a/static/js/core/settings-manager.js +++ b/static/js/core/settings-manager.js @@ -98,24 +98,15 @@ const Settings = { localStorage.setItem('intercept_map_theme_pref', pref); }, - /** - * Whether Cyber map theme should be considered active globally. - * @param {Object} [config] - * @returns {boolean} - */ - _isCyberThemeEnabled(config) { - const resolvedConfig = config || this.getTileConfig(); - return this._getMapThemeClass(resolvedConfig) === 'map-theme-cyber'; - }, - /** * Toggle root class used for hard global Leaflet theming. * @param {Object} [config] */ _syncRootMapThemeClass(config) { if (typeof document === 'undefined' || !document.documentElement) return; - const enabled = this._isCyberThemeEnabled(config); - document.documentElement.classList.toggle('map-cyber-enabled', enabled); + const resolvedConfig = config || this.getTileConfig(); + const themeClass = this._getMapThemeClass(resolvedConfig); + document.documentElement.classList.toggle('map-cyber-enabled', themeClass === 'map-theme-cyber'); }, /** @@ -381,17 +372,19 @@ const Settings = { container.classList.add(themeClass); - if (container.style) { - container.style.background = '#020813'; - } - if (tilePane && tilePane.style) { - tilePane.style.filter = 'sepia(0.74) hue-rotate(176deg) saturate(1.72) brightness(1.05) contrast(1.08)'; - tilePane.style.opacity = '1'; - tilePane.style.willChange = 'filter'; + if (themeClass === 'map-theme-cyber') { + if (container.style) { + container.style.background = '#020813'; + } + if (tilePane && tilePane.style) { + tilePane.style.filter = 'sepia(0.74) hue-rotate(176deg) saturate(1.72) brightness(1.05) contrast(1.08)'; + tilePane.style.opacity = '1'; + tilePane.style.willChange = 'filter'; + } } - // Grid/glow overlays are rendered via CSS pseudo elements on - // `html.map-cyber-enabled .leaflet-container` for consistent stacking. + // Map overlays are rendered via CSS pseudo elements on + // `html.map-*-enabled .leaflet-container` for consistent stacking. }, /** @@ -1265,6 +1258,7 @@ function switchSettingsTab(tabName) { } else if (tabName === 'location') { loadObserverLocation(); } else if (tabName === 'alerts') { + loadVoiceAlertConfig(); if (typeof AlertCenter !== 'undefined') { AlertCenter.loadFeed(); } @@ -1277,6 +1271,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..883cefd --- /dev/null +++ b/static/js/core/voice-alerts.js @@ -0,0 +1,255 @@ +/* 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'; + const RATE_MIN = 0.5; + const RATE_MAX = 2.0; + const PITCH_MIN = 0.5; + const PITCH_MAX = 2.0; + + // Default config + let _config = { + rate: 1.1, + pitch: 0.9, + voiceName: '', + streams: { pager: true, tscm: true, bluetooth: true }, + }; + + function _toNumberInRange(value, fallback, min, max) { + const n = Number(value); + if (!Number.isFinite(n)) return fallback; + return Math.min(max, Math.max(min, n)); + } + + function _normalizeConfig() { + _config.rate = _toNumberInRange(_config.rate, 1.1, RATE_MIN, RATE_MAX); + _config.pitch = _toNumberInRange(_config.pitch, 0.9, PITCH_MIN, PITCH_MAX); + _config.voiceName = typeof _config.voiceName === 'string' ? _config.voiceName : ''; + } + + function _isSpeechSupported() { + return !!(window.speechSynthesis && typeof window.SpeechSynthesisUtterance !== 'undefined'); + } + + function _showVoiceToast(title, message, type) { + if (typeof window.showAppToast === 'function') { + window.showAppToast(title, message, type || 'warning'); + } + } + + 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 (_) {} + _normalizeConfig(); + _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 _createUtterance(text) { + const utt = new SpeechSynthesisUtterance(text); + utt.rate = _toNumberInRange(_config.rate, 1.1, RATE_MIN, RATE_MAX); + utt.pitch = _toNumberInRange(_config.pitch, 0.9, PITCH_MIN, PITCH_MAX); + const voice = _getVoice(); + if (voice) utt.voice = voice; + return utt; + } + + 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 = _createUtterance(item.text); + 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(); + if (_isSpeechSupported()) { + // Prime voices list early so user-triggered test calls are less likely to be silent. + speechSynthesis.getVoices(); + } + _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 = _toNumberInRange(cfg.rate, _config.rate, RATE_MIN, RATE_MAX); + if (cfg.pitch !== undefined) _config.pitch = _toNumberInRange(cfg.pitch, _config.pitch, PITCH_MIN, PITCH_MAX); + if (cfg.voiceName !== undefined) _config.voiceName = cfg.voiceName; + if (cfg.streams) Object.assign(_config.streams, cfg.streams); + _normalizeConfig(); + 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 (!_isSpeechSupported()) { + _showVoiceToast('Voice Unavailable', 'This browser does not support speech synthesis.', 'warning'); + return; + } + + // Make the test immediate and recover from a paused/stalled synthesis engine. + try { + speechSynthesis.getVoices(); + if (speechSynthesis.paused) speechSynthesis.resume(); + speechSynthesis.cancel(); + } catch (_) {} + + const utt = _createUtterance(text || 'Voice alert test. All systems nominal.'); + let started = false; + utt.onstart = () => { started = true; }; + utt.onerror = () => { + _showVoiceToast('Voice Test Failed', 'Speech synthesis failed to start. Check browser audio output.', 'warning'); + }; + speechSynthesis.speak(utt); + + window.setTimeout(() => { + if (!started && !speechSynthesis.speaking && !speechSynthesis.pending) { + _showVoiceToast('No Voice Output', 'Test speech did not play. Verify browser audio and selected voice.', 'warning'); + } + }, 1200); + } + + 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/bluetooth.js b/static/js/modes/bluetooth.js index e791063..2a7c856 100644 --- a/static/js/modes/bluetooth.js +++ b/static/js/modes/bluetooth.js @@ -944,21 +944,36 @@ const BluetoothMode = (function() { } } - async function stopScan() { - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - - try { - if (isAgentMode) { - await fetch(`/controller/agents/${currentAgent}/bluetooth/stop`, { method: 'POST' }); - } else { - await fetch('/api/bluetooth/scan/stop', { method: 'POST' }); - } - setScanning(false); - stopEventStream(); - } catch (err) { - console.error('Failed to stop scan:', err); - } - } + async function stopScan() { + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + const timeoutMs = isAgentMode ? 8000 : 2200; + const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; + const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null; + + // Optimistic UI teardown keeps mode changes responsive. + setScanning(false); + stopEventStream(); + + try { + if (isAgentMode) { + await fetch(`/controller/agents/${currentAgent}/bluetooth/stop`, { + method: 'POST', + ...(controller ? { signal: controller.signal } : {}), + }); + } else { + await fetch('/api/bluetooth/scan/stop', { + method: 'POST', + ...(controller ? { signal: controller.signal } : {}), + }); + } + } catch (err) { + console.error('Failed to stop scan:', err); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } + } function setScanning(scanning) { isScanning = scanning; diff --git a/static/js/modes/bt_locate.js b/static/js/modes/bt_locate.js index a295730..7187c45 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; @@ -44,6 +44,7 @@ const BtLocate = (function() { let queuedDetectionTimer = null; let lastDetectionRenderAt = 0; let startRequestInFlight = false; + let crosshairResetTimer = null; const MAX_HEAT_POINTS = 1200; const MAX_TRAIL_POINTS = 1200; @@ -193,8 +194,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 +227,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 +276,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 +294,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,33 +348,32 @@ const BtLocate = (function() { setStartButtonBusy(false); }); } - + function stop() { + // Update UI immediately — don't wait for the backend response. + if (queuedDetectionTimer) { + clearTimeout(queuedDetectionTimer); + queuedDetectionTimer = null; + } + queuedDetection = null; + queuedDetectionOptions = null; + showIdleUI(); + disconnectSSE(); + stopAudio(); + // Notify backend asynchronously. fetch('/bt_locate/stop', { method: 'POST' }) - .then(r => r.json()) - .then(() => { - if (queuedDetectionTimer) { - clearTimeout(queuedDetectionTimer); - queuedDetectionTimer = null; - } - queuedDetection = null; - queuedDetectionOptions = null; - showIdleUI(); - disconnectSSE(); - 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,40 @@ 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 triggerCrosshairAnimation(lat, lon) { + if (!map) return; + const overlay = document.getElementById('btLocateCrosshairOverlay'); + if (!overlay) return; + const size = map.getSize(); + const point = map.latLngToContainerPoint([lat, lon]); + const targetX = Math.max(0, Math.min(size.x, point.x)); + const targetY = Math.max(0, Math.min(size.y, point.y)); + const startX = size.x + 8; + const startY = size.y + 8; + const duration = 1500; + overlay.style.setProperty('--btl-crosshair-x-start', `${startX}px`); + overlay.style.setProperty('--btl-crosshair-y-start', `${startY}px`); + overlay.style.setProperty('--btl-crosshair-x-end', `${targetX}px`); + overlay.style.setProperty('--btl-crosshair-y-end', `${targetY}px`); + overlay.style.setProperty('--btl-crosshair-duration', `${duration}ms`); + overlay.classList.remove('active'); + void overlay.offsetWidth; + overlay.classList.add('active'); + if (crosshairResetTimer) clearTimeout(crosshairResetTimer); + crosshairResetTimer = setTimeout(() => { + overlay.classList.remove('active'); + crosshairResetTimer = null; + }, duration + 100); + } + function addMapMarker(point, options = {}) { if (!map || point.lat == null || point.lon == null) return false; const lat = Number(point.lat); @@ -737,6 +763,7 @@ const BtLocate = (function() { 'Time: ' + formatPointTimestamp(trailPoint.timestamp) + '' ); + marker.on('click', () => triggerCrosshairAnimation(lat, lon)); trailPoints.push(trailPoint); mapMarkers.push(marker); @@ -1604,242 +1631,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 +1880,7 @@ const BtLocate = (function() { }) .catch(err => console.error('[BtLocate] Clear trail error:', err)); } - + function invalidateMap() { if (safeInvalidateMap()) { flushPendingHeatSync(); @@ -1863,15 +1890,15 @@ const BtLocate = (function() { } scheduleMapStabilization(8); } - + return { init, setActiveMode, start, stop, - handoff, - clearHandoff, - setEnvironment, + handoff, + clearHandoff, + setEnvironment, toggleAudio, toggleHeatmap, toggleMovement, @@ -1879,10 +1906,10 @@ const BtLocate = (function() { toggleSmoothing, exportTrail, clearTrail, - handleDetection, - invalidateMap, - fetchPairedIrks, - }; + handleDetection, + invalidateMap, + fetchPairedIrks, + }; })(); window.BtLocate = BtLocate; diff --git a/static/js/modes/gps.js b/static/js/modes/gps.js index 4c6e9c3..0af071b 100644 --- a/static/js/modes/gps.js +++ b/static/js/modes/gps.js @@ -4,11 +4,14 @@ * position/velocity/DOP readout. Connects to gpsd via backend SSE stream. */ -const GPS = (function() { - let connected = false; - let lastPosition = null; - let lastSky = null; - let skyPollTimer = null; +const GPS = (function() { + let connected = false; + let lastPosition = null; + let lastSky = null; + let skyPollTimer = null; + let themeObserver = null; + let skyRenderer = null; + let skyRendererInitAttempted = false; // Constellation color map const CONST_COLORS = { @@ -20,20 +23,45 @@ const GPS = (function() { 'QZSS': '#cc66ff', }; - function init() { - drawEmptySkyView(); - connect(); - - // Redraw sky view when theme changes - const observer = new MutationObserver(() => { - if (lastSky) { - drawSkyView(lastSky.satellites || []); - } else { - drawEmptySkyView(); - } - }); - observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); - } + function init() { + initSkyRenderer(); + drawEmptySkyView(); + if (!connected) connect(); + + // Redraw sky view when theme changes + if (!themeObserver) { + themeObserver = new MutationObserver(() => { + if (skyRenderer && typeof skyRenderer.requestRender === 'function') { + skyRenderer.requestRender(); + } + if (lastSky) { + drawSkyView(lastSky.satellites || []); + } else { + drawEmptySkyView(); + } + }); + themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); + } + + if (lastPosition) updatePositionUI(lastPosition); + if (lastSky) updateSkyUI(lastSky); + } + + function initSkyRenderer() { + if (skyRendererInitAttempted) return; + skyRendererInitAttempted = true; + + const canvas = document.getElementById('gpsSkyCanvas'); + if (!canvas) return; + + const overlay = document.getElementById('gpsSkyOverlay'); + try { + skyRenderer = createWebGlSkyRenderer(canvas, overlay); + } catch (err) { + skyRenderer = null; + console.warn('GPS sky WebGL renderer failed, falling back to 2D', err); + } + } function connect() { updateConnectionUI(false, false, 'connecting'); @@ -252,139 +280,745 @@ const GPS = (function() { if (el) el.textContent = val; } - // ======================== - // Sky View Polar Plot - // ======================== - - function drawEmptySkyView() { - const canvas = document.getElementById('gpsSkyCanvas'); - if (!canvas) return; - drawSkyViewBase(canvas); - } - - function drawSkyView(satellites) { - const canvas = document.getElementById('gpsSkyCanvas'); - if (!canvas) return; - - const ctx = canvas.getContext('2d'); - const w = canvas.width; - const h = canvas.height; - const cx = w / 2; - const cy = h / 2; - const r = Math.min(cx, cy) - 24; - - drawSkyViewBase(canvas); - - // Plot satellites - satellites.forEach(sat => { - if (sat.elevation == null || sat.azimuth == null) return; - - const elRad = (90 - sat.elevation) / 90; - const azRad = (sat.azimuth - 90) * Math.PI / 180; // N = up - const px = cx + r * elRad * Math.cos(azRad); - const py = cy + r * elRad * Math.sin(azRad); - - const color = CONST_COLORS[sat.constellation] || CONST_COLORS['GPS']; - const dotSize = sat.used ? 6 : 4; - - // Draw dot - ctx.beginPath(); - ctx.arc(px, py, dotSize, 0, Math.PI * 2); - if (sat.used) { - ctx.fillStyle = color; - ctx.fill(); - } else { - ctx.strokeStyle = color; - ctx.lineWidth = 1.5; - ctx.stroke(); - } - - // PRN label - ctx.fillStyle = color; - ctx.font = '8px Roboto Condensed, monospace'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'bottom'; - ctx.fillText(sat.prn, px, py - dotSize - 2); - - // SNR value - if (sat.snr != null) { - ctx.fillStyle = 'rgba(255,255,255,0.4)'; - ctx.font = '7px Roboto Condensed, monospace'; - ctx.textBaseline = 'top'; - ctx.fillText(Math.round(sat.snr), px, py + dotSize + 1); - } - }); - } - - function drawSkyViewBase(canvas) { - const ctx = canvas.getContext('2d'); - const w = canvas.width; - const h = canvas.height; - const cx = w / 2; - const cy = h / 2; - const r = Math.min(cx, cy) - 24; - - ctx.clearRect(0, 0, w, h); - - const cs = getComputedStyle(document.documentElement); - const bgColor = cs.getPropertyValue('--bg-card').trim() || '#0d1117'; - const gridColor = cs.getPropertyValue('--border-color').trim() || '#2a3040'; - const dimColor = cs.getPropertyValue('--text-dim').trim() || '#555'; - const secondaryColor = cs.getPropertyValue('--text-secondary').trim() || '#888'; - - // Background - ctx.fillStyle = bgColor; - ctx.fillRect(0, 0, w, h); - - // Elevation rings (0, 30, 60, 90) - ctx.strokeStyle = gridColor; - ctx.lineWidth = 0.5; - [90, 60, 30].forEach(el => { - const gr = r * (1 - el / 90); - ctx.beginPath(); - ctx.arc(cx, cy, gr, 0, Math.PI * 2); - ctx.stroke(); - // Label - ctx.fillStyle = dimColor; - ctx.font = '9px Roboto Condensed, monospace'; - ctx.textAlign = 'left'; - ctx.textBaseline = 'middle'; - ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2); - }); - - // Horizon circle - ctx.strokeStyle = gridColor; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.arc(cx, cy, r, 0, Math.PI * 2); - ctx.stroke(); - - // Cardinal directions - ctx.fillStyle = secondaryColor; - ctx.font = 'bold 11px Roboto Condensed, monospace'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText('N', cx, cy - r - 12); - ctx.fillText('S', cx, cy + r + 12); - ctx.fillText('E', cx + r + 12, cy); - ctx.fillText('W', cx - r - 12, cy); - - // Crosshairs - ctx.strokeStyle = gridColor; - ctx.lineWidth = 0.5; - ctx.beginPath(); - ctx.moveTo(cx, cy - r); - ctx.lineTo(cx, cy + r); - ctx.moveTo(cx - r, cy); - ctx.lineTo(cx + r, cy); - ctx.stroke(); - - // Zenith dot - ctx.fillStyle = dimColor; - ctx.beginPath(); - ctx.arc(cx, cy, 2, 0, Math.PI * 2); - ctx.fill(); - } + // ======================== + // Sky View Globe (WebGL with 2D fallback) + // ======================== + + function drawEmptySkyView() { + if (!skyRendererInitAttempted) { + initSkyRenderer(); + } + + if (skyRenderer) { + skyRenderer.setSatellites([]); + return; + } + + const canvas = document.getElementById('gpsSkyCanvas'); + if (!canvas) return; + drawSkyViewBase2D(canvas); + } + + function drawSkyView(satellites) { + if (!skyRendererInitAttempted) { + initSkyRenderer(); + } + + const sats = Array.isArray(satellites) ? satellites : []; + + if (skyRenderer) { + skyRenderer.setSatellites(sats); + return; + } + + const canvas = document.getElementById('gpsSkyCanvas'); + if (!canvas) return; + + drawSkyViewBase2D(canvas); + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const w = canvas.width; + const h = canvas.height; + const cx = w / 2; + const cy = h / 2; + const r = Math.min(cx, cy) - 24; + + sats.forEach(sat => { + if (sat.elevation == null || sat.azimuth == null) return; + + const elRad = (90 - sat.elevation) / 90; + const azRad = (sat.azimuth - 90) * Math.PI / 180; + const px = cx + r * elRad * Math.cos(azRad); + const py = cy + r * elRad * Math.sin(azRad); + + const color = CONST_COLORS[sat.constellation] || CONST_COLORS.GPS; + const dotSize = sat.used ? 6 : 4; + + ctx.beginPath(); + ctx.arc(px, py, dotSize, 0, Math.PI * 2); + if (sat.used) { + ctx.fillStyle = color; + ctx.fill(); + } else { + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + + ctx.fillStyle = color; + ctx.font = '8px Roboto Condensed, monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + ctx.fillText(sat.prn, px, py - dotSize - 2); + + if (sat.snr != null) { + ctx.fillStyle = 'rgba(255,255,255,0.4)'; + ctx.font = '7px Roboto Condensed, monospace'; + ctx.textBaseline = 'top'; + ctx.fillText(Math.round(sat.snr), px, py + dotSize + 1); + } + }); + } + + function drawSkyViewBase2D(canvas) { + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const w = canvas.width; + const h = canvas.height; + const cx = w / 2; + const cy = h / 2; + const r = Math.min(cx, cy) - 24; + + ctx.clearRect(0, 0, w, h); + + const cs = getComputedStyle(document.documentElement); + const bgColor = cs.getPropertyValue('--bg-card').trim() || '#0d1117'; + const gridColor = cs.getPropertyValue('--border-color').trim() || '#2a3040'; + const dimColor = cs.getPropertyValue('--text-dim').trim() || '#555'; + const secondaryColor = cs.getPropertyValue('--text-secondary').trim() || '#888'; + + ctx.fillStyle = bgColor; + ctx.fillRect(0, 0, w, h); + + ctx.strokeStyle = gridColor; + ctx.lineWidth = 0.5; + [90, 60, 30].forEach(el => { + const gr = r * (1 - el / 90); + ctx.beginPath(); + ctx.arc(cx, cy, gr, 0, Math.PI * 2); + ctx.stroke(); + + ctx.fillStyle = dimColor; + ctx.font = '9px Roboto Condensed, monospace'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2); + }); + + ctx.strokeStyle = gridColor; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.stroke(); + + ctx.fillStyle = secondaryColor; + ctx.font = 'bold 11px Roboto Condensed, monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('N', cx, cy - r - 12); + ctx.fillText('S', cx, cy + r + 12); + ctx.fillText('E', cx + r + 12, cy); + ctx.fillText('W', cx - r - 12, cy); + + ctx.strokeStyle = gridColor; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(cx, cy - r); + ctx.lineTo(cx, cy + r); + ctx.moveTo(cx - r, cy); + ctx.lineTo(cx + r, cy); + ctx.stroke(); + + ctx.fillStyle = dimColor; + ctx.beginPath(); + ctx.arc(cx, cy, 2, 0, Math.PI * 2); + ctx.fill(); + } + + function createWebGlSkyRenderer(canvas, overlay) { + const gl = canvas.getContext('webgl', { antialias: true, alpha: false, depth: true }); + if (!gl) return null; + + const lineProgram = createProgram( + gl, + [ + 'attribute vec3 aPosition;', + 'uniform mat4 uMVP;', + 'void main(void) {', + ' gl_Position = uMVP * vec4(aPosition, 1.0);', + '}', + ].join('\n'), + [ + 'precision mediump float;', + 'uniform vec4 uColor;', + 'void main(void) {', + ' gl_FragColor = uColor;', + '}', + ].join('\n'), + ); + + const pointProgram = createProgram( + gl, + [ + 'attribute vec3 aPosition;', + 'attribute vec4 aColor;', + 'attribute float aSize;', + 'attribute float aUsed;', + 'uniform mat4 uMVP;', + 'uniform float uDevicePixelRatio;', + 'uniform vec3 uCameraDir;', + 'varying vec4 vColor;', + 'varying float vUsed;', + 'varying float vFacing;', + 'void main(void) {', + ' vec3 normPos = normalize(aPosition);', + ' vFacing = dot(normPos, normalize(uCameraDir));', + ' gl_Position = uMVP * vec4(aPosition, 1.0);', + ' gl_PointSize = aSize * uDevicePixelRatio;', + ' vColor = aColor;', + ' vUsed = aUsed;', + '}', + ].join('\n'), + [ + 'precision mediump float;', + 'varying vec4 vColor;', + 'varying float vUsed;', + 'varying float vFacing;', + 'void main(void) {', + ' if (vFacing <= 0.0) discard;', + ' vec2 c = gl_PointCoord * 2.0 - 1.0;', + ' float d = dot(c, c);', + ' if (d > 1.0) discard;', + ' if (vUsed < 0.5 && d < 0.45) discard;', + ' float edge = smoothstep(1.0, 0.75, d);', + ' gl_FragColor = vec4(vColor.rgb, vColor.a * edge);', + '}', + ].join('\n'), + ); + + if (!lineProgram || !pointProgram) return null; + + const lineLoc = { + position: gl.getAttribLocation(lineProgram, 'aPosition'), + mvp: gl.getUniformLocation(lineProgram, 'uMVP'), + color: gl.getUniformLocation(lineProgram, 'uColor'), + }; + + const pointLoc = { + position: gl.getAttribLocation(pointProgram, 'aPosition'), + color: gl.getAttribLocation(pointProgram, 'aColor'), + size: gl.getAttribLocation(pointProgram, 'aSize'), + used: gl.getAttribLocation(pointProgram, 'aUsed'), + mvp: gl.getUniformLocation(pointProgram, 'uMVP'), + dpr: gl.getUniformLocation(pointProgram, 'uDevicePixelRatio'), + cameraDir: gl.getUniformLocation(pointProgram, 'uCameraDir'), + }; + + const gridVertices = buildSkyGridVertices(); + const horizonVertices = buildSkyRingVertices(0, 4); + + const gridBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, gridBuffer); + gl.bufferData(gl.ARRAY_BUFFER, gridVertices, gl.STATIC_DRAW); + + const horizonBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, horizonBuffer); + gl.bufferData(gl.ARRAY_BUFFER, horizonVertices, gl.STATIC_DRAW); + + const satPosBuffer = gl.createBuffer(); + const satColorBuffer = gl.createBuffer(); + const satSizeBuffer = gl.createBuffer(); + const satUsedBuffer = gl.createBuffer(); + + let satCount = 0; + let satLabels = []; + let cssWidth = 0; + let cssHeight = 0; + let devicePixelRatio = 1; + let mvpMatrix = identityMat4(); + let cameraDir = [0, 1, 0]; + let yaw = 0.8; + let pitch = 0.6; + let distance = 2.7; + let rafId = null; + let destroyed = false; + let activePointerId = null; + let lastPointerX = 0; + let lastPointerY = 0; + + const resizeObserver = (typeof ResizeObserver !== 'undefined') + ? new ResizeObserver(() => { + requestRender(); + }) + : null; + if (resizeObserver) resizeObserver.observe(canvas); + + canvas.addEventListener('pointerdown', onPointerDown); + canvas.addEventListener('pointermove', onPointerMove); + canvas.addEventListener('pointerup', onPointerUp); + canvas.addEventListener('pointercancel', onPointerUp); + canvas.addEventListener('wheel', onWheel, { passive: false }); + + requestRender(); + + function onPointerDown(evt) { + activePointerId = evt.pointerId; + lastPointerX = evt.clientX; + lastPointerY = evt.clientY; + if (canvas.setPointerCapture) canvas.setPointerCapture(evt.pointerId); + } + + function onPointerMove(evt) { + if (activePointerId == null || evt.pointerId !== activePointerId) return; + + const dx = evt.clientX - lastPointerX; + const dy = evt.clientY - lastPointerY; + lastPointerX = evt.clientX; + lastPointerY = evt.clientY; + + yaw += dx * 0.01; + pitch += dy * 0.01; + pitch = Math.max(0.1, Math.min(1.45, pitch)); + requestRender(); + } + + function onPointerUp(evt) { + if (activePointerId == null || evt.pointerId !== activePointerId) return; + if (canvas.releasePointerCapture) { + try { + canvas.releasePointerCapture(evt.pointerId); + } catch (_) {} + } + activePointerId = null; + } + + function onWheel(evt) { + evt.preventDefault(); + distance += evt.deltaY * 0.002; + distance = Math.max(2.0, Math.min(5.0, distance)); + requestRender(); + } + + function setSatellites(satellites) { + const positions = []; + const colors = []; + const sizes = []; + const usedFlags = []; + const labels = []; + + (satellites || []).forEach(sat => { + if (sat.elevation == null || sat.azimuth == null) return; + + const xyz = skyToCartesian(sat.azimuth, sat.elevation); + const hex = CONST_COLORS[sat.constellation] || CONST_COLORS.GPS; + const rgb = hexToRgb01(hex); + + positions.push(xyz[0], xyz[1], xyz[2]); + colors.push(rgb[0], rgb[1], rgb[2], sat.used ? 1 : 0.85); + sizes.push(sat.used ? 8 : 7); + usedFlags.push(sat.used ? 1 : 0); + + labels.push({ + text: String(sat.prn), + point: xyz, + color: hex, + used: !!sat.used, + }); + }); + + satLabels = labels; + satCount = positions.length / 3; + + gl.bindBuffer(gl.ARRAY_BUFFER, satPosBuffer); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.DYNAMIC_DRAW); + + gl.bindBuffer(gl.ARRAY_BUFFER, satColorBuffer); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(colors), gl.DYNAMIC_DRAW); + + gl.bindBuffer(gl.ARRAY_BUFFER, satSizeBuffer); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(sizes), gl.DYNAMIC_DRAW); + + gl.bindBuffer(gl.ARRAY_BUFFER, satUsedBuffer); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(usedFlags), gl.DYNAMIC_DRAW); + + requestRender(); + } + + function requestRender() { + if (destroyed || rafId != null) return; + rafId = requestAnimationFrame(render); + } + + function render() { + rafId = null; + if (destroyed) return; + + resizeCanvas(); + updateCameraMatrices(); + + const palette = getThemePalette(); + + gl.viewport(0, 0, canvas.width, canvas.height); + gl.clearColor(palette.bg[0], palette.bg[1], palette.bg[2], 1); + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + + gl.enable(gl.DEPTH_TEST); + gl.depthFunc(gl.LEQUAL); + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + + gl.useProgram(lineProgram); + gl.uniformMatrix4fv(lineLoc.mvp, false, mvpMatrix); + gl.bindBuffer(gl.ARRAY_BUFFER, gridBuffer); + gl.enableVertexAttribArray(lineLoc.position); + gl.vertexAttribPointer(lineLoc.position, 3, gl.FLOAT, false, 0, 0); + gl.uniform4fv(lineLoc.color, palette.grid); + gl.drawArrays(gl.LINES, 0, gridVertices.length / 3); + + gl.bindBuffer(gl.ARRAY_BUFFER, horizonBuffer); + gl.vertexAttribPointer(lineLoc.position, 3, gl.FLOAT, false, 0, 0); + gl.uniform4fv(lineLoc.color, palette.horizon); + gl.drawArrays(gl.LINES, 0, horizonVertices.length / 3); + + if (satCount > 0) { + gl.useProgram(pointProgram); + gl.uniformMatrix4fv(pointLoc.mvp, false, mvpMatrix); + gl.uniform1f(pointLoc.dpr, devicePixelRatio); + gl.uniform3fv(pointLoc.cameraDir, new Float32Array(cameraDir)); + + gl.bindBuffer(gl.ARRAY_BUFFER, satPosBuffer); + gl.enableVertexAttribArray(pointLoc.position); + gl.vertexAttribPointer(pointLoc.position, 3, gl.FLOAT, false, 0, 0); + + gl.bindBuffer(gl.ARRAY_BUFFER, satColorBuffer); + gl.enableVertexAttribArray(pointLoc.color); + gl.vertexAttribPointer(pointLoc.color, 4, gl.FLOAT, false, 0, 0); + + gl.bindBuffer(gl.ARRAY_BUFFER, satSizeBuffer); + gl.enableVertexAttribArray(pointLoc.size); + gl.vertexAttribPointer(pointLoc.size, 1, gl.FLOAT, false, 0, 0); + + gl.bindBuffer(gl.ARRAY_BUFFER, satUsedBuffer); + gl.enableVertexAttribArray(pointLoc.used); + gl.vertexAttribPointer(pointLoc.used, 1, gl.FLOAT, false, 0, 0); + + gl.drawArrays(gl.POINTS, 0, satCount); + } + + drawOverlayLabels(); + } + + function resizeCanvas() { + cssWidth = Math.max(1, Math.floor(canvas.clientWidth || 400)); + cssHeight = Math.max(1, Math.floor(canvas.clientHeight || 400)); + devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2); + + const renderWidth = Math.floor(cssWidth * devicePixelRatio); + const renderHeight = Math.floor(cssHeight * devicePixelRatio); + if (canvas.width !== renderWidth || canvas.height !== renderHeight) { + canvas.width = renderWidth; + canvas.height = renderHeight; + } + } + + function updateCameraMatrices() { + const cosPitch = Math.cos(pitch); + const eye = [ + distance * Math.sin(yaw) * cosPitch, + distance * Math.sin(pitch), + distance * Math.cos(yaw) * cosPitch, + ]; + + const eyeLen = Math.hypot(eye[0], eye[1], eye[2]) || 1; + cameraDir = [eye[0] / eyeLen, eye[1] / eyeLen, eye[2] / eyeLen]; + + const view = mat4LookAt(eye, [0, 0, 0], [0, 1, 0]); + const proj = mat4Perspective(degToRad(48), Math.max(cssWidth / cssHeight, 0.01), 0.1, 20); + mvpMatrix = mat4Multiply(proj, view); + } + + function drawOverlayLabels() { + if (!overlay) return; + + const fragment = document.createDocumentFragment(); + const cardinals = [ + { text: 'N', point: [0, 0, 1] }, + { text: 'E', point: [1, 0, 0] }, + { text: 'S', point: [0, 0, -1] }, + { text: 'W', point: [-1, 0, 0] }, + { text: 'Z', point: [0, 1, 0] }, + ]; + + cardinals.forEach(entry => { + addLabel(fragment, entry.text, entry.point, 'gps-sky-label gps-sky-label-cardinal'); + }); + + satLabels.forEach(sat => { + const cls = 'gps-sky-label gps-sky-label-sat' + (sat.used ? '' : ' unused'); + addLabel(fragment, sat.text, sat.point, cls, sat.color); + }); + + overlay.replaceChildren(fragment); + } + + function addLabel(fragment, text, point, className, color) { + const facing = point[0] * cameraDir[0] + point[1] * cameraDir[1] + point[2] * cameraDir[2]; + if (facing <= 0.02) return; + + const projected = projectPoint(point, mvpMatrix, cssWidth, cssHeight); + if (!projected) return; + + const label = document.createElement('span'); + label.className = className; + label.textContent = text; + label.style.left = projected.x.toFixed(1) + 'px'; + label.style.top = projected.y.toFixed(1) + 'px'; + if (color) label.style.color = color; + fragment.appendChild(label); + } + + function getThemePalette() { + const cs = getComputedStyle(document.documentElement); + const bg = parseCssColor(cs.getPropertyValue('--bg-card').trim(), '#0d1117'); + const grid = parseCssColor(cs.getPropertyValue('--border-color').trim(), '#3a4254'); + const accent = parseCssColor(cs.getPropertyValue('--accent-cyan').trim(), '#4aa3ff'); + + return { + bg: bg, + grid: [grid[0], grid[1], grid[2], 0.42], + horizon: [accent[0], accent[1], accent[2], 0.56], + }; + } + + function destroy() { + destroyed = true; + if (rafId != null) cancelAnimationFrame(rafId); + canvas.removeEventListener('pointerdown', onPointerDown); + canvas.removeEventListener('pointermove', onPointerMove); + canvas.removeEventListener('pointerup', onPointerUp); + canvas.removeEventListener('pointercancel', onPointerUp); + canvas.removeEventListener('wheel', onWheel); + if (resizeObserver) { + try { + resizeObserver.disconnect(); + } catch (_) {} + } + if (overlay) overlay.replaceChildren(); + } + + return { + setSatellites: setSatellites, + requestRender: requestRender, + destroy: destroy, + }; + } + + function buildSkyGridVertices() { + const vertices = []; + + [15, 30, 45, 60, 75].forEach(el => { + appendLineStrip(vertices, buildRingPoints(el, 6)); + }); + + for (let az = 0; az < 360; az += 30) { + appendLineStrip(vertices, buildMeridianPoints(az, 5)); + } + + return new Float32Array(vertices); + } + + function buildSkyRingVertices(elevation, stepAz) { + const vertices = []; + appendLineStrip(vertices, buildRingPoints(elevation, stepAz)); + return new Float32Array(vertices); + } + + function buildRingPoints(elevation, stepAz) { + const points = []; + for (let az = 0; az <= 360; az += stepAz) { + points.push(skyToCartesian(az, elevation)); + } + return points; + } + + function buildMeridianPoints(azimuth, stepEl) { + const points = []; + for (let el = 0; el <= 90; el += stepEl) { + points.push(skyToCartesian(azimuth, el)); + } + return points; + } + + function appendLineStrip(target, points) { + for (let i = 1; i < points.length; i += 1) { + const a = points[i - 1]; + const b = points[i]; + target.push(a[0], a[1], a[2], b[0], b[1], b[2]); + } + } + + function skyToCartesian(azimuthDeg, elevationDeg) { + const az = degToRad(azimuthDeg); + const el = degToRad(elevationDeg); + const cosEl = Math.cos(el); + return [ + cosEl * Math.sin(az), + Math.sin(el), + cosEl * Math.cos(az), + ]; + } + + function degToRad(deg) { + return deg * Math.PI / 180; + } + + function createProgram(gl, vertexSource, fragmentSource) { + const vertexShader = compileShader(gl, gl.VERTEX_SHADER, vertexSource); + const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource); + if (!vertexShader || !fragmentShader) return null; + + const program = gl.createProgram(); + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + gl.linkProgram(program); + + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + console.warn('WebGL program link failed:', gl.getProgramInfoLog(program)); + gl.deleteProgram(program); + return null; + } + + return program; + } + + function compileShader(gl, type, source) { + const shader = gl.createShader(type); + gl.shaderSource(shader, source); + gl.compileShader(shader); + + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + console.warn('WebGL shader compile failed:', gl.getShaderInfoLog(shader)); + gl.deleteShader(shader); + return null; + } + + return shader; + } + + function identityMat4() { + return new Float32Array([ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + ]); + } + + function mat4Perspective(fovy, aspect, near, far) { + const f = 1 / Math.tan(fovy / 2); + const nf = 1 / (near - far); + + return new Float32Array([ + f / aspect, 0, 0, 0, + 0, f, 0, 0, + 0, 0, (far + near) * nf, -1, + 0, 0, (2 * far * near) * nf, 0, + ]); + } + + function mat4LookAt(eye, center, up) { + const zx = eye[0] - center[0]; + const zy = eye[1] - center[1]; + const zz = eye[2] - center[2]; + const zLen = Math.hypot(zx, zy, zz) || 1; + const znx = zx / zLen; + const zny = zy / zLen; + const znz = zz / zLen; + + const xx = up[1] * znz - up[2] * zny; + const xy = up[2] * znx - up[0] * znz; + const xz = up[0] * zny - up[1] * znx; + const xLen = Math.hypot(xx, xy, xz) || 1; + const xnx = xx / xLen; + const xny = xy / xLen; + const xnz = xz / xLen; + + const ynx = zny * xnz - znz * xny; + const yny = znz * xnx - znx * xnz; + const ynz = znx * xny - zny * xnx; + + return new Float32Array([ + xnx, ynx, znx, 0, + xny, yny, zny, 0, + xnz, ynz, znz, 0, + -(xnx * eye[0] + xny * eye[1] + xnz * eye[2]), + -(ynx * eye[0] + yny * eye[1] + ynz * eye[2]), + -(znx * eye[0] + zny * eye[1] + znz * eye[2]), + 1, + ]); + } + + function mat4Multiply(a, b) { + const out = new Float32Array(16); + for (let col = 0; col < 4; col += 1) { + for (let row = 0; row < 4; row += 1) { + out[col * 4 + row] = + a[row] * b[col * 4] + + a[4 + row] * b[col * 4 + 1] + + a[8 + row] * b[col * 4 + 2] + + a[12 + row] * b[col * 4 + 3]; + } + } + return out; + } + + function projectPoint(point, matrix, width, height) { + const x = point[0]; + const y = point[1]; + const z = point[2]; + + const clipX = matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12]; + const clipY = matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13]; + const clipW = matrix[3] * x + matrix[7] * y + matrix[11] * z + matrix[15]; + if (clipW <= 0.0001) return null; + + const ndcX = clipX / clipW; + const ndcY = clipY / clipW; + if (Math.abs(ndcX) > 1.2 || Math.abs(ndcY) > 1.2) return null; + + return { + x: (ndcX * 0.5 + 0.5) * width, + y: (1 - (ndcY * 0.5 + 0.5)) * height, + }; + } + + function parseCssColor(raw, fallbackHex) { + const value = (raw || '').trim(); + + if (value.startsWith('#')) { + return hexToRgb01(value); + } + + const match = value.match(/rgba?\(([^)]+)\)/i); + if (match) { + const parts = match[1].split(',').map(part => parseFloat(part.trim())); + if (parts.length >= 3 && parts.every(n => Number.isFinite(n))) { + return [parts[0] / 255, parts[1] / 255, parts[2] / 255]; + } + } + + return hexToRgb01(fallbackHex || '#0d1117'); + } + + function hexToRgb01(hex) { + let clean = (hex || '').trim().replace('#', ''); + if (clean.length === 3) { + clean = clean.split('').map(ch => ch + ch).join(''); + } + if (!/^[0-9a-fA-F]{6}$/.test(clean)) { + return [0, 0, 0]; + } + + const num = parseInt(clean, 16); + return [ + ((num >> 16) & 255) / 255, + ((num >> 8) & 255) / 255, + (num & 255) / 255, + ]; + } // ======================== // Signal Strength Bars @@ -439,10 +1073,19 @@ const GPS = (function() { // Cleanup // ======================== - function destroy() { - unsubscribeFromStream(); - stopSkyPolling(); - } + function destroy() { + unsubscribeFromStream(); + stopSkyPolling(); + if (themeObserver) { + themeObserver.disconnect(); + themeObserver = null; + } + if (skyRenderer) { + skyRenderer.destroy(); + skyRenderer = null; + } + skyRendererInitAttempted = false; + } return { init: init, diff --git a/static/js/modes/listening-post.js b/static/js/modes/listening-post.js deleted file mode 100644 index 70f49fb..0000000 --- a/static/js/modes/listening-post.js +++ /dev/null @@ -1,4152 +0,0 @@ -/** - * Intercept - Listening Post Mode - * Frequency scanner and manual audio receiver - */ - -// ============== STATE ============== - -let isScannerRunning = false; -let isScannerPaused = false; -let scannerEventSource = null; -let scannerSignalCount = 0; -let scannerLogEntries = []; -let scannerFreqsScanned = 0; -let scannerCycles = 0; -let scannerStartFreq = 118; -let scannerEndFreq = 137; -let scannerSignalActive = false; -let lastScanProgress = null; -let scannerTotalSteps = 0; -let scannerMethod = null; -let scannerStepKhz = 25; -let lastScanFreq = null; - -// Audio state -let isAudioPlaying = false; -let audioToolsAvailable = { rtl_fm: false, ffmpeg: false }; -let audioReconnectAttempts = 0; -const MAX_AUDIO_RECONNECT = 3; - -// WebSocket audio state -let audioWebSocket = null; -let audioQueue = []; -let isWebSocketAudio = false; -let audioFetchController = null; -let audioUnlockRequested = false; -let scannerSnrThreshold = 8; - -// Visualizer state -let visualizerContext = null; -let visualizerAnalyser = null; -let visualizerSource = null; -let visualizerAnimationId = null; -let peakLevel = 0; -let peakDecay = 0.95; - -// Signal level for synthesizer visualization -let currentSignalLevel = 0; -let signalLevelThreshold = 1000; - -// Track recent signal hits to prevent duplicates -let recentSignalHits = new Map(); - -// Direct listen state -let isDirectListening = false; -let currentModulation = 'am'; - -// Agent mode state -let listeningPostCurrentAgent = null; -let listeningPostPollTimer = null; - -// ============== PRESETS ============== - -const scannerPresets = { - fm: { start: 88, end: 108, step: 200, mod: 'wfm' }, - air: { start: 118, end: 137, step: 25, mod: 'am' }, - marine: { start: 156, end: 163, step: 25, mod: 'fm' }, - amateur2m: { start: 144, end: 148, step: 12.5, mod: 'fm' }, - pager: { start: 152, end: 160, step: 25, mod: 'fm' }, - amateur70cm: { start: 420, end: 450, step: 25, mod: 'fm' } -}; - -/** - * Suggest the appropriate modulation for a given frequency (in MHz). - * Uses standard band allocations to pick AM, NFM, WFM, or USB. - */ -function suggestModulation(freqMhz) { - if (freqMhz < 0.52) return 'am'; // LW/MW AM broadcast - if (freqMhz < 1.7) return 'am'; // MW AM broadcast - if (freqMhz < 30) return 'usb'; // HF/Shortwave - if (freqMhz < 88) return 'fm'; // VHF Low (public safety) - if (freqMhz < 108) return 'wfm'; // FM Broadcast - if (freqMhz < 137) return 'am'; // Airband - if (freqMhz < 174) return 'fm'; // VHF marine, 2m ham, pagers - if (freqMhz < 216) return 'wfm'; // VHF TV/DAB - if (freqMhz < 470) return 'fm'; // UHF various, 70cm, business/GMRS - if (freqMhz < 960) return 'wfm'; // UHF TV - return 'am'; // Microwave/ADS-B -} - -const audioPresets = { - fm: { freq: 98.1, mod: 'wfm' }, - airband: { freq: 121.5, mod: 'am' }, // Emergency/guard frequency - marine: { freq: 156.8, mod: 'fm' }, // Channel 16 - distress - amateur2m: { freq: 146.52, mod: 'fm' }, // 2m calling frequency - amateur70cm: { freq: 446.0, mod: 'fm' } -}; - -// ============== SCANNER TOOLS CHECK ============== - -function checkScannerTools() { - fetch('/listening/tools') - .then(r => r.json()) - .then(data => { - const warnings = []; - if (!data.rtl_fm) { - warnings.push('rtl_fm not found - install rtl-sdr tools'); - } - if (!data.ffmpeg) { - warnings.push('ffmpeg not found - install: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)'); - } - - const warningDiv = document.getElementById('scannerToolsWarning'); - const warningText = document.getElementById('scannerToolsWarningText'); - if (warningDiv && warnings.length > 0) { - warningText.innerHTML = warnings.join('
'); - warningDiv.style.display = 'block'; - document.getElementById('scannerStartBtn').disabled = true; - document.getElementById('scannerStartBtn').style.opacity = '0.5'; - } else if (warningDiv) { - warningDiv.style.display = 'none'; - document.getElementById('scannerStartBtn').disabled = false; - document.getElementById('scannerStartBtn').style.opacity = '1'; - } - }) - .catch(() => {}); -} - -// ============== SCANNER HELPERS ============== - -/** - * Get the currently selected device from the global SDR selector - */ -function getSelectedDevice() { - const select = document.getElementById('deviceSelect'); - return parseInt(select?.value || '0'); -} - -/** - * Get the currently selected SDR type from the global selector - */ -function getSelectedSDRTypeForScanner() { - const select = document.getElementById('sdrTypeSelect'); - return select?.value || 'rtlsdr'; -} - -// ============== SCANNER PRESETS ============== - -function applyScannerPreset() { - const preset = document.getElementById('scannerPreset').value; - if (preset !== 'custom' && scannerPresets[preset]) { - const p = scannerPresets[preset]; - document.getElementById('scannerStartFreq').value = p.start; - document.getElementById('scannerEndFreq').value = p.end; - document.getElementById('scannerStep').value = p.step; - document.getElementById('scannerModulation').value = p.mod; - } -} - -// ============== SCANNER CONTROLS ============== - -function toggleScanner() { - if (isScannerRunning) { - stopScanner(); - } else { - startScanner(); - } -} - -function startScanner() { - // Use unified radio controls - read all current UI values - const startFreq = parseFloat(document.getElementById('radioScanStart')?.value || 118); - const endFreq = parseFloat(document.getElementById('radioScanEnd')?.value || 137); - const stepSelect = document.getElementById('radioScanStep'); - const step = stepSelect ? parseFloat(stepSelect.value) : 25; - const modulation = currentModulation || 'am'; - const squelch = parseInt(document.getElementById('radioSquelchValue')?.textContent) || 30; - const gain = parseInt(document.getElementById('radioGainValue')?.textContent) || 40; - const dwellSelect = document.getElementById('radioScanDwell'); - const dwell = dwellSelect ? parseInt(dwellSelect.value) : 10; - const device = getSelectedDevice(); - const snrThreshold = scannerSnrThreshold || 12; - - // Check if using agent mode - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - listeningPostCurrentAgent = isAgentMode ? currentAgent : null; - - // Disable listen button for agent mode (audio can't stream over HTTP) - updateListenButtonState(isAgentMode); - - if (startFreq >= endFreq) { - if (typeof showNotification === 'function') { - showNotification('Scanner Error', 'End frequency must be greater than start'); - } - return; - } - - // Check if device is available (only for local mode) - if (!isAgentMode && typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('scanner')) { - return; - } - - // Store scanner range for progress calculation - scannerStartFreq = startFreq; - scannerEndFreq = endFreq; - scannerFreqsScanned = 0; - scannerCycles = 0; - lastScanProgress = null; - scannerTotalSteps = Math.max(1, Math.round(((endFreq - startFreq) * 1000) / step)); - scannerStepKhz = step; - lastScanFreq = null; - - // Update sidebar display - updateScannerDisplay('STARTING...', 'var(--accent-orange)'); - - // Show progress bars - const progressEl = document.getElementById('scannerProgress'); - if (progressEl) { - progressEl.style.display = 'block'; - document.getElementById('scannerRangeStart').textContent = startFreq.toFixed(1); - document.getElementById('scannerRangeEnd').textContent = endFreq.toFixed(1); - } - - const mainProgress = document.getElementById('mainScannerProgress'); - if (mainProgress) { - mainProgress.style.display = 'block'; - document.getElementById('mainRangeStart').textContent = startFreq.toFixed(1) + ' MHz'; - document.getElementById('mainRangeEnd').textContent = endFreq.toFixed(1) + ' MHz'; - } - - // Determine endpoint based on agent mode - const endpoint = isAgentMode - ? `/controller/agents/${currentAgent}/listening_post/start` - : '/listening/scanner/start'; - - fetch(endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - start_freq: startFreq, - end_freq: endFreq, - step: step, - modulation: modulation, - squelch: squelch, - gain: gain, - dwell_time: dwell, - device: device, - bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false, - snr_threshold: snrThreshold, - scan_method: 'power' - }) - }) - .then(r => r.json()) - .then(data => { - // Handle controller proxy response format - const scanResult = isAgentMode && data.result ? data.result : data; - - if (scanResult.status === 'started' || scanResult.status === 'success') { - if (!isAgentMode && typeof reserveDevice === 'function') reserveDevice(device, 'scanner'); - isScannerRunning = true; - isScannerPaused = false; - scannerSignalActive = false; - scannerMethod = (scanResult.config && scanResult.config.scan_method) ? scanResult.config.scan_method : 'power'; - if (scanResult.config) { - const cfgStart = parseFloat(scanResult.config.start_freq); - const cfgEnd = parseFloat(scanResult.config.end_freq); - const cfgStep = parseFloat(scanResult.config.step); - if (Number.isFinite(cfgStart)) scannerStartFreq = cfgStart; - if (Number.isFinite(cfgEnd)) scannerEndFreq = cfgEnd; - if (Number.isFinite(cfgStep)) scannerStepKhz = cfgStep; - scannerTotalSteps = Math.max(1, Math.round(((scannerEndFreq - scannerStartFreq) * 1000) / (scannerStepKhz || 1))); - - const startInput = document.getElementById('radioScanStart'); - if (startInput && Number.isFinite(cfgStart)) startInput.value = cfgStart.toFixed(3); - const endInput = document.getElementById('radioScanEnd'); - if (endInput && Number.isFinite(cfgEnd)) endInput.value = cfgEnd.toFixed(3); - - const rangeStart = document.getElementById('scannerRangeStart'); - if (rangeStart && Number.isFinite(cfgStart)) rangeStart.textContent = cfgStart.toFixed(1); - const rangeEnd = document.getElementById('scannerRangeEnd'); - if (rangeEnd && Number.isFinite(cfgEnd)) rangeEnd.textContent = cfgEnd.toFixed(1); - const mainRangeStart = document.getElementById('mainRangeStart'); - if (mainRangeStart && Number.isFinite(cfgStart)) mainRangeStart.textContent = cfgStart.toFixed(1) + ' MHz'; - const mainRangeEnd = document.getElementById('mainRangeEnd'); - if (mainRangeEnd && Number.isFinite(cfgEnd)) mainRangeEnd.textContent = cfgEnd.toFixed(1) + ' MHz'; - } - - // Update controls (with null checks) - const startBtn = document.getElementById('scannerStartBtn'); - if (startBtn) { - startBtn.textContent = 'Stop Scanner'; - startBtn.classList.add('active'); - } - const pauseBtn = document.getElementById('scannerPauseBtn'); - if (pauseBtn) pauseBtn.disabled = false; - - // Update radio scan button to show STOP - const radioScanBtn = document.getElementById('radioScanBtn'); - if (radioScanBtn) { - radioScanBtn.innerHTML = Icons.stop('icon--sm') + ' STOP'; - radioScanBtn.style.background = 'var(--accent-red)'; - radioScanBtn.style.borderColor = 'var(--accent-red)'; - } - - updateScannerDisplay('SCANNING', 'var(--accent-cyan)'); - const statusText = document.getElementById('scannerStatusText'); - if (statusText) statusText.textContent = 'Scanning...'; - - // Show level meter - const levelMeter = document.getElementById('scannerLevelMeter'); - if (levelMeter) levelMeter.style.display = 'block'; - - connectScannerStream(isAgentMode); - addScannerLogEntry('Scanner started', `Range: ${startFreq}-${endFreq} MHz, Step: ${step} kHz`); - if (typeof showNotification === 'function') { - showNotification('Scanner Started', `Scanning ${startFreq} - ${endFreq} MHz`); - } - } else { - updateScannerDisplay('ERROR', 'var(--accent-red)'); - if (typeof showNotification === 'function') { - showNotification('Scanner Error', scanResult.message || scanResult.error || 'Failed to start'); - } - } - }) - .catch(err => { - const statusText = document.getElementById('scannerStatusText'); - if (statusText) statusText.textContent = 'ERROR'; - updateScannerDisplay('ERROR', 'var(--accent-red)'); - if (typeof showNotification === 'function') { - showNotification('Scanner Error', err.message); - } - }); -} - -function stopScanner() { - const isAgentMode = listeningPostCurrentAgent !== null; - const endpoint = isAgentMode - ? `/controller/agents/${listeningPostCurrentAgent}/listening_post/stop` - : '/listening/scanner/stop'; - - return fetch(endpoint, { method: 'POST' }) - .then(() => { - if (!isAgentMode && typeof releaseDevice === 'function') releaseDevice('scanner'); - listeningPostCurrentAgent = null; - isScannerRunning = false; - isScannerPaused = false; - scannerSignalActive = false; - currentSignalLevel = 0; - lastScanProgress = null; - scannerTotalSteps = 0; - scannerMethod = null; - scannerCycles = 0; - scannerFreqsScanned = 0; - lastScanFreq = null; - - // Re-enable listen button (will be in local mode after stop) - updateListenButtonState(false); - - // Clear polling timer - if (listeningPostPollTimer) { - clearInterval(listeningPostPollTimer); - listeningPostPollTimer = null; - } - - // Update sidebar (with null checks) - const startBtn = document.getElementById('scannerStartBtn'); - if (startBtn) { - startBtn.textContent = 'Start Scanner'; - startBtn.classList.remove('active'); - } - const pauseBtn = document.getElementById('scannerPauseBtn'); - if (pauseBtn) { - pauseBtn.disabled = true; - pauseBtn.innerHTML = Icons.pause('icon--sm') + ' Pause'; - } - - // Update radio scan button - const radioScanBtn = document.getElementById('radioScanBtn'); - if (radioScanBtn) { - radioScanBtn.innerHTML = '📡 SCAN'; - radioScanBtn.style.background = ''; - radioScanBtn.style.borderColor = ''; - } - - updateScannerDisplay('STOPPED', 'var(--text-muted)'); - const currentFreq = document.getElementById('scannerCurrentFreq'); - if (currentFreq) currentFreq.textContent = '---.--- MHz'; - const modLabel = document.getElementById('scannerModLabel'); - if (modLabel) modLabel.textContent = '--'; - - const progressEl = document.getElementById('scannerProgress'); - if (progressEl) progressEl.style.display = 'none'; - - const signalPanel = document.getElementById('scannerSignalPanel'); - if (signalPanel) signalPanel.style.display = 'none'; - - const levelMeter = document.getElementById('scannerLevelMeter'); - if (levelMeter) levelMeter.style.display = 'none'; - - const statusText = document.getElementById('scannerStatusText'); - if (statusText) statusText.textContent = 'Ready'; - - // Update main display - const mainModeLabel = document.getElementById('mainScannerModeLabel'); - if (mainModeLabel) { - mainModeLabel.textContent = 'SCANNER STOPPED'; - document.getElementById('mainScannerFreq').textContent = '---.---'; - document.getElementById('mainScannerFreq').style.color = 'var(--text-muted)'; - document.getElementById('mainScannerMod').textContent = '--'; - } - - const mainAnim = document.getElementById('mainScannerAnimation'); - if (mainAnim) mainAnim.style.display = 'none'; - - const mainProgress = document.getElementById('mainScannerProgress'); - if (mainProgress) mainProgress.style.display = 'none'; - - const mainSignalAlert = document.getElementById('mainSignalAlert'); - if (mainSignalAlert) mainSignalAlert.style.display = 'none'; - - // Stop scanner audio - const scannerAudio = document.getElementById('scannerAudioPlayer'); - if (scannerAudio) { - scannerAudio.pause(); - scannerAudio.src = ''; - } - - if (scannerEventSource) { - scannerEventSource.close(); - scannerEventSource = null; - } - addScannerLogEntry('Scanner stopped', ''); - }) - .catch(() => {}); -} - -function pauseScanner() { - const endpoint = isScannerPaused ? '/listening/scanner/resume' : '/listening/scanner/pause'; - fetch(endpoint, { method: 'POST' }) - .then(r => r.json()) - .then(data => { - isScannerPaused = !isScannerPaused; - const pauseBtn = document.getElementById('scannerPauseBtn'); - if (pauseBtn) pauseBtn.innerHTML = isScannerPaused ? Icons.play('icon--sm') + ' Resume' : Icons.pause('icon--sm') + ' Pause'; - const statusText = document.getElementById('scannerStatusText'); - if (statusText) { - statusText.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING'; - statusText.style.color = isScannerPaused ? 'var(--accent-orange)' : 'var(--accent-green)'; - } - - const activityStatus = document.getElementById('scannerActivityStatus'); - if (activityStatus) { - activityStatus.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING'; - activityStatus.style.color = isScannerPaused ? 'var(--accent-orange)' : 'var(--accent-green)'; - } - - // Update main display - const mainModeLabel = document.getElementById('mainScannerModeLabel'); - if (mainModeLabel) { - mainModeLabel.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING'; - } - - addScannerLogEntry(isScannerPaused ? 'Scanner paused' : 'Scanner resumed', ''); - }) - .catch(() => {}); -} - -function skipSignal() { - if (!isScannerRunning) { - if (typeof showNotification === 'function') { - showNotification('Scanner', 'Scanner is not running'); - } - return; - } - - fetch('/listening/scanner/skip', { method: 'POST' }) - .then(r => r.json()) - .then(data => { - if (data.status === 'skipped' && typeof showNotification === 'function') { - showNotification('Signal Skipped', `Continuing scan from ${data.frequency.toFixed(3)} MHz`); - } - }) - .catch(err => { - if (typeof showNotification === 'function') { - showNotification('Skip Error', err.message); - } - }); -} - -// ============== SCANNER STREAM ============== - -function connectScannerStream(isAgentMode = false) { - if (scannerEventSource) { - scannerEventSource.close(); - } - - // Use different stream endpoint for agent mode - const streamUrl = isAgentMode ? '/controller/stream/all' : '/listening/scanner/stream'; - scannerEventSource = new EventSource(streamUrl); - - scannerEventSource.onmessage = function(e) { - try { - const data = JSON.parse(e.data); - - if (isAgentMode) { - // Handle multi-agent stream format - if (data.scan_type === 'listening_post' && data.payload) { - const payload = data.payload; - payload.agent_name = data.agent_name; - handleScannerEvent(payload); - } - } else { - handleScannerEvent(data); - } - } catch (err) { - console.warn('Scanner parse error:', err); - } - }; - - scannerEventSource.onerror = function() { - if (isScannerRunning) { - setTimeout(() => connectScannerStream(isAgentMode), 2000); - } - }; - - // Start polling fallback for agent mode - if (isAgentMode) { - startListeningPostPolling(); - } -} - -// Track last activity count for polling -let lastListeningPostActivityCount = 0; - -function startListeningPostPolling() { - if (listeningPostPollTimer) return; - lastListeningPostActivityCount = 0; - - // Disable listen button for agent mode (audio can't stream over HTTP) - updateListenButtonState(true); - - const pollInterval = 2000; - listeningPostPollTimer = setInterval(async () => { - if (!isScannerRunning || !listeningPostCurrentAgent) { - clearInterval(listeningPostPollTimer); - listeningPostPollTimer = null; - return; - } - - try { - const response = await fetch(`/controller/agents/${listeningPostCurrentAgent}/listening_post/data`); - if (!response.ok) return; - - const data = await response.json(); - const result = data.result || data; - // Controller returns nested structure: data.data.data for agent mode data - const outerData = result.data || {}; - const modeData = outerData.data || outerData; - - // Process activity from polling response - const activity = modeData.activity || []; - if (activity.length > lastListeningPostActivityCount) { - const newActivity = activity.slice(lastListeningPostActivityCount); - newActivity.forEach(item => { - // Convert to scanner event format - const event = { - type: 'signal_found', - frequency: item.frequency, - level: item.level || item.signal_level, - modulation: item.modulation, - agent_name: result.agent_name || 'Remote Agent' - }; - handleScannerEvent(event); - }); - lastListeningPostActivityCount = activity.length; - } - - // Update current frequency if available - if (modeData.current_freq) { - handleScannerEvent({ - type: 'freq_change', - frequency: modeData.current_freq - }); - } - - // Update freqs scanned counter from agent data - if (modeData.freqs_scanned !== undefined) { - const freqsEl = document.getElementById('mainFreqsScanned'); - if (freqsEl) freqsEl.textContent = modeData.freqs_scanned; - scannerFreqsScanned = modeData.freqs_scanned; - } - - // Update signal count from agent data - if (modeData.signal_count !== undefined) { - const signalEl = document.getElementById('mainSignalCount'); - if (signalEl) signalEl.textContent = modeData.signal_count; - } - } catch (err) { - console.error('Listening Post polling error:', err); - } - }, pollInterval); -} - -function handleScannerEvent(data) { - switch (data.type) { - case 'freq_change': - case 'scan_update': - handleFrequencyUpdate(data); - break; - case 'signal_found': - handleSignalFound(data); - break; - case 'signal_lost': - case 'signal_skipped': - handleSignalLost(data); - break; - case 'log': - if (data.entry && data.entry.type === 'scan_cycle') { - scannerCycles++; - lastScanProgress = null; - lastScanFreq = null; - if (scannerTotalSteps > 0) { - scannerFreqsScanned = scannerCycles * scannerTotalSteps; - const freqsEl = document.getElementById('mainFreqsScanned'); - if (freqsEl) freqsEl.textContent = scannerFreqsScanned; - } - const cyclesEl = document.getElementById('mainScanCycles'); - if (cyclesEl) cyclesEl.textContent = scannerCycles; - } - break; - case 'stopped': - stopScanner(); - break; - } -} - -function handleFrequencyUpdate(data) { - if (data.range_start !== undefined && data.range_end !== undefined) { - const newStart = parseFloat(data.range_start); - const newEnd = parseFloat(data.range_end); - if (Number.isFinite(newStart) && Number.isFinite(newEnd) && newEnd > newStart) { - scannerStartFreq = newStart; - scannerEndFreq = newEnd; - scannerTotalSteps = Math.max(1, Math.round(((scannerEndFreq - scannerStartFreq) * 1000) / (scannerStepKhz || 1))); - - const rangeStart = document.getElementById('scannerRangeStart'); - if (rangeStart) rangeStart.textContent = newStart.toFixed(1); - const rangeEnd = document.getElementById('scannerRangeEnd'); - if (rangeEnd) rangeEnd.textContent = newEnd.toFixed(1); - const mainRangeStart = document.getElementById('mainRangeStart'); - if (mainRangeStart) mainRangeStart.textContent = newStart.toFixed(1) + ' MHz'; - const mainRangeEnd = document.getElementById('mainRangeEnd'); - if (mainRangeEnd) mainRangeEnd.textContent = newEnd.toFixed(1) + ' MHz'; - - const startInput = document.getElementById('radioScanStart'); - if (startInput && document.activeElement !== startInput) { - startInput.value = newStart.toFixed(3); - } - const endInput = document.getElementById('radioScanEnd'); - if (endInput && document.activeElement !== endInput) { - endInput.value = newEnd.toFixed(3); - } - } - } - - const range = scannerEndFreq - scannerStartFreq; - if (range <= 0) { - return; - } - - const effectiveRange = scannerEndFreq - scannerStartFreq; - if (effectiveRange <= 0) { - return; - } - - const hasProgress = data.progress !== undefined && Number.isFinite(data.progress); - const freqValue = (typeof data.frequency === 'number' && Number.isFinite(data.frequency)) - ? data.frequency - : null; - const stepMhz = Math.max(0.001, (scannerStepKhz || 1) / 1000); - const freqTolerance = stepMhz * 2; - - let progressValue = null; - if (hasProgress) { - progressValue = data.progress; - const clamped = Math.max(0, Math.min(1, progressValue)); - if (lastScanProgress !== null && clamped < lastScanProgress) { - const isCycleReset = lastScanProgress > 0.85 && clamped < 0.15; - if (!isCycleReset) { - return; - } - } - lastScanProgress = clamped; - } else if (freqValue !== null) { - if (lastScanFreq !== null && (freqValue + freqTolerance) < lastScanFreq) { - const nearEnd = lastScanFreq >= (scannerEndFreq - freqTolerance * 2); - const nearStart = freqValue <= (scannerStartFreq + freqTolerance * 2); - if (!nearEnd || !nearStart) { - return; - } - } - lastScanFreq = freqValue; - progressValue = (freqValue - scannerStartFreq) / effectiveRange; - lastScanProgress = Math.max(0, Math.min(1, progressValue)); - } else { - if (scannerMethod === 'power') { - return; - } - progressValue = 0; - lastScanProgress = 0; - } - - const clampedProgress = Math.max(0, Math.min(1, progressValue)); - - const displayFreq = (freqValue !== null - && freqValue >= (scannerStartFreq - freqTolerance) - && freqValue <= (scannerEndFreq + freqTolerance)) - ? freqValue - : scannerStartFreq + (clampedProgress * effectiveRange); - const freqStr = displayFreq.toFixed(3); - - const currentFreq = document.getElementById('scannerCurrentFreq'); - if (currentFreq) currentFreq.textContent = freqStr + ' MHz'; - - const mainFreq = document.getElementById('mainScannerFreq'); - if (mainFreq) mainFreq.textContent = freqStr; - - if (scannerTotalSteps > 0) { - const stepSize = Math.max(1, scannerStepKhz || 1); - const stepIndex = Math.max(0, Math.round(((displayFreq - scannerStartFreq) * 1000) / stepSize)); - const nextScanned = (scannerCycles * scannerTotalSteps) - + Math.min(scannerTotalSteps, stepIndex); - scannerFreqsScanned = Math.max(scannerFreqsScanned, nextScanned); - const freqsEl = document.getElementById('mainFreqsScanned'); - if (freqsEl) freqsEl.textContent = scannerFreqsScanned; - } - - // Update progress bar - const progress = Math.max(0, Math.min(100, clampedProgress * 100)); - const progressBar = document.getElementById('scannerProgressBar'); - if (progressBar) progressBar.style.width = Math.max(0, Math.min(100, progress)) + '%'; - - const mainProgressBar = document.getElementById('mainProgressBar'); - if (mainProgressBar) mainProgressBar.style.width = Math.max(0, Math.min(100, progress)) + '%'; - - // freqs scanned updated via progress above - - // Update level meter if present - if (data.level !== undefined) { - // Store for synthesizer visualization - currentSignalLevel = data.level; - if (data.threshold !== undefined) { - signalLevelThreshold = data.threshold; - } - - const levelPercent = Math.min(100, (data.level / 5000) * 100); - const levelBar = document.getElementById('scannerLevelBar'); - if (levelBar) { - levelBar.style.width = levelPercent + '%'; - if (data.detected) { - levelBar.style.background = 'var(--accent-green)'; - } else if (data.level > (data.threshold || 0) * 0.7) { - levelBar.style.background = 'var(--accent-orange)'; - } else { - levelBar.style.background = 'var(--accent-cyan)'; - } - } - const levelValue = document.getElementById('scannerLevelValue'); - if (levelValue) levelValue.textContent = data.level; - } - - const statusText = document.getElementById('scannerStatusText'); - if (statusText) statusText.textContent = `${freqStr} MHz${data.level !== undefined ? ` (level: ${data.level})` : ''}`; -} - -function handleSignalFound(data) { - // Only treat signals as "interesting" if they exceed threshold and match modulation - const threshold = data.threshold !== undefined ? data.threshold : signalLevelThreshold; - if (data.level !== undefined && threshold !== undefined && data.level < threshold) { - return; - } - if (data.modulation && currentModulation && data.modulation !== currentModulation) { - return; - } - - scannerSignalCount++; - scannerSignalActive = true; - const freqStr = data.frequency.toFixed(3); - - const signalCount = document.getElementById('scannerSignalCount'); - if (signalCount) signalCount.textContent = scannerSignalCount; - const mainSignalCount = document.getElementById('mainSignalCount'); - if (mainSignalCount) mainSignalCount.textContent = scannerSignalCount; - - // Update sidebar - updateScannerDisplay('SIGNAL FOUND', 'var(--accent-green)'); - const signalPanel = document.getElementById('scannerSignalPanel'); - if (signalPanel) signalPanel.style.display = 'block'; - const statusText = document.getElementById('scannerStatusText'); - if (statusText) statusText.textContent = 'Listening to signal...'; - - // Update main display - const mainModeLabel = document.getElementById('mainScannerModeLabel'); - if (mainModeLabel) mainModeLabel.textContent = 'SIGNAL DETECTED'; - - const mainFreq = document.getElementById('mainScannerFreq'); - if (mainFreq) mainFreq.style.color = 'var(--accent-green)'; - - const mainAnim = document.getElementById('mainScannerAnimation'); - if (mainAnim) mainAnim.style.display = 'none'; - - const mainSignalAlert = document.getElementById('mainSignalAlert'); - if (mainSignalAlert) mainSignalAlert.style.display = 'block'; - - // Start audio playback for the detected signal - if (data.audio_streaming) { - const scannerAudio = document.getElementById('scannerAudioPlayer'); - if (scannerAudio) { - // Pass the signal frequency and modulation to getStreamUrl - const streamUrl = getStreamUrl(data.frequency, data.modulation); - console.log('[SCANNER] Starting audio for signal:', data.frequency, 'MHz'); - scannerAudio.src = streamUrl; - scannerAudio.preload = 'auto'; - scannerAudio.autoplay = true; - scannerAudio.muted = false; - scannerAudio.load(); - // Apply current volume from knob - const volumeKnob = document.getElementById('radioVolumeKnob'); - if (volumeKnob && volumeKnob._knob) { - scannerAudio.volume = volumeKnob._knob.getValue() / 100; - } else if (volumeKnob) { - const knobValue = parseFloat(volumeKnob.dataset.value) || 80; - scannerAudio.volume = knobValue / 100; - } - attemptAudioPlay(scannerAudio); - // Initialize audio visualizer to feed signal levels to synthesizer - initAudioVisualizer(); - } - } - - // Add to sidebar recent signals - if (typeof addSidebarRecentSignal === 'function') { - addSidebarRecentSignal(data.frequency, data.modulation); - } - - addScannerLogEntry('SIGNAL FOUND', `${freqStr} MHz (${data.modulation.toUpperCase()})`, 'signal'); - addSignalHit(data); - - if (typeof showNotification === 'function') { - showNotification('Signal Found!', `${freqStr} MHz - Audio streaming`); - } - - // Auto-trigger signal identification - if (typeof guessSignal === 'function') { - guessSignal(data.frequency, data.modulation); - } -} - -function handleSignalLost(data) { - scannerSignalActive = false; - - // Update sidebar - updateScannerDisplay('SCANNING', 'var(--accent-cyan)'); - const signalPanel = document.getElementById('scannerSignalPanel'); - if (signalPanel) signalPanel.style.display = 'none'; - const statusText = document.getElementById('scannerStatusText'); - if (statusText) statusText.textContent = 'Scanning...'; - - // Update main display - const mainModeLabel = document.getElementById('mainScannerModeLabel'); - if (mainModeLabel) mainModeLabel.textContent = 'SCANNING'; - - const mainFreq = document.getElementById('mainScannerFreq'); - if (mainFreq) mainFreq.style.color = 'var(--accent-cyan)'; - - const mainAnim = document.getElementById('mainScannerAnimation'); - if (mainAnim) mainAnim.style.display = 'block'; - - const mainSignalAlert = document.getElementById('mainSignalAlert'); - if (mainSignalAlert) mainSignalAlert.style.display = 'none'; - - // Stop audio - const scannerAudio = document.getElementById('scannerAudioPlayer'); - if (scannerAudio) { - scannerAudio.pause(); - scannerAudio.src = ''; - } - - const logType = data.type === 'signal_skipped' ? 'info' : 'info'; - const logTitle = data.type === 'signal_skipped' ? 'Signal skipped' : 'Signal lost'; - addScannerLogEntry(logTitle, `${data.frequency.toFixed(3)} MHz`, logType); -} - -/** - * Update listen button state based on agent mode - * Audio streaming isn't practical over HTTP so disable for remote agents - */ -function updateListenButtonState(isAgentMode) { - const listenBtn = document.getElementById('radioListenBtn'); - if (!listenBtn) return; - - if (isAgentMode) { - listenBtn.disabled = true; - listenBtn.style.opacity = '0.5'; - listenBtn.style.cursor = 'not-allowed'; - listenBtn.title = 'Audio listening not available for remote agents'; - } else { - listenBtn.disabled = false; - listenBtn.style.opacity = '1'; - listenBtn.style.cursor = 'pointer'; - listenBtn.title = 'Listen to current frequency'; - } -} - -function updateScannerDisplay(mode, color) { - const modeLabel = document.getElementById('scannerModeLabel'); - if (modeLabel) { - modeLabel.textContent = mode; - modeLabel.style.color = color; - } - - const currentFreq = document.getElementById('scannerCurrentFreq'); - if (currentFreq) currentFreq.style.color = color; - - const mainModeLabel = document.getElementById('mainScannerModeLabel'); - if (mainModeLabel) mainModeLabel.textContent = mode; - - const mainFreq = document.getElementById('mainScannerFreq'); - if (mainFreq) mainFreq.style.color = color; -} - -// ============== SCANNER LOG ============== - -function addScannerLogEntry(title, detail, type = 'info') { - const now = new Date(); - const timestamp = now.toLocaleTimeString(); - const entry = { timestamp, title, detail, type }; - scannerLogEntries.unshift(entry); - - if (scannerLogEntries.length > 100) { - scannerLogEntries.pop(); - } - - // Color based on type - const getTypeColor = (t) => { - switch(t) { - case 'signal': return 'var(--accent-green)'; - case 'error': return 'var(--accent-red)'; - default: return 'var(--text-secondary)'; - } - }; - - // Update sidebar log - const sidebarLog = document.getElementById('scannerLog'); - if (sidebarLog) { - sidebarLog.innerHTML = scannerLogEntries.slice(0, 20).map(e => - `
- [${e.timestamp}] - ${e.title} ${e.detail} -
` - ).join(''); - } - - // Update main activity log - const activityLog = document.getElementById('scannerActivityLog'); - if (activityLog) { - const getBorderColor = (t) => { - switch(t) { - case 'signal': return 'var(--accent-green)'; - case 'error': return 'var(--accent-red)'; - default: return 'var(--border-color)'; - } - }; - activityLog.innerHTML = scannerLogEntries.slice(0, 50).map(e => - `
- [${e.timestamp}] - ${e.title} - ${e.detail} -
` - ).join(''); - } -} - -function addSignalHit(data) { - const tbody = document.getElementById('scannerHitsBody'); - if (!tbody) return; - - const now = Date.now(); - const freqKey = data.frequency.toFixed(3); - - // Check for duplicate - if (recentSignalHits.has(freqKey)) { - const lastHit = recentSignalHits.get(freqKey); - if (now - lastHit < 5000) return; - } - recentSignalHits.set(freqKey, now); - - // Clean up old entries - for (const [freq, time] of recentSignalHits) { - if (now - time > 30000) { - recentSignalHits.delete(freq); - } - } - - const timestamp = new Date().toLocaleTimeString(); - - if (tbody.innerHTML.includes('No signals detected')) { - tbody.innerHTML = ''; - } - - const mod = data.modulation || 'fm'; - const snr = data.snr != null ? data.snr : null; - const snrText = snr != null ? `${snr > 0 ? '+' : ''}${snr.toFixed(1)} dB` : '---'; - const snrColor = snr != null ? (snr >= 10 ? 'var(--accent-green)' : snr >= 3 ? 'var(--accent-cyan)' : 'var(--accent-orange, #f0a030)') : 'var(--text-muted)'; - const row = document.createElement('tr'); - row.style.borderBottom = '1px solid var(--border-color)'; - row.innerHTML = ` - ${timestamp} - ${data.frequency.toFixed(3)} - ${snrText} - ${mod.toUpperCase()} - - - - -
-
Pager
-
433 Sensor
-
RTLAMR
-
-
- - `; - tbody.insertBefore(row, tbody.firstChild); - - while (tbody.children.length > 50) { - tbody.removeChild(tbody.lastChild); - } - - const hitCount = document.getElementById('scannerHitCount'); - if (hitCount) hitCount.textContent = `${tbody.children.length} signals found`; - - // Feed to activity timeline if available - if (typeof addTimelineEvent === 'function') { - const normalized = typeof RFTimelineAdapter !== 'undefined' - ? RFTimelineAdapter.normalizeSignal({ - frequency: data.frequency, - rssi: data.rssi || data.signal_strength, - duration: data.duration || 2000, - modulation: data.modulation - }) - : { - id: String(data.frequency), - label: `${data.frequency.toFixed(3)} MHz`, - strength: 3, - duration: 2000, - type: 'rf' - }; - addTimelineEvent('listening', normalized); - } -} - -function clearScannerLog() { - scannerLogEntries = []; - scannerSignalCount = 0; - scannerFreqsScanned = 0; - scannerCycles = 0; - recentSignalHits.clear(); - - // Clear the timeline if available - const timeline = typeof getTimeline === 'function' ? getTimeline('listening') : null; - if (timeline) { - timeline.clear(); - } - - const signalCount = document.getElementById('scannerSignalCount'); - if (signalCount) signalCount.textContent = '0'; - - const mainSignalCount = document.getElementById('mainSignalCount'); - if (mainSignalCount) mainSignalCount.textContent = '0'; - - const mainFreqsScanned = document.getElementById('mainFreqsScanned'); - if (mainFreqsScanned) mainFreqsScanned.textContent = '0'; - - const mainScanCycles = document.getElementById('mainScanCycles'); - if (mainScanCycles) mainScanCycles.textContent = '0'; - - const sidebarLog = document.getElementById('scannerLog'); - if (sidebarLog) sidebarLog.innerHTML = '
Scanner activity will appear here...
'; - - const activityLog = document.getElementById('scannerActivityLog'); - if (activityLog) activityLog.innerHTML = '
Waiting for scanner to start...
'; - - const hitsBody = document.getElementById('scannerHitsBody'); - if (hitsBody) hitsBody.innerHTML = 'No signals detected'; - - const hitCount = document.getElementById('scannerHitCount'); - if (hitCount) hitCount.textContent = '0 signals found'; -} - -function exportScannerLog() { - if (scannerLogEntries.length === 0) { - if (typeof showNotification === 'function') { - showNotification('Export', 'No log entries to export'); - } - return; - } - - const csv = 'Timestamp,Event,Details\n' + scannerLogEntries.map(e => - `"${e.timestamp}","${e.title}","${e.detail}"` - ).join('\n'); - - const blob = new Blob([csv], { type: 'text/csv' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `scanner_log_${new Date().toISOString().slice(0, 10)}.csv`; - a.click(); - URL.revokeObjectURL(url); - - if (typeof showNotification === 'function') { - showNotification('Export', 'Log exported to CSV'); - } -} - -// ============== AUDIO TOOLS CHECK ============== - -function checkAudioTools() { - fetch('/listening/tools') - .then(r => r.json()) - .then(data => { - audioToolsAvailable.rtl_fm = data.rtl_fm; - audioToolsAvailable.ffmpeg = data.ffmpeg; - - // Only rtl_fm/rx_fm + ffmpeg are required for direct streaming - const warnings = []; - if (!data.rtl_fm && !data.rx_fm) { - warnings.push('rtl_fm/rx_fm not found - install rtl-sdr or soapysdr-tools'); - } - if (!data.ffmpeg) { - warnings.push('ffmpeg not found - install: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)'); - } - - const warningDiv = document.getElementById('audioToolsWarning'); - const warningText = document.getElementById('audioToolsWarningText'); - if (warningDiv) { - if (warnings.length > 0) { - warningText.innerHTML = warnings.join('
'); - warningDiv.style.display = 'block'; - document.getElementById('audioStartBtn').disabled = true; - document.getElementById('audioStartBtn').style.opacity = '0.5'; - } else { - warningDiv.style.display = 'none'; - document.getElementById('audioStartBtn').disabled = false; - document.getElementById('audioStartBtn').style.opacity = '1'; - } - } - }) - .catch(() => {}); -} - -// ============== AUDIO PRESETS ============== - -function applyAudioPreset() { - const preset = document.getElementById('audioPreset').value; - const freqInput = document.getElementById('audioFrequency'); - const modSelect = document.getElementById('audioModulation'); - - if (audioPresets[preset]) { - freqInput.value = audioPresets[preset].freq; - modSelect.value = audioPresets[preset].mod; - } -} - -// ============== AUDIO CONTROLS ============== - -function toggleAudio() { - if (isAudioPlaying) { - stopAudio(); - } else { - startAudio(); - } -} - -function startAudio() { - const frequency = parseFloat(document.getElementById('audioFrequency').value); - const modulation = document.getElementById('audioModulation').value; - const squelch = parseInt(document.getElementById('audioSquelch').value); - const gain = parseInt(document.getElementById('audioGain').value); - const device = getSelectedDevice(); - - if (isNaN(frequency) || frequency <= 0) { - if (typeof showNotification === 'function') { - showNotification('Audio Error', 'Invalid frequency'); - } - return; - } - - // Check if device is in use - if (typeof getDeviceInUseBy === 'function') { - const usedBy = getDeviceInUseBy(device); - if (usedBy && usedBy !== 'audio') { - if (typeof showNotification === 'function') { - showNotification('SDR In Use', `Device ${device} is being used by ${usedBy.toUpperCase()}.`); - } - return; - } - } - - document.getElementById('audioStatus').textContent = 'STARTING...'; - document.getElementById('audioStatus').style.color = 'var(--accent-orange)'; - - // Use direct streaming - no Icecast needed - if (typeof reserveDevice === 'function') reserveDevice(device, 'audio'); - isAudioPlaying = true; - - // Build direct stream URL with parameters - const streamUrl = `/listening/audio/stream?freq=${frequency}&mod=${modulation}&squelch=${squelch}&gain=${gain}&t=${Date.now()}`; - console.log('Connecting to direct stream:', streamUrl); - - // Start browser audio playback - const audioPlayer = document.getElementById('audioPlayer'); - audioPlayer.src = streamUrl; - audioPlayer.volume = document.getElementById('audioVolume').value / 100; - - initAudioVisualizer(); - - audioPlayer.onplaying = () => { - document.getElementById('audioStatus').textContent = 'STREAMING'; - document.getElementById('audioStatus').style.color = 'var(--accent-green)'; - }; - - audioPlayer.onerror = (e) => { - console.error('Audio player error:', e); - document.getElementById('audioStatus').textContent = 'ERROR'; - document.getElementById('audioStatus').style.color = 'var(--accent-red)'; - if (typeof showNotification === 'function') { - showNotification('Audio Error', 'Stream error - check SDR connection'); - } - }; - - audioPlayer.play().catch(e => { - console.warn('Audio autoplay blocked:', e); - if (typeof showNotification === 'function') { - showNotification('Audio Ready', 'Click Play button again if audio does not start'); - } - }); - - document.getElementById('audioStartBtn').innerHTML = Icons.stop('icon--sm') + ' Stop Audio'; - document.getElementById('audioStartBtn').classList.add('active'); - document.getElementById('audioTunedFreq').textContent = frequency.toFixed(2) + ' MHz (' + modulation.toUpperCase() + ')'; - document.getElementById('audioDeviceStatus').textContent = 'SDR ' + device; - - if (typeof showNotification === 'function') { - showNotification('Audio Started', `Streaming ${frequency} MHz to browser`); - } -} - -async function stopAudio() { - stopAudioVisualizer(); - - const audioPlayer = document.getElementById('audioPlayer'); - if (audioPlayer) { - audioPlayer.pause(); - audioPlayer.src = ''; - } - - try { - await fetch('/listening/audio/stop', { method: 'POST' }); - if (typeof releaseDevice === 'function') releaseDevice('audio'); - isAudioPlaying = false; - document.getElementById('audioStartBtn').innerHTML = Icons.play('icon--sm') + ' Play Audio'; - document.getElementById('audioStartBtn').classList.remove('active'); - document.getElementById('audioStatus').textContent = 'STOPPED'; - document.getElementById('audioStatus').style.color = 'var(--text-muted)'; - document.getElementById('audioDeviceStatus').textContent = '--'; - } catch (e) { - console.error('Error stopping audio:', e); - } -} - -function updateAudioVolume() { - const audioPlayer = document.getElementById('audioPlayer'); - if (audioPlayer) { - audioPlayer.volume = document.getElementById('audioVolume').value / 100; - } -} - -function audioFreqUp() { - const input = document.getElementById('audioFrequency'); - const mod = document.getElementById('audioModulation').value; - const step = (mod === 'wfm') ? 0.2 : 0.025; - input.value = (parseFloat(input.value) + step).toFixed(2); - if (isAudioPlaying) { - tuneAudioFrequency(parseFloat(input.value)); - } -} - -function audioFreqDown() { - const input = document.getElementById('audioFrequency'); - const mod = document.getElementById('audioModulation').value; - const step = (mod === 'wfm') ? 0.2 : 0.025; - input.value = (parseFloat(input.value) - step).toFixed(2); - if (isAudioPlaying) { - tuneAudioFrequency(parseFloat(input.value)); - } -} - -function tuneAudioFrequency(frequency) { - fetch('/listening/audio/tune', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ frequency: frequency }) - }) - .then(r => r.json()) - .then(data => { - if (data.status === 'tuned') { - document.getElementById('audioTunedFreq').textContent = frequency.toFixed(2) + ' MHz'; - } - }) - .catch(() => { - stopAudio(); - setTimeout(startAudio, 300); - }); -} - -async function tuneToFrequency(freq, mod) { - try { - // Stop scanner if running - if (isScannerRunning) { - stopScanner(); - await new Promise(resolve => setTimeout(resolve, 300)); - } - - // Update frequency input - const freqInput = document.getElementById('radioScanStart'); - if (freqInput) { - freqInput.value = freq.toFixed(1); - } - - // Update modulation if provided - if (mod) { - setModulation(mod); - } - - // Update tuning dial (silent to avoid duplicate events) - const mainTuningDial = document.getElementById('mainTuningDial'); - if (mainTuningDial && mainTuningDial._dial) { - mainTuningDial._dial.setValue(freq, true); - } - - // Update frequency display - const mainFreq = document.getElementById('mainScannerFreq'); - if (mainFreq) { - mainFreq.textContent = freq.toFixed(3); - } - - // Start listening immediately - await startDirectListenImmediate(); - - if (typeof showNotification === 'function') { - showNotification('Tuned', `Now listening to ${freq.toFixed(3)} MHz (${(mod || currentModulation).toUpperCase()})`); - } - } catch (err) { - console.error('Error tuning to frequency:', err); - if (typeof showNotification === 'function') { - showNotification('Tune Error', 'Failed to tune to frequency: ' + err.message); - } - } -} - -// ============== AUDIO VISUALIZER ============== - -function initAudioVisualizer() { - const audioPlayer = document.getElementById('scannerAudioPlayer'); - if (!audioPlayer) { - console.warn('[VISUALIZER] No audio player found'); - return; - } - - console.log('[VISUALIZER] Initializing with audio player, src:', audioPlayer.src); - - if (!visualizerContext) { - visualizerContext = new (window.AudioContext || window.webkitAudioContext)(); - console.log('[VISUALIZER] Created audio context'); - } - - if (visualizerContext.state === 'suspended') { - console.log('[VISUALIZER] Resuming suspended audio context'); - visualizerContext.resume(); - } - - if (!visualizerSource) { - try { - visualizerSource = visualizerContext.createMediaElementSource(audioPlayer); - visualizerAnalyser = visualizerContext.createAnalyser(); - visualizerAnalyser.fftSize = 256; - visualizerAnalyser.smoothingTimeConstant = 0.7; - - visualizerSource.connect(visualizerAnalyser); - visualizerAnalyser.connect(visualizerContext.destination); - console.log('[VISUALIZER] Audio source and analyser connected'); - } catch (e) { - console.error('[VISUALIZER] Could not create audio source:', e); - // Try to continue anyway if analyser exists - if (!visualizerAnalyser) return; - } - } else { - console.log('[VISUALIZER] Reusing existing audio source'); - } - - const container = document.getElementById('audioVisualizerContainer'); - if (container) container.style.display = 'block'; - - // Start the visualization loop - if (!visualizerAnimationId) { - console.log('[VISUALIZER] Starting draw loop'); - drawAudioVisualizer(); - } else { - console.log('[VISUALIZER] Draw loop already running'); - } -} - -function drawAudioVisualizer() { - if (!visualizerAnalyser) { - console.warn('[VISUALIZER] No analyser available'); - return; - } - - const canvas = document.getElementById('audioSpectrumCanvas'); - const ctx = canvas ? canvas.getContext('2d') : null; - const bufferLength = visualizerAnalyser.frequencyBinCount; - const dataArray = new Uint8Array(bufferLength); - - function draw() { - visualizerAnimationId = requestAnimationFrame(draw); - - visualizerAnalyser.getByteFrequencyData(dataArray); - - let sum = 0; - for (let i = 0; i < bufferLength; i++) { - sum += dataArray[i]; - } - const average = sum / bufferLength; - const levelPercent = (average / 255) * 100; - - // Feed audio level to synthesizer visualization during direct listening - if (isDirectListening || isScannerRunning) { - // Scale 0-255 average to 0-3000 range (matching SSE scan_update levels) - currentSignalLevel = (average / 255) * 3000; - } - - if (levelPercent > peakLevel) { - peakLevel = levelPercent; - } else { - peakLevel *= peakDecay; - } - - const meterFill = document.getElementById('audioSignalMeter'); - const meterPeak = document.getElementById('audioSignalPeak'); - const meterValue = document.getElementById('audioSignalValue'); - - if (meterFill) meterFill.style.width = levelPercent + '%'; - if (meterPeak) meterPeak.style.left = Math.min(peakLevel, 100) + '%'; - - const db = average > 0 ? Math.round(20 * Math.log10(average / 255)) : -60; - if (meterValue) meterValue.textContent = db + ' dB'; - - // Only draw spectrum if canvas exists - if (ctx && canvas) { - ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - - const barWidth = canvas.width / bufferLength * 2.5; - let x = 0; - - for (let i = 0; i < bufferLength; i++) { - const barHeight = (dataArray[i] / 255) * canvas.height; - const hue = 200 - (i / bufferLength) * 60; - const lightness = 40 + (dataArray[i] / 255) * 30; - ctx.fillStyle = `hsl(${hue}, 80%, ${lightness}%)`; - ctx.fillRect(x, canvas.height - barHeight, barWidth - 1, barHeight); - x += barWidth; - } - - ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'; - ctx.font = '8px Roboto Condensed'; - ctx.fillText('0', 2, canvas.height - 2); - ctx.fillText('4kHz', canvas.width / 4, canvas.height - 2); - ctx.fillText('8kHz', canvas.width / 2, canvas.height - 2); - } - } - - draw(); -} - -function stopAudioVisualizer() { - if (visualizerAnimationId) { - cancelAnimationFrame(visualizerAnimationId); - visualizerAnimationId = null; - } - - const meterFill = document.getElementById('audioSignalMeter'); - const meterPeak = document.getElementById('audioSignalPeak'); - const meterValue = document.getElementById('audioSignalValue'); - - if (meterFill) meterFill.style.width = '0%'; - if (meterPeak) meterPeak.style.left = '0%'; - if (meterValue) meterValue.textContent = '-∞ dB'; - - peakLevel = 0; - - const container = document.getElementById('audioVisualizerContainer'); - if (container) container.style.display = 'none'; -} - -// ============== RADIO KNOB CONTROLS ============== - -/** - * Update scanner config on the backend (for live updates while scanning) - */ -function updateScannerConfig(config) { - if (!isScannerRunning) return; - fetch('/listening/scanner/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(config) - }).catch(() => {}); -} - -/** - * Initialize radio knob controls and wire them to scanner parameters - */ -function initRadioKnobControls() { - // Squelch knob - const squelchKnob = document.getElementById('radioSquelchKnob'); - if (squelchKnob) { - squelchKnob.addEventListener('knobchange', function(e) { - const value = Math.round(e.detail.value); - const valueDisplay = document.getElementById('radioSquelchValue'); - if (valueDisplay) valueDisplay.textContent = value; - // Sync with scanner - updateScannerConfig({ squelch: value }); - // Restart stream if direct listening (squelch requires restart) - if (isDirectListening) { - startDirectListen(); - } - }); - } - - // Gain knob - const gainKnob = document.getElementById('radioGainKnob'); - if (gainKnob) { - gainKnob.addEventListener('knobchange', function(e) { - const value = Math.round(e.detail.value); - const valueDisplay = document.getElementById('radioGainValue'); - if (valueDisplay) valueDisplay.textContent = value; - // Sync with scanner - updateScannerConfig({ gain: value }); - // Restart stream if direct listening (gain requires restart) - if (isDirectListening) { - startDirectListen(); - } - }); - } - - // Volume knob - controls scanner audio player volume - const volumeKnob = document.getElementById('radioVolumeKnob'); - if (volumeKnob) { - volumeKnob.addEventListener('knobchange', function(e) { - const audioPlayer = document.getElementById('scannerAudioPlayer'); - if (audioPlayer) { - audioPlayer.volume = e.detail.value / 100; - console.log('[VOLUME] Set to', Math.round(e.detail.value) + '%'); - } - // Update knob value display - const valueDisplay = document.getElementById('radioVolumeValue'); - if (valueDisplay) valueDisplay.textContent = Math.round(e.detail.value); - }); - } - - // Main Tuning dial - updates frequency display and inputs - const mainTuningDial = document.getElementById('mainTuningDial'); - if (mainTuningDial) { - mainTuningDial.addEventListener('knobchange', function(e) { - const freq = e.detail.value; - // Update main frequency display - const mainFreq = document.getElementById('mainScannerFreq'); - if (mainFreq) { - mainFreq.textContent = freq.toFixed(3); - } - // Update radio scan start input - const startFreqInput = document.getElementById('radioScanStart'); - if (startFreqInput) { - startFreqInput.value = freq.toFixed(1); - } - // Update sidebar frequency input - const sidebarFreq = document.getElementById('audioFrequency'); - if (sidebarFreq) { - sidebarFreq.value = freq.toFixed(3); - } - // If currently listening, retune to new frequency - if (isDirectListening) { - startDirectListen(); - } - }); - } - - // Legacy tuning dial support - const tuningDial = document.getElementById('tuningDial'); - if (tuningDial) { - tuningDial.addEventListener('knobchange', function(e) { - const mainFreq = document.getElementById('mainScannerFreq'); - if (mainFreq) mainFreq.textContent = e.detail.value.toFixed(3); - const startFreqInput = document.getElementById('radioScanStart'); - if (startFreqInput) startFreqInput.value = e.detail.value.toFixed(1); - // If currently listening, retune to new frequency - if (isDirectListening) { - startDirectListen(); - } - }); - } - - // Sync radio scan range inputs with sidebar - const radioScanStart = document.getElementById('radioScanStart'); - const radioScanEnd = document.getElementById('radioScanEnd'); - - if (radioScanStart) { - radioScanStart.addEventListener('change', function() { - const sidebarStart = document.getElementById('scanStartFreq'); - if (sidebarStart) sidebarStart.value = this.value; - // Restart stream if direct listening - if (isDirectListening) { - startDirectListen(); - } - }); - } - - if (radioScanEnd) { - radioScanEnd.addEventListener('change', function() { - const sidebarEnd = document.getElementById('scanEndFreq'); - if (sidebarEnd) sidebarEnd.value = this.value; - }); - } -} - -/** - * Set modulation mode (called from HTML onclick) - */ -function setModulation(mod) { - // Update sidebar select - const modSelect = document.getElementById('scanModulation'); - if (modSelect) modSelect.value = mod; - - // Update audio modulation select - const audioMod = document.getElementById('audioModulation'); - if (audioMod) audioMod.value = mod; - - // Update button states in radio panel - document.querySelectorAll('#modBtnBank .radio-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.mod === mod); - }); - - // Update main display badge - const mainBadge = document.getElementById('mainScannerMod'); - if (mainBadge) mainBadge.textContent = mod.toUpperCase(); -} - -/** - * Set band preset (called from HTML onclick) - */ -function setBand(band) { - const preset = scannerPresets[band]; - if (!preset) return; - - // Update button states - document.querySelectorAll('#bandBtnBank .radio-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.band === band); - }); - - // Update sidebar frequency inputs - const sidebarStart = document.getElementById('scanStartFreq'); - const sidebarEnd = document.getElementById('scanEndFreq'); - if (sidebarStart) sidebarStart.value = preset.start; - if (sidebarEnd) sidebarEnd.value = preset.end; - - // Update radio panel frequency inputs - const radioStart = document.getElementById('radioScanStart'); - const radioEnd = document.getElementById('radioScanEnd'); - if (radioStart) radioStart.value = preset.start; - if (radioEnd) radioEnd.value = preset.end; - - // Update tuning dial range and value (silent to avoid triggering restart) - const tuningDial = document.getElementById('tuningDial'); - if (tuningDial && tuningDial._dial) { - tuningDial._dial.min = preset.start; - tuningDial._dial.max = preset.end; - tuningDial._dial.setValue(preset.start, true); - } - - // Update main frequency display - const mainFreq = document.getElementById('mainScannerFreq'); - if (mainFreq) mainFreq.textContent = preset.start.toFixed(3); - - // Update modulation - setModulation(preset.mod); - - // Update main range display if scanning - const rangeStart = document.getElementById('mainRangeStart'); - const rangeEnd = document.getElementById('mainRangeEnd'); - if (rangeStart) rangeStart.textContent = preset.start; - if (rangeEnd) rangeEnd.textContent = preset.end; - - // Store for scanner use - scannerStartFreq = preset.start; - scannerEndFreq = preset.end; -} - -// ============== SYNTHESIZER VISUALIZATION ============== - -let synthAnimationId = null; -let synthCanvas = null; -let synthCtx = null; -let synthBars = []; -const SYNTH_BAR_COUNT = 32; - -function initSynthesizer() { - synthCanvas = document.getElementById('synthesizerCanvas'); - if (!synthCanvas) return; - - // Set canvas size - const rect = synthCanvas.parentElement.getBoundingClientRect(); - synthCanvas.width = rect.width - 20; - synthCanvas.height = 60; - - synthCtx = synthCanvas.getContext('2d'); - - // Initialize bar heights - for (let i = 0; i < SYNTH_BAR_COUNT; i++) { - synthBars[i] = { height: 0, targetHeight: 0, velocity: 0 }; - } - - drawSynthesizer(); -} - -function drawSynthesizer() { - if (!synthCtx || !synthCanvas) return; - - const width = synthCanvas.width; - const height = synthCanvas.height; - const barWidth = (width / SYNTH_BAR_COUNT) - 2; - - // Clear canvas - synthCtx.fillStyle = 'rgba(0, 0, 0, 0.3)'; - synthCtx.fillRect(0, 0, width, height); - - // Determine activity level based on actual signal level - let activityLevel = 0; - let signalIntensity = 0; - - if (isScannerRunning && !isScannerPaused) { - // Use actual signal level data (0-5000 range, normalize to 0-1) - signalIntensity = Math.min(1, currentSignalLevel / 3000); - // Base activity when scanning, boosted by actual signal strength - activityLevel = 0.15 + (signalIntensity * 0.85); - if (scannerSignalActive) { - activityLevel = Math.max(activityLevel, 0.7); - } - } else if (isDirectListening) { - // For direct listening, use signal level if available - signalIntensity = Math.min(1, currentSignalLevel / 3000); - activityLevel = 0.2 + (signalIntensity * 0.8); - } - - // Update bar targets - for (let i = 0; i < SYNTH_BAR_COUNT; i++) { - if (activityLevel > 0) { - // Create wave-like pattern modulated by actual signal strength - const time = Date.now() / 200; - // Multiple wave frequencies for more organic feel - const wave1 = Math.sin(time + (i * 0.3)) * 0.2; - const wave2 = Math.sin(time * 1.7 + (i * 0.5)) * 0.15; - // Less randomness when signal is weak, more when strong - const randomAmount = 0.1 + (signalIntensity * 0.3); - const random = (Math.random() - 0.5) * randomAmount; - // Center bars tend to be taller (frequency spectrum shape) - const centerBoost = 1 - Math.abs((i - SYNTH_BAR_COUNT / 2) / (SYNTH_BAR_COUNT / 2)) * 0.4; - // Combine all factors with signal-driven amplitude - const baseHeight = 0.15 + (signalIntensity * 0.5); - synthBars[i].targetHeight = (baseHeight + wave1 + wave2 + random) * activityLevel * centerBoost * height; - } else { - // Idle state - minimal activity - synthBars[i].targetHeight = (Math.sin((Date.now() / 500) + (i * 0.5)) * 0.1 + 0.1) * height * 0.3; - } - - // Smooth animation - faster response when signal changes - const springStrength = signalIntensity > 0.3 ? 0.15 : 0.1; - const diff = synthBars[i].targetHeight - synthBars[i].height; - synthBars[i].velocity += diff * springStrength; - synthBars[i].velocity *= 0.8; - synthBars[i].height += synthBars[i].velocity; - synthBars[i].height = Math.max(2, Math.min(height - 4, synthBars[i].height)); - } - - // Draw bars - for (let i = 0; i < SYNTH_BAR_COUNT; i++) { - const x = i * (barWidth + 2) + 1; - const barHeight = synthBars[i].height; - const y = (height - barHeight) / 2; - - // Color gradient based on height and state - let hue, saturation, lightness; - if (scannerSignalActive) { - hue = 120; // Green for signal - saturation = 80; - lightness = 40 + (barHeight / height) * 30; - } else if (isScannerRunning || isDirectListening) { - hue = 190 + (i / SYNTH_BAR_COUNT) * 30; // Cyan to blue - saturation = 80; - lightness = 35 + (barHeight / height) * 25; - } else { - hue = 200; - saturation = 50; - lightness = 25 + (barHeight / height) * 15; - } - - const gradient = synthCtx.createLinearGradient(x, y, x, y + barHeight); - gradient.addColorStop(0, `hsla(${hue}, ${saturation}%, ${lightness + 20}%, 0.9)`); - gradient.addColorStop(0.5, `hsla(${hue}, ${saturation}%, ${lightness}%, 1)`); - gradient.addColorStop(1, `hsla(${hue}, ${saturation}%, ${lightness + 20}%, 0.9)`); - - synthCtx.fillStyle = gradient; - synthCtx.fillRect(x, y, barWidth, barHeight); - - // Add glow effect for active bars - if (barHeight > height * 0.5 && activityLevel > 0.5) { - synthCtx.shadowColor = `hsla(${hue}, ${saturation}%, 60%, 0.5)`; - synthCtx.shadowBlur = 8; - synthCtx.fillRect(x, y, barWidth, barHeight); - synthCtx.shadowBlur = 0; - } - } - - // Draw center line - synthCtx.strokeStyle = 'rgba(0, 212, 255, 0.2)'; - synthCtx.lineWidth = 1; - synthCtx.beginPath(); - synthCtx.moveTo(0, height / 2); - synthCtx.lineTo(width, height / 2); - synthCtx.stroke(); - - synthAnimationId = requestAnimationFrame(drawSynthesizer); -} - -function stopSynthesizer() { - if (synthAnimationId) { - cancelAnimationFrame(synthAnimationId); - synthAnimationId = null; - } -} - -// ============== INITIALIZATION ============== - -/** - * Get the audio stream URL with parameters - * Streams directly from Flask - no Icecast needed - */ -function getStreamUrl(freq, mod) { - const frequency = freq || parseFloat(document.getElementById('radioScanStart')?.value) || 118.0; - const modulation = mod || currentModulation || 'am'; - return `/listening/audio/stream?fresh=1&freq=${frequency}&mod=${modulation}&t=${Date.now()}`; -} - -function initListeningPost() { - checkScannerTools(); - checkAudioTools(); - initSnrThresholdControl(); - - // WebSocket audio disabled for now - using HTTP streaming - // initWebSocketAudio(); - - // Initialize synthesizer visualization - initSynthesizer(); - - // Initialize radio knobs if the component is available - if (typeof initRadioKnobs === 'function') { - initRadioKnobs(); - } - - // Connect radio knobs to scanner controls - initRadioKnobControls(); - - initWaterfallZoomControls(); - - // Step dropdown - sync with scanner when changed - const stepSelect = document.getElementById('radioScanStep'); - if (stepSelect) { - stepSelect.addEventListener('change', function() { - const step = parseFloat(this.value); - console.log('[SCANNER] Step changed to:', step, 'kHz'); - updateScannerConfig({ step: step }); - }); - } - - // Dwell dropdown - sync with scanner when changed - const dwellSelect = document.getElementById('radioScanDwell'); - if (dwellSelect) { - dwellSelect.addEventListener('change', function() { - const dwell = parseInt(this.value); - console.log('[SCANNER] Dwell changed to:', dwell, 's'); - updateScannerConfig({ dwell_time: dwell }); - }); - } - - // Set up audio player error handling - const audioPlayer = document.getElementById('audioPlayer'); - if (audioPlayer) { - audioPlayer.addEventListener('error', function(e) { - console.warn('Audio player error:', e); - if (isAudioPlaying && audioReconnectAttempts < MAX_AUDIO_RECONNECT) { - audioReconnectAttempts++; - setTimeout(() => { - audioPlayer.src = getStreamUrl(); - audioPlayer.play().catch(() => {}); - }, 500); - } - }); - - audioPlayer.addEventListener('stalled', function() { - if (isAudioPlaying) { - audioPlayer.load(); - audioPlayer.play().catch(() => {}); - } - }); - - audioPlayer.addEventListener('playing', function() { - audioReconnectAttempts = 0; - }); - } - - // Keyboard controls for frequency tuning - document.addEventListener('keydown', function(e) { - // Only active in listening mode - if (typeof currentMode !== 'undefined' && currentMode !== 'listening') { - return; - } - - // Don't intercept if user is typing in an input - const activeEl = document.activeElement; - if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.tagName === 'SELECT')) { - return; - } - - // Arrow keys for tuning - // Up/Down: fine tuning (Shift for ultra-fine) - // Left/Right: coarse tuning (Shift for very coarse) - let delta = 0; - switch (e.key) { - case 'ArrowUp': - delta = e.shiftKey ? 0.005 : 0.05; - break; - case 'ArrowDown': - delta = e.shiftKey ? -0.005 : -0.05; - break; - case 'ArrowRight': - delta = e.shiftKey ? 1 : 0.1; - break; - case 'ArrowLeft': - delta = e.shiftKey ? -1 : -0.1; - break; - default: - return; // Not a tuning key - } - - e.preventDefault(); - tuneFreq(delta); - }); - - // Check if we arrived from Spy Stations with a tune request - checkIncomingTuneRequest(); -} - -function initSnrThresholdControl() { - const slider = document.getElementById('snrThresholdSlider'); - const valueEl = document.getElementById('snrThresholdValue'); - if (!slider || !valueEl) return; - - const stored = localStorage.getItem('scannerSnrThreshold'); - if (stored) { - const parsed = parseInt(stored, 10); - if (!Number.isNaN(parsed)) { - scannerSnrThreshold = parsed; - } - } - - slider.value = scannerSnrThreshold; - valueEl.textContent = String(scannerSnrThreshold); - - slider.addEventListener('input', () => { - scannerSnrThreshold = parseInt(slider.value, 10); - valueEl.textContent = String(scannerSnrThreshold); - localStorage.setItem('scannerSnrThreshold', String(scannerSnrThreshold)); - }); -} - -/** - * Check for incoming tune request from Spy Stations or other pages - */ -function checkIncomingTuneRequest() { - const tuneFreq = sessionStorage.getItem('tuneFrequency'); - const tuneMode = sessionStorage.getItem('tuneMode'); - - if (tuneFreq) { - // Clear the session storage first - sessionStorage.removeItem('tuneFrequency'); - sessionStorage.removeItem('tuneMode'); - - // Parse and validate frequency - const freq = parseFloat(tuneFreq); - if (!isNaN(freq) && freq >= 0.01 && freq <= 2000) { - console.log('[LISTEN] Incoming tune request:', freq, 'MHz, mode:', tuneMode || 'default'); - - // Determine modulation (default to USB for HF/number stations) - const mod = tuneMode || (freq < 30 ? 'usb' : 'am'); - - // Use quickTune to set frequency and modulation - quickTune(freq, mod); - - // Show notification - if (typeof showNotification === 'function') { - showNotification('Tuned to ' + freq.toFixed(3) + ' MHz', mod.toUpperCase() + ' mode'); - } - } - } -} - -// Initialize when DOM is ready -document.addEventListener('DOMContentLoaded', initListeningPost); - -// ============== UNIFIED RADIO CONTROLS ============== - -/** - * Toggle direct listen mode (tune to start frequency and listen) - */ -function toggleDirectListen() { - console.log('[LISTEN] toggleDirectListen called, isDirectListening:', isDirectListening); - if (isDirectListening) { - stopDirectListen(); - } else { - const audioPlayer = document.getElementById('scannerAudioPlayer'); - if (audioPlayer) { - audioPlayer.muted = false; - audioPlayer.autoplay = true; - audioPlayer.preload = 'auto'; - } - audioUnlockRequested = true; - // First press - start immediately, don't debounce - startDirectListenImmediate(); - } -} - -// Debounce for startDirectListen -let listenDebounceTimer = null; -// Flag to prevent overlapping restart attempts -let isRestarting = false; -// Flag indicating another restart is needed after current one finishes -let restartPending = false; -// Debounce for frequency tuning (user might be scrolling through) -// Needs to be long enough for SDR to fully release between restarts -const TUNE_DEBOUNCE_MS = 600; - -/** - * Start direct listening - debounced for frequency changes - */ -function startDirectListen() { - if (listenDebounceTimer) { - clearTimeout(listenDebounceTimer); - } - listenDebounceTimer = setTimeout(async () => { - // If already restarting, mark that we need another restart when done - if (isRestarting) { - console.log('[LISTEN] Restart in progress, will retry after'); - restartPending = true; - return; - } - - await _startDirectListenInternal(); - - // If another restart was requested during this one, do it now - while (restartPending) { - restartPending = false; - console.log('[LISTEN] Processing pending restart'); - await _startDirectListenInternal(); - } - }, TUNE_DEBOUNCE_MS); -} - -/** - * Start listening immediately (no debounce) - for button press - */ -async function startDirectListenImmediate() { - if (listenDebounceTimer) { - clearTimeout(listenDebounceTimer); - listenDebounceTimer = null; - } - restartPending = false; // Clear any pending - if (isRestarting) { - console.log('[LISTEN] Waiting for current restart to finish...'); - // Wait for current restart to complete (max 5 seconds) - let waitCount = 0; - while (isRestarting && waitCount < 50) { - await new Promise(r => setTimeout(r, 100)); - waitCount++; - } - } - await _startDirectListenInternal(); -} - -// ============== WEBSOCKET AUDIO ============== - -/** - * Initialize WebSocket audio connection - */ -function initWebSocketAudio() { - if (audioWebSocket && audioWebSocket.readyState === WebSocket.OPEN) { - return audioWebSocket; - } - - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${window.location.host}/ws/audio`; - - console.log('[WS-AUDIO] Connecting to:', wsUrl); - audioWebSocket = new WebSocket(wsUrl); - audioWebSocket.binaryType = 'arraybuffer'; - - audioWebSocket.onopen = () => { - console.log('[WS-AUDIO] Connected'); - isWebSocketAudio = true; - }; - - audioWebSocket.onclose = () => { - console.log('[WS-AUDIO] Disconnected'); - isWebSocketAudio = false; - audioWebSocket = null; - }; - - audioWebSocket.onerror = (e) => { - console.error('[WS-AUDIO] Error:', e); - isWebSocketAudio = false; - }; - - audioWebSocket.onmessage = (event) => { - if (typeof event.data === 'string') { - // JSON message (status updates) - try { - const msg = JSON.parse(event.data); - console.log('[WS-AUDIO] Status:', msg); - if (msg.status === 'error') { - addScannerLogEntry('Audio error: ' + msg.message, '', 'error'); - } - } catch (e) {} - } else { - // Binary data (audio) - handleWebSocketAudioData(event.data); - } - }; - - return audioWebSocket; -} - -/** - * Handle incoming WebSocket audio data - */ -function handleWebSocketAudioData(data) { - const audioPlayer = document.getElementById('scannerAudioPlayer'); - if (!audioPlayer) return; - - // Use MediaSource API to stream audio - if (!audioPlayer.msSource) { - setupMediaSource(audioPlayer); - } - - if (audioPlayer.sourceBuffer && !audioPlayer.sourceBuffer.updating) { - try { - audioPlayer.sourceBuffer.appendBuffer(new Uint8Array(data)); - } catch (e) { - // Buffer full or other error, skip this chunk - } - } else { - // Queue data for later - audioQueue.push(new Uint8Array(data)); - if (audioQueue.length > 50) audioQueue.shift(); // Prevent memory buildup - } -} - -/** - * Setup MediaSource for streaming audio - */ -function setupMediaSource(audioPlayer) { - if (!window.MediaSource) { - console.warn('[WS-AUDIO] MediaSource not supported'); - return; - } - - const mediaSource = new MediaSource(); - audioPlayer.src = URL.createObjectURL(mediaSource); - audioPlayer.msSource = mediaSource; - - mediaSource.addEventListener('sourceopen', () => { - try { - const sourceBuffer = mediaSource.addSourceBuffer('audio/mpeg'); - audioPlayer.sourceBuffer = sourceBuffer; - - sourceBuffer.addEventListener('updateend', () => { - // Process queued data - if (audioQueue.length > 0 && !sourceBuffer.updating) { - try { - sourceBuffer.appendBuffer(audioQueue.shift()); - } catch (e) {} - } - }); - } catch (e) { - console.error('[WS-AUDIO] Failed to create source buffer:', e); - } - }); -} - -/** - * Send command over WebSocket - */ -function sendWebSocketCommand(cmd, config = {}) { - if (!audioWebSocket || audioWebSocket.readyState !== WebSocket.OPEN) { - initWebSocketAudio(); - // Wait for connection and retry - setTimeout(() => sendWebSocketCommand(cmd, config), 500); - return; - } - - audioWebSocket.send(JSON.stringify({ cmd, config })); -} - -async function _startDirectListenInternal() { - console.log('[LISTEN] _startDirectListenInternal called'); - - // Prevent overlapping restarts - if (isRestarting) { - console.log('[LISTEN] Already restarting, skipping'); - return; - } - isRestarting = true; - - try { - if (isScannerRunning) { - await stopScanner(); - } - - if (isWaterfallRunning && waterfallMode === 'rf') { - resumeRfWaterfallAfterListening = true; - await stopWaterfall(); - } - - const freqInput = document.getElementById('radioScanStart'); - const freq = freqInput ? parseFloat(freqInput.value) : 118.0; - const squelchValue = parseInt(document.getElementById('radioSquelchValue')?.textContent); - const squelch = Number.isFinite(squelchValue) ? squelchValue : 0; - const gain = parseInt(document.getElementById('radioGainValue')?.textContent) || 40; - const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0; - const sdrType = typeof getSelectedSDRType === 'function' - ? getSelectedSDRType() - : getSelectedSDRTypeForScanner(); - const biasT = typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false; - - console.log('[LISTEN] Tuning to:', freq, 'MHz', currentModulation, 'device', device, 'sdr', sdrType); - - const listenBtn = document.getElementById('radioListenBtn'); - if (listenBtn) { - listenBtn.innerHTML = Icons.loader('icon--sm') + ' TUNING...'; - listenBtn.style.background = 'var(--accent-orange)'; - listenBtn.style.borderColor = 'var(--accent-orange)'; - } - - const audioPlayer = document.getElementById('scannerAudioPlayer'); - if (!audioPlayer) { - addScannerLogEntry('Audio player not found', '', 'error'); - updateDirectListenUI(false); - return; - } - - // Fully reset audio element to clean state - audioPlayer.oncanplay = null; // Remove old handler - try { - audioPlayer.pause(); - } catch (e) {} - audioPlayer.removeAttribute('src'); - audioPlayer.load(); // Reset the element - - // Start audio on backend (it handles stopping old stream) - const response = await fetch('/listening/audio/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - frequency: freq, - modulation: currentModulation, - squelch: 0, - gain: gain, - device: device, - sdr_type: sdrType, - bias_t: biasT - }) - }); - - const result = await response.json(); - console.log('[LISTEN] Backend:', result.status); - - if (result.status !== 'started') { - console.error('[LISTEN] Failed:', result.message); - addScannerLogEntry('Failed: ' + (result.message || 'Unknown error'), '', 'error'); - isDirectListening = false; - updateDirectListenUI(false); - if (resumeRfWaterfallAfterListening) { - scheduleWaterfallResume(); - } - return; - } - - // Wait for stream to be ready (backend needs time after restart) - await new Promise(r => setTimeout(r, 300)); - - // Connect to new stream - const streamUrl = `/listening/audio/stream?fresh=1&t=${Date.now()}`; - console.log('[LISTEN] Connecting to stream:', streamUrl); - audioPlayer.src = streamUrl; - audioPlayer.preload = 'auto'; - audioPlayer.autoplay = true; - audioPlayer.muted = false; - audioPlayer.load(); - - // Apply current volume from knob - const volumeKnob = document.getElementById('radioVolumeKnob'); - if (volumeKnob && volumeKnob._knob) { - audioPlayer.volume = volumeKnob._knob.getValue() / 100; - } else if (volumeKnob) { - const knobValue = parseFloat(volumeKnob.dataset.value) || 80; - audioPlayer.volume = knobValue / 100; - } - - // Wait for audio to be ready then play - audioPlayer.oncanplay = () => { - console.log('[LISTEN] Audio can play'); - attemptAudioPlay(audioPlayer); - }; - - // Also try to play immediately (some browsers need this) - attemptAudioPlay(audioPlayer); - - // If stream is slow, retry play and prompt for manual unlock - setTimeout(async () => { - if (!isDirectListening || !audioPlayer) return; - if (audioPlayer.readyState > 0) return; - audioPlayer.load(); - attemptAudioPlay(audioPlayer); - showAudioUnlock(audioPlayer); - }, 2500); - - // Initialize audio visualizer to feed signal levels to synthesizer - initAudioVisualizer(); - - isDirectListening = true; - - if (resumeRfWaterfallAfterListening) { - isWaterfallRunning = true; - const waterfallPanel = document.getElementById('waterfallPanel'); - if (waterfallPanel) waterfallPanel.style.display = 'block'; - setWaterfallControlButtons(true); - startAudioWaterfall(); - } - updateDirectListenUI(true, freq); - addScannerLogEntry(`${freq.toFixed(3)} MHz (${currentModulation.toUpperCase()})`, '', 'signal'); - - } catch (e) { - console.error('[LISTEN] Error:', e); - addScannerLogEntry('Error: ' + e.message, '', 'error'); - isDirectListening = false; - updateDirectListenUI(false); - if (resumeRfWaterfallAfterListening) { - scheduleWaterfallResume(); - } - } finally { - isRestarting = false; - } -} - -function attemptAudioPlay(audioPlayer) { - if (!audioPlayer) return; - audioPlayer.play().then(() => { - hideAudioUnlock(); - }).catch(() => { - // Autoplay likely blocked; show manual unlock - showAudioUnlock(audioPlayer); - }); -} - -function showAudioUnlock(audioPlayer) { - const unlockBtn = document.getElementById('audioUnlockBtn'); - if (!unlockBtn || !audioUnlockRequested) return; - unlockBtn.style.display = 'block'; - unlockBtn.onclick = () => { - audioPlayer.muted = false; - audioPlayer.play().then(() => { - hideAudioUnlock(); - }).catch(() => {}); - }; -} - -function hideAudioUnlock() { - const unlockBtn = document.getElementById('audioUnlockBtn'); - if (unlockBtn) { - unlockBtn.style.display = 'none'; - } - audioUnlockRequested = false; -} - -async function startFetchAudioStream(streamUrl, audioPlayer) { - if (!window.MediaSource) { - console.warn('[LISTEN] MediaSource not supported for fetch fallback'); - return false; - } - - // Abort any previous fetch stream - if (audioFetchController) { - audioFetchController.abort(); - } - audioFetchController = new AbortController(); - - // Reset audio element for MediaSource - try { - audioPlayer.pause(); - } catch (e) {} - audioPlayer.removeAttribute('src'); - audioPlayer.load(); - - const mediaSource = new MediaSource(); - audioPlayer.src = URL.createObjectURL(mediaSource); - audioPlayer.muted = false; - audioPlayer.autoplay = true; - - return new Promise((resolve) => { - mediaSource.addEventListener('sourceopen', async () => { - let sourceBuffer; - try { - sourceBuffer = mediaSource.addSourceBuffer('audio/mpeg'); - } catch (e) { - console.error('[LISTEN] Failed to create source buffer:', e); - resolve(false); - return; - } - - try { - let attempts = 0; - while (attempts < 5) { - attempts += 1; - const response = await fetch(streamUrl, { - cache: 'no-store', - signal: audioFetchController.signal - }); - - if (response.status === 204) { - console.warn('[LISTEN] Stream not ready (204), retrying...', attempts); - await new Promise(r => setTimeout(r, 500)); - continue; - } - - if (!response.ok || !response.body) { - console.warn('[LISTEN] Fetch stream response invalid', response.status); - resolve(false); - return; - } - - const reader = response.body.getReader(); - const appendChunk = async (chunk) => { - if (!chunk || chunk.length === 0) return; - if (!sourceBuffer.updating) { - sourceBuffer.appendBuffer(chunk); - return; - } - await new Promise(r => sourceBuffer.addEventListener('updateend', r, { once: true })); - sourceBuffer.appendBuffer(chunk); - }; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - await appendChunk(value); - } - - resolve(true); - return; - } - - resolve(false); - } catch (e) { - if (e.name !== 'AbortError') { - console.error('[LISTEN] Fetch stream error:', e); - } - resolve(false); - } - }, { once: true }); - }); -} - -async function startWebSocketListen(config, audioPlayer) { - const selectedType = typeof getSelectedSDRType === 'function' - ? getSelectedSDRType() - : getSelectedSDRTypeForScanner(); - if (selectedType && selectedType !== 'rtlsdr') { - console.warn('[LISTEN] WebSocket audio supports RTL-SDR only'); - return; - } - - try { - // Stop HTTP audio stream before switching - await fetch('/listening/audio/stop', { method: 'POST' }); - } catch (e) {} - - // Reset audio element for MediaSource - try { - audioPlayer.pause(); - } catch (e) {} - audioPlayer.removeAttribute('src'); - audioPlayer.load(); - - const ws = initWebSocketAudio(); - if (!ws) return; - - // Ensure MediaSource is set up - setupMediaSource(audioPlayer); - sendWebSocketCommand('start', config); -} - -/** - * Stop direct listening - */ -async function stopDirectListen() { - console.log('[LISTEN] Stopping'); - - // Clear all pending state - if (listenDebounceTimer) { - clearTimeout(listenDebounceTimer); - listenDebounceTimer = null; - } - restartPending = false; - - const audioPlayer = document.getElementById('scannerAudioPlayer'); - if (audioPlayer) { - audioPlayer.pause(); - // Clear MediaSource if using WebSocket - if (audioPlayer.msSource) { - try { - audioPlayer.msSource.endOfStream(); - } catch (e) {} - audioPlayer.msSource = null; - audioPlayer.sourceBuffer = null; - } - audioPlayer.src = ''; - } - audioQueue = []; - if (audioFetchController) { - audioFetchController.abort(); - audioFetchController = null; - } - - // Stop via WebSocket if connected - if (audioWebSocket && audioWebSocket.readyState === WebSocket.OPEN) { - sendWebSocketCommand('stop'); - } - - // Also stop via HTTP (fallback) - const audioStopPromise = fetch('/listening/audio/stop', { method: 'POST' }).catch(() => {}); - - isDirectListening = false; - currentSignalLevel = 0; - updateDirectListenUI(false); - addScannerLogEntry('Listening stopped'); - - if (waterfallMode === 'audio') { - stopAudioWaterfall(); - } - - if (resumeRfWaterfallAfterListening) { - isWaterfallRunning = false; - setWaterfallControlButtons(false); - await Promise.race([ - audioStopPromise, - new Promise(resolve => setTimeout(resolve, 400)) - ]); - scheduleWaterfallResume(); - } else if (waterfallMode === 'audio' && isWaterfallRunning) { - isWaterfallRunning = false; - setWaterfallControlButtons(false); - } -} - -/** - * Update UI for direct listen mode - */ -function updateDirectListenUI(isPlaying, freq) { - const listenBtn = document.getElementById('radioListenBtn'); - const statusLabel = document.getElementById('mainScannerModeLabel'); - const freqDisplay = document.getElementById('mainScannerFreq'); - const quickStatus = document.getElementById('lpQuickStatus'); - const quickFreq = document.getElementById('lpQuickFreq'); - - if (listenBtn) { - if (isPlaying) { - listenBtn.innerHTML = Icons.stop('icon--sm') + ' STOP'; - listenBtn.classList.add('active'); - } else { - listenBtn.innerHTML = Icons.headphones('icon--sm') + ' LISTEN'; - listenBtn.classList.remove('active'); - } - } - - if (statusLabel) { - statusLabel.textContent = isPlaying ? 'LISTENING' : 'STOPPED'; - statusLabel.style.color = isPlaying ? 'var(--accent-green)' : 'var(--text-muted)'; - } - - if (freqDisplay && freq) { - freqDisplay.textContent = freq.toFixed(3); - } - - if (quickStatus) { - quickStatus.textContent = isPlaying ? 'LISTENING' : 'IDLE'; - quickStatus.style.color = isPlaying ? 'var(--accent-green)' : 'var(--accent-cyan)'; - } - - if (quickFreq && freq) { - quickFreq.textContent = freq.toFixed(3) + ' MHz'; - } -} - -/** - * Tune frequency by delta - */ -function tuneFreq(delta) { - const freqInput = document.getElementById('radioScanStart'); - if (freqInput) { - let newFreq = parseFloat(freqInput.value) + delta; - // Round to 3 decimal places to avoid floating-point precision issues - newFreq = Math.round(newFreq * 1000) / 1000; - newFreq = Math.max(24, Math.min(1800, newFreq)); - freqInput.value = newFreq.toFixed(3); - - // Update display - const freqDisplay = document.getElementById('mainScannerFreq'); - if (freqDisplay) { - freqDisplay.textContent = newFreq.toFixed(3); - } - - // Update tuning dial position (silent to avoid duplicate restart) - const mainTuningDial = document.getElementById('mainTuningDial'); - if (mainTuningDial && mainTuningDial._dial) { - mainTuningDial._dial.setValue(newFreq, true); - } - - const quickFreq = document.getElementById('lpQuickFreq'); - if (quickFreq) { - quickFreq.textContent = newFreq.toFixed(3) + ' MHz'; - } - - // If currently listening, restart stream at new frequency - if (isDirectListening) { - startDirectListen(); - } - } -} - -/** - * Quick tune to a preset frequency - */ -function quickTune(freq, mod) { - // Update frequency inputs - const startInput = document.getElementById('radioScanStart'); - if (startInput) { - startInput.value = freq; - } - - // Update modulation (don't trigger auto-restart here, we'll handle it below) - if (mod) { - currentModulation = mod; - // Update modulation UI without triggering restart - document.querySelectorAll('#modBtnBank .radio-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.mod === mod); - }); - const badge = document.getElementById('mainScannerMod'); - if (badge) { - const modLabels = { am: 'AM', fm: 'NFM', wfm: 'WFM', usb: 'USB', lsb: 'LSB' }; - badge.textContent = modLabels[mod] || mod.toUpperCase(); - } - } - - // Update display - const freqDisplay = document.getElementById('mainScannerFreq'); - if (freqDisplay) { - freqDisplay.textContent = freq.toFixed(3); - } - - // Update tuning dial position (silent to avoid duplicate restart) - const mainTuningDial = document.getElementById('mainTuningDial'); - if (mainTuningDial && mainTuningDial._dial) { - mainTuningDial._dial.setValue(freq, true); - } - - const quickFreq = document.getElementById('lpQuickFreq'); - if (quickFreq) { - quickFreq.textContent = freq.toFixed(3) + ' MHz'; - } - - addScannerLogEntry(`Quick tuned to ${freq.toFixed(3)} MHz (${mod.toUpperCase()})`); - - // If currently listening, restart immediately (this is a deliberate preset selection) - if (isDirectListening) { - startDirectListenImmediate(); - } -} - -/** - * Enhanced setModulation to also update currentModulation - * Uses immediate restart if currently listening - */ -const originalSetModulation = window.setModulation; -window.setModulation = function(mod) { - console.log('[MODULATION] Setting modulation to:', mod, 'isListening:', isDirectListening); - currentModulation = mod; - - // Update modulation button states - document.querySelectorAll('#modBtnBank .radio-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.mod === mod); - }); - - // Update badge - const badge = document.getElementById('mainScannerMod'); - if (badge) { - const modLabels = { am: 'AM', fm: 'NFM', wfm: 'WFM', usb: 'USB', lsb: 'LSB' }; - badge.textContent = modLabels[mod] || mod.toUpperCase(); - } - - // Update scanner modulation select if exists - const modSelect = document.getElementById('scannerModulation'); - if (modSelect) { - modSelect.value = mod; - } - - // Sync with scanner if running - updateScannerConfig({ modulation: mod }); - - // If currently listening, restart immediately (deliberate modulation change) - if (isDirectListening) { - console.log('[MODULATION] Restarting audio with new modulation:', mod); - startDirectListenImmediate(); - } else { - console.log('[MODULATION] Not listening, just updated UI'); - } -}; - -/** - * Update sidebar quick status - */ -function updateQuickStatus() { - const quickStatus = document.getElementById('lpQuickStatus'); - const quickFreq = document.getElementById('lpQuickFreq'); - const quickSignals = document.getElementById('lpQuickSignals'); - - if (quickStatus) { - if (isScannerRunning) { - quickStatus.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING'; - quickStatus.style.color = isScannerPaused ? 'var(--accent-orange)' : 'var(--accent-green)'; - } else if (isDirectListening) { - quickStatus.textContent = 'LISTENING'; - quickStatus.style.color = 'var(--accent-green)'; - } else { - quickStatus.textContent = 'IDLE'; - quickStatus.style.color = 'var(--accent-cyan)'; - } - } - - if (quickSignals) { - quickSignals.textContent = scannerSignalCount; - } -} - -// ============== SIDEBAR CONTROLS ============== - -// Frequency bookmarks stored in localStorage -let frequencyBookmarks = []; - -/** - * Load bookmarks from localStorage - */ -function loadFrequencyBookmarks() { - try { - const saved = localStorage.getItem('lpBookmarks'); - if (saved) { - frequencyBookmarks = JSON.parse(saved); - renderBookmarks(); - } - } catch (e) { - console.warn('Failed to load bookmarks:', e); - } -} - -/** - * Save bookmarks to localStorage - */ -function saveFrequencyBookmarks() { - try { - localStorage.setItem('lpBookmarks', JSON.stringify(frequencyBookmarks)); - } catch (e) { - console.warn('Failed to save bookmarks:', e); - } -} - -/** - * Add a frequency bookmark - */ -function addFrequencyBookmark() { - const input = document.getElementById('bookmarkFreqInput'); - if (!input) return; - - const freq = parseFloat(input.value); - if (isNaN(freq) || freq <= 0) { - if (typeof showNotification === 'function') { - showNotification('Invalid Frequency', 'Please enter a valid frequency'); - } - return; - } - - // Check for duplicates - if (frequencyBookmarks.some(b => Math.abs(b.freq - freq) < 0.001)) { - if (typeof showNotification === 'function') { - showNotification('Duplicate', 'This frequency is already bookmarked'); - } - return; - } - - frequencyBookmarks.push({ - freq: freq, - mod: currentModulation || 'am', - added: new Date().toISOString() - }); - - saveFrequencyBookmarks(); - renderBookmarks(); - input.value = ''; - - if (typeof showNotification === 'function') { - showNotification('Bookmark Added', `${freq.toFixed(3)} MHz saved`); - } -} - -/** - * Remove a bookmark by index - */ -function removeBookmark(index) { - frequencyBookmarks.splice(index, 1); - saveFrequencyBookmarks(); - renderBookmarks(); -} - -/** - * Render bookmarks list - */ -function renderBookmarks() { - const container = document.getElementById('bookmarksList'); - if (!container) return; - - if (frequencyBookmarks.length === 0) { - container.innerHTML = '
No bookmarks saved
'; - return; - } - - container.innerHTML = frequencyBookmarks.map((b, i) => ` -
- ${b.freq.toFixed(3)} MHz - ${b.mod.toUpperCase()} - -
- `).join(''); -} - - -/** - * Add a signal to the sidebar recent signals list - */ -function addSidebarRecentSignal(freq, mod) { - const container = document.getElementById('sidebarRecentSignals'); - if (!container) return; - - // Clear placeholder if present - if (container.innerHTML.includes('No signals yet')) { - container.innerHTML = ''; - } - - const timestamp = new Date().toLocaleTimeString(); - const signalDiv = document.createElement('div'); - signalDiv.style.cssText = 'display: flex; justify-content: space-between; align-items: center; padding: 3px 6px; background: rgba(0,255,100,0.1); border-left: 2px solid var(--accent-green); margin-bottom: 2px; border-radius: 2px;'; - signalDiv.innerHTML = ` - ${freq.toFixed(3)} - ${timestamp} - `; - - container.insertBefore(signalDiv, container.firstChild); - - // Keep only last 10 signals - while (container.children.length > 10) { - container.removeChild(container.lastChild); - } -} - -// Load bookmarks on init -document.addEventListener('DOMContentLoaded', loadFrequencyBookmarks); - -/** - * Set listening post running state from external source (agent sync). - * Called by syncModeUI in agents.js when switching to an agent that already has scan running. - */ -function setListeningPostRunning(isRunning, agentId = null) { - console.log(`[ListeningPost] setListeningPostRunning: ${isRunning}, agent: ${agentId}`); - - isScannerRunning = isRunning; - - if (isRunning && agentId !== null && agentId !== 'local') { - // Agent has scan running - sync UI and start polling - listeningPostCurrentAgent = agentId; - - // Update main scan button (radioScanBtn is the actual ID) - const radioScanBtn = document.getElementById('radioScanBtn'); - if (radioScanBtn) { - radioScanBtn.innerHTML = 'STOP'; - radioScanBtn.style.background = 'var(--accent-red)'; - radioScanBtn.style.borderColor = 'var(--accent-red)'; - } - - // Update status display - updateScannerDisplay('SCANNING', 'var(--accent-green)'); - - // Disable listen button (can't stream audio from agent) - updateListenButtonState(true); - - // Start polling for agent data - startListeningPostPolling(); - } else if (!isRunning) { - // Not running - reset UI - listeningPostCurrentAgent = null; - - // Reset scan button - const radioScanBtn = document.getElementById('radioScanBtn'); - if (radioScanBtn) { - radioScanBtn.innerHTML = 'SCAN'; - radioScanBtn.style.background = ''; - radioScanBtn.style.borderColor = ''; - } - - // Update status - updateScannerDisplay('IDLE', 'var(--text-secondary)'); - - // Only re-enable listen button if we're in local mode - // (agent mode can't stream audio over HTTP) - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - updateListenButtonState(isAgentMode); - - // Clear polling - if (listeningPostPollTimer) { - clearInterval(listeningPostPollTimer); - listeningPostPollTimer = null; - } - } -} - -// Export for agent sync -window.setListeningPostRunning = setListeningPostRunning; -window.updateListenButtonState = updateListenButtonState; - -// Export functions for HTML onclick handlers -window.toggleDirectListen = toggleDirectListen; -window.startDirectListen = startDirectListen; -// ============== SIGNAL IDENTIFICATION ============== - -function guessSignal(frequencyMhz, modulation) { - const body = { frequency_mhz: frequencyMhz }; - if (modulation) body.modulation = modulation; - - return fetch('/listening/signal/guess', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) - }) - .then(r => r.json()) - .then(data => { - if (data.status === 'ok') { - renderSignalGuess(data); - } - return data; - }) - .catch(err => console.error('[SIGNAL-ID] Error:', err)); -} - -function renderSignalGuess(result) { - const panel = document.getElementById('signalGuessPanel'); - if (!panel) return; - panel.style.display = 'block'; - - const label = document.getElementById('signalGuessLabel'); - const badge = document.getElementById('signalGuessBadge'); - const explanation = document.getElementById('signalGuessExplanation'); - const tagsEl = document.getElementById('signalGuessTags'); - const altsEl = document.getElementById('signalGuessAlternatives'); - - if (label) label.textContent = result.primary_label || 'Unknown'; - - if (badge) { - badge.textContent = result.confidence || ''; - const colors = { 'HIGH': '#00e676', 'MEDIUM': '#ff9800', 'LOW': '#9e9e9e' }; - badge.style.background = colors[result.confidence] || '#9e9e9e'; - badge.style.color = '#000'; - } - - if (explanation) explanation.textContent = result.explanation || ''; - - if (tagsEl) { - tagsEl.innerHTML = (result.tags || []).map(tag => - `${tag}` - ).join(''); - } - - if (altsEl) { - if (result.alternatives && result.alternatives.length > 0) { - altsEl.innerHTML = 'Also: ' + result.alternatives.map(a => - `${a.label} (${a.confidence})` - ).join(', '); - } else { - altsEl.innerHTML = ''; - } - } - - const sendToEl = document.getElementById('signalGuessSendTo'); - if (sendToEl) { - const freqInput = document.getElementById('signalGuessFreqInput'); - const freq = freqInput ? parseFloat(freqInput.value) : NaN; - if (!isNaN(freq) && freq > 0) { - const tags = (result.tags || []).map(t => t.toLowerCase()); - const modes = [ - { key: 'pager', label: 'Pager', highlight: tags.some(t => t.includes('pager') || t.includes('pocsag') || t.includes('flex')) }, - { key: 'sensor', label: '433 Sensor', highlight: tags.some(t => t.includes('ism') || t.includes('433') || t.includes('sensor') || t.includes('iot')) }, - { key: 'rtlamr', label: 'RTLAMR', highlight: tags.some(t => t.includes('meter') || t.includes('amr') || t.includes('utility')) } - ]; - sendToEl.style.display = 'block'; - sendToEl.innerHTML = '
Send to:
' + - modes.map(m => - `` - ).join('') + '
'; - } else { - sendToEl.style.display = 'none'; - } - } -} - -function manualSignalGuess() { - const input = document.getElementById('signalGuessFreqInput'); - if (!input || !input.value) return; - const freq = parseFloat(input.value); - if (isNaN(freq) || freq <= 0) return; - guessSignal(freq, currentModulation); -} - - -// ============== WATERFALL / SPECTROGRAM ============== - -let isWaterfallRunning = false; -let waterfallEventSource = null; -let waterfallCanvas = null; -let waterfallCtx = null; -let spectrumCanvas = null; -let spectrumCtx = null; -let waterfallStartFreq = 88; -let waterfallEndFreq = 108; -let waterfallRowImage = null; -let waterfallPalette = null; -let lastWaterfallDraw = 0; -const WATERFALL_MIN_INTERVAL_MS = 200; -let waterfallInteractionBound = false; -let waterfallResizeObserver = null; -let waterfallMode = 'rf'; -let audioWaterfallAnimId = null; -let lastAudioWaterfallDraw = 0; -let resumeRfWaterfallAfterListening = false; -let waterfallResumeTimer = null; -let waterfallResumeAttempts = 0; -const WATERFALL_RESUME_MAX_ATTEMPTS = 8; -const WATERFALL_RESUME_RETRY_MS = 350; -const WATERFALL_ZOOM_MIN_MHZ = 0.1; -const WATERFALL_ZOOM_MAX_MHZ = 500; -const WATERFALL_DEFAULT_SPAN_MHZ = 2.0; - -// WebSocket waterfall state -let waterfallWebSocket = null; -let waterfallUseWebSocket = false; - -function resizeCanvasToDisplaySize(canvas) { - if (!canvas) return false; - const dpr = window.devicePixelRatio || 1; - const rect = canvas.getBoundingClientRect(); - if (rect.width === 0 || rect.height === 0) return false; - const width = Math.max(1, Math.round(rect.width * dpr)); - const height = Math.max(1, Math.round(rect.height * dpr)); - if (canvas.width !== width || canvas.height !== height) { - canvas.width = width; - canvas.height = height; - return true; - } - return false; -} - -function getWaterfallRowHeight() { - const dpr = window.devicePixelRatio || 1; - return Math.max(1, Math.round(dpr)); -} - -function initWaterfallCanvas() { - waterfallCanvas = document.getElementById('waterfallCanvas'); - spectrumCanvas = document.getElementById('spectrumCanvas'); - if (waterfallCanvas) { - resizeCanvasToDisplaySize(waterfallCanvas); - waterfallCtx = waterfallCanvas.getContext('2d'); - if (waterfallCtx) { - waterfallCtx.imageSmoothingEnabled = false; - waterfallRowImage = waterfallCtx.createImageData( - waterfallCanvas.width, - getWaterfallRowHeight() - ); - } - } - if (spectrumCanvas) { - resizeCanvasToDisplaySize(spectrumCanvas); - spectrumCtx = spectrumCanvas.getContext('2d'); - if (spectrumCtx) { - spectrumCtx.imageSmoothingEnabled = false; - } - } - if (!waterfallPalette) waterfallPalette = buildWaterfallPalette(); - - if (!waterfallInteractionBound) { - bindWaterfallInteraction(); - waterfallInteractionBound = true; - } - - if (!waterfallResizeObserver && waterfallCanvas) { - const observerTarget = waterfallCanvas.parentElement; - if (observerTarget && typeof ResizeObserver !== 'undefined') { - waterfallResizeObserver = new ResizeObserver(() => { - const resizedWaterfall = resizeCanvasToDisplaySize(waterfallCanvas); - const resizedSpectrum = spectrumCanvas ? resizeCanvasToDisplaySize(spectrumCanvas) : false; - if (resizedWaterfall && waterfallCtx) { - waterfallRowImage = waterfallCtx.createImageData( - waterfallCanvas.width, - getWaterfallRowHeight() - ); - } - if (resizedWaterfall || resizedSpectrum) { - lastWaterfallDraw = 0; - } - }); - waterfallResizeObserver.observe(observerTarget); - } - } -} - -function setWaterfallControlButtons(running) { - const startBtn = document.getElementById('startWaterfallBtn'); - const stopBtn = document.getElementById('stopWaterfallBtn'); - if (!startBtn || !stopBtn) return; - startBtn.style.display = running ? 'none' : 'inline-block'; - stopBtn.style.display = running ? 'inline-block' : 'none'; - const dot = document.getElementById('waterfallStripDot'); - if (dot) { - dot.className = running ? 'status-dot sweeping' : 'status-dot inactive'; - } -} - -function getWaterfallRangeFromInputs() { - const startInput = document.getElementById('waterfallStartFreq'); - const endInput = document.getElementById('waterfallEndFreq'); - const startVal = parseFloat(startInput?.value); - const endVal = parseFloat(endInput?.value); - const start = Number.isFinite(startVal) ? startVal : waterfallStartFreq; - const end = Number.isFinite(endVal) ? endVal : waterfallEndFreq; - return { start, end }; -} - -function updateWaterfallZoomLabel(start, end) { - const label = document.getElementById('waterfallZoomSpan'); - if (!label) return; - if (!Number.isFinite(start) || !Number.isFinite(end)) return; - const span = Math.max(0, end - start); - if (span >= 1) { - label.textContent = `${span.toFixed(1)} MHz`; - } else { - label.textContent = `${Math.round(span * 1000)} kHz`; - } -} - -function setWaterfallRange(center, span) { - if (!Number.isFinite(center) || !Number.isFinite(span)) return; - const clampedSpan = Math.max(WATERFALL_ZOOM_MIN_MHZ, Math.min(WATERFALL_ZOOM_MAX_MHZ, span)); - const half = clampedSpan / 2; - let start = center - half; - let end = center + half; - const minFreq = 0.01; - if (start < minFreq) { - end += (minFreq - start); - start = minFreq; - } - if (end <= start) { - end = start + WATERFALL_ZOOM_MIN_MHZ; - } - - waterfallStartFreq = start; - waterfallEndFreq = end; - - const startInput = document.getElementById('waterfallStartFreq'); - const endInput = document.getElementById('waterfallEndFreq'); - if (startInput) startInput.value = start.toFixed(3); - if (endInput) endInput.value = end.toFixed(3); - - const rangeLabel = document.getElementById('waterfallFreqRange'); - if (rangeLabel && !isWaterfallRunning) { - rangeLabel.textContent = `${start.toFixed(1)} - ${end.toFixed(1)} MHz`; - } - updateWaterfallZoomLabel(start, end); -} - -function getWaterfallCenterForZoom(start, end) { - const tuned = parseFloat(document.getElementById('radioScanStart')?.value || ''); - if (Number.isFinite(tuned) && tuned > 0) return tuned; - return (start + end) / 2; -} - -async function syncWaterfallToFrequency(freq, options = {}) { - const { autoStart = false, restartIfRunning = true, silent = true } = options; - const numericFreq = parseFloat(freq); - if (!Number.isFinite(numericFreq) || numericFreq <= 0) return { started: false }; - - const { start, end } = getWaterfallRangeFromInputs(); - const span = (Number.isFinite(start) && Number.isFinite(end) && end > start) - ? (end - start) - : WATERFALL_DEFAULT_SPAN_MHZ; - - setWaterfallRange(numericFreq, span); - - if (!autoStart) return { started: false }; - if (isDirectListening || waterfallMode === 'audio') return { started: false }; - - if (isWaterfallRunning && waterfallMode === 'rf' && restartIfRunning) { - // Reuse existing WebSocket to avoid USB device release race - if (waterfallUseWebSocket && waterfallWebSocket && waterfallWebSocket.readyState === WebSocket.OPEN) { - const sf = parseFloat(document.getElementById('waterfallStartFreq')?.value || 88); - const ef = parseFloat(document.getElementById('waterfallEndFreq')?.value || 108); - const fft = parseInt(document.getElementById('waterfallFftSize')?.value || document.getElementById('waterfallBinSize')?.value || 1024); - const g = parseInt(document.getElementById('waterfallGain')?.value || 40); - const dev = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0; - waterfallWebSocket.send(JSON.stringify({ - cmd: 'start', - center_freq: (sf + ef) / 2, - span_mhz: Math.max(0.1, ef - sf), - gain: g, - device: dev, - sdr_type: (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr', - fft_size: fft, - fps: 25, - avg_count: 4, - })); - return { started: true }; - } - await stopWaterfall(); - return await startWaterfall({ silent: silent }); - } - - if (!isWaterfallRunning) { - return await startWaterfall({ silent: silent }); - } - - return { started: true }; -} - -async function zoomWaterfall(direction) { - const { start, end } = getWaterfallRangeFromInputs(); - if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return; - - const zoomIn = direction === 'in' || direction === '+'; - const zoomOut = direction === 'out' || direction === '-'; - if (!zoomIn && !zoomOut) return; - - const span = end - start; - const newSpan = zoomIn ? span / 2 : span * 2; - const center = getWaterfallCenterForZoom(start, end); - setWaterfallRange(center, newSpan); - - if (isWaterfallRunning && waterfallMode === 'rf' && !isDirectListening) { - // Reuse existing WebSocket to avoid USB device release race - if (waterfallUseWebSocket && waterfallWebSocket && waterfallWebSocket.readyState === WebSocket.OPEN) { - const sf = parseFloat(document.getElementById('waterfallStartFreq')?.value || 88); - const ef = parseFloat(document.getElementById('waterfallEndFreq')?.value || 108); - const fft = parseInt(document.getElementById('waterfallFftSize')?.value || document.getElementById('waterfallBinSize')?.value || 1024); - const g = parseInt(document.getElementById('waterfallGain')?.value || 40); - const dev = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0; - waterfallWebSocket.send(JSON.stringify({ - cmd: 'start', - center_freq: (sf + ef) / 2, - span_mhz: Math.max(0.1, ef - sf), - gain: g, - device: dev, - sdr_type: (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr', - fft_size: fft, - fps: 25, - avg_count: 4, - })); - } else { - await stopWaterfall(); - await startWaterfall({ silent: true }); - } - } -} - -function initWaterfallZoomControls() { - const startInput = document.getElementById('waterfallStartFreq'); - const endInput = document.getElementById('waterfallEndFreq'); - if (!startInput && !endInput) return; - - const sync = () => { - const { start, end } = getWaterfallRangeFromInputs(); - if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return; - waterfallStartFreq = start; - waterfallEndFreq = end; - updateWaterfallZoomLabel(start, end); - }; - - if (startInput) startInput.addEventListener('input', sync); - if (endInput) endInput.addEventListener('input', sync); - sync(); -} - -function scheduleWaterfallResume() { - if (!resumeRfWaterfallAfterListening) return; - if (waterfallResumeTimer) { - clearTimeout(waterfallResumeTimer); - waterfallResumeTimer = null; - } - waterfallResumeAttempts = 0; - waterfallResumeTimer = setTimeout(attemptWaterfallResume, 200); -} - -async function attemptWaterfallResume() { - if (!resumeRfWaterfallAfterListening) return; - if (isDirectListening) { - waterfallResumeTimer = setTimeout(attemptWaterfallResume, WATERFALL_RESUME_RETRY_MS); - return; - } - - const result = await startWaterfall({ silent: true, resume: true }); - if (result && result.started) { - waterfallResumeTimer = null; - return; - } - - const retryable = result ? result.retryable : true; - if (retryable && waterfallResumeAttempts < WATERFALL_RESUME_MAX_ATTEMPTS) { - waterfallResumeAttempts += 1; - waterfallResumeTimer = setTimeout(attemptWaterfallResume, WATERFALL_RESUME_RETRY_MS); - return; - } - - resumeRfWaterfallAfterListening = false; - waterfallResumeTimer = null; -} - -function setWaterfallMode(mode) { - waterfallMode = mode; - const header = document.getElementById('waterfallFreqRange'); - if (!header) return; - if (mode === 'audio') { - header.textContent = 'Audio Spectrum (0 - 22 kHz)'; - } -} - -function startAudioWaterfall() { - if (audioWaterfallAnimId) return; - if (!visualizerAnalyser) { - initAudioVisualizer(); - } - if (!visualizerAnalyser) return; - - setWaterfallMode('audio'); - initWaterfallCanvas(); - - const sampleRate = visualizerContext ? visualizerContext.sampleRate : 44100; - const maxFreqKhz = (sampleRate / 2) / 1000; - const dataArray = new Uint8Array(visualizerAnalyser.frequencyBinCount); - - const drawFrame = (ts) => { - if (!isDirectListening || waterfallMode !== 'audio') { - stopAudioWaterfall(); - return; - } - if (ts - lastAudioWaterfallDraw >= WATERFALL_MIN_INTERVAL_MS) { - lastAudioWaterfallDraw = ts; - visualizerAnalyser.getByteFrequencyData(dataArray); - drawWaterfallRow(dataArray); - drawSpectrumLine(dataArray, 0, maxFreqKhz, 'kHz'); - } - audioWaterfallAnimId = requestAnimationFrame(drawFrame); - }; - - audioWaterfallAnimId = requestAnimationFrame(drawFrame); -} - -function stopAudioWaterfall() { - if (audioWaterfallAnimId) { - cancelAnimationFrame(audioWaterfallAnimId); - audioWaterfallAnimId = null; - } - if (waterfallMode === 'audio') { - waterfallMode = 'rf'; - } -} - -function dBmToRgb(normalized) { - // Viridis-inspired: dark blue -> cyan -> green -> yellow - const n = Math.max(0, Math.min(1, normalized)); - let r, g, b; - if (n < 0.25) { - const t = n / 0.25; - r = Math.round(20 + t * 20); - g = Math.round(10 + t * 60); - b = Math.round(80 + t * 100); - } else if (n < 0.5) { - const t = (n - 0.25) / 0.25; - r = Math.round(40 - t * 20); - g = Math.round(70 + t * 130); - b = Math.round(180 - t * 30); - } else if (n < 0.75) { - const t = (n - 0.5) / 0.25; - r = Math.round(20 + t * 180); - g = Math.round(200 + t * 55); - b = Math.round(150 - t * 130); - } else { - const t = (n - 0.75) / 0.25; - r = Math.round(200 + t * 55); - g = Math.round(255 - t * 55); - b = Math.round(20 - t * 20); - } - return [r, g, b]; -} - -function buildWaterfallPalette() { - const palette = new Array(256); - for (let i = 0; i < 256; i++) { - palette[i] = dBmToRgb(i / 255); - } - return palette; -} - -function drawWaterfallRow(bins) { - if (!waterfallCtx || !waterfallCanvas) return; - const w = waterfallCanvas.width; - const h = waterfallCanvas.height; - const rowHeight = waterfallRowImage ? waterfallRowImage.height : 1; - - // Scroll existing content down by 1 pixel (GPU-accelerated) - waterfallCtx.drawImage(waterfallCanvas, 0, 0, w, h - rowHeight, 0, rowHeight, w, h - rowHeight); - - // Find min/max for normalization - let minVal = Infinity, maxVal = -Infinity; - for (let i = 0; i < bins.length; i++) { - if (bins[i] < minVal) minVal = bins[i]; - if (bins[i] > maxVal) maxVal = bins[i]; - } - const range = maxVal - minVal || 1; - - // Draw new row at top using ImageData - if (!waterfallRowImage || waterfallRowImage.width !== w || waterfallRowImage.height !== rowHeight) { - waterfallRowImage = waterfallCtx.createImageData(w, rowHeight); - } - const rowData = waterfallRowImage.data; - const palette = waterfallPalette || buildWaterfallPalette(); - const binCount = bins.length; - for (let x = 0; x < w; x++) { - const pos = (x / (w - 1)) * (binCount - 1); - const i0 = Math.floor(pos); - const i1 = Math.min(binCount - 1, i0 + 1); - const t = pos - i0; - const val = (bins[i0] * (1 - t)) + (bins[i1] * t); - const normalized = (val - minVal) / range; - const color = palette[Math.max(0, Math.min(255, Math.floor(normalized * 255)))] || [0, 0, 0]; - for (let y = 0; y < rowHeight; y++) { - const offset = (y * w + x) * 4; - rowData[offset] = color[0]; - rowData[offset + 1] = color[1]; - rowData[offset + 2] = color[2]; - rowData[offset + 3] = 255; - } - } - waterfallCtx.putImageData(waterfallRowImage, 0, 0); -} - -function drawSpectrumLine(bins, startFreq, endFreq, labelUnit) { - if (!spectrumCtx || !spectrumCanvas) return; - const w = spectrumCanvas.width; - const h = spectrumCanvas.height; - - spectrumCtx.clearRect(0, 0, w, h); - - // Background - spectrumCtx.fillStyle = 'rgba(0, 0, 0, 0.8)'; - spectrumCtx.fillRect(0, 0, w, h); - - // Grid lines - spectrumCtx.strokeStyle = 'rgba(0, 200, 255, 0.1)'; - spectrumCtx.lineWidth = 0.5; - for (let i = 0; i < 5; i++) { - const y = (h / 5) * i; - spectrumCtx.beginPath(); - spectrumCtx.moveTo(0, y); - spectrumCtx.lineTo(w, y); - spectrumCtx.stroke(); - } - - // Frequency labels - const dpr = window.devicePixelRatio || 1; - spectrumCtx.fillStyle = 'rgba(0, 200, 255, 0.5)'; - spectrumCtx.font = `${9 * dpr}px monospace`; - const freqRange = endFreq - startFreq; - for (let i = 0; i <= 4; i++) { - const freq = startFreq + (freqRange / 4) * i; - const x = (w / 4) * i; - const label = labelUnit === 'kHz' ? freq.toFixed(0) : freq.toFixed(1); - spectrumCtx.fillText(label, x + 2, h - 2); - } - - if (bins.length === 0) return; - - // Find min/max for scaling - let minVal = Infinity, maxVal = -Infinity; - for (let i = 0; i < bins.length; i++) { - if (bins[i] < minVal) minVal = bins[i]; - if (bins[i] > maxVal) maxVal = bins[i]; - } - const range = maxVal - minVal || 1; - - // Draw spectrum line - spectrumCtx.strokeStyle = 'rgba(0, 255, 255, 0.9)'; - spectrumCtx.lineWidth = 1.5; - spectrumCtx.beginPath(); - for (let i = 0; i < bins.length; i++) { - const x = (i / (bins.length - 1)) * w; - const normalized = (bins[i] - minVal) / range; - const y = h - 12 - normalized * (h - 16); - if (i === 0) spectrumCtx.moveTo(x, y); - else spectrumCtx.lineTo(x, y); - } - spectrumCtx.stroke(); - - // Fill under line - const lastX = w; - const lastY = h - 12 - ((bins[bins.length - 1] - minVal) / range) * (h - 16); - spectrumCtx.lineTo(lastX, h); - spectrumCtx.lineTo(0, h); - spectrumCtx.closePath(); - spectrumCtx.fillStyle = 'rgba(0, 255, 255, 0.08)'; - spectrumCtx.fill(); -} - -function connectWaterfallWebSocket(config) { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${window.location.host}/ws/waterfall`; - - return new Promise((resolve, reject) => { - try { - const ws = new WebSocket(wsUrl); - ws.binaryType = 'arraybuffer'; - - const timeout = setTimeout(() => { - ws.close(); - reject(new Error('WebSocket connection timeout')); - }, 5000); - - ws.onopen = () => { - clearTimeout(timeout); - ws.send(JSON.stringify({ cmd: 'start', ...config })); - }; - - ws.onmessage = (event) => { - if (typeof event.data === 'string') { - const msg = JSON.parse(event.data); - if (msg.status === 'started') { - waterfallWebSocket = ws; - waterfallUseWebSocket = true; - if (typeof msg.start_freq === 'number') waterfallStartFreq = msg.start_freq; - if (typeof msg.end_freq === 'number') waterfallEndFreq = msg.end_freq; - const rangeLabel = document.getElementById('waterfallFreqRange'); - if (rangeLabel) { - rangeLabel.textContent = `${waterfallStartFreq.toFixed(1)} - ${waterfallEndFreq.toFixed(1)} MHz`; - } - updateWaterfallZoomLabel(waterfallStartFreq, waterfallEndFreq); - resolve(ws); - } else if (msg.status === 'error') { - ws.close(); - reject(new Error(msg.message || 'WebSocket waterfall error')); - } else if (msg.status === 'stopped') { - // Server confirmed stop - } - } else if (event.data instanceof ArrayBuffer) { - const now = Date.now(); - if (now - lastWaterfallDraw < WATERFALL_MIN_INTERVAL_MS) return; - lastWaterfallDraw = now; - parseBinaryWaterfallFrame(event.data); - } - }; - - ws.onerror = () => { - clearTimeout(timeout); - reject(new Error('WebSocket connection failed')); - }; - - ws.onclose = () => { - if (waterfallUseWebSocket && isWaterfallRunning) { - waterfallWebSocket = null; - waterfallUseWebSocket = false; - isWaterfallRunning = false; - setWaterfallControlButtons(false); - if (typeof releaseDevice === 'function') { - releaseDevice('waterfall'); - } - } - }; - } catch (e) { - reject(e); - } - }); -} - -function parseBinaryWaterfallFrame(buffer) { - if (buffer.byteLength < 11) return; - const view = new DataView(buffer); - const msgType = view.getUint8(0); - if (msgType !== 0x01) return; - - const startFreq = view.getFloat32(1, true); - const endFreq = view.getFloat32(5, true); - const binCount = view.getUint16(9, true); - - if (buffer.byteLength < 11 + binCount) return; - - const bins = new Uint8Array(buffer, 11, binCount); - - waterfallStartFreq = startFreq; - waterfallEndFreq = endFreq; - const rangeLabel = document.getElementById('waterfallFreqRange'); - if (rangeLabel) { - rangeLabel.textContent = `${startFreq.toFixed(1)} - ${endFreq.toFixed(1)} MHz`; - } - updateWaterfallZoomLabel(startFreq, endFreq); - - drawWaterfallRowBinary(bins); - drawSpectrumLineBinary(bins, startFreq, endFreq); -} - -function drawWaterfallRowBinary(bins) { - if (!waterfallCtx || !waterfallCanvas) return; - const w = waterfallCanvas.width; - const h = waterfallCanvas.height; - const rowHeight = waterfallRowImage ? waterfallRowImage.height : 1; - - // Scroll existing content down - waterfallCtx.drawImage(waterfallCanvas, 0, 0, w, h - rowHeight, 0, rowHeight, w, h - rowHeight); - - if (!waterfallRowImage || waterfallRowImage.width !== w || waterfallRowImage.height !== rowHeight) { - waterfallRowImage = waterfallCtx.createImageData(w, rowHeight); - } - const rowData = waterfallRowImage.data; - const palette = waterfallPalette || buildWaterfallPalette(); - const binCount = bins.length; - - for (let x = 0; x < w; x++) { - const pos = (x / (w - 1)) * (binCount - 1); - const i0 = Math.floor(pos); - const i1 = Math.min(binCount - 1, i0 + 1); - const t = pos - i0; - // Interpolate between bins (already uint8, 0-255) - const val = Math.round(bins[i0] * (1 - t) + bins[i1] * t); - const color = palette[Math.max(0, Math.min(255, val))] || [0, 0, 0]; - for (let y = 0; y < rowHeight; y++) { - const offset = (y * w + x) * 4; - rowData[offset] = color[0]; - rowData[offset + 1] = color[1]; - rowData[offset + 2] = color[2]; - rowData[offset + 3] = 255; - } - } - waterfallCtx.putImageData(waterfallRowImage, 0, 0); -} - -function drawSpectrumLineBinary(bins, startFreq, endFreq) { - if (!spectrumCtx || !spectrumCanvas) return; - const w = spectrumCanvas.width; - const h = spectrumCanvas.height; - - spectrumCtx.clearRect(0, 0, w, h); - - // Background - spectrumCtx.fillStyle = 'rgba(0, 0, 0, 0.8)'; - spectrumCtx.fillRect(0, 0, w, h); - - // Grid lines - spectrumCtx.strokeStyle = 'rgba(0, 200, 255, 0.1)'; - spectrumCtx.lineWidth = 0.5; - for (let i = 0; i < 5; i++) { - const y = (h / 5) * i; - spectrumCtx.beginPath(); - spectrumCtx.moveTo(0, y); - spectrumCtx.lineTo(w, y); - spectrumCtx.stroke(); - } - - // Frequency labels - const dpr = window.devicePixelRatio || 1; - spectrumCtx.fillStyle = 'rgba(0, 200, 255, 0.5)'; - spectrumCtx.font = `${9 * dpr}px monospace`; - const freqRange = endFreq - startFreq; - for (let i = 0; i <= 4; i++) { - const freq = startFreq + (freqRange / 4) * i; - const x = (w / 4) * i; - spectrumCtx.fillText(freq.toFixed(1), x + 2, h - 2); - } - - if (bins.length === 0) return; - - // Draw spectrum line — bins are pre-quantized 0-255 - spectrumCtx.strokeStyle = 'rgba(0, 255, 255, 0.9)'; - spectrumCtx.lineWidth = 1.5; - spectrumCtx.beginPath(); - for (let i = 0; i < bins.length; i++) { - const x = (i / (bins.length - 1)) * w; - const normalized = bins[i] / 255; - const y = h - 12 - normalized * (h - 16); - if (i === 0) spectrumCtx.moveTo(x, y); - else spectrumCtx.lineTo(x, y); - } - spectrumCtx.stroke(); - - // Fill under line - const lastX = w; - const lastY = h - 12 - (bins[bins.length - 1] / 255) * (h - 16); - spectrumCtx.lineTo(lastX, h); - spectrumCtx.lineTo(0, h); - spectrumCtx.closePath(); - spectrumCtx.fillStyle = 'rgba(0, 255, 255, 0.08)'; - spectrumCtx.fill(); -} - -async function startWaterfall(options = {}) { - const { silent = false, resume = false } = options; - const startFreq = parseFloat(document.getElementById('waterfallStartFreq')?.value || 88); - const endFreq = parseFloat(document.getElementById('waterfallEndFreq')?.value || 108); - const fftSize = parseInt(document.getElementById('waterfallFftSize')?.value || document.getElementById('waterfallBinSize')?.value || 1024); - const gain = parseInt(document.getElementById('waterfallGain')?.value || 40); - const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0; - initWaterfallCanvas(); - const maxBins = Math.min(4096, Math.max(128, waterfallCanvas ? waterfallCanvas.width : 800)); - - if (startFreq >= endFreq) { - if (!silent && typeof showNotification === 'function') { - showNotification('Error', 'End frequency must be greater than start'); - } - return { started: false, retryable: false }; - } - - waterfallStartFreq = startFreq; - waterfallEndFreq = endFreq; - const rangeLabel = document.getElementById('waterfallFreqRange'); - if (rangeLabel) { - rangeLabel.textContent = `${startFreq.toFixed(1)} - ${endFreq.toFixed(1)} MHz`; - } - updateWaterfallZoomLabel(startFreq, endFreq); - - if (isDirectListening && !resume) { - isWaterfallRunning = true; - const waterfallPanel = document.getElementById('waterfallPanel'); - if (waterfallPanel) waterfallPanel.style.display = 'block'; - setWaterfallControlButtons(true); - startAudioWaterfall(); - resumeRfWaterfallAfterListening = true; - return { started: true }; - } - - if (isDirectListening && resume) { - return { started: false, retryable: true }; - } - - setWaterfallMode('rf'); - - // Try WebSocket path first (I/Q + server-side FFT) - const centerFreq = (startFreq + endFreq) / 2; - const spanMhz = Math.max(0.1, endFreq - startFreq); - - try { - const wsConfig = { - center_freq: centerFreq, - span_mhz: spanMhz, - gain: gain, - device: device, - sdr_type: (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr', - fft_size: fftSize, - fps: 25, - avg_count: 4, - }; - await connectWaterfallWebSocket(wsConfig); - - isWaterfallRunning = true; - setWaterfallControlButtons(true); - const waterfallPanel = document.getElementById('waterfallPanel'); - if (waterfallPanel) waterfallPanel.style.display = 'block'; - lastWaterfallDraw = 0; - initWaterfallCanvas(); - if (typeof reserveDevice === 'function') { - reserveDevice(parseInt(device), 'waterfall'); - } - if (resume || resumeRfWaterfallAfterListening) { - resumeRfWaterfallAfterListening = false; - } - if (waterfallResumeTimer) { - clearTimeout(waterfallResumeTimer); - waterfallResumeTimer = null; - } - console.log('[WATERFALL] WebSocket connected'); - return { started: true }; - } catch (wsErr) { - console.log('[WATERFALL] WebSocket unavailable, falling back to SSE:', wsErr.message); - } - - // Fallback: SSE / rtl_power path - const segments = Math.max(1, Math.ceil(spanMhz / 2.4)); - const targetSweepSeconds = 0.8; - const interval = Math.max(0.1, Math.min(0.3, targetSweepSeconds / segments)); - const binSize = fftSize; - - try { - const response = await fetch('/listening/waterfall/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - start_freq: startFreq, - end_freq: endFreq, - bin_size: binSize, - gain: gain, - device: device, - max_bins: maxBins, - interval: interval, - }) - }); - - let data = {}; - try { - data = await response.json(); - } catch (e) {} - - if (!response.ok || data.status !== 'started') { - if (!silent && typeof showNotification === 'function') { - showNotification('Error', data.message || 'Failed to start waterfall'); - } - return { - started: false, - retryable: response.status === 409 || data.error_type === 'DEVICE_BUSY' - }; - } - - isWaterfallRunning = true; - setWaterfallControlButtons(true); - const waterfallPanel = document.getElementById('waterfallPanel'); - if (waterfallPanel) waterfallPanel.style.display = 'block'; - lastWaterfallDraw = 0; - initWaterfallCanvas(); - connectWaterfallSSE(); - if (typeof reserveDevice === 'function') { - reserveDevice(parseInt(device), 'waterfall'); - } - if (resume || resumeRfWaterfallAfterListening) { - resumeRfWaterfallAfterListening = false; - } - if (waterfallResumeTimer) { - clearTimeout(waterfallResumeTimer); - waterfallResumeTimer = null; - } - return { started: true }; - } catch (err) { - console.error('[WATERFALL] Start error:', err); - if (!silent && typeof showNotification === 'function') { - showNotification('Error', 'Failed to start waterfall'); - } - return { started: false, retryable: true }; - } -} - -async function stopWaterfall() { - if (waterfallMode === 'audio') { - stopAudioWaterfall(); - isWaterfallRunning = false; - setWaterfallControlButtons(false); - return; - } - - // WebSocket path - if (waterfallUseWebSocket && waterfallWebSocket) { - const ws = waterfallWebSocket; - try { - if (ws.readyState === WebSocket.OPEN) { - // Wait for server to confirm stop (it terminates the IQ - // process and releases the USB device before responding). - await new Promise((resolve) => { - const timeout = setTimeout(resolve, 4000); - const prevHandler = ws.onmessage; - ws.onmessage = (event) => { - if (typeof event.data === 'string') { - try { - const msg = JSON.parse(event.data); - if (msg.status === 'stopped') { - clearTimeout(timeout); - resolve(); - return; - } - } catch (_) {} - } - if (prevHandler) prevHandler(event); - }; - ws.send(JSON.stringify({ cmd: 'stop' })); - }); - } - ws.close(); - } catch (e) { - console.error('[WATERFALL] WebSocket stop error:', e); - } - waterfallWebSocket = null; - waterfallUseWebSocket = false; - isWaterfallRunning = false; - setWaterfallControlButtons(false); - if (typeof releaseDevice === 'function') { - releaseDevice('waterfall'); - } - return; - } - - // SSE fallback path - try { - await fetch('/listening/waterfall/stop', { method: 'POST' }); - isWaterfallRunning = false; - if (waterfallEventSource) { waterfallEventSource.close(); waterfallEventSource = null; } - setWaterfallControlButtons(false); - if (typeof releaseDevice === 'function') { - releaseDevice('waterfall'); - } - } catch (err) { - console.error('[WATERFALL] Stop error:', err); - } -} - -function connectWaterfallSSE() { - if (waterfallEventSource) waterfallEventSource.close(); - waterfallEventSource = new EventSource('/listening/waterfall/stream'); - waterfallMode = 'rf'; - - waterfallEventSource.onmessage = function(event) { - const msg = JSON.parse(event.data); - if (msg.type === 'waterfall_sweep') { - if (typeof msg.start_freq === 'number') waterfallStartFreq = msg.start_freq; - if (typeof msg.end_freq === 'number') waterfallEndFreq = msg.end_freq; - const rangeLabel = document.getElementById('waterfallFreqRange'); - if (rangeLabel) { - rangeLabel.textContent = `${waterfallStartFreq.toFixed(1)} - ${waterfallEndFreq.toFixed(1)} MHz`; - } - updateWaterfallZoomLabel(waterfallStartFreq, waterfallEndFreq); - const now = Date.now(); - if (now - lastWaterfallDraw < WATERFALL_MIN_INTERVAL_MS) return; - lastWaterfallDraw = now; - drawWaterfallRow(msg.bins); - drawSpectrumLine(msg.bins, msg.start_freq, msg.end_freq); - } - }; - - waterfallEventSource.onerror = function() { - if (isWaterfallRunning) { - setTimeout(connectWaterfallSSE, 2000); - } - }; -} - -function bindWaterfallInteraction() { - const handler = (event) => { - if (waterfallMode === 'audio') { - return; - } - const canvas = event.currentTarget; - const rect = canvas.getBoundingClientRect(); - const x = event.clientX - rect.left; - const ratio = Math.max(0, Math.min(1, x / rect.width)); - const freq = waterfallStartFreq + ratio * (waterfallEndFreq - waterfallStartFreq); - if (typeof tuneToFrequency === 'function') { - tuneToFrequency(freq, suggestModulation(freq)); - } - }; - - // Tooltip for showing frequency + modulation on hover - let tooltip = document.getElementById('waterfallTooltip'); - if (!tooltip) { - tooltip = document.createElement('div'); - tooltip.id = 'waterfallTooltip'; - tooltip.style.cssText = 'position:fixed;pointer-events:none;background:rgba(0,0,0,0.85);color:#0f0;padding:4px 8px;border-radius:4px;font-size:12px;font-family:monospace;z-index:9999;display:none;white-space:nowrap;border:1px solid #333;'; - document.body.appendChild(tooltip); - } - - const hoverHandler = (event) => { - if (waterfallMode === 'audio') { - tooltip.style.display = 'none'; - return; - } - const canvas = event.currentTarget; - const rect = canvas.getBoundingClientRect(); - const x = event.clientX - rect.left; - const ratio = Math.max(0, Math.min(1, x / rect.width)); - const freq = waterfallStartFreq + ratio * (waterfallEndFreq - waterfallStartFreq); - const mod = suggestModulation(freq); - tooltip.textContent = `${freq.toFixed(3)} MHz \u00b7 ${mod.toUpperCase()}`; - tooltip.style.left = (event.clientX + 12) + 'px'; - tooltip.style.top = (event.clientY - 28) + 'px'; - tooltip.style.display = 'block'; - }; - - const leaveHandler = () => { - tooltip.style.display = 'none'; - }; - - // Right-click context menu for "Send to" decoder - let ctxMenu = document.getElementById('waterfallCtxMenu'); - if (!ctxMenu) { - ctxMenu = document.createElement('div'); - ctxMenu.id = 'waterfallCtxMenu'; - ctxMenu.style.cssText = 'position:fixed;display:none;background:var(--bg-primary);border:1px solid var(--border-color);border-radius:4px;z-index:10000;min-width:120px;padding:4px 0;box-shadow:0 4px 12px rgba(0,0,0,0.5);font-size:11px;'; - document.body.appendChild(ctxMenu); - document.addEventListener('click', () => { ctxMenu.style.display = 'none'; }); - } - - const contextHandler = (event) => { - if (waterfallMode === 'audio') return; - event.preventDefault(); - const canvas = event.currentTarget; - const rect = canvas.getBoundingClientRect(); - const x = event.clientX - rect.left; - const ratio = Math.max(0, Math.min(1, x / rect.width)); - const freq = waterfallStartFreq + ratio * (waterfallEndFreq - waterfallStartFreq); - - const modes = [ - { key: 'pager', label: 'Pager' }, - { key: 'sensor', label: '433 Sensor' }, - { key: 'rtlamr', label: 'RTLAMR' } - ]; - - ctxMenu.innerHTML = `
${freq.toFixed(3)} MHz →
` + - modes.map(m => - `
Send to ${m.label}
` - ).join(''); - - ctxMenu.style.left = event.clientX + 'px'; - ctxMenu.style.top = event.clientY + 'px'; - ctxMenu.style.display = 'block'; - }; - - if (waterfallCanvas) { - waterfallCanvas.style.cursor = 'crosshair'; - waterfallCanvas.addEventListener('click', handler); - waterfallCanvas.addEventListener('mousemove', hoverHandler); - waterfallCanvas.addEventListener('mouseleave', leaveHandler); - waterfallCanvas.addEventListener('contextmenu', contextHandler); - } - if (spectrumCanvas) { - spectrumCanvas.style.cursor = 'crosshair'; - spectrumCanvas.addEventListener('click', handler); - spectrumCanvas.addEventListener('mousemove', hoverHandler); - spectrumCanvas.addEventListener('mouseleave', leaveHandler); - spectrumCanvas.addEventListener('contextmenu', contextHandler); - } -} - - -// ============== CROSS-MODULE FREQUENCY ROUTING ============== - -function sendFrequencyToMode(freqMhz, targetMode) { - const inputMap = { - pager: 'frequency', - sensor: 'sensorFrequency', - rtlamr: 'rtlamrFrequency' - }; - - const inputId = inputMap[targetMode]; - if (!inputId) return; - - if (typeof switchMode === 'function') { - switchMode(targetMode); - } - - setTimeout(() => { - const input = document.getElementById(inputId); - if (input) { - input.value = freqMhz.toFixed(4); - } - }, 300); - - if (typeof showNotification === 'function') { - const modeLabels = { pager: 'Pager', sensor: '433 Sensor', rtlamr: 'RTLAMR' }; - showNotification('Frequency Sent', `${freqMhz.toFixed(3)} MHz → ${modeLabels[targetMode] || targetMode}`); - } -} - -window.sendFrequencyToMode = sendFrequencyToMode; -window.stopDirectListen = stopDirectListen; -window.toggleScanner = toggleScanner; -window.startScanner = startScanner; -window.stopScanner = stopScanner; -window.pauseScanner = pauseScanner; -window.skipSignal = skipSignal; -// Note: setModulation is already exported with enhancements above -window.setBand = setBand; -window.tuneFreq = tuneFreq; -window.quickTune = quickTune; -window.checkIncomingTuneRequest = checkIncomingTuneRequest; -window.addFrequencyBookmark = addFrequencyBookmark; -window.removeBookmark = removeBookmark; -window.tuneToFrequency = tuneToFrequency; -window.clearScannerLog = clearScannerLog; -window.exportScannerLog = exportScannerLog; -window.manualSignalGuess = manualSignalGuess; -window.guessSignal = guessSignal; -window.startWaterfall = startWaterfall; -window.stopWaterfall = stopWaterfall; -window.zoomWaterfall = zoomWaterfall; -window.syncWaterfallToFrequency = syncWaterfallToFrequency; diff --git a/static/js/modes/spy-stations.js b/static/js/modes/spy-stations.js index d6d3c34..a6176f6 100644 --- a/static/js/modes/spy-stations.js +++ b/static/js/modes/spy-stations.js @@ -269,12 +269,10 @@ const SpyStations = (function() { */ function tuneToStation(stationId, freqKhz) { const freqMhz = freqKhz / 1000; - sessionStorage.setItem('tuneFrequency', freqMhz.toString()); // Find the station and determine mode const station = stations.find(s => s.id === stationId); const tuneMode = station ? getModeFromStation(station.mode) : 'usb'; - sessionStorage.setItem('tuneMode', tuneMode); const stationName = station ? station.name : 'Station'; @@ -282,12 +280,18 @@ const SpyStations = (function() { showNotification('Tuning to ' + stationName, formatFrequency(freqKhz) + ' (' + tuneMode.toUpperCase() + ')'); } - // Switch to listening post mode - if (typeof selectMode === 'function') { - selectMode('listening'); - } else if (typeof switchMode === 'function') { - switchMode('listening'); + // Switch to spectrum waterfall mode and tune after mode init. + if (typeof switchMode === 'function') { + switchMode('waterfall'); + } else if (typeof selectMode === 'function') { + selectMode('waterfall'); } + + setTimeout(() => { + if (typeof Waterfall !== 'undefined' && typeof Waterfall.quickTune === 'function') { + Waterfall.quickTune(freqMhz, tuneMode); + } + }, 220); } /** @@ -305,7 +309,7 @@ const SpyStations = (function() { * Check if we arrived from another page with a tune request */ function checkTuneFrequency() { - // This is for the listening post to check - spy stations sets, listening post reads + // Reserved for cross-mode tune handoff behavior. } /** @@ -445,7 +449,7 @@ const SpyStations = (function() {
How to Listen

- Click "Tune In" on any station to open the Listening Post with the frequency pre-configured. + Click "Tune In" on any station to open Spectrum Waterfall with the frequency pre-configured. Most number stations use USB (Upper Sideband) mode. You'll need an SDR capable of receiving HF frequencies (typically 3-30 MHz) and an appropriate antenna.

diff --git a/static/js/modes/sstv-general.js b/static/js/modes/sstv-general.js index 6613122..c16791d 100644 --- a/static/js/modes/sstv-general.js +++ b/static/js/modes/sstv-general.js @@ -15,13 +15,21 @@ const SSTVGeneral = (function() { let sstvGeneralScopeCtx = null; let sstvGeneralScopeAnim = null; let sstvGeneralScopeHistory = []; + let sstvGeneralScopeWaveBuffer = []; + let sstvGeneralScopeDisplayWave = []; const SSTV_GENERAL_SCOPE_LEN = 200; + const SSTV_GENERAL_SCOPE_WAVE_BUFFER_LEN = 2048; + const SSTV_GENERAL_SCOPE_WAVE_INPUT_SMOOTH_ALPHA = 0.55; + const SSTV_GENERAL_SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA = 0.22; + const SSTV_GENERAL_SCOPE_WAVE_IDLE_DECAY = 0.96; let sstvGeneralScopeRms = 0; let sstvGeneralScopePeak = 0; let sstvGeneralScopeTargetRms = 0; let sstvGeneralScopeTargetPeak = 0; let sstvGeneralScopeMsgBurst = 0; let sstvGeneralScopeTone = null; + let sstvGeneralScopeLastWaveAt = 0; + let sstvGeneralScopeLastInputSample = 0; /** * Initialize the SSTV General mode @@ -205,20 +213,64 @@ const SSTVGeneral = (function() { /** * Initialize signal scope canvas */ + function resizeSstvGeneralScopeCanvas(canvas) { + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + const width = Math.max(1, Math.floor(rect.width * dpr)); + const height = Math.max(1, Math.floor(rect.height * dpr)); + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + } + + function applySstvGeneralScopeData(scopeData) { + if (!scopeData || typeof scopeData !== 'object') return; + + sstvGeneralScopeTargetRms = Number(scopeData.rms) || 0; + sstvGeneralScopeTargetPeak = Number(scopeData.peak) || 0; + if (scopeData.tone !== undefined) { + sstvGeneralScopeTone = scopeData.tone; + } + + if (Array.isArray(scopeData.waveform) && scopeData.waveform.length) { + for (const packedSample of scopeData.waveform) { + const sample = Number(packedSample); + if (!Number.isFinite(sample)) continue; + const normalized = Math.max(-127, Math.min(127, sample)) / 127; + sstvGeneralScopeLastInputSample += (normalized - sstvGeneralScopeLastInputSample) * SSTV_GENERAL_SCOPE_WAVE_INPUT_SMOOTH_ALPHA; + sstvGeneralScopeWaveBuffer.push(sstvGeneralScopeLastInputSample); + } + if (sstvGeneralScopeWaveBuffer.length > SSTV_GENERAL_SCOPE_WAVE_BUFFER_LEN) { + sstvGeneralScopeWaveBuffer.splice(0, sstvGeneralScopeWaveBuffer.length - SSTV_GENERAL_SCOPE_WAVE_BUFFER_LEN); + } + sstvGeneralScopeLastWaveAt = performance.now(); + } + } + function initSstvGeneralScope() { const canvas = document.getElementById('sstvGeneralScopeCanvas'); if (!canvas) return; - const rect = canvas.getBoundingClientRect(); - canvas.width = rect.width * (window.devicePixelRatio || 1); - canvas.height = rect.height * (window.devicePixelRatio || 1); + + if (sstvGeneralScopeAnim) { + cancelAnimationFrame(sstvGeneralScopeAnim); + sstvGeneralScopeAnim = null; + } + + resizeSstvGeneralScopeCanvas(canvas); sstvGeneralScopeCtx = canvas.getContext('2d'); sstvGeneralScopeHistory = new Array(SSTV_GENERAL_SCOPE_LEN).fill(0); + sstvGeneralScopeWaveBuffer = []; + sstvGeneralScopeDisplayWave = []; sstvGeneralScopeRms = 0; sstvGeneralScopePeak = 0; sstvGeneralScopeTargetRms = 0; sstvGeneralScopeTargetPeak = 0; sstvGeneralScopeMsgBurst = 0; sstvGeneralScopeTone = null; + sstvGeneralScopeLastWaveAt = 0; + sstvGeneralScopeLastInputSample = 0; drawSstvGeneralScope(); } @@ -228,12 +280,14 @@ const SSTVGeneral = (function() { function drawSstvGeneralScope() { const ctx = sstvGeneralScopeCtx; if (!ctx) return; + + resizeSstvGeneralScopeCanvas(ctx.canvas); const W = ctx.canvas.width; const H = ctx.canvas.height; const midY = H / 2; // Phosphor persistence - ctx.fillStyle = 'rgba(5, 5, 16, 0.3)'; + ctx.fillStyle = 'rgba(5, 5, 16, 0.26)'; ctx.fillRect(0, 0, W, H); // Smooth towards target @@ -256,32 +310,84 @@ const SSTVGeneral = (function() { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); } - // Waveform - const stepX = W / (SSTV_GENERAL_SCOPE_LEN - 1); - ctx.strokeStyle = '#c080ff'; - ctx.lineWidth = 1.5; - ctx.shadowColor = '#c080ff'; - ctx.shadowBlur = 4; - - // Upper half + // Envelope + const envStepX = W / (SSTV_GENERAL_SCOPE_LEN - 1); + ctx.strokeStyle = 'rgba(168, 110, 255, 0.45)'; + ctx.lineWidth = 1; ctx.beginPath(); for (let i = 0; i < sstvGeneralScopeHistory.length; i++) { - const x = i * stepX; - const amp = sstvGeneralScopeHistory[i] * midY * 0.9; + const x = i * envStepX; + const amp = sstvGeneralScopeHistory[i] * midY * 0.85; const y = midY - amp; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); - - // Lower half (mirror) ctx.beginPath(); for (let i = 0; i < sstvGeneralScopeHistory.length; i++) { - const x = i * stepX; - const amp = sstvGeneralScopeHistory[i] * midY * 0.9; + const x = i * envStepX; + const amp = sstvGeneralScopeHistory[i] * midY * 0.85; const y = midY + amp; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); + + // Actual waveform trace + const waveformPointCount = Math.min(Math.max(120, Math.floor(W / 3.2)), 420); + if (sstvGeneralScopeWaveBuffer.length > 1) { + const waveIsFresh = (performance.now() - sstvGeneralScopeLastWaveAt) < 1000; + const sourceLen = sstvGeneralScopeWaveBuffer.length; + const sourceWindow = Math.min(sourceLen, 1536); + const sourceStart = sourceLen - sourceWindow; + + if (sstvGeneralScopeDisplayWave.length !== waveformPointCount) { + sstvGeneralScopeDisplayWave = new Array(waveformPointCount).fill(0); + } + + for (let i = 0; i < waveformPointCount; i++) { + const a = sourceStart + Math.floor((i / waveformPointCount) * sourceWindow); + const b = sourceStart + Math.floor(((i + 1) / waveformPointCount) * sourceWindow); + const start = Math.max(sourceStart, Math.min(sourceLen - 1, a)); + const end = Math.max(start + 1, Math.min(sourceLen, b)); + + let sum = 0; + let count = 0; + for (let j = start; j < end; j++) { + sum += sstvGeneralScopeWaveBuffer[j]; + count++; + } + const targetSample = count > 0 ? (sum / count) : 0; + sstvGeneralScopeDisplayWave[i] += (targetSample - sstvGeneralScopeDisplayWave[i]) * SSTV_GENERAL_SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA; + } + + ctx.strokeStyle = waveIsFresh ? '#c080ff' : 'rgba(192, 128, 255, 0.45)'; + ctx.lineWidth = 1.7; + ctx.shadowColor = '#c080ff'; + ctx.shadowBlur = waveIsFresh ? 6 : 2; + + const stepX = waveformPointCount > 1 ? (W / (waveformPointCount - 1)) : W; + ctx.beginPath(); + const firstY = midY - (sstvGeneralScopeDisplayWave[0] * midY * 0.9); + ctx.moveTo(0, firstY); + for (let i = 1; i < waveformPointCount - 1; i++) { + const x = i * stepX; + const y = midY - (sstvGeneralScopeDisplayWave[i] * midY * 0.9); + const nx = (i + 1) * stepX; + const ny = midY - (sstvGeneralScopeDisplayWave[i + 1] * midY * 0.9); + const cx = (x + nx) / 2; + const cy = (y + ny) / 2; + ctx.quadraticCurveTo(x, y, cx, cy); + } + const lastX = (waveformPointCount - 1) * stepX; + const lastY = midY - (sstvGeneralScopeDisplayWave[waveformPointCount - 1] * midY * 0.9); + ctx.lineTo(lastX, lastY); + ctx.stroke(); + + if (!waveIsFresh) { + for (let i = 0; i < sstvGeneralScopeDisplayWave.length; i++) { + sstvGeneralScopeDisplayWave[i] *= SSTV_GENERAL_SCOPE_WAVE_IDLE_DECAY; + } + } + } ctx.shadowBlur = 0; // Peak indicator @@ -317,8 +423,17 @@ const SSTVGeneral = (function() { else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; } } if (statusLabel) { - if (sstvGeneralScopeRms > 500) { statusLabel.textContent = 'SIGNAL'; statusLabel.style.color = '#0f0'; } - else { statusLabel.textContent = 'MONITORING'; statusLabel.style.color = '#555'; } + const waveIsFresh = (performance.now() - sstvGeneralScopeLastWaveAt) < 1000; + if (sstvGeneralScopeRms > 900 && waveIsFresh) { + statusLabel.textContent = 'DEMODULATING'; + statusLabel.style.color = '#c080ff'; + } else if (sstvGeneralScopeRms > 500) { + statusLabel.textContent = 'CARRIER'; + statusLabel.style.color = '#e0b8ff'; + } else { + statusLabel.textContent = 'QUIET'; + statusLabel.style.color = '#555'; + } } sstvGeneralScopeAnim = requestAnimationFrame(drawSstvGeneralScope); @@ -330,6 +445,11 @@ const SSTVGeneral = (function() { function stopSstvGeneralScope() { if (sstvGeneralScopeAnim) { cancelAnimationFrame(sstvGeneralScopeAnim); sstvGeneralScopeAnim = null; } sstvGeneralScopeCtx = null; + sstvGeneralScopeWaveBuffer = []; + sstvGeneralScopeDisplayWave = []; + sstvGeneralScopeHistory = []; + sstvGeneralScopeLastWaveAt = 0; + sstvGeneralScopeLastInputSample = 0; } /** @@ -353,9 +473,7 @@ const SSTVGeneral = (function() { if (data.type === 'sstv_progress') { handleProgress(data); } else if (data.type === 'sstv_scope') { - sstvGeneralScopeTargetRms = data.rms; - sstvGeneralScopeTargetPeak = data.peak; - if (data.tone !== undefined) sstvGeneralScopeTone = data.tone; + applySstvGeneralScopeData(data); } } catch (err) { console.error('Failed to parse SSE message:', err); diff --git a/static/js/modes/waterfall.js b/static/js/modes/waterfall.js new file mode 100644 index 0000000..7851144 --- /dev/null +++ b/static/js/modes/waterfall.js @@ -0,0 +1,3481 @@ +/* + * 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 _controlListenersAttached = false; + + let _retuneTimer = null; + let _monitorRetuneTimer = null; + let _pendingMonitorRetune = false; + + 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 _lastBins = 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 _pendingCaptureVfoMhz = null; + let _pendingMonitorTuneMhz = null; + let _audioConnectNonce = 0; + let _audioAnalyser = null; + let _audioContext = null; + let _audioSourceNode = null; + let _smeterRaf = null; + let _audioUnlockRequired = false; + let _lastTouchTuneAt = 0; + + let _devices = []; + let _scanRunning = false; + let _scanPausedOnSignal = false; + let _scanTimer = null; + let _scanConfig = null; + let _scanAwaitingCapture = false; + let _scanStartPending = false; + let _scanRestartAttempts = 0; + let _scanLogEntries = []; + let _scanSignalHits = []; + let _scanRecentHitTimes = new Map(); + let _scanSignalCount = 0; + let _scanStepCount = 0; + let _scanCycleCount = 0; + let _frequencyBookmarks = []; + + const PALETTES = {}; + const SCAN_LOG_LIMIT = 160; + const SIGNAL_HIT_LIMIT = 60; + const BOOKMARK_STORAGE_KEY = 'wfBookmarks'; + + const RF_BANDS = [ + [0.1485, 0.2835, 'LW Broadcast', 'rgba(255,220,120,0.18)'], + [0.530, 1.705, 'AM Broadcast', 'rgba(255,200,50,0.15)'], + [1.8, 2.0, '160m Ham', 'rgba(255,168,88,0.22)'], + [2.3, 2.495, '120m SW', 'rgba(255,205,84,0.18)'], + [3.2, 3.4, '90m SW', 'rgba(255,205,84,0.18)'], + [3.5, 4.0, '80m Ham', 'rgba(255,168,88,0.22)'], + [4.75, 5.06, '60m SW', 'rgba(255,205,84,0.18)'], + [5.3305, 5.4065, '60m Ham', 'rgba(255,168,88,0.22)'], + [5.9, 6.2, '49m SW', 'rgba(255,205,84,0.18)'], + [7.0, 7.3, '40m Ham', 'rgba(255,168,88,0.22)'], + [9.4, 9.9, '31m SW', 'rgba(255,205,84,0.18)'], + [10.1, 10.15, '30m Ham', 'rgba(255,168,88,0.22)'], + [11.6, 12.1, '25m SW', 'rgba(255,205,84,0.18)'], + [13.57, 13.87, '22m SW', 'rgba(255,205,84,0.18)'], + [14.0, 14.35, '20m Ham', 'rgba(255,168,88,0.22)'], + [15.1, 15.8, '19m SW', 'rgba(255,205,84,0.18)'], + [17.48, 17.9, '16m SW', 'rgba(255,205,84,0.18)'], + [18.068, 18.168, '17m Ham', 'rgba(255,168,88,0.22)'], + [21.0, 21.45, '15m Ham', 'rgba(255,168,88,0.22)'], + [24.89, 24.99, '12m Ham', 'rgba(255,168,88,0.22)'], + [26.965, 27.405, 'CB 11m', 'rgba(255,186,88,0.2)'], + [28.0, 29.7, '10m Ham', 'rgba(255,168,88,0.22)'], + [50.0, 54.0, '6m Ham', 'rgba(255,168,88,0.22)'], + [70.0, 70.5, '4m Ham', 'rgba(255,168,88,0.22)'], + [87.5, 108.0, 'FM Broadcast', 'rgba(255,100,100,0.15)'], + [108.0, 137.0, 'Airband', 'rgba(100,220,100,0.12)'], + [137.0, 138.0, 'NOAA WX Sat', 'rgba(50,200,255,0.25)'], + [138.0, 144.0, 'VHF Federal', 'rgba(120,210,255,0.15)'], + [144.0, 148.0, '2m Ham', 'rgba(255,165,0,0.20)'], + [150.0, 156.0, 'VHF Land Mobile', 'rgba(85,170,255,0.2)'], + [156.0, 162.025, 'Marine', 'rgba(50,150,255,0.15)'], + [162.4, 162.55, 'NOAA Weather', 'rgba(50,255,200,0.35)'], + [174.0, 216.0, 'VHF TV', 'rgba(129,160,255,0.13)'], + [216.0, 225.0, '1.25m Ham', 'rgba(255,165,0,0.2)'], + [225.0, 400.0, 'UHF Mil Air', 'rgba(106,221,120,0.12)'], + [315.0, 316.0, 'ISM 315', 'rgba(255,80,255,0.2)'], + [380.0, 400.0, 'TETRA', 'rgba(90,180,255,0.2)'], + [400.0, 406.1, 'Meteosonde', 'rgba(85,225,225,0.2)'], + [406.0, 420.0, 'UHF Sat', 'rgba(90,215,170,0.17)'], + [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)'], + [462.5625, 467.7125, 'FRS/GMRS', 'rgba(101,186,255,0.22)'], + [470.0, 608.0, 'UHF TV', 'rgba(129,160,255,0.13)'], + [758.0, 768.0, 'P25 700 UL', 'rgba(95,145,255,0.18)'], + [788.0, 798.0, 'P25 700 DL', 'rgba(95,145,255,0.18)'], + [806.0, 824.0, 'SMR 800', 'rgba(95,145,255,0.18)'], + [824.0, 849.0, 'Cell 850 UL', 'rgba(130,130,255,0.16)'], + [851.0, 869.0, 'Public Safety 800', 'rgba(95,145,255,0.2)'], + [863.0, 870.0, 'ISM 868', 'rgba(255,80,255,0.22)'], + [869.0, 894.0, 'Cell 850 DL', 'rgba(130,130,255,0.16)'], + [902.0, 928.0, 'ISM 915', 'rgba(255,80,255,0.18)'], + [929.0, 932.0, 'Paging', 'rgba(125,180,255,0.2)'], + [935.0, 941.0, 'Studio Link', 'rgba(110,180,255,0.16)'], + [960.0, 1215.0, 'L-Band Aero/Nav', 'rgba(120,225,140,0.13)'], + [1089.95, 1090.05, 'ADS-B', 'rgba(50,255,80,0.45)'], + [1200.0, 1300.0, '23cm Ham', 'rgba(255,165,0,0.2)'], + [1575.3, 1575.6, 'GPS L1', 'rgba(88,220,120,0.2)'], + [1610.0, 1626.5, 'Iridium', 'rgba(95,225,165,0.18)'], + [2400.0, 2483.5, '2.4G ISM', 'rgba(255,165,0,0.12)'], + [5150.0, 5925.0, '5G WiFi', 'rgba(255,165,0,0.1)'], + [5725.0, 5875.0, '5.8G ISM', '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'; + } + const hero = document.getElementById('wfHeroVisualStatus'); + if (hero) { + hero.textContent = text || 'IDLE'; + } + } + + function _setMonitorState(text) { + const el = document.getElementById('wfMonitorState'); + if (el) { + el.textContent = text || 'No audio monitor'; + } + } + + function _setHandoffStatus(text, isError = false) { + const el = document.getElementById('wfHandoffStatus'); + if (!el) return; + el.textContent = text || ''; + el.style.color = isError ? 'var(--accent-red)' : 'var(--text-dim)'; + } + + function _setScanState(text, isError = false) { + const el = document.getElementById('wfScanState'); + if (!el) return; + el.textContent = text || ''; + el.style.color = isError ? 'var(--accent-red)' : 'var(--text-dim)'; + _updateHeroReadout(); + } + + function _updateHeroReadout() { + const freqEl = document.getElementById('wfHeroFreq'); + if (freqEl) { + freqEl.textContent = `${_monitorFreqMhz.toFixed(4)} MHz`; + } + + const modeEl = document.getElementById('wfHeroMode'); + if (modeEl) { + modeEl.textContent = _getMonitorMode().toUpperCase(); + } + + const scanEl = document.getElementById('wfHeroScan'); + if (scanEl) { + let text = 'Idle'; + if (_scanRunning) text = _scanPausedOnSignal ? 'Hold' : 'Running'; + scanEl.textContent = text; + } + + const hitEl = document.getElementById('wfHeroHits'); + if (hitEl) { + hitEl.textContent = String(_scanSignalCount); + } + } + + function _syncScanStatsUi() { + const signals = document.getElementById('wfScanSignalsCount'); + const steps = document.getElementById('wfScanStepsCount'); + const cycles = document.getElementById('wfScanCyclesCount'); + const hitCount = document.getElementById('wfSignalHitCount'); + + if (signals) signals.textContent = String(_scanSignalCount); + if (steps) steps.textContent = String(_scanStepCount); + if (cycles) cycles.textContent = String(_scanCycleCount); + if (hitCount) hitCount.textContent = `${_scanSignalCount} signals found`; + _updateHeroReadout(); + } + + function _escapeHtml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function _safeSigIdUrl(url) { + try { + const parsed = new URL(String(url || '')); + if (parsed.protocol === 'https:' && parsed.hostname.endsWith('sigidwiki.com')) { + return parsed.toString(); + } + } catch (_) { + // Ignore malformed URLs. + } + return null; + } + + function _setSignalIdStatus(text, isError = false) { + const el = document.getElementById('wfSigIdStatus'); + if (!el) return; + el.textContent = text || ''; + el.style.color = isError ? 'var(--accent-red)' : 'var(--text-dim)'; + } + + function _signalIdFreqInput() { + return document.getElementById('wfSigIdFreq'); + } + + function _syncSignalIdFreq(force = false) { + const input = _signalIdFreqInput(); + if (!input) return; + if (!force && document.activeElement === input) return; + input.value = _monitorFreqMhz.toFixed(4); + } + + function _clearSignalIdPanels() { + const local = document.getElementById('wfSigIdResult'); + const external = document.getElementById('wfSigIdExternal'); + if (local) { + local.style.display = 'none'; + local.innerHTML = ''; + } + if (external) { + external.style.display = 'none'; + external.innerHTML = ''; + } + } + + function _signalIdModeHint() { + const modeEl = document.getElementById('wfSigIdMode'); + const raw = String(modeEl?.value || 'auto').toLowerCase(); + if (!raw || raw === 'auto') return _getMonitorMode(); + return raw; + } + + function _renderLocalSignalGuess(result, frequencyMhz) { + const panel = document.getElementById('wfSigIdResult'); + if (!panel) return; + + if (!result || result.status !== 'ok') { + panel.style.display = 'block'; + panel.innerHTML = '
Local signal guess failed
'; + return; + } + + const label = _escapeHtml(result.primary_label || 'Unknown Signal'); + const confidence = _escapeHtml(result.confidence || 'LOW'); + const confidenceColor = { + HIGH: 'var(--accent-green)', + MEDIUM: 'var(--accent-orange)', + LOW: 'var(--text-dim)', + }[String(result.confidence || '').toUpperCase()] || 'var(--text-dim)'; + const explanation = _escapeHtml(result.explanation || ''); + const tags = Array.isArray(result.tags) ? result.tags : []; + const alternatives = Array.isArray(result.alternatives) ? result.alternatives : []; + + const tagsHtml = tags.slice(0, 8).map((tag) => ( + `${_escapeHtml(tag)}` + )).join(''); + + const altsHtml = alternatives.slice(0, 4).map((alt) => { + const altLabel = _escapeHtml(alt.label || 'Unknown'); + const altConf = _escapeHtml(alt.confidence || 'LOW'); + return `${altLabel} (${altConf})`; + }).join(', '); + + panel.style.display = 'block'; + panel.innerHTML = ` +
+
${label}
+
${confidence}
+
+
${Number(frequencyMhz).toFixed(4)} MHz
+
${explanation}
+ ${tagsHtml ? `
${tagsHtml}
` : ''} + ${altsHtml ? `
Also: ${altsHtml}
` : ''} + `; + } + + function _renderExternalSignalMatches(result) { + const panel = document.getElementById('wfSigIdExternal'); + if (!panel) return; + + if (!result || result.status !== 'ok') { + panel.style.display = 'block'; + panel.innerHTML = '
SigID Wiki lookup failed
'; + return; + } + + const matches = Array.isArray(result.matches) ? result.matches : []; + if (!matches.length) { + panel.style.display = 'block'; + panel.innerHTML = '
SigID Wiki: no close matches
'; + return; + } + + const items = matches.slice(0, 5).map((match) => { + const title = _escapeHtml(match.title || 'Unknown'); + const safeUrl = _safeSigIdUrl(match.url); + const titleHtml = safeUrl + ? `${title}` + : `${title}`; + const freqs = Array.isArray(match.frequencies_mhz) + ? match.frequencies_mhz.slice(0, 3).map((f) => Number(f).toFixed(4)).join(', ') + : ''; + const modes = Array.isArray(match.modes) ? match.modes.join(', ') : ''; + const mods = Array.isArray(match.modulations) ? match.modulations.join(', ') : ''; + const distance = Number.isFinite(match.distance_hz) ? `${Math.round(match.distance_hz)} Hz offset` : ''; + return ` +
+
${titleHtml}
+
+ ${freqs ? `Freq: ${_escapeHtml(freqs)} MHz` : 'Freq: n/a'} + ${distance ? ` • ${_escapeHtml(distance)}` : ''} +
+
+ ${modes ? `Mode: ${_escapeHtml(modes)}` : 'Mode: n/a'} + ${mods ? ` • Modulation: ${_escapeHtml(mods)}` : ''} +
+
+ `; + }).join(''); + + const label = result.search_used ? 'SigID Wiki (search fallback)' : 'SigID Wiki'; + panel.style.display = 'block'; + panel.innerHTML = `
${_escapeHtml(label)}
${items}`; + } + + function useTuneForSignalId() { + _syncSignalIdFreq(true); + _setSignalIdStatus(`Using tuned ${_monitorFreqMhz.toFixed(4)} MHz`); + } + + async function identifySignal() { + const input = _signalIdFreqInput(); + const fallbackFreq = Number.isFinite(_monitorFreqMhz) ? _monitorFreqMhz : _currentCenter(); + const frequencyMhz = Number.parseFloat(input?.value || `${fallbackFreq}`); + if (!Number.isFinite(frequencyMhz) || frequencyMhz <= 0) { + _setSignalIdStatus('Signal ID frequency is invalid', true); + return; + } + if (input) input.value = frequencyMhz.toFixed(4); + + const modulation = _signalIdModeHint(); + _setSignalIdStatus(`Identifying ${frequencyMhz.toFixed(4)} MHz...`); + _clearSignalIdPanels(); + + const localReq = fetch('/receiver/signal/guess', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ frequency_mhz: frequencyMhz, modulation }), + }).then((r) => r.json()); + + const externalReq = fetch('/signalid/sigidwiki', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ frequency_mhz: frequencyMhz, modulation, limit: 5 }), + }).then((r) => r.json()); + + const [localRes, externalRes] = await Promise.allSettled([localReq, externalReq]); + + const localOk = localRes.status === 'fulfilled' && localRes.value && localRes.value.status === 'ok'; + const externalOk = externalRes.status === 'fulfilled' && externalRes.value && externalRes.value.status === 'ok'; + + if (localRes.status === 'fulfilled') { + _renderLocalSignalGuess(localRes.value, frequencyMhz); + } else { + _renderLocalSignalGuess({ status: 'error' }, frequencyMhz); + } + + if (externalRes.status === 'fulfilled') { + _renderExternalSignalMatches(externalRes.value); + } else { + _renderExternalSignalMatches({ status: 'error' }); + } + + if (localOk && externalOk) { + _setSignalIdStatus(`Signal ID complete for ${frequencyMhz.toFixed(4)} MHz`); + } else if (localOk) { + _setSignalIdStatus(`Local ID complete; SigID lookup unavailable`, true); + } else { + _setSignalIdStatus('Signal ID lookup failed', true); + } + } + + function _safeMode(mode) { + const raw = String(mode || '').toLowerCase(); + if (['wfm', 'fm', 'am', 'usb', 'lsb'].includes(raw)) return raw; + return 'wfm'; + } + + function _bookmarkMode(mode) { + const raw = String(mode || '').toLowerCase(); + if (raw === 'auto' || !raw) return _getMonitorMode(); + return _safeMode(raw); + } + + function _saveBookmarks() { + try { + localStorage.setItem(BOOKMARK_STORAGE_KEY, JSON.stringify(_frequencyBookmarks)); + } catch (_) { + // Ignore storage quota/permission failures. + } + } + + function _renderBookmarks() { + const list = document.getElementById('wfBookmarkList'); + if (!list) return; + + if (!_frequencyBookmarks.length) { + list.innerHTML = '
No bookmarks saved
'; + return; + } + + list.innerHTML = _frequencyBookmarks.map((b, idx) => { + const freq = Number(b.freq); + const mode = _safeMode(b.mode); + return ` +
+ + ${mode.toUpperCase()} + +
+ `; + }).join(''); + } + + function _renderRecentSignals() { + const list = document.getElementById('wfRecentSignals'); + if (!list) return; + + const items = _scanSignalHits.slice(0, 10); + if (!items.length) { + list.innerHTML = '
No recent signal hits
'; + return; + } + + list.innerHTML = items.map((hit) => { + const freq = Number(hit.frequencyMhz); + const mode = _safeMode(hit.modulation); + return ` +
+ + ${_escapeHtml(hit.timestamp)} +
+ `; + }).join(''); + } + + function _loadBookmarks() { + try { + const raw = localStorage.getItem(BOOKMARK_STORAGE_KEY); + if (!raw) { + _frequencyBookmarks = []; + _renderBookmarks(); + return; + } + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + _frequencyBookmarks = []; + _renderBookmarks(); + return; + } + _frequencyBookmarks = parsed + .map((entry) => ({ + freq: Number.parseFloat(entry.freq), + mode: _safeMode(entry.mode), + })) + .filter((entry) => Number.isFinite(entry.freq) && entry.freq > 0) + .slice(0, 80); + _renderBookmarks(); + } catch (_) { + _frequencyBookmarks = []; + _renderBookmarks(); + } + } + + function useTuneForBookmark() { + const input = document.getElementById('wfBookmarkFreqInput'); + if (!input) return; + input.value = _monitorFreqMhz.toFixed(4); + } + + function addBookmarkFromInput() { + const input = document.getElementById('wfBookmarkFreqInput'); + const modeInput = document.getElementById('wfBookmarkMode'); + if (!input) return; + const freq = Number.parseFloat(input.value); + if (!Number.isFinite(freq) || freq <= 0) { + if (typeof showNotification === 'function') { + showNotification('Bookmark', 'Enter a valid frequency'); + } + return; + } + const mode = _bookmarkMode(modeInput?.value || 'auto'); + const duplicate = _frequencyBookmarks.some((entry) => Math.abs(entry.freq - freq) < 0.0005 && entry.mode === mode); + if (duplicate) { + if (typeof showNotification === 'function') { + showNotification('Bookmark', 'Frequency already saved'); + } + return; + } + _frequencyBookmarks.unshift({ freq, mode }); + if (_frequencyBookmarks.length > 80) _frequencyBookmarks.length = 80; + _saveBookmarks(); + _renderBookmarks(); + input.value = ''; + if (typeof showNotification === 'function') { + showNotification('Bookmark', `Saved ${freq.toFixed(4)} MHz (${mode.toUpperCase()})`); + } + } + + function removeBookmark(index) { + if (!Number.isInteger(index) || index < 0 || index >= _frequencyBookmarks.length) return; + _frequencyBookmarks.splice(index, 1); + _saveBookmarks(); + _renderBookmarks(); + } + + function quickTunePreset(freqMhz, mode = 'auto') { + const freq = Number.parseFloat(`${freqMhz}`); + if (!Number.isFinite(freq) || freq <= 0) return; + const safeMode = _bookmarkMode(mode); + _setMonitorMode(safeMode); + _setAndTune(freq, true); + _setStatus(`Quick tuned ${freq.toFixed(4)} MHz (${safeMode.toUpperCase()})`); + _addScanLogEntry('Quick tune', `${freq.toFixed(4)} MHz (${safeMode.toUpperCase()})`); + } + + function _renderScanLog() { + const el = document.getElementById('wfActivityLog'); + if (!el) return; + + if (!_scanLogEntries.length) { + el.innerHTML = '
Ready
'; + return; + } + + el.innerHTML = _scanLogEntries.slice(0, 60).map((entry) => { + const cls = entry.type === 'signal' ? 'is-signal' : (entry.type === 'error' ? 'is-error' : ''); + const detail = entry.detail ? ` ${_escapeHtml(entry.detail)}` : ''; + return `
${_escapeHtml(entry.timestamp)}${_escapeHtml(entry.title)}${detail}
`; + }).join(''); + } + + function _addScanLogEntry(title, detail = '', type = 'info') { + const now = new Date(); + _scanLogEntries.unshift({ + timestamp: now.toLocaleTimeString(), + title: String(title || ''), + detail: String(detail || ''), + type: String(type || 'info'), + }); + if (_scanLogEntries.length > SCAN_LOG_LIMIT) { + _scanLogEntries.length = SCAN_LOG_LIMIT; + } + _renderScanLog(); + } + + function _renderSignalHits() { + const tbody = document.getElementById('wfSignalHitsBody'); + if (!tbody) return; + + if (!_scanSignalHits.length) { + tbody.innerHTML = 'No signals detected'; + return; + } + + tbody.innerHTML = _scanSignalHits.slice(0, 80).map((hit) => { + const freq = Number(hit.frequencyMhz); + const mode = _safeMode(hit.modulation); + const level = Math.round(Number(hit.level) || 0); + return ` + + ${_escapeHtml(hit.timestamp)} + ${freq.toFixed(4)} + ${level} + ${mode.toUpperCase()} + + + `; + }).join(''); + } + + function _recordSignalHit({ frequencyMhz, level, modulation }) { + const freq = Number.parseFloat(`${frequencyMhz}`); + if (!Number.isFinite(freq) || freq <= 0) return; + + const now = Date.now(); + const key = freq.toFixed(4); + const last = _scanRecentHitTimes.get(key); + if (last && (now - last) < 5000) return; + _scanRecentHitTimes.set(key, now); + + for (const [hitKey, timestamp] of _scanRecentHitTimes.entries()) { + if ((now - timestamp) > 60000) _scanRecentHitTimes.delete(hitKey); + } + + const entry = { + timestamp: new Date(now).toLocaleTimeString(), + frequencyMhz: freq, + level: Number.isFinite(level) ? level : 0, + modulation: _safeMode(modulation), + }; + _scanSignalHits.unshift(entry); + if (_scanSignalHits.length > SIGNAL_HIT_LIMIT) { + _scanSignalHits.length = SIGNAL_HIT_LIMIT; + } + _scanSignalCount += 1; + _renderSignalHits(); + _renderRecentSignals(); + _syncScanStatsUi(); + _addScanLogEntry( + 'Signal hit', + `${freq.toFixed(4)} MHz (level ${Math.round(entry.level)})`, + 'signal' + ); + } + + function _recordScanStep(wrapped) { + _scanStepCount += 1; + if (wrapped) _scanCycleCount += 1; + _syncScanStatsUi(); + } + + function clearScanHistory() { + _scanLogEntries = []; + _scanSignalHits = []; + _scanRecentHitTimes = new Map(); + _scanSignalCount = 0; + _scanStepCount = 0; + _scanCycleCount = 0; + _renderScanLog(); + _renderSignalHits(); + _renderRecentSignals(); + _syncScanStatsUi(); + _setStatus('Scan history cleared'); + } + + function exportScanLog() { + if (!_scanLogEntries.length) { + if (typeof showNotification === 'function') { + showNotification('Export', 'No scan activity to export'); + } + return; + } + const csv = 'Timestamp,Event,Detail\n' + _scanLogEntries.map((entry) => ( + `"${entry.timestamp}","${String(entry.title || '').replace(/"/g, '""')}","${String(entry.detail || '').replace(/"/g, '""')}"` + )).join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `waterfall_scan_log_${new Date().toISOString().slice(0, 10)}.csv`; + link.click(); + URL.revokeObjectURL(url); + } + + 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); + }; + + // Only treat actual playback as success. `loadeddata` and + // `canplay` fire when just the WAV header arrives — before any + // real audio samples have been decoded — which caused the + // monitor to report "started" while the stream was still silent. + const onReady = () => { + if (player.currentTime > 0 || (!player.paused && player.readyState >= 4)) { + finish(true); + } + }; + const onFail = () => finish(false); + const events = ['playing', 'timeupdate']; + 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); + }, timeoutMs); + + if (!player.paused && player.currentTime > 0) { + 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 _formatBandFreq(freqMhz) { + if (!Number.isFinite(freqMhz)) return '--'; + if (freqMhz >= 1000) return freqMhz.toFixed(2); + if (freqMhz >= 100) return freqMhz.toFixed(3); + return freqMhz.toFixed(4); + } + + function _shortBandLabel(label) { + const lookup = { + 'AM Broadcast': 'AM BC', + 'FM Broadcast': 'FM BC', + 'NOAA WX Sat': 'NOAA SAT', + 'NOAA Weather': 'NOAA WX', + 'VHF Land Mobile': 'VHF LMR', + 'Public Safety 800': 'PS 800', + 'L-Band Aero/Nav': 'L-BAND', + }; + if (lookup[label]) return lookup[label]; + const compact = String(label || '').trim().replace(/\s+/g, ' '); + if (compact.length <= 11) return compact; + return `${compact.slice(0, 10)}.`; + } + + 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(); + _updateHeroReadout(); + } + + 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'; + _updateScanButtons(); + } + + 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(); + + _syncSignalIdFreq(false); + _updateTuneLine(); + _updateHeroReadout(); + } + + function _updateScanButtons() { + const startBtn = document.getElementById('wfScanStartBtn'); + const stopBtn = document.getElementById('wfScanStopBtn'); + if (startBtn) startBtn.disabled = _scanRunning; + if (stopBtn) stopBtn.disabled = !_scanRunning; + } + + function _scanSignalLevelAt(freqMhz) { + const bins = _lastBins; + if (!bins || !bins.length) return 0; + const span = _endMhz - _startMhz; + if (!Number.isFinite(span) || span <= 0) return 0; + const frac = (freqMhz - _startMhz) / span; + if (!Number.isFinite(frac)) return 0; + const centerIdx = Math.round(_clamp(frac, 0, 1) * (bins.length - 1)); + let peak = 0; + for (let i = -2; i <= 2; i += 1) { + const idx = centerIdx + i; + if (idx < 0 || idx >= bins.length) continue; + peak = Math.max(peak, Number(bins[idx]) || 0); + } + return peak; + } + + function _readScanConfig() { + const start = parseFloat(document.getElementById('wfScanStart')?.value || `${_startMhz}`); + const end = parseFloat(document.getElementById('wfScanEnd')?.value || `${_endMhz}`); + const stepKhz = parseFloat(document.getElementById('wfScanStepKhz')?.value || '100'); + const dwellMs = parseInt(document.getElementById('wfScanDwellMs')?.value, 10); + const threshold = parseInt(document.getElementById('wfScanThreshold')?.value, 10); + const holdMs = parseInt(document.getElementById('wfScanHoldMs')?.value, 10); + const stopOnSignal = !!document.getElementById('wfScanStopOnSignal')?.checked; + + if (!Number.isFinite(start) || !Number.isFinite(end) || start <= 0 || end <= 0 || end <= start) { + throw new Error('Scan range is invalid'); + } + if (!Number.isFinite(stepKhz) || stepKhz <= 0) { + throw new Error('Scan step must be > 0'); + } + if (!Number.isFinite(dwellMs) || dwellMs < 60) { + throw new Error('Dwell must be at least 60 ms'); + } + + return { + start, + end, + stepMhz: stepKhz / 1000.0, + dwellMs: Math.max(60, dwellMs), + threshold: _clamp(Number.isFinite(threshold) ? threshold : 170, 0, 255), + holdMs: Math.max(0, Number.isFinite(holdMs) ? holdMs : 2500), + stopOnSignal, + }; + } + + function _scanTuneTo(freqMhz) { + const clamped = _clamp(freqMhz, 0.001, 6000.0); + _monitorFreqMhz = clamped; + _pendingCaptureVfoMhz = clamped; + _pendingMonitorTuneMhz = clamped; + _updateFreqDisplay(); + + if (_monitoring && !_isSharedMonitorActive()) { + _queueMonitorRetune(70); + } + + const hasTransport = ((_ws && _ws.readyState === WebSocket.OPEN) || _transport === 'sse'); + if (!hasTransport) return false; + + const configuredSpan = _clamp(_currentSpan(), 0.05, 30.0); + const insideCapture = clamped >= _startMhz && clamped <= _endMhz; + + if (_transport === 'ws') { + if (insideCapture) { + _sendWsTuneCmd(); + return false; + } + + const input = document.getElementById('wfCenterFreq'); + if (input) input.value = clamped.toFixed(4); + _startMhz = clamped - configuredSpan / 2; + _endMhz = clamped + configuredSpan / 2; + _drawFreqAxis(); + _scanStartPending = true; + _sendStartCmd(); + return true; + } + + const input = document.getElementById('wfCenterFreq'); + if (input) input.value = clamped.toFixed(4); + _startMhz = clamped - configuredSpan / 2; + _endMhz = clamped + configuredSpan / 2; + _drawFreqAxis(); + _scanStartPending = true; + _sendStartCmd(); + return true; + } + + function _clearScanTimer() { + if (_scanTimer) { + clearTimeout(_scanTimer); + _scanTimer = null; + } + } + + function _scheduleScanTick(delayMs) { + _clearScanTimer(); + if (!_scanRunning) return; + _scanTimer = setTimeout(() => { + _runScanTick().catch((err) => { + stopScan(`Scan error: ${err}`, { silent: false, isError: true }); + }); + }, Math.max(10, delayMs)); + } + + async function _runScanTick() { + if (!_scanRunning) return; + if (!_scanConfig) _scanConfig = _readScanConfig(); + const cfg = _scanConfig; + + if (_scanAwaitingCapture) { + if (_scanStartPending) { + _setScanState('Waiting for capture retune...'); + _scheduleScanTick(Math.max(180, Math.min(650, cfg.dwellMs))); + return; + } + + if (_running) { + _scanAwaitingCapture = false; + _scanRestartAttempts = 0; + } else { + _scanRestartAttempts += 1; + if (_scanRestartAttempts > 6) { + stopScan('Waterfall error - scan ended after retry limit', { silent: false, isError: true }); + return; + } + const restarted = _scanTuneTo(_monitorFreqMhz); + if (!restarted) { + stopScan('Waterfall error - unable to restart capture', { silent: false, isError: true }); + return; + } + _setScanState(`Retuning capture... retry ${_scanRestartAttempts}/6`); + _scheduleScanTick(Math.max(700, cfg.dwellMs + 280)); + return; + } + } + + if (!_running) { + stopScan('Waterfall stopped - scan ended', { silent: false, isError: true }); + return; + } + + if (cfg.stopOnSignal) { + const level = _scanSignalLevelAt(_monitorFreqMhz); + if (level >= cfg.threshold) { + const isNewHit = !_scanPausedOnSignal; + _scanPausedOnSignal = true; + if (isNewHit) { + _recordSignalHit({ + frequencyMhz: _monitorFreqMhz, + level, + modulation: _getMonitorMode(), + }); + } + _setScanState(`Signal hit ${_monitorFreqMhz.toFixed(4)} MHz (level ${Math.round(level)})`); + _setStatus(`Scan paused on signal at ${_monitorFreqMhz.toFixed(4)} MHz`); + _scheduleScanTick(Math.max(120, cfg.holdMs)); + return; + } + } + + if (_scanPausedOnSignal) { + _addScanLogEntry('Signal cleared', `${_monitorFreqMhz.toFixed(4)} MHz`); + } + _scanPausedOnSignal = false; + let current = Number(_monitorFreqMhz); + if (!Number.isFinite(current) || current < cfg.start || current > cfg.end) { + current = cfg.start; + } + + let next = current + cfg.stepMhz; + const wrapped = next > cfg.end + 1e-9; + if (wrapped) next = cfg.start; + _recordScanStep(wrapped); + const restarted = _scanTuneTo(next); + if (restarted) { + _scanAwaitingCapture = true; + _scanRestartAttempts = 0; + _setScanState(`Retuning capture window @ ${next.toFixed(4)} MHz`); + _scheduleScanTick(Math.max(cfg.dwellMs, 900)); + return; + } + _setScanState(`Scanning ${cfg.start.toFixed(4)}-${cfg.end.toFixed(4)} MHz @ ${next.toFixed(4)} MHz`); + _scheduleScanTick(cfg.dwellMs); + } + + async function startScan() { + if (_scanRunning) { + _setScanState('Scan already running'); + return; + } + let cfg = null; + try { + cfg = _readScanConfig(); + } catch (err) { + const msg = err && err.message ? err.message : 'Invalid scan configuration'; + _setScanState(msg, true); + _setStatus(msg); + return; + } + + if (!_running) { + try { + await start(); + } catch (err) { + const msg = `Cannot start scan: ${err}`; + _setScanState(msg, true); + _setStatus(msg); + return; + } + } + + _scanConfig = cfg; + _scanRunning = true; + _scanPausedOnSignal = false; + _scanAwaitingCapture = false; + _scanStartPending = false; + _scanRestartAttempts = 0; + _addScanLogEntry( + 'Scan started', + `${cfg.start.toFixed(4)}-${cfg.end.toFixed(4)} MHz step ${(cfg.stepMhz * 1000).toFixed(1)} kHz` + ); + const restarted = _scanTuneTo(cfg.start); + _updateScanButtons(); + _setScanState(`Scanning ${cfg.start.toFixed(4)}-${cfg.end.toFixed(4)} MHz`); + _setStatus(`Scan started ${cfg.start.toFixed(4)}-${cfg.end.toFixed(4)} MHz`); + if (restarted) { + _scanAwaitingCapture = true; + _scheduleScanTick(Math.max(cfg.dwellMs, 900)); + } else { + _scheduleScanTick(cfg.dwellMs); + } + } + + function stopScan(reason = 'Scan stopped', { silent = false, isError = false } = {}) { + _scanRunning = false; + _scanPausedOnSignal = false; + _scanConfig = null; + _scanAwaitingCapture = false; + _scanStartPending = false; + _scanRestartAttempts = 0; + _clearScanTimer(); + _updateScanButtons(); + _updateHeroReadout(); + if (!silent) { + _addScanLogEntry(isError ? 'Scan error' : 'Scan stopped', reason, isError ? 'error' : 'info'); + } + if (!silent) { + _setScanState(reason, isError); + _setStatus(reason); + } + } + + function setScanRangeFromView() { + const startEl = document.getElementById('wfScanStart'); + const endEl = document.getElementById('wfScanEnd'); + if (startEl) startEl.value = _startMhz.toFixed(4); + if (endEl) endEl.value = _endMhz.toFixed(4); + _setScanState(`Range synced to ${_startMhz.toFixed(4)}-${_endMhz.toFixed(4)} MHz`); + } + + function _switchMode(modeName) { + if (typeof switchMode === 'function') { + switchMode(modeName); + return true; + } + if (typeof selectMode === 'function') { + selectMode(modeName); + return true; + } + return false; + } + + function handoff(target) { + const currentFreq = Number.isFinite(_monitorFreqMhz) ? _monitorFreqMhz : _currentCenter(); + + try { + if (target === 'pager') { + if (typeof setFreq === 'function') { + setFreq(currentFreq.toFixed(4)); + } else { + const el = document.getElementById('frequency'); + if (el) el.value = currentFreq.toFixed(4); + } + _switchMode('pager'); + _setHandoffStatus(`Sent ${currentFreq.toFixed(4)} MHz to Pager`); + } else if (target === 'subghz' || target === 'subghz433') { + const freq = target === 'subghz433' ? 433.920 : currentFreq; + if (typeof SubGhz !== 'undefined' && SubGhz.setFreq) { + SubGhz.setFreq(freq); + if (SubGhz.switchTab) SubGhz.switchTab('rx'); + } else { + const el = document.getElementById('subghzFrequency'); + if (el) el.value = freq.toFixed(3); + } + _switchMode('subghz'); + _setHandoffStatus(`Sent ${freq.toFixed(4)} MHz to SubGHz`); + } else if (target === 'signalid') { + useTuneForSignalId(); + _setHandoffStatus(`Running Signal ID at ${currentFreq.toFixed(4)} MHz`); + identifySignal().catch((err) => { + _setSignalIdStatus(`Signal ID failed: ${err && err.message ? err.message : 'unknown error'}`, true); + }); + } else { + throw new Error('Unsupported handoff target'); + } + + if (typeof showNotification === 'function') { + const targetLabel = { + pager: 'Pager', + subghz: 'SubGHz', + subghz433: 'SubGHz 433 profile', + signalid: 'Signal ID', + }[target] || target; + showNotification('Frequency Handoff', `${currentFreq.toFixed(4)} MHz routed to ${targetLabel}`); + } + } catch (err) { + const msg = err && err.message ? err.message : 'Handoff failed'; + _setHandoffStatus(msg, true); + _setStatus(msg); + } + } + + 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; + _lastBins = bins; + + 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 _drawBandStrip() { + const strip = document.getElementById('wfBandStrip'); + if (!strip) return; + + if (!_showAnnotations) { + strip.innerHTML = ''; + strip.style.display = 'none'; + return; + } + + strip.style.display = ''; + strip.innerHTML = ''; + + const span = _endMhz - _startMhz; + if (!Number.isFinite(span) || span <= 0) return; + + const stripWidth = strip.clientWidth || 0; + const markerLaneRight = [-Infinity, -Infinity]; + let markerOrdinal = 0; + for (const [bandStart, bandEnd, bandLabel, bandColor] of RF_BANDS) { + if (bandEnd <= _startMhz || bandStart >= _endMhz) continue; + + const visibleStart = Math.max(bandStart, _startMhz); + const visibleEnd = Math.min(bandEnd, _endMhz); + const widthRatio = (visibleEnd - visibleStart) / span; + if (!Number.isFinite(widthRatio) || widthRatio <= 0) continue; + + const leftPct = ((visibleStart - _startMhz) / span) * 100; + const widthPct = widthRatio * 100; + const centerPct = leftPct + widthPct / 2; + const px = stripWidth > 0 ? stripWidth * widthRatio : 0; + + if (px > 0 && px < 40) { + const marker = document.createElement('div'); + marker.className = 'wf-band-marker'; + marker.style.left = `${centerPct.toFixed(4)}%`; + marker.title = `${bandLabel}: ${visibleStart.toFixed(4)} - ${visibleEnd.toFixed(4)} MHz`; + + const markerLabel = document.createElement('span'); + markerLabel.className = 'wf-band-marker-label'; + markerLabel.textContent = _shortBandLabel(bandLabel); + marker.appendChild(markerLabel); + + let lane = 0; + if (stripWidth > 0) { + const centerPx = (centerPct / 100) * stripWidth; + const estWidth = Math.max(26, markerLabel.textContent.length * 6 + 10); + const canLane0 = (centerPx - (estWidth / 2)) > (markerLaneRight[0] + 4); + const canLane1 = (centerPx - (estWidth / 2)) > (markerLaneRight[1] + 4); + + if (canLane0) { + lane = 0; + markerLaneRight[0] = centerPx + (estWidth / 2); + } else if (canLane1) { + lane = 1; + markerLaneRight[1] = centerPx + (estWidth / 2); + } else { + marker.classList.add('is-overlap'); + lane = markerLaneRight[0] <= markerLaneRight[1] ? 0 : 1; + } + } else { + lane = markerOrdinal % 2; + } + markerOrdinal += 1; + marker.classList.add(lane === 0 ? 'lane-0' : 'lane-1'); + strip.appendChild(marker); + continue; + } + + const block = document.createElement('div'); + block.className = 'wf-band-block'; + block.style.left = `${leftPct.toFixed(4)}%`; + block.style.width = `${widthPct.toFixed(4)}%`; + block.title = `${bandLabel}: ${visibleStart.toFixed(4)} - ${visibleEnd.toFixed(4)} MHz`; + if (bandColor) { + block.style.background = bandColor; + } + + const isTight = !!(px && px < 128); + const isMini = !!(px && px < 72); + if (isTight) block.classList.add('is-tight'); + if (isMini) block.classList.add('is-mini'); + + const start = document.createElement('span'); + start.className = 'wf-band-edge wf-band-edge-start'; + start.textContent = _formatBandFreq(visibleStart); + + const name = document.createElement('span'); + name.className = 'wf-band-name'; + name.textContent = isMini + ? `${_formatBandFreq(visibleStart)}-${_formatBandFreq(visibleEnd)}` + : bandLabel; + + const end = document.createElement('span'); + end.className = 'wf-band-edge wf-band-edge-end'; + end.textContent = _formatBandFreq(visibleEnd); + + block.appendChild(start); + block.appendChild(name); + block.appendChild(end); + strip.appendChild(block); + } + + if (!strip.childElementCount) { + const empty = document.createElement('div'); + empty.className = 'wf-band-strip-empty'; + empty.textContent = 'No known bands in current span'; + strip.appendChild(empty); + } + } + + function _drawFreqAxis() { + const axis = document.getElementById('wfFreqAxis'); + if (axis) { + 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); + } + } + _drawBandStrip(); + _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 _clientXFromEvent(event) { + if (event && Number.isFinite(event.clientX)) return event.clientX; + const touch = event?.changedTouches?.[0] || event?.touches?.[0]; + if (touch && Number.isFinite(touch.clientX)) return touch.clientX; + return null; + } + + function _showTooltip(canvas, event) { + const tooltip = document.getElementById('wfTooltip'); + if (!tooltip) return; + + const clientX = _clientXFromEvent(event); + if (!Number.isFinite(clientX)) return; + const freq = _freqAtX(canvas, clientX); + const wrap = document.querySelector('.wf-waterfall-canvas-wrap'); + if (wrap) { + const rect = wrap.getBoundingClientRect(); + tooltip.style.left = `${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); + + // If a monitor start is already in-flight, invalidate it so the + // latest click/retune request wins. + if (_startingMonitor) { + _audioConnectNonce += 1; + _pendingMonitorRetune = true; + } + + const runRetune = () => { + if (!_monitoring) return; + if (_startingMonitor) { + // Keep trying until the in-flight monitor start fully exits. + _monitorRetuneTimer = setTimeout(runRetune, 90); + return; + } + _pendingMonitorRetune = false; + _startMonitorInternal({ wasRunningWaterfall: false, retuneOnly: true }).catch(() => {}); + }; + + _monitorRetuneTimer = setTimeout( + runRetune, + _startingMonitor ? Math.max(delayMs, 220) : 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 _setSpanAndRetune(spanMhz, { retuneDelayMs = 250 } = {}) { + const safeSpan = _clamp(spanMhz, 0.05, 30.0); + const spanEl = document.getElementById('wfSpanMhz'); + if (spanEl) spanEl.value = safeSpan.toFixed(3); + + _startMhz = _currentCenter() - safeSpan / 2; + _endMhz = _currentCenter() + safeSpan / 2; + _drawFreqAxis(); + + if (_monitoring) _queueMonitorAdjust(retuneDelayMs, { allowSharedTune: false }); + if (_running) _queueRetune(retuneDelayMs); + return safeSpan; + } + + 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; + _pendingCaptureVfoMhz = clamped; + _pendingMonitorTuneMhz = 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 sharedMonitor = _isSharedMonitorActive(); + // While monitoring audio, force a capture recenter/restart for each + // click so monitor retunes are deterministic across the full span. + const needsRetune = !withinCapture || _monitoring; + + if (needsRetune) { + _startMhz = clamped - configuredSpan / 2; + _endMhz = clamped + configuredSpan / 2; + _drawFreqAxis(); + } else { + _updateFreqDisplay(); + } + + 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)) { + _monitorFreqMhz = Number(msg.vfo_freq_mhz); + _pendingCaptureVfoMhz = _monitorFreqMhz; + _pendingMonitorTuneMhz = _monitorFreqMhz; + 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 current = _currentSpan(); + const factor = event.deltaY < 0 ? 1 / 1.2 : 1.2; + const next = _clamp(current * factor, 0.05, 30.0); + _setSpanAndRetune(next, { retuneDelayMs: 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 clientX = _clientXFromEvent(event); + if (!Number.isFinite(clientX)) return; + const target = _freqAtX(canvas, clientX); + if (!Number.isFinite(target)) return; + _setAndTune(target, true); + } + + function _bindCanvasInteraction(canvas) { + if (!canvas) return; + if (canvas.dataset.wfInteractive === '1') return; + canvas.dataset.wfInteractive = '1'; + canvas.style.cursor = 'crosshair'; + + canvas.addEventListener('mousemove', (e) => _showTooltip(canvas, e)); + canvas.addEventListener('mouseleave', _hideTooltip); + canvas.addEventListener('click', (e) => { + // Mobile touch emits a synthetic click shortly after touchend. + if (Date.now() - _lastTouchTuneAt < 450) return; + _clickTune(canvas, e); + }); + canvas.addEventListener('wheel', _handleCanvasWheel, { passive: false }); + canvas.addEventListener('touchmove', (e) => { + _showTooltip(canvas, e); + }, { passive: true }); + canvas.addEventListener('touchend', (e) => { + _lastTouchTuneAt = Date.now(); + _clickTune(canvas, e); + _hideTooltip(); + e.preventDefault(); + }, { passive: false }); + canvas.addEventListener('touchcancel', _hideTooltip); + } + + function _setupCanvasInteraction() { + _bindCanvasInteraction(_wfCanvas); + _bindCanvasInteraction(_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', () => { + _setSpanAndRetune(_currentSpan(), { retuneDelayMs: 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; + }); + } + + const scanThreshold = document.getElementById('wfScanThreshold'); + const scanThresholdValue = document.getElementById('wfScanThresholdValue'); + if (scanThreshold) { + scanThreshold.addEventListener('input', () => { + const v = parseInt(scanThreshold.value, 10) || 0; + if (scanThresholdValue) scanThresholdValue.textContent = String(v); + if (_scanConfig) _scanConfig.threshold = _clamp(v, 0, 255); + }); + if (scanThresholdValue) { + scanThresholdValue.textContent = String(parseInt(scanThreshold.value, 10) || 0); + } + } + + ['wfScanStart', 'wfScanEnd', 'wfScanStepKhz', 'wfScanDwellMs', 'wfScanHoldMs', 'wfScanStopOnSignal'].forEach((id) => { + const el = document.getElementById(id); + if (!el) return; + const evt = el.tagName === 'SELECT' || el.type === 'checkbox' ? 'change' : 'input'; + el.addEventListener(evt, () => { + if (!_scanRunning) return; + try { + _scanConfig = _readScanConfig(); + _setScanState('Scan configuration updated'); + } catch (err) { + _setScanState(err && err.message ? err.message : 'Invalid scan configuration', true); + } + }); + }); + + const bookmarkFreq = document.getElementById('wfBookmarkFreqInput'); + if (bookmarkFreq) { + bookmarkFreq.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + addBookmarkFromInput(); + } + }); + } + + 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; + _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 targetVfoMhz = Number.isFinite(_pendingCaptureVfoMhz) + ? _pendingCaptureVfoMhz + : (Number.isFinite(_monitorFreqMhz) ? _monitorFreqMhz : cfg.centerMhz); + + const payload = { + cmd: 'start', + center_freq_mhz: cfg.centerMhz, + center_freq: cfg.centerMhz, + vfo_freq_mhz: targetVfoMhz, + 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('/receiver/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('/receiver/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('/receiver/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(`/receiver/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(); + _scanAwaitingCapture = false; + _scanStartPending = false; + _scanRestartAttempts = 0; + if (Number.isFinite(_pendingCaptureVfoMhz)) { + _monitorFreqMhz = _pendingCaptureVfoMhz; + _pendingCaptureVfoMhz = null; + } else 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 (_monitoring) { + _pendingSharedMonitorRearm = false; + // After any capture restart, always retune monitor + // audio to the current VFO frequency. + _queueMonitorRetune(_monitorSource === 'waterfall' ? 120 : 80); + } else if (_pendingSharedMonitorRearm) { + _pendingSharedMonitorRearm = false; + } + } else if (msg.status === 'tuned') { + if (_onRetuneRequired(msg)) return; + if (Number.isFinite(_pendingCaptureVfoMhz)) { + _monitorFreqMhz = _pendingCaptureVfoMhz; + _pendingCaptureVfoMhz = null; + } else 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; + _pendingCaptureVfoMhz = null; + _pendingMonitorTuneMhz = null; + _scanAwaitingCapture = false; + _scanStartPending = false; + _scanRestartAttempts = 0; + if (_scanRunning) { + stopScan('Waterfall stopped - scan ended', { silent: false, isError: true }); + } + _updateRunButtons(); + _setStatus('Waterfall stopped'); + _setVisualStatus('STOPPED'); + } else if (msg.status === 'error') { + _running = false; + _pendingCaptureVfoMhz = null; + _pendingMonitorTuneMhz = null; + _scanStartPending = false; + _pendingSharedMonitorRearm = false; + // If the monitor was using the shared IQ stream that + // just failed, tear down the stale monitor state so + // the button becomes clickable again after restart. + if (_monitoring && _monitorSource === 'waterfall') { + clearTimeout(_monitorRetuneTimer); + _monitoring = false; + _monitorSource = 'process'; + _syncMonitorButtons(); + _setMonitorState('Monitor stopped (waterfall error)'); + } + if (_scanRunning) { + _scanAwaitingCapture = true; + _setScanState(msg.message || 'Waterfall retune error, retrying...', true); + _setStatus(msg.message || 'Waterfall retune error, retrying...'); + _scheduleScanTick(850); + return; + } + _scanAwaitingCapture = false; + _scanRestartAttempts = 0; + _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 = `/receiver/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) { + _setMonitorState(`Waiting for audio stream (attempt ${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, + requestToken, + }) { + const response = await fetch('/receiver/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, + request_token: requestToken, + }), + }); + + 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); + // Allow clicking Stop Monitor during retunes (monitor already + // active, just reconnecting audio). Only disable when starting + // from scratch so users can't double-click Start. + monitorBtn.disabled = _startingMonitor && !_monitoring; + } + + 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; + } + + // Keep an explicit pending tune target so retunes cannot fall + // back to a stale frequency during capture restart churn. + const requestedTuneMhz = Number.isFinite(_pendingMonitorTuneMhz) + ? _pendingMonitorTuneMhz + : ( + Number.isFinite(_pendingCaptureVfoMhz) + ? _pendingCaptureVfoMhz + : (Number.isFinite(_monitorFreqMhz) ? _monitorFreqMhz : _currentCenter()) + ); + const centerMhz = retuneOnly + ? (Number.isFinite(requestedTuneMhz) ? requestedTuneMhz : _currentCenter()) + : _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; + + if (!retuneOnly) { + _monitorFreqMhz = centerMhz; + } else if (Number.isFinite(centerMhz)) { + _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()}...`); + } + + // Use live _monitorFreqMhz for retunes so that any user + // clicks that changed the VFO during the async setup are + // picked up rather than overridden. + let { response, payload } = await _requestAudioStart({ + frequency: centerMhz, + modulation: mode, + squelch, + gain, + device: monitorDevice, + biasT, + requestToken: nonce, + }); + if (nonce !== _audioConnectNonce) return; + + const staleStart = payload?.superseded === true || payload?.status === 'stale'; + if (staleStart) return; + const busy = payload?.error_type === 'DEVICE_BUSY' || (response.status === 409 && !staleStart); + 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, + requestToken: nonce, + })); + if (nonce !== _audioConnectNonce) return; + if (payload?.superseded === true || payload?.status === 'stale') 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 ( + Number.isFinite(_pendingMonitorTuneMhz) + && Math.abs(_pendingMonitorTuneMhz - centerMhz) < 1e-6 + ) { + _pendingMonitorTuneMhz = null; + } + + 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('/receiver/audio/stop', { method: 'POST' }); + } catch (_) { + // Ignore cleanup stop failures + } + if (!retuneOnly && _resumeWaterfallAfterMonitor && _active) { + await start(); + } + return; + } + + _monitoring = true; + _syncMonitorButtons(); + _startSmeter(attach.player); + // Use live VFO for display — user may have clicked a new + // frequency while the retune was reconnecting audio. + const displayMhz = retuneOnly ? _monitorFreqMhz : centerMhz; + if (_monitorSource === 'waterfall') { + _setMonitorState( + `Monitoring ${displayMhz.toFixed(4)} MHz ${mode.toUpperCase()} via shared IQ` + ); + } else if (usingSecondaryDevice) { + _setMonitorState( + `Monitoring ${displayMhz.toFixed(4)} MHz ${mode.toUpperCase()} ` + + `via ${monitorDevice.sdrType.toUpperCase()} #${monitorDevice.deviceIndex}` + ); + } else { + _setMonitorState(`Monitoring ${displayMhz.toFixed(4)} MHz ${mode.toUpperCase()}`); + } + _setStatus(`Audio monitor active on ${displayMhz.toFixed(4)} MHz (${mode.toUpperCase()})`); + _setVisualStatus('MONITOR'); + // After a retune reconnect, sync the backend to the latest + // VFO in case the user clicked a new frequency while the + // audio stream was reconnecting. + if (retuneOnly && _monitorSource === 'waterfall' && _ws && _ws.readyState === WebSocket.OPEN) { + _sendWsTuneCmd(); + } + } 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; + _pendingMonitorRetune = false; + + // Immediately pause audio and update the UI so the user gets instant + // feedback. The backend cleanup (which can block for 1-2 s while the + // SDR process group is reaped) happens afterwards. + _stopSmeter(); + _setUnlockVisible(false); + _audioUnlockRequired = false; + await _pauseMonitorAudioElement(); + + _monitoring = false; + _monitorSource = 'process'; + _pendingSharedMonitorRearm = false; + _pendingCaptureVfoMhz = null; + _pendingMonitorTuneMhz = null; + _syncMonitorButtons(); + _setMonitorState('No audio monitor'); + + if (_running) { + _setVisualStatus('RUNNING'); + } else { + _setVisualStatus('READY'); + } + + // Backend stop is fire-and-forget; UI is already updated above. + try { + await fetch('/receiver/audio/stop', { method: 'POST' }); + } catch (_) { + // Ignore backend stop errors + } + + 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 = () => { + // stop() sets _ws = null before the async onclose fires. + if (!_ws) return; + 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 (_scanRunning) { + stopScan('Waterfall disconnected - scan stopped', { silent: false, isError: true }); + } + if (_active) { + _setStatus('Waterfall disconnected'); + if (!_monitoring) { + _setVisualStatus('DISCONNECTED'); + } + } + }; + } + + async function stop({ keepStatus = false } = {}) { + stopScan('Scan stopped', { silent: keepStatus }); + clearTimeout(_retuneTimer); + clearTimeout(_monitorRetuneTimer); + _clearWsFallbackTimer(); + _wsOpened = false; + _pendingSharedMonitorRearm = false; + _pendingCaptureVfoMhz = null; + _pendingMonitorTuneMhz = null; + // Reset in-flight monitor start flag so the button is not left + // disabled after a waterfall stop/restart cycle. + if (_startingMonitor) { + _audioConnectNonce += 1; + _startingMonitor = false; + _syncMonitorButtons(); + } + + 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('/receiver/waterfall/stop', { method: 'POST' }); + } catch (_) { + // Ignore fallback stop errors. + } + } + + _sseStartConfigKey = ''; + _running = false; + _lastBins = null; + _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; + _drawBandStrip(); + if (_lastBins && _lastBins.length) { + _drawSpectrum(_lastBins); + } else { + _drawFreqAxis(); + } + } + + 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 zoomBy(factor) { + if (!Number.isFinite(factor) || factor <= 0) return; + const next = _setSpanAndRetune(_currentSpan() * factor, { retuneDelayMs: 220 }); + _setStatus(`Span set to ${next.toFixed(3)} MHz`); + } + + function zoomIn() { + zoomBy(1 / 1.25); + } + + function zoomOut() { + zoomBy(1.25); + } + + 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; + _loadBookmarks(); + _renderRecentSignals(); + _renderSignalHits(); + _renderScanLog(); + _syncScanStatsUi(); + _setHandoffStatus('Ready'); + _setSignalIdStatus('Ready'); + _syncSignalIdFreq(true); + _clearSignalIdPanels(); + _setScanState('Scan idle'); + _updateScanButtons(); + setScanRangeFromView(); + + _setMonitorMode(_getMonitorMode()); + _setUnlockVisible(false); + _setSmeter(0, 'S0'); + _syncMonitorButtons(); + _updateRunButtons(); + _setVisualStatus('CONNECTING'); + _setStatus('Connecting waterfall stream...'); + _updateHeroReadout(); + + 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); + _pendingMonitorRetune = false; + stopScan('Scan stopped', { silent: true }); + _lastBins = null; + + 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; + _pendingCaptureVfoMhz = null; + _pendingMonitorTuneMhz = null; + _sseStartConfigKey = ''; + _sseStartPromise = null; + } + + return { + init, + destroy, + start, + stop, + stepFreq, + zoomIn, + zoomOut, + zoomBy, + setPalette, + togglePeakHold, + toggleAnnotations, + toggleAutoRange, + onDeviceChange, + toggleMonitor, + toggleMute, + unlockAudio, + applyPreset, + stopMonitor, + handoff, + identifySignal, + useTuneForSignalId, + quickTune: quickTunePreset, + addBookmarkFromInput, + removeBookmark, + useTuneForBookmark, + clearScanHistory, + exportScanLog, + startScan, + stopScan, + setScanRangeFromView, + }; +})(); + +window.Waterfall = Waterfall; diff --git a/static/js/modes/websdr.js b/static/js/modes/websdr.js index 72ade80..de4bcfe 100644 --- a/static/js/modes/websdr.js +++ b/static/js/modes/websdr.js @@ -9,6 +9,20 @@ let websdrMarkers = []; let websdrReceivers = []; let websdrInitialized = false; let websdrSpyStationsLoaded = false; +let websdrMapType = null; +let websdrGlobe = null; +let websdrGlobePopup = null; +let websdrSelectedReceiverIndex = null; +let websdrGlobeScriptPromise = null; +let websdrResizeObserver = null; +let websdrResizeHooked = false; +let websdrGlobeFallbackNotified = false; + +const WEBSDR_GLOBE_SCRIPT_URLS = [ + 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js', + 'https://cdn.jsdelivr.net/npm/globe.gl@2.33.1/dist/globe.gl.min.js', +]; +const WEBSDR_GLOBE_TEXTURE_URL = '/static/images/globe/earth-dark.jpg'; // KiwiSDR audio state let kiwiWebSocket = null; @@ -29,54 +43,50 @@ const KIWI_SAMPLE_RATE = 12000; async function initWebSDR() { if (websdrInitialized) { - if (websdrMap) { - setTimeout(() => websdrMap.invalidateSize(), 100); - } + setTimeout(invalidateWebSDRViewport, 100); return; } const mapEl = document.getElementById('websdrMap'); - if (!mapEl || typeof L === 'undefined') return; + if (!mapEl) return; - // Calculate minimum zoom so tiles fill the container vertically - const mapHeight = mapEl.clientHeight || 500; - const minZoom = Math.ceil(Math.log2(mapHeight / 256)); + const globeReady = await ensureWebsdrGlobeLibrary(); - websdrMap = L.map('websdrMap', { - center: [20, 0], - zoom: Math.max(minZoom, 2), - minZoom: Math.max(minZoom, 2), - zoomControl: true, - maxBounds: [[-85, -360], [85, 360]], - maxBoundsViscosity: 1.0, - }); + // Wait for a paint frame so the browser computes layout after the + // display:flex change in switchMode. Without this, Globe()(mapEl) can + // run before clientWidth/clientHeight are non-zero (especially when + // scripts are served from cache and resolve before the first layout pass). + await new Promise(resolve => requestAnimationFrame(resolve)); - if (typeof Settings !== 'undefined' && Settings.createTileLayer) { - await Settings.init(); - Settings.createTileLayer().addTo(websdrMap); - Settings.registerMap(websdrMap); + // If the mode was switched away while scripts were loading, abort so + // websdrInitialized stays false and we retry cleanly next time. + if (!mapEl.clientWidth || !mapEl.clientHeight) return; + + if (globeReady && initWebsdrGlobe(mapEl)) { + websdrMapType = 'globe'; + } else if (typeof L !== 'undefined' && await initWebsdrLeaflet(mapEl)) { + websdrMapType = 'leaflet'; + if (!websdrGlobeFallbackNotified && typeof showNotification === 'function') { + showNotification('WebSDR', '3D globe unavailable, using fallback map'); + websdrGlobeFallbackNotified = true; + } } else { - L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { - attribution: '© OpenStreetMap contributors © CARTO', - subdomains: 'abcd', - maxZoom: 19, - className: 'tile-layer-cyan', - }).addTo(websdrMap); + console.error('[WEBSDR] Unable to initialize globe or map renderer'); + return; } - // Match background to tile ocean color so any remaining edge is seamless - mapEl.style.background = '#1a1d29'; - websdrInitialized = true; if (!websdrSpyStationsLoaded) { loadSpyStationPresets(); } + setupWebsdrResizeHandling(mapEl); + if (websdrReceivers.length > 0) { + plotReceiversOnMap(websdrReceivers); + } [100, 300, 600, 1000].forEach(delay => { - setTimeout(() => { - if (websdrMap) websdrMap.invalidateSize(); - }, delay); + setTimeout(invalidateWebSDRViewport, delay); }); } @@ -94,6 +104,8 @@ function searchReceivers(refresh) { .then(data => { if (data.status === 'success') { websdrReceivers = data.receivers || []; + websdrSelectedReceiverIndex = null; + hideWebsdrGlobePopup(); renderReceiverList(websdrReceivers); plotReceiversOnMap(websdrReceivers); @@ -107,6 +119,11 @@ function searchReceivers(refresh) { // ============== MAP ============== function plotReceiversOnMap(receivers) { + if (websdrMapType === 'globe' && websdrGlobe) { + plotReceiversOnGlobe(receivers); + return; + } + if (!websdrMap) return; websdrMarkers.forEach(m => websdrMap.removeLayer(m)); @@ -144,6 +161,369 @@ function plotReceiversOnMap(receivers) { } } +async function ensureWebsdrGlobeLibrary() { + if (typeof window.Globe === 'function') return true; + if (!isWebglSupported()) return false; + + if (!websdrGlobeScriptPromise) { + websdrGlobeScriptPromise = WEBSDR_GLOBE_SCRIPT_URLS + .reduce( + (promise, src) => promise.then(() => loadWebsdrScript(src)), + Promise.resolve() + ) + .then(() => typeof window.Globe === 'function') + .catch((error) => { + console.warn('[WEBSDR] Failed to load globe scripts:', error); + return false; + }); + } + + const loaded = await websdrGlobeScriptPromise; + if (!loaded) { + websdrGlobeScriptPromise = null; + } + return loaded; +} + +function loadWebsdrScript(src) { + return new Promise((resolve, reject) => { + const selector = `script[data-websdr-src="${src}"]`; + const existing = document.querySelector(selector); + + if (existing) { + if (existing.dataset.loaded === 'true') { + resolve(); + return; + } + if (existing.dataset.failed === 'true') { + existing.remove(); + } else { + existing.addEventListener('load', () => resolve(), { once: true }); + existing.addEventListener('error', () => reject(new Error(`Failed to load ${src}`)), { once: true }); + return; + } + } + + const script = document.createElement('script'); + script.src = src; + script.async = true; + script.crossOrigin = 'anonymous'; + script.dataset.websdrSrc = src; + script.onload = () => { + script.dataset.loaded = 'true'; + resolve(); + }; + script.onerror = () => { + script.dataset.failed = 'true'; + reject(new Error(`Failed to load ${src}`)); + }; + document.head.appendChild(script); + }); +} + +function isWebglSupported() { + try { + const canvas = document.createElement('canvas'); + return !!(canvas.getContext('webgl') || canvas.getContext('experimental-webgl')); + } catch (_) { + return false; + } +} + +function initWebsdrGlobe(mapEl) { + if (typeof window.Globe !== 'function' || !isWebglSupported()) return false; + + mapEl.innerHTML = ''; + mapEl.style.background = 'radial-gradient(circle at 30% 20%, rgba(14, 42, 68, 0.9), rgba(4, 9, 16, 0.95) 58%, rgba(2, 4, 9, 0.98) 100%)'; + mapEl.style.cursor = 'grab'; + + websdrGlobe = window.Globe()(mapEl) + .backgroundColor('rgba(0,0,0,0)') + .globeImageUrl(WEBSDR_GLOBE_TEXTURE_URL) + .showAtmosphere(true) + .atmosphereColor('#3bb9ff') + .atmosphereAltitude(0.17) + .pointRadius('radius') + .pointAltitude('altitude') + .pointColor('color') + .pointsTransitionDuration(250) + .pointLabel(point => point.label || '') + .onPointHover(point => { + mapEl.style.cursor = point ? 'pointer' : 'grab'; + }) + .onPointClick((point, event) => { + if (!point) return; + showWebsdrGlobePopup(point, event); + }); + + const controls = websdrGlobe.controls(); + if (controls) { + controls.autoRotate = true; + controls.autoRotateSpeed = 0.25; + controls.enablePan = false; + controls.minDistance = 140; + controls.maxDistance = 380; + controls.rotateSpeed = 0.7; + controls.zoomSpeed = 0.8; + } + + ensureWebsdrGlobePopup(mapEl); + resizeWebsdrGlobe(); + return true; +} + +async function initWebsdrLeaflet(mapEl) { + if (typeof L === 'undefined') return false; + + mapEl.innerHTML = ''; + const mapHeight = mapEl.clientHeight || 500; + const minZoom = Math.ceil(Math.log2(mapHeight / 256)); + + websdrMap = L.map('websdrMap', { + center: [20, 0], + zoom: Math.max(minZoom, 2), + minZoom: Math.max(minZoom, 2), + zoomControl: true, + maxBounds: [[-85, -360], [85, 360]], + maxBoundsViscosity: 1.0, + }); + + if (typeof Settings !== 'undefined' && Settings.createTileLayer) { + await Settings.init(); + Settings.createTileLayer().addTo(websdrMap); + Settings.registerMap(websdrMap); + } else { + L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + attribution: '© OpenStreetMap contributors © CARTO', + subdomains: 'abcd', + maxZoom: 19, + className: 'tile-layer-cyan', + }).addTo(websdrMap); + } + + mapEl.style.background = '#1a1d29'; + return true; +} + +function setupWebsdrResizeHandling(mapEl) { + if (typeof ResizeObserver !== 'undefined') { + if (websdrResizeObserver) { + websdrResizeObserver.disconnect(); + } + websdrResizeObserver = new ResizeObserver(() => invalidateWebSDRViewport()); + websdrResizeObserver.observe(mapEl); + } + + if (!websdrResizeHooked) { + window.addEventListener('resize', invalidateWebSDRViewport); + window.addEventListener('orientationchange', () => setTimeout(invalidateWebSDRViewport, 120)); + websdrResizeHooked = true; + } +} + +function invalidateWebSDRViewport() { + if (websdrMapType === 'globe') { + resizeWebsdrGlobe(); + return; + } + if (websdrMap && typeof websdrMap.invalidateSize === 'function') { + websdrMap.invalidateSize({ pan: false, animate: false }); + } +} + +function resizeWebsdrGlobe() { + if (!websdrGlobe) return; + const mapEl = document.getElementById('websdrMap'); + if (!mapEl) return; + + const width = mapEl.clientWidth; + const height = mapEl.clientHeight; + if (!width || !height) return; + + websdrGlobe.width(width); + websdrGlobe.height(height); +} + +function plotReceiversOnGlobe(receivers) { + if (!websdrGlobe) return; + + const points = []; + receivers.forEach((rx, idx) => { + const lat = Number(rx.lat); + const lon = Number(rx.lon); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return; + + const selected = idx === websdrSelectedReceiverIndex; + points.push({ + lat: lat, + lng: lon, + receiverIndex: idx, + radius: selected ? 0.52 : 0.38, + altitude: selected ? 0.1 : 0.04, + color: selected ? '#00ff88' : (rx.available ? '#00d4ff' : '#5f6976'), + label: buildWebsdrPointLabel(rx, idx), + }); + }); + + websdrGlobe.pointsData(points); + + if (points.length > 0) { + if (websdrSelectedReceiverIndex != null) { + const selectedPoint = points.find(point => point.receiverIndex === websdrSelectedReceiverIndex); + if (selectedPoint) { + websdrGlobe.pointOfView({ lat: selectedPoint.lat, lng: selectedPoint.lng, altitude: 1.45 }, 900); + return; + } + } + + const center = computeWebsdrGlobeCenter(points); + websdrGlobe.pointOfView(center, 900); + } +} + +function computeWebsdrGlobeCenter(points) { + if (!points.length) return { lat: 20, lng: 0, altitude: 2.1 }; + + let x = 0; + let y = 0; + let z = 0; + points.forEach(point => { + const latRad = point.lat * Math.PI / 180; + const lonRad = point.lng * Math.PI / 180; + x += Math.cos(latRad) * Math.cos(lonRad); + y += Math.cos(latRad) * Math.sin(lonRad); + z += Math.sin(latRad); + }); + + const count = points.length; + x /= count; + y /= count; + z /= count; + + const hyp = Math.sqrt((x * x) + (y * y)); + const centerLat = Math.atan2(z, hyp) * 180 / Math.PI; + const centerLng = Math.atan2(y, x) * 180 / Math.PI; + + let meanAngularDistance = 0; + const centerLatRad = centerLat * Math.PI / 180; + const centerLngRad = centerLng * Math.PI / 180; + points.forEach(point => { + const latRad = point.lat * Math.PI / 180; + const lonRad = point.lng * Math.PI / 180; + const cosAngle = ( + (Math.sin(centerLatRad) * Math.sin(latRad)) + + (Math.cos(centerLatRad) * Math.cos(latRad) * Math.cos(lonRad - centerLngRad)) + ); + const safeCos = Math.max(-1, Math.min(1, cosAngle)); + meanAngularDistance += Math.acos(safeCos) * 180 / Math.PI; + }); + meanAngularDistance /= count; + + const altitude = Math.min(2.9, Math.max(1.35, 1.35 + (meanAngularDistance / 45))); + return { lat: centerLat, lng: centerLng, altitude: altitude }; +} + +function ensureWebsdrGlobePopup(mapEl) { + if (websdrGlobePopup) { + if (websdrGlobePopup.parentElement !== mapEl) { + mapEl.appendChild(websdrGlobePopup); + } + return; + } + + websdrGlobePopup = document.createElement('div'); + websdrGlobePopup.id = 'websdrGlobePopup'; + websdrGlobePopup.style.position = 'absolute'; + websdrGlobePopup.style.minWidth = '220px'; + websdrGlobePopup.style.maxWidth = '260px'; + websdrGlobePopup.style.padding = '10px'; + websdrGlobePopup.style.borderRadius = '8px'; + websdrGlobePopup.style.border = '1px solid rgba(0, 212, 255, 0.35)'; + websdrGlobePopup.style.background = 'rgba(5, 13, 20, 0.92)'; + websdrGlobePopup.style.backdropFilter = 'blur(4px)'; + websdrGlobePopup.style.boxShadow = '0 8px 24px rgba(0, 0, 0, 0.4)'; + websdrGlobePopup.style.color = 'var(--text-primary)'; + websdrGlobePopup.style.display = 'none'; + websdrGlobePopup.style.zIndex = '20'; + mapEl.appendChild(websdrGlobePopup); + + if (!mapEl.dataset.websdrPopupHooked) { + mapEl.addEventListener('click', (event) => { + if (!websdrGlobePopup || websdrGlobePopup.style.display === 'none') return; + if (event.target.closest('#websdrGlobePopup')) return; + hideWebsdrGlobePopup(); + }); + mapEl.dataset.websdrPopupHooked = 'true'; + } +} + +function showWebsdrGlobePopup(point, event) { + if (!websdrGlobePopup || !point || point.receiverIndex == null) return; + const rx = websdrReceivers[point.receiverIndex]; + if (!rx) return; + + const mapEl = document.getElementById('websdrMap'); + if (!mapEl) return; + + websdrSelectedReceiverIndex = point.receiverIndex; + renderReceiverList(websdrReceivers); + plotReceiversOnGlobe(websdrReceivers); + + websdrGlobePopup.innerHTML = ` +
+ ${escapeHtmlWebsdr(rx.name)} + +
+ ${rx.location ? `
${escapeHtmlWebsdr(rx.location)}
` : ''} +
Antenna: ${escapeHtmlWebsdr(rx.antenna || 'Unknown')}
+
Users: ${rx.users}/${rx.users_max}
+ + `; + websdrGlobePopup.style.display = 'block'; + + const rect = mapEl.getBoundingClientRect(); + const x = event && Number.isFinite(event.clientX) ? (event.clientX - rect.left) : (rect.width / 2); + const y = event && Number.isFinite(event.clientY) ? (event.clientY - rect.top) : (rect.height / 2); + const popupWidth = 260; + const popupHeight = 155; + const left = Math.max(12, Math.min(rect.width - popupWidth - 12, x + 12)); + const top = Math.max(12, Math.min(rect.height - popupHeight - 12, y + 12)); + websdrGlobePopup.style.left = `${left}px`; + websdrGlobePopup.style.top = `${top}px`; + + const closeBtn = websdrGlobePopup.querySelector('[data-websdr-popup-close]'); + if (closeBtn) { + closeBtn.onclick = () => hideWebsdrGlobePopup(); + } + const listenBtn = websdrGlobePopup.querySelector('[data-websdr-listen]'); + if (listenBtn) { + listenBtn.onclick = () => selectReceiver(point.receiverIndex); + } + + if (event && typeof event.stopPropagation === 'function') { + event.stopPropagation(); + } +} + +function hideWebsdrGlobePopup() { + if (websdrGlobePopup) { + websdrGlobePopup.style.display = 'none'; + } +} + +function buildWebsdrPointLabel(rx, idx) { + const location = rx.location ? escapeHtmlWebsdr(rx.location) : 'Unknown location'; + const antenna = escapeHtmlWebsdr(rx.antenna || 'Unknown antenna'); + return ` +
+
${escapeHtmlWebsdr(rx.name)}
+
${location}
+
${antenna} · ${rx.users}/${rx.users_max}
+
Receiver #${idx + 1}
+
+ `; +} + // ============== RECEIVER LIST ============== function renderReceiverList(receivers) { @@ -155,12 +535,16 @@ function renderReceiverList(receivers) { return; } - container.innerHTML = receivers.slice(0, 50).map((rx, idx) => ` -
{ + const selected = idx === websdrSelectedReceiverIndex; + const baseBg = selected ? 'rgba(0,212,255,0.14)' : 'transparent'; + const hoverBg = selected ? 'rgba(0,212,255,0.18)' : 'rgba(0,212,255,0.05)'; + return ` +
- ${escapeHtmlWebsdr(rx.name)} + ${escapeHtmlWebsdr(rx.name)} ${rx.users}/${rx.users_max}
@@ -168,7 +552,8 @@ function renderReceiverList(receivers) { ${rx.distance_km !== undefined ? ` · ${rx.distance_km} km` : ''}
- `).join(''); + `; + }).join(''); } // ============== SELECT RECEIVER ============== @@ -180,14 +565,30 @@ function selectReceiver(index) { const freqKhz = parseFloat(document.getElementById('websdrFrequency')?.value || 7000); const mode = document.getElementById('websdrMode_select')?.value || 'am'; + websdrSelectedReceiverIndex = index; + renderReceiverList(websdrReceivers); + focusReceiverOnMap(rx); + hideWebsdrGlobePopup(); + kiwiReceiverName = rx.name; // Connect via backend proxy connectToReceiver(rx.url, freqKhz, mode); +} - // Highlight on map - if (websdrMap && rx.lat != null && rx.lon != null) { - websdrMap.setView([rx.lat, rx.lon], 6); +function focusReceiverOnMap(rx) { + const lat = Number(rx.lat); + const lon = Number(rx.lon); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return; + + if (websdrMapType === 'globe' && websdrGlobe) { + plotReceiversOnGlobe(websdrReceivers); + websdrGlobe.pointOfView({ lat: lat, lng: lon, altitude: 1.4 }, 900); + return; + } + + if (websdrMap) { + websdrMap.setView([lat, lon], 6); } } @@ -551,6 +952,8 @@ function tuneToSpyStation(stationId, freqKhz) { .then(data => { if (data.status === 'success') { websdrReceivers = data.receivers || []; + websdrSelectedReceiverIndex = null; + hideWebsdrGlobePopup(); renderReceiverList(websdrReceivers); plotReceiversOnMap(websdrReceivers); diff --git a/static/js/modes/wifi.js b/static/js/modes/wifi.js index 6294f84..35c93c0 100644 --- a/static/js/modes/wifi.js +++ b/static/js/modes/wifi.js @@ -572,8 +572,8 @@ const WiFiMode = (function() { } } - async function stopScan() { - console.log('[WiFiMode] Stopping scan...'); + async function stopScan() { + console.log('[WiFiMode] Stopping scan...'); // Stop polling if (pollTimer) { @@ -585,26 +585,41 @@ const WiFiMode = (function() { stopAgentDeepScanPolling(); // Close event stream - if (eventSource) { - eventSource.close(); - eventSource = null; - } - - // Stop scan on server (local or agent) - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - - try { - if (isAgentMode) { - await fetch(`/controller/agents/${currentAgent}/wifi/stop`, { method: 'POST' }); - } else if (scanMode === 'deep') { - await fetch(`${CONFIG.apiBase}/scan/stop`, { method: 'POST' }); - } - } catch (error) { - console.warn('[WiFiMode] Error stopping scan:', error); - } - - setScanning(false); - } + if (eventSource) { + eventSource.close(); + eventSource = null; + } + + // Update UI immediately so mode transitions are responsive even if the + // backend needs extra time to terminate subprocesses. + setScanning(false); + + // Stop scan on server (local or agent) + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + const timeoutMs = isAgentMode ? 8000 : 2200; + const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; + const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null; + + try { + if (isAgentMode) { + await fetch(`/controller/agents/${currentAgent}/wifi/stop`, { + method: 'POST', + ...(controller ? { signal: controller.signal } : {}), + }); + } else if (scanMode === 'deep') { + await fetch(`${CONFIG.apiBase}/scan/stop`, { + method: 'POST', + ...(controller ? { signal: controller.signal } : {}), + }); + } + } catch (error) { + console.warn('[WiFiMode] Error stopping scan:', error); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } + } function setScanning(scanning, mode = null) { isScanning = scanning; 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..aefdafa --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,27 @@ +{ + "name": "INTERCEPT Signal Intelligence", + "short_name": "INTERCEPT", + "description": "Unified SIGINT platform for software-defined radio analysis", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#0b1118", + "theme_color": "#0b1118", + "icons": [ + { + "src": "/static/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/static/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "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..b270128 --- /dev/null +++ b/static/sw.js @@ -0,0 +1,122 @@ +/* INTERCEPT Service Worker — cache-first static, network-only for API/SSE/WS */ +const CACHE_NAME = 'intercept-v3'; + +const NETWORK_ONLY_PREFIXES = [ + '/stream', '/ws/', '/api/', '/gps/', '/wifi/', '/bluetooth/', + '/adsb/', '/ais/', '/acars/', '/aprs/', '/tscm/', '/satellite/', + '/meshtastic/', '/bt_locate/', '/receiver/', '/sensor/', '/pager/', + '/sstv/', '/weather-sat/', '/subghz/', '/rtlamr/', '/dsc/', '/vdl2/', + '/spy/', '/space-weather/', '/websdr/', '/analytics/', '/correlation/', + '/recordings/', '/controller/', '/ops/', +]; + +const STATIC_PREFIXES = [ + '/static/css/', + '/static/js/', + '/static/icons/', + '/static/fonts/', +]; + +const CACHE_EXACT = ['/manifest.json']; + +function isHttpRequest(req) { + const url = new URL(req.url); + return url.protocol === 'http:' || url.protocol === 'https:'; +} + +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)); +} + +function fallbackResponse(req, status = 503) { + const accept = req.headers.get('Accept') || ''; + if (accept.includes('application/json')) { + return new Response( + JSON.stringify({ status: 'error', message: 'Network unavailable' }), + { + status, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + if (accept.includes('text/event-stream')) { + return new Response('', { + status, + headers: { 'Content-Type': 'text/event-stream' }, + }); + } + + return new Response('Offline', { + status, + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }); +} + +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; + + // Ignore non-HTTP(S) requests so extensions/browser-internal URLs are untouched. + if (!isHttpRequest(req)) { + return; + } + + // Always bypass service worker for non-GET and streaming routes + if (isNetworkOnly(req)) { + e.respondWith( + fetch(req).catch(() => fallbackResponse(req, 503)) + ); + 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; + }).catch(() => fallbackResponse(req, 504)); + }) + ) + ); + 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/adsb_dashboard.html b/templates/adsb_dashboard.html index cc0531e..7634945 100644 --- a/templates/adsb_dashboard.html +++ b/templates/adsb_dashboard.html @@ -258,6 +258,10 @@
+
@@ -429,6 +433,17 @@ let alertsEnabled = true; let detectionSoundEnabled = localStorage.getItem('adsb_detectionSound') !== 'false'; // Default on let soundedAircraft = {}; // Track aircraft we've played detection sound for + const MAP_CROSSHAIR_DURATION_MS = 1500; + const PANEL_SELECTION_BASE_ZOOM = 10; + const PANEL_SELECTION_MAX_ZOOM = 12; + const PANEL_SELECTION_ZOOM_INCREMENT = 1.4; + const PANEL_SELECTION_STAGE1_DURATION_SEC = 1.05; + const PANEL_SELECTION_STAGE2_DURATION_SEC = 1.15; + const PANEL_SELECTION_STAGE_GAP_MS = 180; + let mapCrosshairResetTimer = null; + let panelSelectionFallbackTimer = null; + let panelSelectionStageTimer = null; + let mapCrosshairRequestId = 0; // Watchlist - persisted to localStorage let watchlist = JSON.parse(localStorage.getItem('adsb_watchlist') || '[]'); @@ -2620,7 +2635,7 @@ sudo make install } else { markers[icao] = L.marker([ac.lat, ac.lon], { icon: createMarkerIcon(rotation, color, iconType, isSelected) }) .addTo(radarMap) - .on('click', () => selectAircraft(icao)); + .on('click', () => selectAircraft(icao, 'map')); markers[icao].bindTooltip(`${callsign}
${alt}`, { permanent: false, direction: 'top', className: 'aircraft-tooltip' }); @@ -2724,7 +2739,7 @@ sudo make install const div = document.createElement('div'); div.className = `aircraft-item ${selectedIcao === ac.icao ? 'selected' : ''} ${isOnWatchlist(ac) ? 'watched' : ''}`; div.setAttribute('data-icao', ac.icao); - div.onclick = () => selectAircraft(ac.icao); + div.onclick = () => selectAircraft(ac.icao, 'panel'); div.innerHTML = buildAircraftItemHTML(ac); fragment.appendChild(div); }); @@ -2797,34 +2812,139 @@ sudo make install `; } - function selectAircraft(icao) { - // Toggle: clicking the same aircraft deselects it - if (selectedIcao === icao) { - const prev = selectedIcao; - selectedIcao = null; - // Reset previous marker icon - if (prev && markers[prev] && aircraft[prev]) { - const ac = aircraft[prev]; - const militaryInfo = isMilitaryAircraft(prev, ac.callsign); - const rotation = Math.round((ac.heading || 0) / 5) * 5; - const color = militaryInfo.military ? '#556b2f' : getAltitudeColor(ac.altitude); - const iconType = getAircraftIconType(ac.type_code, militaryInfo.military); - markers[prev].setIcon(createMarkerIcon(rotation, color, iconType, false)); - if (markerState[prev]) markerState[prev].isSelected = false; - } - renderAircraftList(); - showAircraftDetails(null); - updateFlightLookupBtn(); - highlightSidebarMessages(null); - if (acarsMessageTimer) { - clearInterval(acarsMessageTimer); - acarsMessageTimer = null; - } + function triggerMapCrosshairAnimation(lat, lon, durationMs = MAP_CROSSHAIR_DURATION_MS, lockToMapCenter = false) { + if (!radarMap) return; + const overlay = document.getElementById('mapCrosshairOverlay'); + if (!overlay) return; + + const size = radarMap.getSize(); + let targetX; + let targetY; + + if (lockToMapCenter) { + targetX = size.x / 2; + targetY = size.y / 2; + } else { + const point = radarMap.latLngToContainerPoint([lat, lon]); + targetX = Math.max(0, Math.min(size.x, point.x)); + targetY = Math.max(0, Math.min(size.y, point.y)); + } + + const startX = size.x + 8; + const startY = size.y + 8; + + overlay.style.setProperty('--crosshair-x-start', `${startX}px`); + overlay.style.setProperty('--crosshair-y-start', `${startY}px`); + overlay.style.setProperty('--crosshair-x-end', `${targetX}px`); + overlay.style.setProperty('--crosshair-y-end', `${targetY}px`); + overlay.style.setProperty('--crosshair-duration', `${durationMs}ms`); + overlay.classList.remove('active'); + void overlay.offsetWidth; + overlay.classList.add('active'); + + if (mapCrosshairResetTimer) { + clearTimeout(mapCrosshairResetTimer); + } + mapCrosshairResetTimer = setTimeout(() => { + overlay.classList.remove('active'); + mapCrosshairResetTimer = null; + }, durationMs + 100); + } + + function getPanelSelectionFinalZoom() { + if (!radarMap) return PANEL_SELECTION_BASE_ZOOM; + const currentZoom = radarMap.getZoom(); + const maxZoom = typeof radarMap.getMaxZoom === 'function' ? radarMap.getMaxZoom() : PANEL_SELECTION_MAX_ZOOM; + return Math.min( + PANEL_SELECTION_MAX_ZOOM, + maxZoom, + Math.max(PANEL_SELECTION_BASE_ZOOM, currentZoom + PANEL_SELECTION_ZOOM_INCREMENT) + ); + } + + function getPanelSelectionIntermediateZoom(finalZoom) { + if (!radarMap) return finalZoom; + const currentZoom = radarMap.getZoom(); + if (finalZoom - currentZoom < 0.8) { + return finalZoom; + } + const midpointZoom = currentZoom + ((finalZoom - currentZoom) * 0.55); + return Math.min(finalZoom - 0.45, midpointZoom); + } + + function runPanelSelectionAnimation(lat, lon, requestId) { + if (!radarMap) return; + + const finalZoom = getPanelSelectionFinalZoom(); + const intermediateZoom = getPanelSelectionIntermediateZoom(finalZoom); + const sequenceDurationMs = Math.round( + ((PANEL_SELECTION_STAGE1_DURATION_SEC + PANEL_SELECTION_STAGE2_DURATION_SEC) * 1000) + + PANEL_SELECTION_STAGE_GAP_MS + 260 + ); + const startSecondStage = () => { + if (requestId !== mapCrosshairRequestId) return; + radarMap.flyTo([lat, lon], finalZoom, { + animate: true, + duration: PANEL_SELECTION_STAGE2_DURATION_SEC, + easeLinearity: 0.2 + }); + }; + + triggerMapCrosshairAnimation( + lat, + lon, + Math.max(MAP_CROSSHAIR_DURATION_MS, sequenceDurationMs), + true + ); + + if (intermediateZoom >= finalZoom - 0.1) { + radarMap.flyTo([lat, lon], finalZoom, { + animate: true, + duration: PANEL_SELECTION_STAGE2_DURATION_SEC, + easeLinearity: 0.2 + }); return; } + let stage1Handled = false; + const finishStage1 = () => { + if (stage1Handled || requestId !== mapCrosshairRequestId) return; + stage1Handled = true; + if (panelSelectionFallbackTimer) { + clearTimeout(panelSelectionFallbackTimer); + panelSelectionFallbackTimer = null; + } + panelSelectionStageTimer = setTimeout(() => { + panelSelectionStageTimer = null; + startSecondStage(); + }, PANEL_SELECTION_STAGE_GAP_MS); + }; + + radarMap.once('moveend', finishStage1); + panelSelectionFallbackTimer = setTimeout( + finishStage1, + Math.round(PANEL_SELECTION_STAGE1_DURATION_SEC * 1000) + 160 + ); + + radarMap.flyTo([lat, lon], intermediateZoom, { + animate: true, + duration: PANEL_SELECTION_STAGE1_DURATION_SEC, + easeLinearity: 0.2 + }); + } + + function selectAircraft(icao, source = 'map') { const prevSelected = selectedIcao; selectedIcao = icao; + mapCrosshairRequestId += 1; + if (panelSelectionFallbackTimer) { + clearTimeout(panelSelectionFallbackTimer); + panelSelectionFallbackTimer = null; + } + if (panelSelectionStageTimer) { + clearTimeout(panelSelectionStageTimer); + panelSelectionStageTimer = null; + } // Update marker icons for both previous and new selection [prevSelected, icao].forEach(targetIcao => { @@ -2850,7 +2970,15 @@ sudo make install const ac = aircraft[icao]; if (ac && ac.lat !== undefined && ac.lat !== null && ac.lon !== undefined && ac.lon !== null) { - radarMap.setView([ac.lat, ac.lon], 10); + const targetLat = ac.lat; + const targetLon = ac.lon; + + if (source === 'panel' && radarMap) { + runPanelSelectionAnimation(targetLat, targetLon, mapCrosshairRequestId); + return; + } + + radarMap.setView([targetLat, targetLon], 10); } } @@ -3264,7 +3392,7 @@ sudo make install function initAirband() { // Check if audio tools are available - fetch('/listening/tools') + fetch('/receiver/tools') .then(r => r.json()) .then(data => { const missingTools = []; @@ -3414,7 +3542,7 @@ sudo make install try { // Start audio on backend - const response = await fetch('/listening/audio/start', { + const response = await fetch('/receiver/audio/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -3451,7 +3579,7 @@ sudo make install audioPlayer.load(); // Connect to stream - const streamUrl = `/listening/audio/stream?t=${Date.now()}`; + const streamUrl = `/receiver/audio/stream?t=${Date.now()}`; console.log('[AIRBAND] Connecting to stream:', streamUrl); audioPlayer.src = streamUrl; @@ -3495,7 +3623,7 @@ sudo make install audioPlayer.pause(); audioPlayer.src = ''; - fetch('/listening/audio/stop', { method: 'POST' }) + fetch('/receiver/audio/stop', { method: 'POST' }) .then(r => r.json()) .then(() => { isAirbandPlaying = false; diff --git a/templates/index.html b/templates/index.html index a72830c..c41c28b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -6,6 +6,11 @@ iNTERCEPT // See the Invisible + + + + + @@ -189,14 +210,14 @@ Meters - +
@@ -281,10 +302,6 @@ TSCM - + @@ -600,12 +618,8 @@ {% include 'partials/modes/space-weather.html' %} - {% include 'partials/modes/listening-post.html' %} - {% include 'partials/modes/tscm.html' %} - {% include 'partials/modes/analytics.html' %} - {% include 'partials/modes/ais.html' %} {% include 'partials/modes/spy-stations.html' %} @@ -617,6 +631,7 @@ {% include 'partials/modes/subghz.html' %} {% include 'partials/modes/bt_locate.html' %} + {% include 'partials/modes/waterfall.html' %} @@ -1170,9 +1185,11 @@

Satellite Sky View

-
- +
+ +
+
Drag to orbit | Scroll to zoom
GPS
GLONASS
@@ -1225,442 +1242,6 @@
- - -