From 7ea06caaa2cb3c82738c6db41a85aa253f0bf842 Mon Sep 17 00:00:00 2001 From: Smittix Date: Mon, 23 Feb 2026 13:34:00 +0000 Subject: [PATCH] Remove legacy RF modes and add SignalID route/tests --- routes/__init__.py | 66 +- routes/fingerprint.py | 113 - routes/listening_post.py | 106 +- routes/signalid.py | 352 ++ static/css/adsb_dashboard.css | 76 + static/css/modes/fingerprint.css | 78 - static/css/modes/gps.css | 71 +- static/css/modes/rfheatmap.css | 44 - static/css/modes/waterfall.css | 516 ++ static/js/components/activity-timeline.js | 4 +- .../timeline-adapters/rf-adapter.js | 46 +- static/js/core/app.js | 17 +- static/js/core/cheat-sheets.js | 11 +- static/js/core/command-palette.js | 2 +- static/js/core/first-run-setup.js | 8 +- static/js/core/keyboard-shortcuts.js | 14 +- static/js/core/voice-alerts.js | 439 +- static/js/modes/fingerprint.js | 404 -- static/js/modes/gps.js | 955 +++- static/js/modes/listening-post.js | 4152 ----------------- static/js/modes/rfheatmap.js | 456 -- static/js/modes/spy-stations.js | 22 +- static/js/modes/waterfall.js | 1283 ++++- static/sw.js | 10 +- templates/adsb_dashboard.html | 73 +- templates/index.html | 597 +-- templates/partials/help-modal.html | 22 +- templates/partials/modes/fingerprint.html | 115 - templates/partials/modes/listening-post.html | 68 - templates/partials/modes/rfheatmap.html | 126 - templates/partials/modes/waterfall.html | 241 +- templates/partials/nav.html | 6 - templates/partials/settings-modal.html | 1 - tests/test_signalid_sigidwiki_api.py | 99 + utils/rf_fingerprint.py | 210 - 35 files changed, 3883 insertions(+), 6920 deletions(-) delete mode 100644 routes/fingerprint.py create mode 100644 routes/signalid.py delete mode 100644 static/css/modes/fingerprint.css delete mode 100644 static/css/modes/rfheatmap.css delete mode 100644 static/js/modes/fingerprint.js delete mode 100644 static/js/modes/listening-post.js delete mode 100644 static/js/modes/rfheatmap.js delete mode 100644 templates/partials/modes/fingerprint.html delete mode 100644 templates/partials/modes/listening-post.html delete mode 100644 templates/partials/modes/rfheatmap.html create mode 100644 tests/test_signalid_sigidwiki_api.py delete mode 100644 utils/rf_fingerprint.py diff --git a/routes/__init__.py b/routes/__init__.py index 50eea2f..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 .recordings import recordings_bp - from .subghz import subghz_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 .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 .fingerprint import fingerprint_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) @@ -68,9 +68,9 @@ def register_blueprints(app): app.register_blueprint(alerts_bp) # Cross-mode alerts app.register_blueprint(recordings_bp) # Session recordings app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF) - app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking - app.register_blueprint(space_weather_bp) # Space weather monitoring - app.register_blueprint(fingerprint_bp) # RF fingerprinting + app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking + app.register_blueprint(space_weather_bp) # Space weather monitoring + app.register_blueprint(signalid_bp) # External signal ID enrichment # Initialize TSCM state with queue and lock from app import app as app_module diff --git a/routes/fingerprint.py b/routes/fingerprint.py deleted file mode 100644 index 6852e77..0000000 --- a/routes/fingerprint.py +++ /dev/null @@ -1,113 +0,0 @@ -"""RF Fingerprinting CRUD + compare API.""" - -from __future__ import annotations - -import os -import threading -from flask import Blueprint, jsonify, request - -fingerprint_bp = Blueprint("fingerprint", __name__, url_prefix="/fingerprint") - -_fingerprinter = None -_fingerprinter_lock = threading.Lock() - -_active_session_id: int | None = None -_session_lock = threading.Lock() - - -def _get_fingerprinter(): - global _fingerprinter - if _fingerprinter is None: - with _fingerprinter_lock: - if _fingerprinter is None: - from utils.rf_fingerprint import RFFingerprinter - db_path = os.path.join( - os.path.dirname(os.path.dirname(__file__)), "instance", "rf_fingerprints.db" - ) - os.makedirs(os.path.dirname(db_path), exist_ok=True) - _fingerprinter = RFFingerprinter(db_path) - return _fingerprinter - - -@fingerprint_bp.route("/start", methods=["POST"]) -def start_session(): - global _active_session_id - data = request.get_json(force=True) or {} - name = data.get("name", "Unnamed Session") - location = data.get("location") - fp = _get_fingerprinter() - with _session_lock: - if _active_session_id is not None: - return jsonify({"error": "Session already active", "session_id": _active_session_id}), 409 - session_id = fp.start_session(name, location) - _active_session_id = session_id - return jsonify({"session_id": session_id, "name": name}) - - -@fingerprint_bp.route("/stop", methods=["POST"]) -def stop_session(): - global _active_session_id - fp = _get_fingerprinter() - with _session_lock: - if _active_session_id is None: - return jsonify({"error": "No active session"}), 400 - session_id = _active_session_id - result = fp.finalize(session_id) - _active_session_id = None - return jsonify(result) - - -@fingerprint_bp.route("/observation", methods=["POST"]) -def add_observation(): - global _active_session_id - fp = _get_fingerprinter() - data = request.get_json(force=True) or {} - observations = data.get("observations", []) - with _session_lock: - session_id = _active_session_id - if session_id is None: - return jsonify({"error": "No active session"}), 400 - if not observations: - return jsonify({"added": 0}) - fp.add_observations_batch(session_id, observations) - return jsonify({"added": len(observations)}) - - -@fingerprint_bp.route("/list", methods=["GET"]) -def list_sessions(): - fp = _get_fingerprinter() - sessions = fp.list_sessions() - with _session_lock: - active_id = _active_session_id - return jsonify({"sessions": sessions, "active_session_id": active_id}) - - -@fingerprint_bp.route("/compare", methods=["POST"]) -def compare(): - fp = _get_fingerprinter() - data = request.get_json(force=True) or {} - baseline_id = data.get("baseline_id") - observations = data.get("observations", []) - if not baseline_id: - return jsonify({"error": "baseline_id required"}), 400 - anomalies = fp.compare(int(baseline_id), observations) - bands = fp.get_baseline_bands(int(baseline_id)) - return jsonify({"anomalies": anomalies, "baseline_bands": bands}) - - -@fingerprint_bp.route("/", methods=["DELETE"]) -def delete_session(session_id: int): - global _active_session_id - fp = _get_fingerprinter() - with _session_lock: - if _active_session_id == session_id: - _active_session_id = None - fp.delete_session(session_id) - return jsonify({"deleted": session_id}) - - -@fingerprint_bp.route("/status", methods=["GET"]) -def session_status(): - with _session_lock: - active_id = _active_session_id - return jsonify({"active_session_id": active_id}) diff --git a/routes/listening_post.py b/routes/listening_post.py index 9a019a5..386303d 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 @@ -29,9 +29,9 @@ from utils.constants import ( ) from utils.sdr import SDRFactory, SDRType -logger = get_logger('intercept.listening_post') - -listening_post_bp = Blueprint('listening_post', __name__, url_prefix='/listening') +logger = get_logger('intercept.receiver') + +receiver_bp = Blueprint('receiver', __name__, url_prefix='/receiver') # ============================================ # GLOBAL STATE @@ -53,7 +53,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, @@ -941,7 +941,7 @@ def _stop_audio_stream_internal(): # API ENDPOINTS # ============================================ -@listening_post_bp.route('/tools') +@receiver_bp.route('/tools') def check_tools() -> Response: """Check for required tools.""" rtl_fm = find_rtl_fm() @@ -967,10 +967,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: @@ -1036,9 +1036,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: @@ -1064,9 +1064,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({ @@ -1086,7 +1086,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 @@ -1110,7 +1110,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 @@ -1132,7 +1132,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 @@ -1152,7 +1152,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 {} @@ -1194,7 +1194,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({ @@ -1207,16 +1207,16 @@ def scanner_status() -> Response: }) -@listening_post_bp.route('/scanner/stream') +@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('listening_scanner', msg, msg.get('type')) + process_event('receiver_scanner', msg, msg.get('type')) response = Response( sse_stream_fanout( source_queue=scanner_queue, - channel_key='listening_scanner', + channel_key='receiver_scanner', timeout=SSE_QUEUE_TIMEOUT, keepalive_interval=SSE_KEEPALIVE_INTERVAL, on_message=_on_msg, @@ -1228,7 +1228,7 @@ def stream_scanner_events() -> Response: 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) @@ -1239,7 +1239,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: @@ -1247,7 +1247,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 = [ @@ -1267,10 +1267,10 @@ def get_presets() -> Response: # MANUAL AUDIO ENDPOINTS (for direct listening) # ============================================ -@listening_post_bp.route('/audio/start', methods=['POST']) +@receiver_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 + global scanner_running, scanner_active_device, receiver_active_device, scanner_power_process, scanner_thread global audio_running, audio_frequency, audio_modulation, audio_source # Stop scanner if running @@ -1363,9 +1363,9 @@ def start_audio() -> Response: audio_modulation = modulation audio_source = 'waterfall' # Shared monitor uses the waterfall's existing SDR claim. - if listening_active_device is not None: - app_module.release_sdr_device(listening_active_device) - listening_active_device = None + 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, @@ -1385,15 +1385,15 @@ def start_audio() -> Response: # 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 + 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, 'listening') + error = app_module.claim_sdr_device(device, 'receiver') if not error: break if attempt < max_claim_attempts - 1: @@ -1409,7 +1409,7 @@ def start_audio() -> Response: 'error_type': 'DEVICE_BUSY', 'message': error }), 409 - listening_active_device = device + receiver_active_device = device _start_audio_stream(frequency, modulation) @@ -1423,9 +1423,9 @@ def start_audio() -> Response: }) else: # Avoid leaving a stale device claim after startup failure. - if listening_active_device is not None: - app_module.release_sdr_device(listening_active_device) - listening_active_device = None + 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'): @@ -1447,18 +1447,18 @@ def start_audio() -> Response: }), 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 @@ -1479,7 +1479,7 @@ def audio_status() -> Response: }) -@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' @@ -1519,7 +1519,7 @@ def audio_debug() -> Response: }) -@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 @@ -1559,7 +1559,7 @@ 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.""" if audio_source == 'waterfall': @@ -1682,7 +1682,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 {} @@ -1962,7 +1962,7 @@ 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 @@ -2023,7 +2023,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() @@ -2031,7 +2031,7 @@ def stop_waterfall() -> Response: return jsonify({'status': 'stopped'}) -@listening_post_bp.route('/waterfall/stream') +@receiver_bp.route('/waterfall/stream') def stream_waterfall() -> Response: """SSE stream for waterfall data.""" def _on_msg(msg: dict[str, Any]) -> None: @@ -2040,7 +2040,7 @@ def stream_waterfall() -> Response: response = Response( sse_stream_fanout( source_queue=waterfall_queue, - channel_key='listening_waterfall', + channel_key='receiver_waterfall', timeout=SSE_QUEUE_TIMEOUT, keepalive_interval=SSE_KEEPALIVE_INTERVAL, on_message=_on_msg, 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/static/css/adsb_dashboard.css b/static/css/adsb_dashboard.css index 6cedd7f..0dbcb43 100644 --- a/static/css/adsb_dashboard.css +++ b/static/css/adsb_dashboard.css @@ -893,6 +893,82 @@ body { display: block; } +.map-crosshair-overlay { + position: absolute; + inset: 0; + pointer-events: none; + overflow: hidden; + z-index: 650; + --target-x: 50%; + --target-y: 50%; +} + +.map-crosshair-line { + position: absolute; + opacity: 0; + background: var(--accent-cyan); + box-shadow: 0 0 10px var(--accent-cyan); +} + +.map-crosshair-vertical { + top: 0; + bottom: 0; + width: 2px; + right: 0; + transform: translateX(0); +} + +.map-crosshair-horizontal { + left: 0; + right: 0; + height: 2px; + bottom: 0; + transform: translateY(0); +} + +.map-crosshair-overlay.active .map-crosshair-vertical { + animation: mapCrosshairSweepX 620ms cubic-bezier(0.2, 0.85, 0.28, 1) forwards; +} + +.map-crosshair-overlay.active .map-crosshair-horizontal { + animation: mapCrosshairSweepY 620ms cubic-bezier(0.2, 0.85, 0.28, 1) forwards; +} + +@keyframes mapCrosshairSweepX { + 0% { + transform: translateX(0); + opacity: 0.95; + } + 75% { + opacity: 0.95; + } + 100% { + transform: translateX(calc(var(--target-x) - 100%)); + opacity: 0; + } +} + +@keyframes mapCrosshairSweepY { + 0% { + transform: translateY(0); + opacity: 0.95; + } + 75% { + opacity: 0.95; + } + 100% { + transform: translateY(calc(var(--target-y) - 100%)); + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .map-crosshair-overlay.active .map-crosshair-vertical, + .map-crosshair-overlay.active .map-crosshair-horizontal { + animation-duration: 120ms; + } +} + /* Right sidebar - Mobile first */ .sidebar { display: flex; diff --git a/static/css/modes/fingerprint.css b/static/css/modes/fingerprint.css deleted file mode 100644 index f37b05c..0000000 --- a/static/css/modes/fingerprint.css +++ /dev/null @@ -1,78 +0,0 @@ -/* Signal Fingerprinting Mode Styles */ - -.fp-tab-btn { - flex: 1; - padding: 5px 10px; - font-family: var(--font-mono, monospace); - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - background: rgba(255,255,255,0.04); - border: 1px solid rgba(255,255,255,0.1); - border-radius: 4px; - color: var(--text-secondary, #aaa); - cursor: pointer; - transition: all 0.15s; -} - -.fp-tab-btn.active { - background: rgba(74,163,255,0.15); - border-color: var(--accent-cyan, #4aa3ff); - color: var(--accent-cyan, #4aa3ff); -} - -.fp-anomaly-item { - display: flex; - flex-direction: column; - gap: 2px; - padding: 6px 8px; - border-radius: 4px; - border: 1px solid rgba(255,255,255,0.08); - margin-bottom: 4px; - font-family: var(--font-mono, monospace); - font-size: 10px; -} - -.fp-anomaly-item.severity-alert { - background: rgba(239,68,68,0.12); - border-color: rgba(239,68,68,0.4); -} - -.fp-anomaly-item.severity-warn { - background: rgba(251,191,36,0.1); - border-color: rgba(251,191,36,0.4); -} - -.fp-anomaly-item.severity-new { - background: rgba(168,85,247,0.12); - border-color: rgba(168,85,247,0.4); -} - -.fp-anomaly-band { - font-weight: 700; - color: var(--text-primary, #fff); - font-size: 12px; -} - -.fp-anomaly-type-badge { - font-size: 9px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.08em; - padding: 1px 5px; - border-radius: 3px; - display: inline-block; -} - -.fp-chart-container { - flex: 1; - min-height: 0; - padding: 10px; - overflow: hidden; -} - -#fpChartCanvas { - width: 100%; - height: 100%; -} diff --git a/static/css/modes/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/rfheatmap.css b/static/css/modes/rfheatmap.css deleted file mode 100644 index e4a9957..0000000 --- a/static/css/modes/rfheatmap.css +++ /dev/null @@ -1,44 +0,0 @@ -/* RF Heatmap Mode Styles */ - -.rfhm-map-container { - flex: 1; - min-height: 0; - position: relative; -} - -#rfheatmapMapEl { - width: 100%; - height: 100%; -} - -.rfhm-overlay { - position: absolute; - top: 8px; - right: 8px; - z-index: 450; - display: flex; - flex-direction: column; - gap: 4px; - pointer-events: none; -} - -.rfhm-stat-chip { - background: rgba(0,0,0,0.75); - border: 1px solid rgba(255,255,255,0.15); - border-radius: 6px; - padding: 4px 8px; - font-family: var(--font-mono, monospace); - font-size: 10px; - color: var(--accent-cyan, #4aa3ff); - pointer-events: none; - backdrop-filter: blur(4px); -} - -.rfhm-recording-pulse { - animation: rfhm-rec 1s ease-in-out infinite; -} - -@keyframes rfhm-rec { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.3; } -} diff --git a/static/css/modes/waterfall.css b/static/css/modes/waterfall.css index 476fba9..d1ab8fb 100644 --- a/static/css/modes/waterfall.css +++ b/static/css/modes/waterfall.css @@ -412,6 +412,11 @@ 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; @@ -494,6 +499,142 @@ 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 { @@ -664,3 +805,378 @@ 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/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 index 8106fe1..9bfc56a 100644 --- a/static/js/core/cheat-sheets.js +++ b/static/js/core/cheat-sheets.js @@ -16,17 +16,14 @@ const CheatSheets = (function () { sstv: { title: 'ISS SSTV', icon: '🖼️', hardware: 'RTL-SDR + 145MHz antenna', description: 'Receives ISS SSTV images via slowrx.', whatToExpect: 'Color images during ISS SSTV events (PD180 mode).', tips: ['ISS SSTV: 145.800 MHz', 'Check ARISS for active event dates', 'ISS must be overhead — check pass times'] }, weathersat: { title: 'Weather Satellites', icon: '🌤️', hardware: 'RTL-SDR + 137MHz turnstile/QFH antenna', description: 'Decodes NOAA APT and Meteor LRPT weather imagery via SatDump.', whatToExpect: 'Infrared/visible cloud imagery.', tips: ['NOAA 15/18/19: 137.1–137.9 MHz APT', 'Meteor M2-3: 137.9 MHz LRPT', 'Use circular polarized antenna (QFH or turnstile)'] }, sstv_general:{ title: 'HF SSTV', icon: '📷', hardware: 'RTL-SDR + HF upconverter', description: 'Receives HF SSTV transmissions.', whatToExpect: 'Amateur radio images on 14.230 MHz (USB mode).', tips: ['14.230 MHz USB is primary HF SSTV frequency', 'Scottie 1 and Martin 1 most common', 'Best during daylight hours'] }, - gps: { title: 'GPS Receiver', icon: '🗺️', hardware: 'USB GPS receiver (NMEA)', description: 'Streams GPS position and feeds location to other modes.', whatToExpect: 'Lat/lon, altitude, speed, heading, satellite count.', tips: ['GPS feeds into RF Heatmap', 'BT Locate uses GPS for trail logging', 'Set observer location for satellite prediction'] }, + 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'] }, - listening: { title: 'Listening Post', icon: '🎧', hardware: 'RTL-SDR dongle', description: 'Wideband scanner and audio receiver for AM/FM/USB/LSB/CW.', whatToExpect: 'Audio from any frequency, spectrum waterfall, squelch.', tips: ['VHF air band: 118–136 MHz AM', 'Marine VHF: 156–174 MHz FM', 'HF requires upconverter or direct-sampling SDR'] }, - tscm: { title: 'TSCM Counter-Surveillance', icon: '🔍', hardware: 'WiFi + Bluetooth adapters', description: 'Technical Surveillance Countermeasures — detects hidden devices.', whatToExpect: 'RF baseline comparison, rogue device alerts, tracker detection.', tips: ['Take baseline in a known-clean environment', 'New strong signals = potential bug', 'Correlate WiFi + Bluetooth observations'] }, - spystations: { title: 'Spy Stations', icon: '🕵️', hardware: 'RTL-SDR + HF antenna', description: 'Database of known number stations, military, and diplomatic HF signals.', whatToExpect: 'Scheduled broadcasts, frequency database, tune-to links.', tips: ['Numbers stations often broadcast on the hour', 'Use Listening Post to tune directly', 'STANAG and HF mil signals are common'] }, + 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'] }, - rfheatmap: { title: 'RF Heatmap', icon: '🗺️', hardware: 'GPS receiver + WiFi/BT/SDR', description: 'GPS-tagged signal strength heatmap. Walk to build coverage maps.', whatToExpect: 'Leaflet map with heat overlay showing signal by location.', tips: ['Connect GPS first, wait for fix', 'Set min sample distance to avoid duplicates', 'Export GeoJSON for use in QGIS'] }, - fingerprint: { title: 'RF Fingerprinting', icon: '🔬', hardware: 'RTL-SDR + Listening Post scanner', description: 'Records RF baselines and detects anomalies via statistical comparison.', whatToExpect: 'Band-by-band power comparison, z-score anomaly detection.', tips: ['Take baseline in a clean RF environment', 'Z-score ≥3 = statistically significant anomaly', 'New bands highlighted in purple'] }, + 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) { diff --git a/static/js/core/command-palette.js b/static/js/core/command-palette.js index 316efce..9bc1fd0 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' }, diff --git a/static/js/core/first-run-setup.js b/static/js/core/first-run-setup.js index c903b41..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'], @@ -149,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/keyboard-shortcuts.js b/static/js/core/keyboard-shortcuts.js index 273566c..1028687 100644 --- a/static/js/core/keyboard-shortcuts.js +++ b/static/js/core/keyboard-shortcuts.js @@ -8,14 +8,12 @@ const KeyboardShortcuts = (function () { function _handle(e) { if (e.target.matches(GUARD_SELECTOR)) return; - if (e.altKey) { - switch (e.key.toLowerCase()) { - case 'w': e.preventDefault(); window.switchMode && switchMode('waterfall'); break; - case 'h': e.preventDefault(); window.switchMode && switchMode('rfheatmap'); break; - case 'n': e.preventDefault(); window.switchMode && switchMode('fingerprint'); break; - case 'm': e.preventDefault(); window.VoiceAlerts && VoiceAlerts.toggleMute(); break; - case 's': e.preventDefault(); _toggleSidebar(); break; - case 'k': e.preventDefault(); showHelp(); break; + if (e.altKey) { + switch (e.key.toLowerCase()) { + case 'w': e.preventDefault(); window.switchMode && switchMode('waterfall'); break; + case 'm': e.preventDefault(); window.VoiceAlerts && VoiceAlerts.toggleMute(); break; + case 's': e.preventDefault(); _toggleSidebar(); break; + case 'k': e.preventDefault(); showHelp(); break; case 'c': e.preventDefault(); window.CheatSheets && CheatSheets.showForCurrentMode(); break; default: if (e.key >= '1' && e.key <= '9') { diff --git a/static/js/core/voice-alerts.js b/static/js/core/voice-alerts.js index 00e0aeb..883cefd 100644 --- a/static/js/core/voice-alerts.js +++ b/static/js/core/voice-alerts.js @@ -1,113 +1,145 @@ -/* INTERCEPT Voice Alerts — Web Speech API queue with priority system */ -const VoiceAlerts = (function () { - 'use strict'; - - const PRIORITY = { LOW: 0, MEDIUM: 1, HIGH: 2 }; - let _enabled = true; - let _muted = false; - let _queue = []; - let _speaking = false; - let _sources = {}; - const STORAGE_KEY = 'intercept-voice-muted'; - const CONFIG_KEY = 'intercept-voice-config'; - - // Default config - let _config = { - rate: 1.1, - pitch: 0.9, - voiceName: '', - streams: { pager: true, tscm: true, bluetooth: true }, - }; - - function _loadConfig() { - _muted = localStorage.getItem(STORAGE_KEY) === 'true'; - try { - const stored = localStorage.getItem(CONFIG_KEY); - if (stored) { - const parsed = JSON.parse(stored); - _config.rate = parsed.rate ?? _config.rate; - _config.pitch = parsed.pitch ?? _config.pitch; - _config.voiceName = parsed.voiceName ?? _config.voiceName; - if (parsed.streams) { - Object.assign(_config.streams, parsed.streams); - } - } - } catch (_) {} - _updateMuteButton(); - } - - function _updateMuteButton() { - const btn = document.getElementById('voiceMuteBtn'); - if (!btn) return; - btn.classList.toggle('voice-muted', _muted); - btn.title = _muted ? 'Unmute voice alerts' : 'Mute voice alerts'; - btn.style.opacity = _muted ? '0.4' : '1'; - } - - function _getVoice() { - if (!_config.voiceName) return null; - const voices = window.speechSynthesis ? speechSynthesis.getVoices() : []; - return voices.find(v => v.name === _config.voiceName) || null; - } - - function speak(text, priority) { - if (priority === undefined) priority = PRIORITY.MEDIUM; - if (!_enabled || _muted) return; - if (!window.speechSynthesis) return; - if (priority === PRIORITY.LOW && _speaking) return; - if (priority === PRIORITY.HIGH && _speaking) { - window.speechSynthesis.cancel(); - _queue = []; - _speaking = false; - } - _queue.push({ text, priority }); - if (!_speaking) _dequeue(); - } - - function _dequeue() { - if (_queue.length === 0) { _speaking = false; return; } - _speaking = true; - const item = _queue.shift(); - const utt = new SpeechSynthesisUtterance(item.text); - utt.rate = _config.rate; - utt.pitch = _config.pitch; - const voice = _getVoice(); - if (voice) utt.voice = voice; - utt.onend = () => { _speaking = false; _dequeue(); }; - utt.onerror = () => { _speaking = false; _dequeue(); }; - window.speechSynthesis.speak(utt); - } - - function toggleMute() { - _muted = !_muted; - localStorage.setItem(STORAGE_KEY, _muted ? 'true' : 'false'); - _updateMuteButton(); - if (_muted && window.speechSynthesis) window.speechSynthesis.cancel(); - } - - function _openStream(url, handler, key) { - if (_sources[key]) return; - const es = new EventSource(url); - es.onmessage = handler; - es.onerror = () => { es.close(); delete _sources[key]; }; - _sources[key] = es; - } - - function _startStreams() { - if (!_enabled) return; - - // Pager stream - if (_config.streams.pager) { - _openStream('/stream', (ev) => { - try { - const d = JSON.parse(ev.data); - if (d.address && d.message) { - speak(`Pager message to ${d.address}: ${String(d.message).slice(0, 60)}`, PRIORITY.MEDIUM); - } - } catch (_) {} - }, 'pager'); - } - +/* 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) => { @@ -116,85 +148,108 @@ const VoiceAlerts = (function () { if (d.threat_level && d.description) { speak(`TSCM alert: ${d.threat_level} — ${d.description}`, PRIORITY.HIGH); } - } catch (_) {} - }, 'tscm'); - } - - // Bluetooth stream — tracker detection only - if (_config.streams.bluetooth) { - _openStream('/api/bluetooth/stream', (ev) => { - try { - const d = JSON.parse(ev.data); - if (d.service_data && d.service_data.tracker_type) { - speak(`Tracker detected: ${d.service_data.tracker_type}`, PRIORITY.HIGH); - } - } catch (_) {} - }, 'bluetooth'); - } - - } - - function _stopStreams() { - Object.values(_sources).forEach(es => { try { es.close(); } catch (_) {} }); - _sources = {}; - } - - function init() { - _loadConfig(); - _startStreams(); - } - - function setEnabled(val) { - _enabled = val; - if (!val) { - _stopStreams(); - if (window.speechSynthesis) window.speechSynthesis.cancel(); - } else { - _startStreams(); - } - } - - // ── Config API (used by Ops Center voice config panel) ───────────── - - function getConfig() { - return JSON.parse(JSON.stringify(_config)); - } - - function setConfig(cfg) { - if (cfg.rate !== undefined) _config.rate = cfg.rate; - if (cfg.pitch !== undefined) _config.pitch = cfg.pitch; - if (cfg.voiceName !== undefined) _config.voiceName = cfg.voiceName; - if (cfg.streams) Object.assign(_config.streams, cfg.streams); - localStorage.setItem(CONFIG_KEY, JSON.stringify(_config)); - // Restart streams to apply per-stream toggle changes - _stopStreams(); - _startStreams(); - } - - function getAvailableVoices() { - return new Promise(resolve => { - if (!window.speechSynthesis) { resolve([]); return; } - let voices = speechSynthesis.getVoices(); - if (voices.length > 0) { resolve(voices); return; } - speechSynthesis.onvoiceschanged = () => { - resolve(speechSynthesis.getVoices()); - }; - // Timeout fallback - setTimeout(() => resolve(speechSynthesis.getVoices()), 500); - }); - } - - function testVoice(text) { - if (!window.speechSynthesis) return; - const utt = new SpeechSynthesisUtterance(text || 'Voice alert test. All systems nominal.'); - utt.rate = _config.rate; - utt.pitch = _config.pitch; - const voice = _getVoice(); - if (voice) utt.voice = voice; - speechSynthesis.speak(utt); - } - - return { init, speak, toggleMute, setEnabled, getConfig, setConfig, getAvailableVoices, testVoice, PRIORITY }; -})(); - -window.VoiceAlerts = VoiceAlerts; + } 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/fingerprint.js b/static/js/modes/fingerprint.js deleted file mode 100644 index ea7446f..0000000 --- a/static/js/modes/fingerprint.js +++ /dev/null @@ -1,404 +0,0 @@ -/* Signal Fingerprinting — RF baseline recorder + anomaly comparator */ -const Fingerprint = (function () { - 'use strict'; - - let _active = false; - let _recording = false; - let _scannerSource = null; - let _pendingObs = []; - let _flushTimer = null; - let _currentTab = 'record'; - let _chartInstance = null; - let _ownedScanner = false; - let _obsCount = 0; - - function _flushObservations() { - if (!_recording || _pendingObs.length === 0) return; - const batch = _pendingObs.splice(0); - fetch('/fingerprint/observation', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ observations: batch }), - }).catch(() => {}); - } - - function _startScannerStream() { - if (_scannerSource) { _scannerSource.close(); _scannerSource = null; } - _scannerSource = new EventSource('/listening/scanner/stream'); - _scannerSource.onmessage = (ev) => { - try { - const d = JSON.parse(ev.data); - // Only collect meaningful signal events (signal_found has SNR) - if (d.type && d.type !== 'signal_found' && d.type !== 'scan_update') return; - - const freq = d.frequency ?? d.freq_mhz ?? null; - if (freq === null) return; - - // Prefer SNR (dB) from signal_found events; fall back to level for scan_update - let power = null; - if (d.snr !== undefined && d.snr !== null) { - power = d.snr; - } else if (d.level !== undefined && d.level !== null) { - // level is RMS audio — skip scan_update noise floor readings - if (d.type === 'signal_found') { - power = d.level; - } else { - return; // scan_update with no SNR — skip - } - } else if (d.power_dbm !== undefined) { - power = d.power_dbm; - } - - if (power === null) return; - - if (_recording) { - _pendingObs.push({ freq_mhz: parseFloat(freq), power_dbm: parseFloat(power) }); - _obsCount++; - _updateObsCounter(); - } - } catch (_) {} - }; - } - - function _updateObsCounter() { - const el = document.getElementById('fpObsCount'); - if (el) el.textContent = _obsCount; - } - - function _setStatus(msg) { - const el = document.getElementById('fpRecordStatus'); - if (el) el.textContent = msg; - } - - // ── Scanner lifecycle (standalone control) ───────────────────────── - - async function _checkScannerStatus() { - try { - const r = await fetch('/listening/scanner/status'); - if (r.ok) { - const d = await r.json(); - return !!d.running; - } - } catch (_) {} - return false; - } - - async function _updateScannerStatusUI() { - const running = await _checkScannerStatus(); - const dotEl = document.getElementById('fpScannerDot'); - const textEl = document.getElementById('fpScannerStatusText'); - const startB = document.getElementById('fpScannerStartBtn'); - const stopB = document.getElementById('fpScannerStopBtn'); - - if (dotEl) dotEl.style.background = running ? 'var(--accent-green, #00ff88)' : 'rgba(255,255,255,0.2)'; - if (textEl) textEl.textContent = running ? 'Scanner running' : 'Scanner not running'; - if (startB) startB.style.display = running ? 'none' : ''; - if (stopB) stopB.style.display = (running && _ownedScanner) ? '' : 'none'; - - // Auto-connect to stream if scanner is running - if (running && !_scannerSource) _startScannerStream(); - } - - async function startScanner() { - const deviceVal = document.getElementById('fpDevice')?.value || 'rtlsdr:0'; - const [sdrType, idxStr] = deviceVal.includes(':') ? deviceVal.split(':') : ['rtlsdr', '0']; - const startB = document.getElementById('fpScannerStartBtn'); - if (startB) { startB.disabled = true; startB.textContent = 'Starting…'; } - - try { - const res = await fetch('/listening/scanner/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ start_freq: 24, end_freq: 1700, sdr_type: sdrType, device: parseInt(idxStr) || 0 }), - }); - if (res.ok) { - _ownedScanner = true; - _startScannerStream(); - } - } catch (_) {} - - if (startB) { startB.disabled = false; startB.textContent = 'Start Scanner'; } - await _updateScannerStatusUI(); - } - - async function stopScanner() { - if (!_ownedScanner) return; - try { - await fetch('/listening/scanner/stop', { method: 'POST' }); - } catch (_) {} - _ownedScanner = false; - if (_scannerSource) { _scannerSource.close(); _scannerSource = null; } - await _updateScannerStatusUI(); - } - - // ── Recording ────────────────────────────────────────────────────── - - async function startRecording() { - // Check scanner is running first - const running = await _checkScannerStatus(); - if (!running) { - _setStatus('Scanner not running — start it first (Step 2)'); - return; - } - - const name = document.getElementById('fpSessionName')?.value.trim() || 'Session ' + new Date().toLocaleString(); - const location = document.getElementById('fpSessionLocation')?.value.trim() || null; - try { - const res = await fetch('/fingerprint/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, location }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || 'Start failed'); - _recording = true; - _pendingObs = []; - _obsCount = 0; - _updateObsCounter(); - _flushTimer = setInterval(_flushObservations, 5000); - if (!_scannerSource) _startScannerStream(); - const startBtn = document.getElementById('fpStartBtn'); - const stopBtn = document.getElementById('fpStopBtn'); - if (startBtn) startBtn.style.display = 'none'; - if (stopBtn) stopBtn.style.display = ''; - _setStatus('Recording… session #' + data.session_id); - } catch (e) { - _setStatus('Error: ' + e.message); - } - } - - async function stopRecording() { - _recording = false; - _flushObservations(); - if (_flushTimer) { clearInterval(_flushTimer); _flushTimer = null; } - if (_scannerSource) { _scannerSource.close(); _scannerSource = null; } - try { - const res = await fetch('/fingerprint/stop', { method: 'POST' }); - const data = await res.json(); - _setStatus(`Saved: ${data.bands_recorded} bands recorded (${_obsCount} observations)`); - } catch (e) { - _setStatus('Error saving: ' + e.message); - } - const startBtn = document.getElementById('fpStartBtn'); - const stopBtn = document.getElementById('fpStopBtn'); - if (startBtn) startBtn.style.display = ''; - if (stopBtn) stopBtn.style.display = 'none'; - _loadSessions(); - } - - async function _loadSessions() { - try { - const res = await fetch('/fingerprint/list'); - const data = await res.json(); - const sel = document.getElementById('fpBaselineSelect'); - if (!sel) return; - const sessions = (data.sessions || []).filter(s => s.finalized_at); - sel.innerHTML = sessions.length - ? sessions.map(s => ``).join('') - : ''; - } catch (_) {} - } - - // ── Compare ──────────────────────────────────────────────────────── - - async function compareNow() { - const baselineId = document.getElementById('fpBaselineSelect')?.value; - if (!baselineId) return; - - // Check scanner is running - const running = await _checkScannerStatus(); - if (!running) { - const statusEl = document.getElementById('fpCompareStatus'); - if (statusEl) statusEl.textContent = 'Scanner not running — start it first'; - return; - } - - const statusEl = document.getElementById('fpCompareStatus'); - const compareBtn = document.querySelector('#fpComparePanel .run-btn'); - if (statusEl) statusEl.textContent = 'Collecting observations…'; - if (compareBtn) { compareBtn.disabled = true; compareBtn.textContent = 'Scanning…'; } - - // Collect live observations for ~3 seconds - const obs = []; - const tmpSrc = new EventSource('/listening/scanner/stream'); - const deadline = Date.now() + 3000; - - await new Promise(resolve => { - tmpSrc.onmessage = (ev) => { - if (Date.now() > deadline) { tmpSrc.close(); resolve(); return; } - try { - const d = JSON.parse(ev.data); - if (d.type && d.type !== 'signal_found' && d.type !== 'scan_update') return; - const freq = d.frequency ?? d.freq_mhz ?? null; - let power = null; - if (d.snr !== undefined && d.snr !== null) power = d.snr; - else if (d.type === 'signal_found' && d.level !== undefined) power = d.level; - else if (d.power_dbm !== undefined) power = d.power_dbm; - if (freq !== null && power !== null) obs.push({ freq_mhz: parseFloat(freq), power_dbm: parseFloat(power) }); - if (statusEl) statusEl.textContent = `Collecting… ${obs.length} observations`; - } catch (_) {} - }; - tmpSrc.onerror = () => { tmpSrc.close(); resolve(); }; - setTimeout(() => { tmpSrc.close(); resolve(); }, 3500); - }); - - if (statusEl) statusEl.textContent = `Comparing ${obs.length} observations against baseline…`; - - try { - const res = await fetch('/fingerprint/compare', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ baseline_id: parseInt(baselineId), observations: obs }), - }); - const data = await res.json(); - _renderAnomalies(data.anomalies || []); - _renderChart(data.baseline_bands || [], data.anomalies || []); - if (statusEl) statusEl.textContent = `Done — ${obs.length} observations, ${(data.anomalies || []).length} anomalies`; - } catch (e) { - console.error('Compare failed:', e); - if (statusEl) statusEl.textContent = 'Compare failed: ' + e.message; - } - - if (compareBtn) { compareBtn.disabled = false; compareBtn.textContent = 'Compare Now'; } - } - - function _renderAnomalies(anomalies) { - const panel = document.getElementById('fpAnomalyList'); - const items = document.getElementById('fpAnomalyItems'); - if (!panel || !items) return; - - if (anomalies.length === 0) { - items.innerHTML = '
No significant anomalies detected.
'; - panel.style.display = 'block'; - return; - } - - items.innerHTML = anomalies.map(a => { - const z = a.z_score !== null ? Math.abs(a.z_score) : 999; - let cls = 'severity-warn', badge = 'POWER'; - if (a.anomaly_type === 'new') { cls = 'severity-new'; badge = 'NEW'; } - else if (a.anomaly_type === 'missing') { cls = 'severity-warn'; badge = 'MISSING'; } - else if (z >= 3) { cls = 'severity-alert'; } - - const zText = a.z_score !== null ? `z=${a.z_score.toFixed(1)}` : ''; - const powerText = a.current_power !== null ? `${a.current_power.toFixed(1)} dBm` : 'absent'; - const baseText = a.baseline_mean !== null ? `baseline: ${a.baseline_mean.toFixed(1)} dBm` : ''; - - return `
-
- ${a.band_label} - ${badge} - ${z >= 3 ? 'ALERT' : ''} -
-
${powerText} ${baseText} ${zText}
-
`; - }).join(''); - panel.style.display = 'block'; - - // Voice alert for high-severity anomalies - const highZ = anomalies.find(a => (a.z_score !== null && Math.abs(a.z_score) >= 3) || a.anomaly_type === 'new'); - if (highZ && window.VoiceAlerts) { - VoiceAlerts.speak(`RF anomaly detected: ${highZ.band_label} — ${highZ.anomaly_type}`, 2); - } - } - - function _renderChart(baselineBands, anomalies) { - const canvas = document.getElementById('fpChartCanvas'); - if (!canvas || typeof Chart === 'undefined') return; - - const anomalyMap = {}; - anomalies.forEach(a => { anomalyMap[a.band_center_mhz] = a; }); - - const bands = baselineBands.slice(0, 40); - const labels = bands.map(b => b.band_center_mhz.toFixed(1)); - const means = bands.map(b => b.mean_dbm); - const currentPowers = bands.map(b => { - const a = anomalyMap[b.band_center_mhz]; - return a ? a.current_power : b.mean_dbm; - }); - const barColors = bands.map(b => { - const a = anomalyMap[b.band_center_mhz]; - if (!a) return 'rgba(74,163,255,0.6)'; - if (a.anomaly_type === 'new') return 'rgba(168,85,247,0.8)'; - if (a.z_score !== null && Math.abs(a.z_score) >= 3) return 'rgba(239,68,68,0.8)'; - return 'rgba(251,191,36,0.7)'; - }); - - if (_chartInstance) { _chartInstance.destroy(); _chartInstance = null; } - - _chartInstance = new Chart(canvas, { - type: 'bar', - data: { - labels, - datasets: [ - { label: 'Baseline Mean', data: means, backgroundColor: 'rgba(74,163,255,0.3)', borderColor: 'rgba(74,163,255,0.8)', borderWidth: 1 }, - { label: 'Current', data: currentPowers, backgroundColor: barColors, borderColor: barColors, borderWidth: 1 }, - ], - }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { legend: { labels: { color: '#aaa', font: { size: 10 } } } }, - scales: { - x: { ticks: { color: '#666', font: { size: 9 }, maxRotation: 90 }, grid: { color: 'rgba(255,255,255,0.05)' } }, - y: { ticks: { color: '#666', font: { size: 10 } }, grid: { color: 'rgba(255,255,255,0.05)' }, title: { display: true, text: 'Power (dBm)', color: '#666' } }, - }, - }, - }); - } - - function showTab(tab) { - _currentTab = tab; - const recordPanel = document.getElementById('fpRecordPanel'); - const comparePanel = document.getElementById('fpComparePanel'); - if (recordPanel) recordPanel.style.display = tab === 'record' ? '' : 'none'; - if (comparePanel) comparePanel.style.display = tab === 'compare' ? '' : 'none'; - document.querySelectorAll('.fp-tab-btn').forEach(b => b.classList.remove('active')); - const activeBtn = tab === 'record' - ? document.getElementById('fpTabRecord') - : document.getElementById('fpTabCompare'); - if (activeBtn) activeBtn.classList.add('active'); - const hintEl = document.getElementById('fpTabHint'); - if (hintEl) hintEl.innerHTML = TAB_HINTS[tab] || ''; - if (tab === 'compare') _loadSessions(); - } - - function _loadDevices() { - const sel = document.getElementById('fpDevice'); - if (!sel) return; - fetch('/devices').then(r => r.json()).then(devices => { - if (!devices || devices.length === 0) { - sel.innerHTML = ''; - return; - } - sel.innerHTML = devices.map(d => { - const label = d.serial ? `${d.name} [${d.serial}]` : d.name; - return ``; - }).join(''); - }).catch(() => { sel.innerHTML = ''; }); - } - - const TAB_HINTS = { - record: 'Record a baseline in a known-clean RF environment, then use Compare later to detect new or anomalous signals.', - compare: 'Select a saved baseline and click Compare Now to scan for deviations. Anomalies are flagged by statistical z-score.', - }; - - function init() { - _active = true; - _loadDevices(); - _loadSessions(); - _updateScannerStatusUI(); - } - - function destroy() { - _active = false; - if (_recording) stopRecording(); - if (_scannerSource) { _scannerSource.close(); _scannerSource = null; } - if (_chartInstance) { _chartInstance.destroy(); _chartInstance = null; } - if (_ownedScanner) stopScanner(); - } - - return { init, destroy, showTab, startRecording, stopRecording, compareNow, startScanner, stopScanner }; -})(); - -window.Fingerprint = Fingerprint; diff --git a/static/js/modes/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/rfheatmap.js b/static/js/modes/rfheatmap.js deleted file mode 100644 index 4956dc5..0000000 --- a/static/js/modes/rfheatmap.js +++ /dev/null @@ -1,456 +0,0 @@ -/* RF Heatmap — GPS + signal strength Leaflet heatmap */ -const RFHeatmap = (function () { - 'use strict'; - - let _map = null; - let _heatLayer = null; - let _gpsSource = null; - let _sigSource = null; - let _heatPoints = []; - let _isRecording = false; - let _lastLat = null, _lastLng = null; - let _minDist = 5; - let _source = 'wifi'; - let _gpsPos = null; - let _lastSignal = null; - let _active = false; - let _ownedSource = false; // true if heatmap started the source itself - - const RSSI_RANGES = { - wifi: { min: -90, max: -30 }, - bluetooth: { min: -100, max: -40 }, - scanner: { min: -120, max: -20 }, - }; - - function _norm(val, src) { - const r = RSSI_RANGES[src] || RSSI_RANGES.wifi; - return Math.max(0, Math.min(1, (val - r.min) / (r.max - r.min))); - } - - function _haversineM(lat1, lng1, lat2, lng2) { - const R = 6371000; - const dLat = (lat2 - lat1) * Math.PI / 180; - const dLng = (lng2 - lng1) * Math.PI / 180; - const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2; - return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - } - - function _ensureLeafletHeat(cb) { - if (window.L && L.heatLayer) { cb(); return; } - const s = document.createElement('script'); - s.src = '/static/js/vendor/leaflet-heat.js'; - s.onload = cb; - s.onerror = () => console.warn('RF Heatmap: leaflet-heat.js failed to load'); - document.head.appendChild(s); - } - - function _initMap() { - if (_map) return; - const el = document.getElementById('rfheatmapMapEl'); - if (!el) return; - - // Defer map creation until container has non-zero dimensions (prevents leaflet-heat IndexSizeError) - if (el.offsetWidth === 0 || el.offsetHeight === 0) { - setTimeout(_initMap, 200); - return; - } - - const fallback = _getFallbackPos(); - const lat = _gpsPos ? _gpsPos.lat : (fallback ? fallback.lat : 37.7749); - const lng = _gpsPos ? _gpsPos.lng : (fallback ? fallback.lng : -122.4194); - - _map = L.map(el, { zoomControl: true }).setView([lat, lng], 16); - L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { - attribution: '© OpenStreetMap contributors © CARTO', - subdomains: 'abcd', - maxZoom: 20, - }).addTo(_map); - - _heatLayer = L.heatLayer([], { radius: 25, blur: 15, maxZoom: 17 }).addTo(_map); - } - - function _startGPS() { - if (_gpsSource) { _gpsSource.close(); _gpsSource = null; } - _gpsSource = new EventSource('/gps/stream'); - _gpsSource.onmessage = (ev) => { - try { - const d = JSON.parse(ev.data); - if (d.lat && d.lng && d.fix) { - _gpsPos = { lat: parseFloat(d.lat), lng: parseFloat(d.lng) }; - _updateGpsPill(true, _gpsPos.lat, _gpsPos.lng); - if (_map) _map.setView([_gpsPos.lat, _gpsPos.lng], _map.getZoom(), { animate: false }); - } else { - _updateGpsPill(false); - } - } catch (_) {} - }; - _gpsSource.onerror = () => _updateGpsPill(false); - } - - function _updateGpsPill(fix, lat, lng) { - const pill = document.getElementById('rfhmGpsPill'); - if (!pill) return; - if (fix && lat !== undefined) { - pill.textContent = `${lat.toFixed(5)}, ${lng.toFixed(5)}`; - pill.style.color = 'var(--accent-green, #00ff88)'; - } else { - const fallback = _getFallbackPos(); - pill.textContent = fallback ? 'No Fix (using fallback)' : 'No Fix'; - pill.style.color = fallback ? 'var(--accent-yellow, #f59e0b)' : 'var(--text-dim, #555)'; - } - } - - function _startSignalStream() { - if (_sigSource) { _sigSource.close(); _sigSource = null; } - let url; - if (_source === 'wifi') url = '/wifi/stream'; - else if (_source === 'bluetooth') url = '/api/bluetooth/stream'; - else url = '/listening/scanner/stream'; - - _sigSource = new EventSource(url); - _sigSource.onmessage = (ev) => { - try { - const d = JSON.parse(ev.data); - let rssi = null; - if (_source === 'wifi') rssi = d.signal_level ?? d.signal ?? null; - else if (_source === 'bluetooth') rssi = d.rssi ?? null; - else rssi = d.power_level ?? d.power ?? null; - if (rssi !== null) { - _lastSignal = parseFloat(rssi); - _updateSignalDisplay(_lastSignal); - } - _maybeSample(); - } catch (_) {} - }; - } - - function _maybeSample() { - if (!_isRecording || _lastSignal === null) return; - if (!_gpsPos) { - const fb = _getFallbackPos(); - if (fb) _gpsPos = fb; - else return; - } - - const { lat, lng } = _gpsPos; - if (_lastLat !== null) { - const dist = _haversineM(_lastLat, _lastLng, lat, lng); - if (dist < _minDist) return; - } - - const intensity = _norm(_lastSignal, _source); - _heatPoints.push([lat, lng, intensity]); - _lastLat = lat; - _lastLng = lng; - - if (_heatLayer) { - const el = document.getElementById('rfheatmapMapEl'); - if (el && el.offsetWidth > 0 && el.offsetHeight > 0) _heatLayer.setLatLngs(_heatPoints); - } - _updateCount(); - } - - function _updateCount() { - const el = document.getElementById('rfhmPointCount'); - if (el) el.textContent = _heatPoints.length; - } - - function _updateSignalDisplay(rssi) { - const valEl = document.getElementById('rfhmLiveSignal'); - const barEl = document.getElementById('rfhmSignalBar'); - const statusEl = document.getElementById('rfhmSignalStatus'); - if (!valEl) return; - - valEl.textContent = rssi !== null ? `${rssi.toFixed(1)} dBm` : '— dBm'; - - if (rssi !== null) { - // Normalise to 0–100% for the bar - const pct = Math.round(_norm(rssi, _source) * 100); - if (barEl) barEl.style.width = pct + '%'; - - // Colour the value by strength - let color, label; - if (pct >= 66) { color = 'var(--accent-green, #00ff88)'; label = 'Strong'; } - else if (pct >= 33) { color = 'var(--accent-cyan, #4aa3ff)'; label = 'Moderate'; } - else { color = '#f59e0b'; label = 'Weak'; } - valEl.style.color = color; - if (barEl) barEl.style.background = color; - - if (statusEl) { - statusEl.textContent = _isRecording - ? `${label} — recording point every ${_minDist}m` - : `${label} — press Start Recording to begin`; - } - } else { - if (barEl) barEl.style.width = '0%'; - valEl.style.color = 'var(--text-dim)'; - if (statusEl) statusEl.textContent = 'No signal data received yet'; - } - } - - function setSource(src) { - _source = src; - if (_active) _startSignalStream(); - } - - function setMinDist(m) { - _minDist = m; - } - - function startRecording() { - _isRecording = true; - _lastLat = null; _lastLng = null; - const startBtn = document.getElementById('rfhmRecordBtn'); - const stopBtn = document.getElementById('rfhmStopBtn'); - if (startBtn) startBtn.style.display = 'none'; - if (stopBtn) { stopBtn.style.display = ''; stopBtn.classList.add('rfhm-recording-pulse'); } - } - - function stopRecording() { - _isRecording = false; - const startBtn = document.getElementById('rfhmRecordBtn'); - const stopBtn = document.getElementById('rfhmStopBtn'); - if (startBtn) startBtn.style.display = ''; - if (stopBtn) { stopBtn.style.display = 'none'; stopBtn.classList.remove('rfhm-recording-pulse'); } - } - - function clearPoints() { - _heatPoints = []; - if (_heatLayer) { - const el = document.getElementById('rfheatmapMapEl'); - if (el && el.offsetWidth > 0 && el.offsetHeight > 0) _heatLayer.setLatLngs([]); - } - _updateCount(); - } - - function exportGeoJSON() { - const features = _heatPoints.map(([lat, lng, intensity]) => ({ - type: 'Feature', - geometry: { type: 'Point', coordinates: [lng, lat] }, - properties: { intensity, source: _source }, - })); - const geojson = { type: 'FeatureCollection', features }; - const blob = new Blob([JSON.stringify(geojson, null, 2)], { type: 'application/json' }); - const a = document.createElement('a'); - a.href = URL.createObjectURL(blob); - a.download = `rf_heatmap_${Date.now()}.geojson`; - a.click(); - } - - function invalidateMap() { - if (!_map) return; - const el = document.getElementById('rfheatmapMapEl'); - if (el && el.offsetWidth > 0 && el.offsetHeight > 0) { - _map.invalidateSize(); - } - } - - // ── Source lifecycle (start / stop / status) ────────────────────── - - async function _checkSourceStatus() { - const src = _source; - let running = false; - let detail = null; - try { - if (src === 'wifi') { - const r = await fetch('/wifi/v2/scan/status'); - if (r.ok) { const d = await r.json(); running = !!d.is_scanning; detail = d.interface || null; } - } else if (src === 'bluetooth') { - const r = await fetch('/api/bluetooth/scan/status'); - if (r.ok) { const d = await r.json(); running = !!d.is_scanning; } - } else if (src === 'scanner') { - const r = await fetch('/listening/scanner/status'); - if (r.ok) { const d = await r.json(); running = !!d.running; } - } - } catch (_) {} - return { running, detail }; - } - - async function startSource() { - const src = _source; - const btn = document.getElementById('rfhmSourceStartBtn'); - const status = document.getElementById('rfhmSourceStatus'); - if (btn) { btn.disabled = true; btn.textContent = 'Starting…'; } - - try { - let res; - if (src === 'wifi') { - // Try to find a monitor interface from the WiFi status first - let iface = null; - try { - const st = await fetch('/wifi/v2/scan/status'); - if (st.ok) { const d = await st.json(); iface = d.interface || null; } - } catch (_) {} - if (!iface) { - // Ask the user to enter an interface name - const entered = prompt('Enter your monitor-mode WiFi interface name (e.g. wlan0mon):'); - if (!entered) { _updateSourceStatusUI(); return; } - iface = entered.trim(); - } - res = await fetch('/wifi/v2/scan/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ interface: iface }) }); - } else if (src === 'bluetooth') { - res = await fetch('/api/bluetooth/scan/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'auto' }) }); - } else if (src === 'scanner') { - const deviceVal = document.getElementById('rfhmDevice')?.value || 'rtlsdr:0'; - const [sdrType, idxStr] = deviceVal.includes(':') ? deviceVal.split(':') : ['rtlsdr', '0']; - res = await fetch('/listening/scanner/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ start_freq: 88, end_freq: 108, sdr_type: sdrType, device: parseInt(idxStr) || 0 }) }); - } - if (res && res.ok) { - _ownedSource = true; - _startSignalStream(); - } - } catch (_) {} - - await _updateSourceStatusUI(); - } - - async function stopSource() { - if (!_ownedSource) return; - try { - if (_source === 'wifi') await fetch('/wifi/v2/scan/stop', { method: 'POST' }); - else if (_source === 'bluetooth') await fetch('/api/bluetooth/scan/stop', { method: 'POST' }); - else if (_source === 'scanner') await fetch('/listening/scanner/stop', { method: 'POST' }); - } catch (_) {} - _ownedSource = false; - await _updateSourceStatusUI(); - } - - async function _updateSourceStatusUI() { - const { running, detail } = await _checkSourceStatus(); - const row = document.getElementById('rfhmSourceStatusRow'); - const dotEl = document.getElementById('rfhmSourceDot'); - const textEl = document.getElementById('rfhmSourceStatusText'); - const startB = document.getElementById('rfhmSourceStartBtn'); - const stopB = document.getElementById('rfhmSourceStopBtn'); - if (!row) return; - - const SOURCE_NAMES = { wifi: 'WiFi Scanner', bluetooth: 'Bluetooth Scanner', scanner: 'SDR Scanner' }; - const name = SOURCE_NAMES[_source] || _source; - - if (dotEl) dotEl.style.background = running ? 'var(--accent-green)' : 'rgba(255,255,255,0.2)'; - if (textEl) textEl.textContent = running - ? `${name} running${detail ? ' · ' + detail : ''}` - : `${name} not running`; - if (startB) { startB.style.display = running ? 'none' : ''; startB.disabled = false; startB.textContent = `Start ${name}`; } - if (stopB) stopB.style.display = (running && _ownedSource) ? '' : 'none'; - - // Auto-subscribe to stream if source just became running - if (running && !_sigSource) _startSignalStream(); - } - - const SOURCE_HINTS = { - wifi: 'Walk with your device — stronger WiFi signals are plotted brighter on the map.', - bluetooth: 'Walk near Bluetooth devices — signal strength is mapped by RSSI.', - scanner: 'SDR scanner power levels are mapped by GPS position. Start the Listening Post scanner first.', - }; - - function onSourceChange() { - const src = document.getElementById('rfhmSource')?.value || 'wifi'; - const hint = document.getElementById('rfhmSourceHint'); - const dg = document.getElementById('rfhmDeviceGroup'); - if (hint) hint.textContent = SOURCE_HINTS[src] || ''; - if (dg) dg.style.display = src === 'scanner' ? '' : 'none'; - _lastSignal = null; - _ownedSource = false; - _updateSignalDisplay(null); - _updateSourceStatusUI(); - // Re-subscribe to correct stream - if (_sigSource) { _sigSource.close(); _sigSource = null; } - _startSignalStream(); - } - - function _loadDevices() { - const sel = document.getElementById('rfhmDevice'); - if (!sel) return; - fetch('/devices').then(r => r.json()).then(devices => { - if (!devices || devices.length === 0) { - sel.innerHTML = ''; - return; - } - sel.innerHTML = devices.map(d => { - const label = d.serial ? `${d.name} [${d.serial}]` : d.name; - return ``; - }).join(''); - }).catch(() => { sel.innerHTML = ''; }); - } - - function _getFallbackPos() { - // Try observer location from localStorage (shared across all map modes) - try { - const stored = localStorage.getItem('observerLocation'); - if (stored) { - const p = JSON.parse(stored); - if (p && typeof p.lat === 'number' && typeof p.lon === 'number') { - return { lat: p.lat, lng: p.lon }; - } - } - } catch (_) {} - // Try manual coord inputs - const lat = parseFloat(document.getElementById('rfhmManualLat')?.value); - const lng = parseFloat(document.getElementById('rfhmManualLon')?.value); - if (!isNaN(lat) && !isNaN(lng)) return { lat, lng }; - return null; - } - - function setManualCoords() { - const lat = parseFloat(document.getElementById('rfhmManualLat')?.value); - const lng = parseFloat(document.getElementById('rfhmManualLon')?.value); - if (!isNaN(lat) && !isNaN(lng) && !_gpsPos && _map) { - _map.setView([lat, lng], _map.getZoom(), { animate: false }); - } - } - - function useObserverLocation() { - try { - const stored = localStorage.getItem('observerLocation'); - if (stored) { - const p = JSON.parse(stored); - if (p && typeof p.lat === 'number' && typeof p.lon === 'number') { - const latEl = document.getElementById('rfhmManualLat'); - const lonEl = document.getElementById('rfhmManualLon'); - if (latEl) latEl.value = p.lat.toFixed(5); - if (lonEl) lonEl.value = p.lon.toFixed(5); - if (_map) _map.setView([p.lat, p.lon], _map.getZoom(), { animate: true }); - return; - } - } - } catch (_) {} - } - - function init() { - _active = true; - _loadDevices(); - onSourceChange(); - - // Pre-fill manual coords from observer location if available - const fallback = _getFallbackPos(); - if (fallback) { - const latEl = document.getElementById('rfhmManualLat'); - const lonEl = document.getElementById('rfhmManualLon'); - if (latEl && !latEl.value) latEl.value = fallback.lat.toFixed(5); - if (lonEl && !lonEl.value) lonEl.value = fallback.lng.toFixed(5); - } - - _updateSignalDisplay(null); - _updateSourceStatusUI(); - _ensureLeafletHeat(() => { - setTimeout(() => { - _initMap(); - _startGPS(); - _startSignalStream(); - }, 50); - }); - } - - function destroy() { - _active = false; - if (_isRecording) stopRecording(); - if (_ownedSource) stopSource(); - if (_gpsSource) { _gpsSource.close(); _gpsSource = null; } - if (_sigSource) { _sigSource.close(); _sigSource = null; } - } - - return { init, destroy, setSource, setMinDist, startRecording, stopRecording, clearPoints, exportGeoJSON, invalidateMap, onSourceChange, setManualCoords, useObserverLocation, startSource, stopSource }; -})(); - -window.RFHeatmap = RFHeatmap; diff --git a/static/js/modes/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/waterfall.js b/static/js/modes/waterfall.js index 39bce4b..0074ebc 100644 --- a/static/js/modes/waterfall.js +++ b/static/js/modes/waterfall.js @@ -32,6 +32,7 @@ const Waterfall = (function () { let _wfCanvas = null; let _wfCtx = null; let _peakLine = null; + let _lastBins = null; let _startMhz = 98.8; let _endMhz = 101.2; @@ -51,25 +52,89 @@ const Waterfall = (function () { let _audioUnlockRequired = false; 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.535, 1.705, 'AM', 'rgba(255,200,50,0.15)'], - [87.5, 108.0, 'FM', 'rgba(255,100,100,0.15)'], - [108.0, 137.0, 'Aviation', 'rgba(100,220,100,0.12)'], - [137.5, 137.9125, 'NOAA APT', 'rgba(50,200,255,0.25)'], + [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)'], - [156.0, 174.0, 'Marine', 'rgba(50,150,255,0.15)'], - [162.4, 162.55, 'Wx Radio', 'rgba(50,255,200,0.35)'], + [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)'], - [868.0, 868.6, 'ISM 868', 'rgba(255,80,255,0.22)'], + [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)'], - [2400.0, 2500.0, '2.4G WiFi', 'rgba(255,165,0,0.12)'], - [5725.0, 5875.0, '5.8G WiFi', 'rgba(255,165,0,0.12)'], + [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 = { @@ -92,6 +157,10 @@ const Waterfall = (function () { if (el) { el.textContent = text || 'IDLE'; } + const hero = document.getElementById('wfHeroVisualStatus'); + if (hero) { + hero.textContent = text || 'IDLE'; + } } function _setMonitorState(text) { @@ -101,6 +170,544 @@ const Waterfall = (function () { } } + 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; @@ -321,6 +928,29 @@ const Waterfall = (function () { 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'; } @@ -340,6 +970,7 @@ const Waterfall = (function () { _setModeButtons(safeMode); const modeReadout = document.getElementById('wfRxModeReadout'); if (modeReadout) modeReadout.textContent = safeMode.toUpperCase(); + _updateHeroReadout(); } function _setSmeter(levelPct, text) { @@ -423,6 +1054,7 @@ const Waterfall = (function () { const stopBtn = document.getElementById('wfStopBtn'); if (startBtn) startBtn.style.display = _running ? 'none' : ''; if (stopBtn) stopBtn.style.display = _running ? '' : 'none'; + _updateScanButtons(); } function _updateTuneLine() { @@ -480,7 +1112,339 @@ const Waterfall = (function () { 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; + _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) { @@ -551,6 +1515,7 @@ const Waterfall = (function () { function _drawSpectrum(bins) { if (!_specCtx || !_specCanvas || !bins || bins.length === 0) return; + _lastBins = bins; const width = _specCanvas.width; const height = _specCanvas.height; @@ -634,20 +1599,133 @@ const Waterfall = (function () { _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) return; - axis.innerHTML = ''; - const ticks = 8; - for (let i = 0; i <= ticks; i += 1) { - const frac = i / ticks; - const freq = _startMhz + frac * (_endMhz - _startMhz); - const tick = document.createElement('div'); - tick.className = 'wf-freq-tick'; - tick.style.left = `${frac * 100}%`; - tick.textContent = freq.toFixed(2); - axis.appendChild(tick); + 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(); } @@ -736,6 +1814,20 @@ const Waterfall = (function () { _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); @@ -809,20 +1901,10 @@ const Waterfall = (function () { event.preventDefault(); if (event.ctrlKey || event.metaKey) { - const spanEl = document.getElementById('wfSpanMhz'); const current = _currentSpan(); const factor = event.deltaY < 0 ? 1 / 1.2 : 1.2; const next = _clamp(current * factor, 0.05, 30.0); - if (spanEl) spanEl.value = next.toFixed(3); - _startMhz = _currentCenter() - next / 2; - _endMhz = _currentCenter() + next / 2; - _drawFreqAxis(); - - if (_monitoring) { - _queueMonitorAdjust(260, { allowSharedTune: false }); - } else if (_running) { - _queueRetune(260); - } + _setSpanAndRetune(next, { retuneDelayMs: 260 }); return; } @@ -944,14 +2026,7 @@ const Waterfall = (function () { const spanEl = document.getElementById('wfSpanMhz'); if (spanEl) { spanEl.addEventListener('change', () => { - const span = _clamp(_currentSpan(), 0.05, 30.0); - spanEl.value = span.toFixed(3); - _startMhz = _currentCenter() - span / 2; - _endMhz = _currentCenter() + span / 2; - _drawFreqAxis(); - - if (_monitoring) _queueMonitorAdjust(250, { allowSharedTune: false }); - if (_running) _queueRetune(250); + _setSpanAndRetune(_currentSpan(), { retuneDelayMs: 250 }); }); } @@ -1023,6 +2098,44 @@ const Waterfall = (function () { }); } + 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); } @@ -1144,7 +2257,7 @@ const Waterfall = (function () { const payloadKey = _ssePayloadKey(payload); const startOnce = async () => { - const response = await fetch('/listening/waterfall/start', { + const response = await fetch('/receiver/waterfall/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), @@ -1167,7 +2280,7 @@ const Waterfall = (function () { const runStart = (async () => { const shouldRestart = forceRestart || (_running && _sseStartConfigKey && _sseStartConfigKey !== payloadKey); if (shouldRestart) { - await fetch('/listening/waterfall/stop', { method: 'POST' }).catch(() => {}); + await fetch('/receiver/waterfall/stop', { method: 'POST' }).catch(() => {}); _running = false; _updateRunButtons(); await _wait(140); @@ -1182,7 +2295,7 @@ const Waterfall = (function () { // If we attached to an existing backend worker after a page refresh, // restart once so requested center/span is definitely applied. if (_isWaterfallAlreadyRunningConflict(response, body) && !_sseStartConfigKey) { - await fetch('/listening/waterfall/stop', { method: 'POST' }).catch(() => {}); + await fetch('/receiver/waterfall/stop', { method: 'POST' }).catch(() => {}); await _wait(140); ({ response, body } = await startOnce()); if (_isWaterfallDeviceBusy(response, body)) { @@ -1251,7 +2364,7 @@ const Waterfall = (function () { function _openSseStream() { if (_es) return; - const source = new EventSource(`/listening/waterfall/stream?t=${Date.now()}`); + const source = new EventSource(`/receiver/waterfall/stream?t=${Date.now()}`); _es = source; source.onmessage = (event) => { let msg = null; @@ -1319,6 +2432,9 @@ const Waterfall = (function () { if (msg.status === 'started') { _running = true; _updateRunButtons(); + _scanAwaitingCapture = false; + _scanStartPending = false; + _scanRestartAttempts = 0; if (Number.isFinite(msg.vfo_freq_mhz)) { _monitorFreqMhz = Number(msg.vfo_freq_mhz); } @@ -1345,11 +2461,27 @@ const Waterfall = (function () { return; } else if (msg.status === 'stopped') { _running = false; + _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; + _scanStartPending = false; + 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'); @@ -1396,7 +2528,7 @@ const Waterfall = (function () { } await _pauseMonitorAudioElement(); - player.src = `/listening/audio/stream?fresh=1&t=${Date.now()}-${attempt}`; + player.src = `/receiver/audio/stream?fresh=1&t=${Date.now()}-${attempt}`; player.load(); try { @@ -1478,7 +2610,7 @@ const Waterfall = (function () { device, biasT, }) { - const response = await fetch('/listening/audio/start', { + const response = await fetch('/receiver/audio/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -1630,7 +2762,7 @@ const Waterfall = (function () { _setVisualStatus('ERROR'); _syncMonitorButtons(); try { - await fetch('/listening/audio/stop', { method: 'POST' }); + await fetch('/receiver/audio/stop', { method: 'POST' }); } catch (_) { // Ignore cleanup stop failures } @@ -1682,7 +2814,7 @@ const Waterfall = (function () { _audioConnectNonce += 1; try { - await fetch('/listening/audio/stop', { method: 'POST' }); + await fetch('/receiver/audio/stop', { method: 'POST' }); } catch (_) { // Ignore backend stop errors } @@ -1859,6 +2991,9 @@ const Waterfall = (function () { _clearWsFallbackTimer(); _running = false; _updateRunButtons(); + if (_scanRunning) { + stopScan('Waterfall disconnected - scan stopped', { silent: false, isError: true }); + } if (_active) { _setStatus('Waterfall disconnected'); if (!_monitoring) { @@ -1869,6 +3004,7 @@ const Waterfall = (function () { } async function stop({ keepStatus = false } = {}) { + stopScan('Scan stopped', { silent: keepStatus }); clearTimeout(_retuneTimer); _clearWsFallbackTimer(); _wsOpened = false; @@ -1891,7 +3027,7 @@ const Waterfall = (function () { if (_es) { _closeSseStream(); try { - await fetch('/listening/waterfall/stop', { method: 'POST' }); + await fetch('/receiver/waterfall/stop', { method: 'POST' }); } catch (_) { // Ignore fallback stop errors. } @@ -1899,6 +3035,7 @@ const Waterfall = (function () { _sseStartConfigKey = ''; _running = false; + _lastBins = null; _updateRunButtons(); if (!keepStatus) { _setStatus('Waterfall stopped'); @@ -1917,6 +3054,12 @@ const Waterfall = (function () { function toggleAnnotations(value) { _showAnnotations = !!value; + _drawBandStrip(); + if (_lastBins && _lastBins.length) { + _drawSpectrum(_lastBins); + } else { + _drawFreqAxis(); + } } function toggleAutoRange(value) { @@ -1936,6 +3079,20 @@ const Waterfall = (function () { _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; @@ -2068,6 +3225,18 @@ const Waterfall = (function () { 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); @@ -2076,6 +3245,7 @@ const Waterfall = (function () { _updateRunButtons(); _setVisualStatus('CONNECTING'); _setStatus('Connecting waterfall stream...'); + _updateHeroReadout(); setTimeout(_resizeCanvases, 60); _drawFreqAxis(); @@ -2089,6 +3259,8 @@ const Waterfall = (function () { _active = false; clearTimeout(_retuneTimer); clearTimeout(_monitorRetuneTimer); + stopScan('Scan stopped', { silent: true }); + _lastBins = null; if (_monitoring) { await stopMonitor({ resumeWaterfall: false }); @@ -2118,6 +3290,9 @@ const Waterfall = (function () { start, stop, stepFreq, + zoomIn, + zoomOut, + zoomBy, setPalette, togglePeakHold, toggleAnnotations, @@ -2128,6 +3303,18 @@ const Waterfall = (function () { unlockAudio, applyPreset, stopMonitor, + handoff, + identifySignal, + useTuneForSignalId, + quickTune: quickTunePreset, + addBookmarkFromInput, + removeBookmark, + useTuneForBookmark, + clearScanHistory, + exportScanLog, + startScan, + stopScan, + setScanRangeFromView, }; })(); diff --git a/static/sw.js b/static/sw.js index a1e30ea..283be7e 100644 --- a/static/sw.js +++ b/static/sw.js @@ -1,14 +1,14 @@ /* INTERCEPT Service Worker — cache-first static, network-only for API/SSE/WS */ -const CACHE_NAME = 'intercept-v1'; +const CACHE_NAME = 'intercept-v2'; const NETWORK_ONLY_PREFIXES = [ '/stream', '/ws/', '/api/', '/gps/', '/wifi/', '/bluetooth/', '/adsb/', '/ais/', '/acars/', '/aprs/', '/tscm/', '/satellite/', - '/meshtastic/', '/bt_locate/', '/listening/', '/sensor/', '/pager/', + '/meshtastic/', '/bt_locate/', '/receiver/', '/sensor/', '/pager/', '/sstv/', '/weather-sat/', '/subghz/', '/rtlamr/', '/dsc/', '/vdl2/', - '/spy/', '/space-weather/', '/websdr/', '/analytics/', '/correlation/', - '/recordings/', '/controller/', '/fingerprint/', '/ops/', -]; + '/spy/', '/space-weather/', '/websdr/', '/analytics/', '/correlation/', + '/recordings/', '/controller/', '/ops/', +]; const STATIC_PREFIXES = [ '/static/css/', diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html index 4a7ed47..82119dc 100644 --- a/templates/adsb_dashboard.html +++ b/templates/adsb_dashboard.html @@ -248,6 +248,10 @@
+
@@ -419,6 +423,10 @@ 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 = 620; + let mapCrosshairResetTimer = null; + let mapCrosshairFallbackTimer = null; + let mapCrosshairRequestId = 0; // Watchlist - persisted to localStorage let watchlist = JSON.parse(localStorage.getItem('adsb_watchlist') || '[]'); @@ -2610,7 +2618,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' }); @@ -2714,7 +2722,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); }); @@ -2784,9 +2792,39 @@ sudo make install `; } - function selectAircraft(icao) { + function triggerMapCrosshairAnimation(lat, lon) { + if (!radarMap) return; + const overlay = document.getElementById('mapCrosshairOverlay'); + if (!overlay) return; + + const point = radarMap.latLngToContainerPoint([lat, lon]); + const size = radarMap.getSize(); + const targetX = Math.max(0, Math.min(size.x, point.x)); + const targetY = Math.max(0, Math.min(size.y, point.y)); + + overlay.style.setProperty('--target-x', `${targetX}px`); + overlay.style.setProperty('--target-y', `${targetY}px`); + overlay.classList.remove('active'); + void overlay.offsetWidth; + overlay.classList.add('active'); + + if (mapCrosshairResetTimer) { + clearTimeout(mapCrosshairResetTimer); + } + mapCrosshairResetTimer = setTimeout(() => { + overlay.classList.remove('active'); + mapCrosshairResetTimer = null; + }, MAP_CROSSHAIR_DURATION_MS + 40); + } + + function selectAircraft(icao, source = 'map') { const prevSelected = selectedIcao; selectedIcao = icao; + mapCrosshairRequestId += 1; + if (mapCrosshairFallbackTimer) { + clearTimeout(mapCrosshairFallbackTimer); + mapCrosshairFallbackTimer = null; + } // Update marker icons for both previous and new selection [prevSelected, icao].forEach(targetIcao => { @@ -2811,7 +2849,26 @@ 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) { + const requestId = mapCrosshairRequestId; + let crosshairTriggered = false; + const runCrosshair = () => { + if (crosshairTriggered || requestId !== mapCrosshairRequestId) return; + crosshairTriggered = true; + if (mapCrosshairFallbackTimer) { + clearTimeout(mapCrosshairFallbackTimer); + mapCrosshairFallbackTimer = null; + } + triggerMapCrosshairAnimation(targetLat, targetLon); + }; + radarMap.once('moveend', runCrosshair); + mapCrosshairFallbackTimer = setTimeout(runCrosshair, 450); + } + + radarMap.setView([targetLat, targetLon], 10); } } @@ -3081,7 +3138,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 = []; @@ -3231,7 +3288,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({ @@ -3268,7 +3325,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; @@ -3312,7 +3369,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 65025b3..d9fb0b0 100644 --- a/templates/index.html +++ b/templates/index.html @@ -18,7 +18,7 @@ document.write(''); window._showDisclaimerOnLoad = true; } - // If navigating with a mode param (e.g. /?mode=listening), hide welcome immediately + // If navigating with a mode param (e.g. /?mode=waterfall), hide welcome immediately // to prevent flash of welcome screen before JS applies the mode else if (new URLSearchParams(window.location.search).get('mode')) { document.write(''); @@ -79,20 +79,34 @@ subghz: "{{ url_for('static', filename='css/modes/subghz.css') }}?v={{ version }}&r=subghz_layout9", bt_locate: "{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate4", spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}", - waterfall: "{{ url_for('static', filename='css/modes/waterfall.css') }}?v={{ version }}&r=wfdeck10", - rfheatmap: "{{ url_for('static', filename='css/modes/rfheatmap.css') }}", - fingerprint: "{{ url_for('static', filename='css/modes/fingerprint.css') }}" + waterfall: "{{ url_for('static', filename='css/modes/waterfall.css') }}?v={{ version }}&r=wfdeck19" }; window.INTERCEPT_MODE_STYLE_LOADED = {}; window.ensureModeStyles = function(mode) { const href = window.INTERCEPT_MODE_STYLE_MAP ? window.INTERCEPT_MODE_STYLE_MAP[mode] : null; if (!href) return; - if (window.INTERCEPT_MODE_STYLE_LOADED[href]) return; - window.INTERCEPT_MODE_STYLE_LOADED[href] = true; + const absHref = new URL(href, window.location.href).href; + const existing = Array.from(document.querySelectorAll('link[data-mode-style]')) + .find((link) => link.href === absHref); + if (existing) { + window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loaded'; + return; + } + if (window.INTERCEPT_MODE_STYLE_LOADED[href] === 'loading') return; + window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loading'; const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = href; link.dataset.modeStyle = mode; + link.onload = () => { + window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loaded'; + }; + link.onerror = () => { + delete window.INTERCEPT_MODE_STYLE_LOADED[href]; + try { + link.remove(); + } catch (_) {} + }; document.head.appendChild(link); }; @@ -196,10 +210,6 @@ Meters - - - @@ -622,8 +624,6 @@ {% include 'partials/modes/space-weather.html' %} - {% include 'partials/modes/listening-post.html' %} - {% include 'partials/modes/tscm.html' %} {% include 'partials/modes/ais.html' %} @@ -638,8 +638,6 @@ {% include 'partials/modes/bt_locate.html' %} {% include 'partials/modes/waterfall.html' %} - {% include 'partials/modes/rfheatmap.html' %} - {% include 'partials/modes/fingerprint.html' %} @@ -1193,9 +1191,11 @@

Satellite Sky View

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