From 4e3f0ad8006a8db066404521c1a188c3d14b95db Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 6 Feb 2026 15:38:08 +0000 Subject: [PATCH] Add DMR digital voice, WebSDR, and listening post enhancements - DMR/P25 digital voice decoder mode with DSD-FME integration - WebSDR mode with KiwiSDR audio proxy and websocket-client support - Listening post waterfall/spectrogram visualization and audio streaming - Dockerfile updates for mbelib and DSD-FME build dependencies - New tests for DMR, WebSDR, KiwiSDR, waterfall, and signal guess API - Chart.js date adapter for time-scale axes Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 29 + app.py | 39 +- pyproject.toml | 1 + requirements.txt | 2 + routes/dmr.py | 352 +++++++++++ routes/listening_post.py | 597 +++++++++++++----- routes/websdr.py | 504 +++++++++++++++ setup.sh | 248 +++++++- static/js/modes/dmr.js | 200 ++++++ static/js/modes/listening-post.js | 284 +++++++++ static/js/modes/websdr.js | 573 +++++++++++++++++ .../chartjs-adapter-date-fns.bundle.min.js | 124 ++++ templates/partials/modes/dmr.html | 71 +++ templates/partials/modes/listening-post.html | 46 ++ templates/partials/modes/websdr.html | 78 +++ tests/conftest.py | 6 +- tests/test_dmr.py | 145 +++++ tests/test_kiwisdr.py | 321 ++++++++++ tests/test_signal_guess_api.py | 100 +++ tests/test_waterfall.py | 80 +++ tests/test_websdr.py | 170 +++++ utils/kiwisdr.py | 288 +++++++++ 22 files changed, 4065 insertions(+), 193 deletions(-) create mode 100644 routes/dmr.py create mode 100644 routes/websdr.py create mode 100644 static/js/modes/dmr.js create mode 100644 static/js/modes/websdr.js create mode 100644 static/vendor/chartjs/chartjs-adapter-date-fns.bundle.min.js create mode 100644 templates/partials/modes/dmr.html create mode 100644 templates/partials/modes/websdr.html create mode 100644 tests/test_dmr.py create mode 100644 tests/test_kiwisdr.py create mode 100644 tests/test_signal_guess_api.py create mode 100644 tests/test_waterfall.py create mode 100644 tests/test_websdr.py create mode 100644 utils/kiwisdr.py diff --git a/Dockerfile b/Dockerfile index 5447b09..1520cb2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,6 +63,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libcurl4-openssl-dev \ zlib1g-dev \ libzmq3-dev \ + libpulse-dev \ + libfftw3-dev \ + liblapack-dev \ + libcodec2-dev \ # Build dump1090 && cd /tmp \ && git clone --depth 1 https://github.com/flightaware/dump1090.git \ @@ -109,6 +113,27 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && make \ && cp acarsdec /usr/bin/acarsdec \ && rm -rf /tmp/acarsdec \ + # Build mbelib (required by DSD) + && cd /tmp \ + && git clone https://github.com/lwvmobile/mbelib.git \ + && cd mbelib \ + && (git checkout ambe_tones || true) \ + && mkdir build && cd build \ + && cmake .. \ + && make -j$(nproc) \ + && make install \ + && ldconfig \ + && rm -rf /tmp/mbelib \ + # Build DSD-FME (Digital Speech Decoder for DMR/P25) + && cd /tmp \ + && git clone --depth 1 https://github.com/lwvmobile/dsd-fme.git \ + && cd dsd-fme \ + && mkdir build && cd build \ + && cmake .. \ + && make -j$(nproc) \ + && make install \ + && ldconfig \ + && rm -rf /tmp/dsd-fme \ # Cleanup build tools to reduce image size && apt-get remove -y \ build-essential \ @@ -124,6 +149,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libcurl4-openssl-dev \ zlib1g-dev \ libzmq3-dev \ + libpulse-dev \ + libfftw3-dev \ + liblapack-dev \ + libcodec2-dev \ && apt-get autoremove -y \ && rm -rf /var/lib/apt/lists/* diff --git a/app.py b/app.py index 373b368..5891d3d 100644 --- a/app.py +++ b/app.py @@ -105,7 +105,7 @@ def inject_offline_settings(): 'enabled': get_setting('offline.enabled', False), 'assets_source': get_setting('offline.assets_source', 'cdn'), 'fonts_source': get_setting('offline.fonts_source', 'cdn'), - 'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'), + 'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'), 'tile_server_url': get_setting('offline.tile_server_url', '') } } @@ -172,6 +172,12 @@ dsc_rtl_process = None dsc_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) dsc_lock = threading.Lock() +# DMR / Digital Voice +dmr_process = None +dmr_rtl_process = None +dmr_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) +dmr_lock = threading.Lock() + # TSCM (Technical Surveillance Countermeasures) tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) tscm_lock = threading.Lock() @@ -278,13 +284,13 @@ def get_sdr_device_status() -> dict[int, str]: # ============================================ @app.before_request -def require_login(): - # Routes that don't require login (to avoid infinite redirect loop) - allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check'] - - # Allow audio streaming endpoints without session auth - if request.path.startswith('/listening/audio/'): - return None +def require_login(): + # Routes that don't require login (to avoid infinite redirect loop) + allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check'] + + # Allow audio streaming endpoints without session auth + if request.path.startswith('/listening/audio/'): + return None # Controller API endpoints use API key auth, not session auth # Allow agent push/pull endpoints without session login @@ -635,6 +641,7 @@ def health_check() -> Response: 'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False), 'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False), 'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False), + 'dmr': dmr_process is not None and (dmr_process.poll() is None if dmr_process else False), }, 'data': { 'aircraft_count': len(adsb_aircraft), @@ -652,6 +659,7 @@ def kill_all() -> Response: """Kill all decoder, WiFi, and Bluetooth processes.""" global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process + global dmr_process, dmr_rtl_process # Import adsb and ais modules to reset their state from routes import adsb as adsb_module @@ -663,7 +671,7 @@ def kill_all() -> Response: 'rtl_fm', 'multimon-ng', 'rtl_433', 'airodump-ng', 'aireplay-ng', 'airmon-ng', 'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher', - 'hcitool', 'bluetoothctl' + 'hcitool', 'bluetoothctl', 'dsd' ] for proc in processes_to_kill: @@ -707,6 +715,11 @@ def kill_all() -> Response: dsc_process = None dsc_rtl_process = None + # Reset DMR state + with dmr_lock: + dmr_process = None + dmr_rtl_process = None + # Reset Bluetooth state (legacy) with bt_lock: if bt_process: @@ -847,6 +860,14 @@ def main() -> None: except ImportError as e: print(f"WebSocket audio disabled (install flask-sock): {e}") + # Initialize KiwiSDR WebSocket audio proxy + try: + from routes.websdr import init_websdr_audio + init_websdr_audio(app) + print("KiwiSDR audio proxy enabled") + except ImportError as e: + print(f"KiwiSDR audio proxy disabled: {e}") + print(f"Open http://localhost:{args.port} in your browser") print() print("Press Ctrl+C to stop") diff --git a/pyproject.toml b/pyproject.toml index 4aebb82..c5dc2ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "flask-limiter>=2.5.4", "bleak>=0.21.0", "flask-sock", + "websocket-client>=1.6.0", "requests>=2.28.0", ] diff --git a/requirements.txt b/requirements.txt index a6d0adf..ae63a67 100644 --- a/requirements.txt +++ b/requirements.txt @@ -35,4 +35,6 @@ qrcode[pil]>=7.4 # ruff>=0.1.0 # black>=23.0.0 # mypy>=1.0.0 +# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post) flask-sock +websocket-client>=1.6.0 diff --git a/routes/dmr.py b/routes/dmr.py new file mode 100644 index 0000000..c8b177e --- /dev/null +++ b/routes/dmr.py @@ -0,0 +1,352 @@ +"""DMR / P25 / Digital Voice decoding routes.""" + +from __future__ import annotations + +import os +import queue +import re +import shutil +import subprocess +import threading +import time +from datetime import datetime +from typing import Generator, Optional + +from flask import Blueprint, jsonify, request, Response + +import app as app_module +from utils.logging import get_logger +from utils.sse import format_sse +from utils.constants import ( + SSE_QUEUE_TIMEOUT, + SSE_KEEPALIVE_INTERVAL, + QUEUE_MAX_SIZE, +) + +logger = get_logger('intercept.dmr') + +dmr_bp = Blueprint('dmr', __name__, url_prefix='/dmr') + +# ============================================ +# GLOBAL STATE +# ============================================ + +dmr_rtl_process: Optional[subprocess.Popen] = None +dmr_dsd_process: Optional[subprocess.Popen] = None +dmr_thread: Optional[threading.Thread] = None +dmr_running = False +dmr_lock = threading.Lock() +dmr_queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) +dmr_active_device: Optional[int] = None + +VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice'] +PROTOCOL_FLAGS = { + 'auto': [], + 'dmr': ['-fd'], + 'p25': ['-fp'], + 'nxdn': ['-fn'], + 'dstar': ['-fi'], + 'provoice': ['-fv'], +} + +# ============================================ +# HELPERS +# ============================================ + + +def find_dsd() -> str | None: + """Find DSD (Digital Speech Decoder) binary.""" + return shutil.which('dsd') + + +def find_rtl_fm() -> str | None: + """Find rtl_fm binary.""" + return shutil.which('rtl_fm') + + +def parse_dsd_output(line: str) -> dict | None: + """Parse a line of DSD stderr output into a structured event.""" + line = line.strip() + if not line: + return None + + # Sync detection: "Sync: +DMR (data)" or "Sync: +P25 Phase 1" + sync_match = re.match(r'Sync:\s*\+?(\S+.*)', line) + if sync_match: + return { + 'type': 'sync', + 'protocol': sync_match.group(1).strip(), + 'timestamp': datetime.now().strftime('%H:%M:%S'), + } + + # Talkgroup and Source: "TG: 12345 Src: 67890" + tg_match = re.match(r'.*TG:\s*(\d+)\s+Src:\s*(\d+)', line) + if tg_match: + return { + 'type': 'call', + 'talkgroup': int(tg_match.group(1)), + 'source_id': int(tg_match.group(2)), + 'timestamp': datetime.now().strftime('%H:%M:%S'), + } + + # Slot info: "Slot 1" or "Slot 2" + slot_match = re.match(r'.*Slot\s*(\d+)', line) + if slot_match: + return { + 'type': 'slot', + 'slot': int(slot_match.group(1)), + 'timestamp': datetime.now().strftime('%H:%M:%S'), + } + + # DMR voice frame + if 'Voice' in line or 'voice' in line: + return { + 'type': 'voice', + 'detail': line, + 'timestamp': datetime.now().strftime('%H:%M:%S'), + } + + # P25 NAC (Network Access Code) + nac_match = re.match(r'.*NAC:\s*(\w+)', line) + if nac_match: + return { + 'type': 'nac', + 'nac': nac_match.group(1), + 'timestamp': datetime.now().strftime('%H:%M:%S'), + } + + return None + + +def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Popen): + """Read DSD stderr output and push parsed events to the queue.""" + global dmr_running + + try: + dmr_queue.put_nowait({'type': 'status', 'text': 'started'}) + + while dmr_running: + if dsd_process.poll() is not None: + break + + line = dsd_process.stderr.readline() + if not line: + if dsd_process.poll() is not None: + break + continue + + text = line.decode('utf-8', errors='replace').strip() + if not text: + continue + + parsed = parse_dsd_output(text) + if parsed: + try: + dmr_queue.put_nowait(parsed) + except queue.Full: + try: + dmr_queue.get_nowait() + except queue.Empty: + pass + try: + dmr_queue.put_nowait(parsed) + except queue.Full: + pass + + except Exception as e: + logger.error(f"DSD stream error: {e}") + finally: + dmr_running = False + try: + dmr_queue.put_nowait({'type': 'status', 'text': 'stopped'}) + except queue.Full: + pass + logger.info("DSD stream thread stopped") + + +# ============================================ +# API ENDPOINTS +# ============================================ + +@dmr_bp.route('/tools') +def check_tools() -> Response: + """Check for required tools.""" + dsd = find_dsd() + rtl_fm = find_rtl_fm() + return jsonify({ + 'dsd': dsd is not None, + 'rtl_fm': rtl_fm is not None, + 'available': dsd is not None and rtl_fm is not None, + 'protocols': VALID_PROTOCOLS, + }) + + +@dmr_bp.route('/start', methods=['POST']) +def start_dmr() -> Response: + """Start digital voice decoding.""" + global dmr_rtl_process, dmr_dsd_process, dmr_thread, dmr_running, dmr_active_device + + with dmr_lock: + if dmr_running: + return jsonify({'status': 'error', 'message': 'Already running'}), 409 + + dsd_path = find_dsd() + if not dsd_path: + return jsonify({'status': 'error', 'message': 'dsd not found. Install Digital Speech Decoder.'}), 503 + + rtl_fm_path = find_rtl_fm() + if not rtl_fm_path: + return jsonify({'status': 'error', 'message': 'rtl_fm not found. Install rtl-sdr tools.'}), 503 + + data = request.json or {} + + try: + frequency = float(data.get('frequency', 462.5625)) + gain = int(data.get('gain', 40)) + device = int(data.get('device', 0)) + protocol = str(data.get('protocol', 'auto')).lower() + except (ValueError, TypeError) as e: + return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400 + + if frequency <= 0: + return jsonify({'status': 'error', 'message': 'Frequency must be positive'}), 400 + + if protocol not in VALID_PROTOCOLS: + return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400 + + # Clear stale queue + try: + while True: + dmr_queue.get_nowait() + except queue.Empty: + pass + + # Claim SDR device + error = app_module.claim_sdr_device(device, 'dmr') + if error: + return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409 + + dmr_active_device = device + + freq_hz = int(frequency * 1e6) + + # Build rtl_fm command (48kHz sample rate for DSD) + rtl_cmd = [ + rtl_fm_path, + '-M', 'fm', + '-f', str(freq_hz), + '-s', '48000', + '-g', str(gain), + '-d', str(device), + '-l', '1', # squelch level + ] + + # Build DSD command + dsd_cmd = [dsd_path, '-i', '-'] + dsd_cmd.extend(PROTOCOL_FLAGS.get(protocol, [])) + + try: + dmr_rtl_process = subprocess.Popen( + rtl_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + + dmr_dsd_process = subprocess.Popen( + dsd_cmd, + stdin=dmr_rtl_process.stdout, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + + # Allow rtl_fm to send directly to dsd + dmr_rtl_process.stdout.close() + + time.sleep(0.3) + + if dmr_rtl_process.poll() is not None or dmr_dsd_process.poll() is not None: + # Process died + if dmr_active_device is not None: + app_module.release_sdr_device(dmr_active_device) + dmr_active_device = None + return jsonify({'status': 'error', 'message': 'Failed to start DSD pipeline'}), 500 + + dmr_running = True + dmr_thread = threading.Thread( + target=stream_dsd_output, + args=(dmr_rtl_process, dmr_dsd_process), + daemon=True, + ) + dmr_thread.start() + + return jsonify({ + 'status': 'started', + 'frequency': frequency, + 'protocol': protocol, + }) + + except Exception as e: + logger.error(f"Failed to start DMR: {e}") + if dmr_active_device is not None: + app_module.release_sdr_device(dmr_active_device) + dmr_active_device = None + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@dmr_bp.route('/stop', methods=['POST']) +def stop_dmr() -> Response: + """Stop digital voice decoding.""" + global dmr_rtl_process, dmr_dsd_process, dmr_running, dmr_active_device + + dmr_running = False + + for proc in [dmr_dsd_process, dmr_rtl_process]: + if proc and proc.poll() is None: + try: + proc.terminate() + proc.wait(timeout=2) + except Exception: + try: + proc.kill() + except Exception: + pass + + dmr_rtl_process = None + dmr_dsd_process = None + + if dmr_active_device is not None: + app_module.release_sdr_device(dmr_active_device) + dmr_active_device = None + + return jsonify({'status': 'stopped'}) + + +@dmr_bp.route('/status') +def dmr_status() -> Response: + """Get DMR decoder status.""" + return jsonify({ + 'running': dmr_running, + 'device': dmr_active_device, + }) + + +@dmr_bp.route('/stream') +def stream_dmr() -> Response: + """SSE stream for DMR decoder events.""" + def generate() -> Generator[str, None, None]: + last_keepalive = time.time() + while True: + try: + msg = dmr_queue.get(timeout=SSE_QUEUE_TIMEOUT) + last_keepalive = time.time() + yield format_sse(msg) + except queue.Empty: + now = time.time() + if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL: + yield format_sse({'type': 'keepalive'}) + last_keepalive = now + + response = Response(generate(), mimetype='text/event-stream') + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + return response diff --git a/routes/listening_post.py b/routes/listening_post.py index 3633153..a2cccfc 100644 --- a/routes/listening_post.py +++ b/routes/listening_post.py @@ -96,27 +96,27 @@ def find_rx_fm() -> str | None: return shutil.which('rx_fm') -def find_ffmpeg() -> str | None: - """Find ffmpeg for audio encoding.""" - return shutil.which('ffmpeg') - - -VALID_MODULATIONS = ['fm', 'wfm', 'am', 'usb', 'lsb'] - - -def normalize_modulation(value: str) -> str: - """Normalize and validate modulation string.""" - mod = str(value or '').lower().strip() - if mod not in VALID_MODULATIONS: - raise ValueError(f'Invalid modulation. Use: {", ".join(VALID_MODULATIONS)}') - return mod - - - - -def add_activity_log(event_type: str, frequency: float, details: str = ''): - """Add entry to activity log.""" - with activity_log_lock: +def find_ffmpeg() -> str | None: + """Find ffmpeg for audio encoding.""" + return shutil.which('ffmpeg') + + +VALID_MODULATIONS = ['fm', 'wfm', 'am', 'usb', 'lsb'] + + +def normalize_modulation(value: str) -> str: + """Normalize and validate modulation string.""" + mod = str(value or '').lower().strip() + if mod not in VALID_MODULATIONS: + raise ValueError(f'Invalid modulation. Use: {", ".join(VALID_MODULATIONS)}') + return mod + + + + +def add_activity_log(event_type: str, frequency: float, details: str = ''): + """Add entry to activity log.""" + with activity_log_lock: entry = { 'timestamp': datetime.utcnow().isoformat() + 'Z', 'type': event_type, @@ -734,106 +734,106 @@ def _start_audio_stream(frequency: float, modulation: str): 'pipe:1' ] - try: - # Use subprocess piping for reliable streaming. - # Log stderr to temp files for error diagnosis. - rtl_stderr_log = '/tmp/rtl_fm_stderr.log' - ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log' - logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={scanner_config['device']}") - - # Retry loop for USB device contention (device may not be - # released immediately after a previous process exits) - max_attempts = 3 - for attempt in range(max_attempts): - audio_rtl_process = None - audio_process = None - rtl_err_handle = None - ffmpeg_err_handle = None - try: - rtl_err_handle = open(rtl_stderr_log, 'w') - ffmpeg_err_handle = open(ffmpeg_stderr_log, 'w') - audio_rtl_process = subprocess.Popen( - sdr_cmd, - stdout=subprocess.PIPE, - stderr=rtl_err_handle, - bufsize=0, - start_new_session=True # Create new process group for clean shutdown - ) - audio_process = subprocess.Popen( - encoder_cmd, - stdin=audio_rtl_process.stdout, - stdout=subprocess.PIPE, - stderr=ffmpeg_err_handle, - bufsize=0, - start_new_session=True # Create new process group for clean shutdown - ) - if audio_rtl_process.stdout: - audio_rtl_process.stdout.close() - finally: - if rtl_err_handle: - rtl_err_handle.close() - if ffmpeg_err_handle: - ffmpeg_err_handle.close() - - # Brief delay to check if process started successfully - time.sleep(0.3) - - if (audio_rtl_process and audio_rtl_process.poll() is not None) or ( - audio_process and audio_process.poll() is not None - ): - # Read stderr from temp files - rtl_stderr = '' - ffmpeg_stderr = '' - try: - with open(rtl_stderr_log, 'r') as f: - rtl_stderr = f.read().strip() - except Exception: - pass - try: - with open(ffmpeg_stderr_log, 'r') as f: - ffmpeg_stderr = f.read().strip() - except Exception: - pass - - if 'usb_claim_interface' in rtl_stderr and attempt < max_attempts - 1: - logger.warning(f"USB device busy (attempt {attempt + 1}/{max_attempts}), waiting for release...") - if audio_process: - try: - audio_process.terminate() - audio_process.wait(timeout=0.5) - except Exception: - pass - if audio_rtl_process: - try: - audio_rtl_process.terminate() - audio_rtl_process.wait(timeout=0.5) - except Exception: - pass - time.sleep(1.0) - continue - - if audio_process and audio_process.poll() is None: - try: - audio_process.terminate() - audio_process.wait(timeout=0.5) - except Exception: - pass - if audio_rtl_process and audio_rtl_process.poll() is None: - try: - audio_rtl_process.terminate() - audio_rtl_process.wait(timeout=0.5) - except Exception: - pass - audio_process = None - audio_rtl_process = None - - logger.error( - f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}" - ) - return - - # Pipeline started successfully - break + try: + # Use subprocess piping for reliable streaming. + # Log stderr to temp files for error diagnosis. + rtl_stderr_log = '/tmp/rtl_fm_stderr.log' + ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log' + logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={scanner_config['device']}") + + # Retry loop for USB device contention (device may not be + # released immediately after a previous process exits) + max_attempts = 3 + for attempt in range(max_attempts): + audio_rtl_process = None + audio_process = None + rtl_err_handle = None + ffmpeg_err_handle = None + try: + rtl_err_handle = open(rtl_stderr_log, 'w') + ffmpeg_err_handle = open(ffmpeg_stderr_log, 'w') + audio_rtl_process = subprocess.Popen( + sdr_cmd, + stdout=subprocess.PIPE, + stderr=rtl_err_handle, + bufsize=0, + start_new_session=True # Create new process group for clean shutdown + ) + audio_process = subprocess.Popen( + encoder_cmd, + stdin=audio_rtl_process.stdout, + stdout=subprocess.PIPE, + stderr=ffmpeg_err_handle, + bufsize=0, + start_new_session=True # Create new process group for clean shutdown + ) + if audio_rtl_process.stdout: + audio_rtl_process.stdout.close() + finally: + if rtl_err_handle: + rtl_err_handle.close() + if ffmpeg_err_handle: + ffmpeg_err_handle.close() + + # Brief delay to check if process started successfully + time.sleep(0.3) + + if (audio_rtl_process and audio_rtl_process.poll() is not None) or ( + audio_process and audio_process.poll() is not None + ): + # Read stderr from temp files + rtl_stderr = '' + ffmpeg_stderr = '' + try: + with open(rtl_stderr_log, 'r') as f: + rtl_stderr = f.read().strip() + except Exception: + pass + try: + with open(ffmpeg_stderr_log, 'r') as f: + ffmpeg_stderr = f.read().strip() + except Exception: + pass + + if 'usb_claim_interface' in rtl_stderr and attempt < max_attempts - 1: + logger.warning(f"USB device busy (attempt {attempt + 1}/{max_attempts}), waiting for release...") + if audio_process: + try: + audio_process.terminate() + audio_process.wait(timeout=0.5) + except Exception: + pass + if audio_rtl_process: + try: + audio_rtl_process.terminate() + audio_rtl_process.wait(timeout=0.5) + except Exception: + pass + time.sleep(1.0) + continue + + if audio_process and audio_process.poll() is None: + try: + audio_process.terminate() + audio_process.wait(timeout=0.5) + except Exception: + pass + if audio_rtl_process and audio_rtl_process.poll() is None: + try: + audio_rtl_process.terminate() + audio_rtl_process.wait(timeout=0.5) + except Exception: + pass + audio_process = None + audio_rtl_process = None + + logger.error( + f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}" + ) + return + + # Pipeline started successfully + break # Validate that audio is producing data quickly try: @@ -858,38 +858,38 @@ def _stop_audio_stream(): _stop_audio_stream_internal() -def _stop_audio_stream_internal(): - """Internal stop (must hold lock).""" - global audio_process, audio_rtl_process, audio_running, audio_frequency - - # Set flag first to stop any streaming - audio_running = False - audio_frequency = 0.0 - - # Kill the pipeline processes and their groups - if audio_process: - try: - # Kill entire process group (SDR demod + ffmpeg) - try: - os.killpg(os.getpgid(audio_process.pid), signal.SIGKILL) - except (ProcessLookupError, PermissionError): - audio_process.kill() - audio_process.wait(timeout=0.5) - except Exception: - pass - - if audio_rtl_process: - try: - try: - os.killpg(os.getpgid(audio_rtl_process.pid), signal.SIGKILL) - except (ProcessLookupError, PermissionError): - audio_rtl_process.kill() - audio_rtl_process.wait(timeout=0.5) - except Exception: - pass - - audio_process = None - audio_rtl_process = None +def _stop_audio_stream_internal(): + """Internal stop (must hold lock).""" + global audio_process, audio_rtl_process, audio_running, audio_frequency + + # Set flag first to stop any streaming + audio_running = False + audio_frequency = 0.0 + + # Kill the pipeline processes and their groups + if audio_process: + try: + # Kill entire process group (SDR demod + ffmpeg) + try: + os.killpg(os.getpgid(audio_process.pid), signal.SIGKILL) + except (ProcessLookupError, PermissionError): + audio_process.kill() + audio_process.wait(timeout=0.5) + except Exception: + pass + + if audio_rtl_process: + try: + try: + os.killpg(os.getpgid(audio_rtl_process.pid), signal.SIGKILL) + except (ProcessLookupError, PermissionError): + audio_rtl_process.kill() + audio_rtl_process.wait(timeout=0.5) + except Exception: + pass + + audio_process = None + audio_rtl_process = None # Kill any orphaned rtl_fm, rtl_power, and ffmpeg processes for proc_pattern in ['rtl_fm', 'rtl_power']: @@ -962,7 +962,7 @@ def start_scanner() -> Response: scanner_config['start_freq'] = float(data.get('start_freq', 88.0)) scanner_config['end_freq'] = float(data.get('end_freq', 108.0)) scanner_config['step'] = float(data.get('step', 0.1)) - scanner_config['modulation'] = normalize_modulation(data.get('modulation', 'wfm')) + scanner_config['modulation'] = normalize_modulation(data.get('modulation', 'wfm')) scanner_config['squelch'] = int(data.get('squelch', 0)) scanner_config['dwell_time'] = float(data.get('dwell_time', 3.0)) scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5)) @@ -1144,15 +1144,15 @@ def update_scanner_config() -> Response: scanner_config['dwell_time'] = int(data['dwell_time']) updated.append(f"dwell={data['dwell_time']}s") - if 'modulation' in data: - try: - scanner_config['modulation'] = normalize_modulation(data['modulation']) - updated.append(f"mod={data['modulation']}") - except (ValueError, TypeError) as e: - return jsonify({ - 'status': 'error', - 'message': str(e) - }), 400 + if 'modulation' in data: + try: + scanner_config['modulation'] = normalize_modulation(data['modulation']) + updated.append(f"mod={data['modulation']}") + except (ValueError, TypeError) as e: + return jsonify({ + 'status': 'error', + 'message': str(e) + }), 400 if updated: logger.info(f"Scanner config updated: {', '.join(updated)}") @@ -1274,7 +1274,7 @@ def start_audio() -> Response: try: frequency = float(data.get('frequency', 0)) - modulation = normalize_modulation(data.get('modulation', 'wfm')) + modulation = normalize_modulation(data.get('modulation', 'wfm')) squelch = int(data.get('squelch', 0)) gain = int(data.get('gain', 40)) device = int(data.get('device', 0)) @@ -1467,3 +1467,272 @@ def stream_audio() -> Response: 'Transfer-Encoding': 'chunked', } ) + + +# ============================================ +# SIGNAL IDENTIFICATION ENDPOINT +# ============================================ + +@listening_post_bp.route('/signal/guess', methods=['POST']) +def guess_signal() -> Response: + """Identify a signal based on frequency, modulation, and other parameters.""" + data = request.json or {} + + freq_mhz = data.get('frequency_mhz') + if freq_mhz is None: + return jsonify({'status': 'error', 'message': 'frequency_mhz is required'}), 400 + + try: + freq_mhz = float(freq_mhz) + except (ValueError, TypeError): + return jsonify({'status': 'error', 'message': 'Invalid frequency_mhz'}), 400 + + if freq_mhz <= 0: + return jsonify({'status': 'error', 'message': 'frequency_mhz must be positive'}), 400 + + frequency_hz = int(freq_mhz * 1e6) + + modulation = data.get('modulation') + bandwidth_hz = data.get('bandwidth_hz') + if bandwidth_hz is not None: + try: + bandwidth_hz = int(bandwidth_hz) + except (ValueError, TypeError): + bandwidth_hz = None + + region = data.get('region', 'UK/EU') + + try: + from utils.signal_guess import guess_signal_type_dict + result = guess_signal_type_dict( + frequency_hz=frequency_hz, + modulation=modulation, + bandwidth_hz=bandwidth_hz, + region=region, + ) + return jsonify({'status': 'ok', **result}) + except Exception as e: + logger.error(f"Signal guess error: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +# ============================================ +# WATERFALL / SPECTROGRAM ENDPOINTS +# ============================================ + +waterfall_process: Optional[subprocess.Popen] = None +waterfall_thread: Optional[threading.Thread] = None +waterfall_running = False +waterfall_lock = threading.Lock() +waterfall_queue: queue.Queue = queue.Queue(maxsize=200) +waterfall_active_device: Optional[int] = None +waterfall_config = { + 'start_freq': 88.0, + 'end_freq': 108.0, + 'bin_size': 10000, + 'gain': 40, + 'device': 0, +} + + +def _waterfall_loop(): + """Continuous rtl_power sweep loop emitting waterfall data.""" + global waterfall_running, waterfall_process + + rtl_power_path = find_rtl_power() + if not rtl_power_path: + logger.error("rtl_power not found for waterfall") + waterfall_running = False + return + + try: + while waterfall_running: + start_hz = int(waterfall_config['start_freq'] * 1e6) + end_hz = int(waterfall_config['end_freq'] * 1e6) + bin_hz = int(waterfall_config['bin_size']) + gain = waterfall_config['gain'] + device = waterfall_config['device'] + + cmd = [ + rtl_power_path, + '-f', f'{start_hz}:{end_hz}:{bin_hz}', + '-i', '0.5', + '-1', + '-g', str(gain), + '-d', str(device), + ] + + try: + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + waterfall_process = proc + stdout, _ = proc.communicate(timeout=15) + except subprocess.TimeoutExpired: + proc.kill() + stdout = b'' + finally: + waterfall_process = None + + if not waterfall_running: + break + + if not stdout: + time.sleep(0.2) + continue + + # Parse rtl_power CSV output + all_bins = [] + sweep_start_hz = start_hz + sweep_end_hz = end_hz + + for line in stdout.decode(errors='ignore').splitlines(): + if not line or line.startswith('#'): + continue + parts = [p.strip() for p in line.split(',')] + start_idx = None + for i, tok in enumerate(parts): + try: + val = float(tok) + except ValueError: + continue + if val > 1e5: + start_idx = i + break + if start_idx is None or len(parts) < start_idx + 4: + continue + try: + seg_start = float(parts[start_idx]) + seg_end = float(parts[start_idx + 1]) + seg_bin = float(parts[start_idx + 2]) + raw_values = [] + for v in parts[start_idx + 3:]: + try: + raw_values.append(float(v)) + except ValueError: + continue + if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]): + raw_values = raw_values[1:] + all_bins.extend(raw_values) + sweep_start_hz = min(sweep_start_hz, seg_start) + sweep_end_hz = max(sweep_end_hz, seg_end) + except ValueError: + continue + + if all_bins: + msg = { + 'type': 'waterfall_sweep', + 'start_freq': sweep_start_hz / 1e6, + 'end_freq': sweep_end_hz / 1e6, + 'bins': all_bins, + 'timestamp': datetime.now().isoformat(), + } + try: + waterfall_queue.put_nowait(msg) + except queue.Full: + try: + waterfall_queue.get_nowait() + except queue.Empty: + pass + try: + waterfall_queue.put_nowait(msg) + except queue.Full: + pass + + time.sleep(0.1) + + except Exception as e: + logger.error(f"Waterfall loop error: {e}") + finally: + waterfall_running = False + logger.info("Waterfall loop stopped") + + +@listening_post_bp.route('/waterfall/start', methods=['POST']) +def start_waterfall() -> Response: + """Start the waterfall/spectrogram display.""" + global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device + + with waterfall_lock: + if waterfall_running: + return jsonify({'status': 'error', 'message': 'Waterfall already running'}), 409 + + if not find_rtl_power(): + return jsonify({'status': 'error', 'message': 'rtl_power not found'}), 503 + + data = request.json or {} + + try: + waterfall_config['start_freq'] = float(data.get('start_freq', 88.0)) + waterfall_config['end_freq'] = float(data.get('end_freq', 108.0)) + waterfall_config['bin_size'] = int(data.get('bin_size', 10000)) + waterfall_config['gain'] = int(data.get('gain', 40)) + waterfall_config['device'] = int(data.get('device', 0)) + except (ValueError, TypeError) as e: + return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400 + + if waterfall_config['start_freq'] >= waterfall_config['end_freq']: + return jsonify({'status': 'error', 'message': 'start_freq must be less than end_freq'}), 400 + + # Clear stale queue + try: + while True: + waterfall_queue.get_nowait() + except queue.Empty: + pass + + # Claim SDR device + error = app_module.claim_sdr_device(waterfall_config['device'], 'waterfall') + if error: + return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409 + + waterfall_active_device = waterfall_config['device'] + waterfall_running = True + waterfall_thread = threading.Thread(target=_waterfall_loop, daemon=True) + waterfall_thread.start() + + return jsonify({'status': 'started', 'config': waterfall_config}) + + +@listening_post_bp.route('/waterfall/stop', methods=['POST']) +def stop_waterfall() -> Response: + """Stop the waterfall display.""" + global waterfall_running, waterfall_process, waterfall_active_device + + waterfall_running = False + if waterfall_process and waterfall_process.poll() is None: + try: + waterfall_process.terminate() + waterfall_process.wait(timeout=1) + except Exception: + try: + waterfall_process.kill() + except Exception: + pass + waterfall_process = None + + if waterfall_active_device is not None: + app_module.release_sdr_device(waterfall_active_device) + waterfall_active_device = None + + return jsonify({'status': 'stopped'}) + + +@listening_post_bp.route('/waterfall/stream') +def stream_waterfall() -> Response: + """SSE stream for waterfall data.""" + def generate() -> Generator[str, None, None]: + last_keepalive = time.time() + while True: + try: + msg = waterfall_queue.get(timeout=SSE_QUEUE_TIMEOUT) + last_keepalive = time.time() + yield format_sse(msg) + except queue.Empty: + now = time.time() + if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL: + yield format_sse({'type': 'keepalive'}) + last_keepalive = now + + response = Response(generate(), mimetype='text/event-stream') + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + return response diff --git a/routes/websdr.py b/routes/websdr.py new file mode 100644 index 0000000..a93528c --- /dev/null +++ b/routes/websdr.py @@ -0,0 +1,504 @@ +"""HF/Shortwave WebSDR Integration - KiwiSDR network access.""" + +from __future__ import annotations + +import json +import math +import queue +import re +import struct +import threading +import time +from typing import Optional + +from flask import Blueprint, Flask, jsonify, request, Response + +try: + from flask_sock import Sock + WEBSOCKET_AVAILABLE = True +except ImportError: + WEBSOCKET_AVAILABLE = False + +from utils.kiwisdr import KiwiSDRClient, KIWI_SAMPLE_RATE, VALID_MODES, parse_host_port +from utils.logging import get_logger + +logger = get_logger('intercept.websdr') + +websdr_bp = Blueprint('websdr', __name__, url_prefix='/websdr') + +# ============================================ +# RECEIVER CACHE +# ============================================ + +_receiver_cache: list[dict] = [] +_cache_lock = threading.Lock() +_cache_timestamp: float = 0 +CACHE_TTL = 3600 # 1 hour + + +def _parse_gps_coord(coord_str: str) -> Optional[float]: + """Parse a GPS coordinate string like '51.5074' or '(-33.87)' into a float.""" + if not coord_str: + return None + # Remove parentheses and whitespace + cleaned = coord_str.strip().strip('()').strip() + try: + return float(cleaned) + except (ValueError, TypeError): + return None + + +def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Calculate distance in km between two GPS coordinates.""" + R = 6371 # Earth radius in km + dlat = math.radians(lat2 - lat1) + dlon = math.radians(lon2 - lon1) + a = (math.sin(dlat / 2) ** 2 + + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * + math.sin(dlon / 2) ** 2) + c = 2 * math.asin(math.sqrt(a)) + return R * c + + +KIWI_DATA_URLS = [ + 'https://rx.skywavelinux.com/kiwisdr_com.js', + 'http://rx.linkfanel.net/kiwisdr_com.js', +] + + +def _fetch_kiwi_receivers() -> list[dict]: + """Fetch the KiwiSDR receiver list from the public directory.""" + import urllib.request + import json + + receivers = [] + raw = None + + # Try each data source until one works + for data_url in KIWI_DATA_URLS: + try: + req = urllib.request.Request(data_url, headers={ + 'User-Agent': 'INTERCEPT-SIGINT/1.0', + }) + with urllib.request.urlopen(req, timeout=20) as resp: + raw = resp.read().decode('utf-8', errors='replace') + if raw and len(raw) > 100: + logger.info(f"Fetched KiwiSDR data from {data_url}") + break + raw = None + except Exception as e: + logger.warning(f"Failed to fetch from {data_url}: {e}") + continue + + if not raw: + logger.error("All KiwiSDR data sources failed") + return receivers + + # The JS file contains: var kiwisdr_com = [ {...}, {...}, ... ]; + # Extract the JSON array + match = re.search(r'var\s+kiwisdr_com\s*=\s*(\[.*\])\s*;?', raw, re.DOTALL) + if not match: + # Try bare array + match = re.search(r'(\[\s*\{.*\}\s*\])', raw, re.DOTALL) + if not match: + logger.warning("Could not find receiver array in KiwiSDR data") + return receivers + + arr_str = match.group(1) + + # Parse JSON + try: + raw_list = json.loads(arr_str) + except json.JSONDecodeError: + # Fix common JS → JSON issues (trailing commas) + fixed = re.sub(r',\s*}', '}', arr_str) + fixed = re.sub(r',\s*]', ']', fixed) + try: + raw_list = json.loads(fixed) + except json.JSONDecodeError: + logger.error("Failed to parse KiwiSDR JSON") + return receivers + + for entry in raw_list: + if not isinstance(entry, dict): + continue + + # Skip offline receivers + if entry.get('offline') == 'yes' or entry.get('status') != 'active': + continue + + name = entry.get('name', 'Unknown') + url = entry.get('url', '') + gps = entry.get('gps', '') + antenna = entry.get('antenna', '') + location = entry.get('loc', '') + + # Parse users (strings in actual data) + try: + users = int(entry.get('users', 0)) + except (ValueError, TypeError): + users = 0 + try: + users_max = int(entry.get('users_max', 4)) + except (ValueError, TypeError): + users_max = 4 + + # Parse bands field: "0-30000000" (Hz) → freq_lo/freq_hi in kHz + bands_str = entry.get('bands', '0-30000000') + freq_lo = 0 + freq_hi = 30000 + if bands_str and '-' in str(bands_str): + try: + parts = str(bands_str).split('-') + freq_lo = int(parts[0]) / 1000 # Hz to kHz + freq_hi = int(parts[1]) / 1000 # Hz to kHz + except (ValueError, IndexError): + pass + + # Parse GPS: "(51.317266, -2.950479)" format + lat, lon = None, None + if gps: + parts = str(gps).replace('(', '').replace(')', '').split(',') + if len(parts) >= 2: + lat = _parse_gps_coord(parts[0]) + lon = _parse_gps_coord(parts[1]) + + if not url: + continue + + # Ensure URL has protocol + if not url.startswith('http'): + url = 'http://' + url + + receivers.append({ + 'name': name, + 'url': url.rstrip('/'), + 'lat': lat, + 'lon': lon, + 'location': location, + 'users': users, + 'users_max': users_max, + 'antenna': antenna, + 'bands': bands_str, + 'freq_lo': freq_lo, + 'freq_hi': freq_hi, + 'available': users < users_max, + }) + + return receivers + + +def get_receivers(force_refresh: bool = False) -> list[dict]: + """Get cached receiver list, refreshing if stale.""" + global _receiver_cache, _cache_timestamp + + with _cache_lock: + now = time.time() + if force_refresh or not _receiver_cache or (now - _cache_timestamp) > CACHE_TTL: + logger.info("Refreshing KiwiSDR receiver list...") + _receiver_cache = _fetch_kiwi_receivers() + _cache_timestamp = now + logger.info(f"Loaded {len(_receiver_cache)} KiwiSDR receivers") + + return _receiver_cache + + +# ============================================ +# API ENDPOINTS +# ============================================ + +@websdr_bp.route('/receivers') +def list_receivers() -> Response: + """List KiwiSDR receivers, with optional filters.""" + freq_khz = request.args.get('freq_khz', type=float) + available = request.args.get('available', type=str) + refresh = request.args.get('refresh', type=str) + + receivers = get_receivers(force_refresh=(refresh == 'true')) + + filtered = receivers + if available == 'true': + filtered = [r for r in filtered if r.get('available', True)] + + if freq_khz is not None: + filtered = [ + r for r in filtered + if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000) + ] + + return jsonify({ + 'status': 'success', + 'receivers': filtered[:100], + 'total': len(filtered), + 'cached_total': len(receivers), + }) + + +@websdr_bp.route('/receivers/nearest') +def nearest_receivers() -> Response: + """Find receivers nearest to a given location.""" + lat = request.args.get('lat', type=float) + lon = request.args.get('lon', type=float) + freq_khz = request.args.get('freq_khz', type=float) + + if lat is None or lon is None: + return jsonify({'status': 'error', 'message': 'lat and lon are required'}), 400 + + receivers = get_receivers() + + # Filter by frequency if specified + if freq_khz is not None: + receivers = [ + r for r in receivers + if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000) + ] + + # Calculate distances and sort + with_distance = [] + for r in receivers: + if r.get('lat') is not None and r.get('lon') is not None: + dist = _haversine(lat, lon, r['lat'], r['lon']) + entry = dict(r) + entry['distance_km'] = round(dist, 1) + with_distance.append(entry) + + with_distance.sort(key=lambda x: x['distance_km']) + + return jsonify({ + 'status': 'success', + 'receivers': with_distance[:10], + }) + + +@websdr_bp.route('/spy-station//receivers') +def spy_station_receivers(station_id: str) -> Response: + """Find receivers that can tune to a spy station's frequency.""" + try: + from routes.spy_stations import STATIONS + except ImportError: + return jsonify({'status': 'error', 'message': 'Spy stations module not available'}), 503 + + # Find the station + station = None + for s in STATIONS: + if s.get('id') == station_id: + station = s + break + + if not station: + return jsonify({'status': 'error', 'message': 'Station not found'}), 404 + + # Get primary frequency + freq_khz = None + for f in station.get('frequencies', []): + if f.get('primary'): + freq_khz = f.get('freq_khz') + break + if freq_khz is None and station.get('frequencies'): + freq_khz = station['frequencies'][0].get('freq_khz') + + if freq_khz is None: + return jsonify({'status': 'error', 'message': 'No frequency found for station'}), 404 + + receivers = get_receivers() + + # Filter receivers that cover this frequency and are available + matching = [ + r for r in receivers + if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000) and r.get('available', True) + ] + + return jsonify({ + 'status': 'success', + 'station': { + 'id': station['id'], + 'name': station.get('name', ''), + 'nickname': station.get('nickname', ''), + 'freq_khz': freq_khz, + 'mode': station.get('mode', 'USB'), + }, + 'receivers': matching[:20], + 'total': len(matching), + }) + + +@websdr_bp.route('/status') +def websdr_status() -> Response: + """Get WebSDR connection and cache status.""" + return jsonify({ + 'status': 'ok', + 'cached_receivers': len(_receiver_cache), + 'cache_age_seconds': round(time.time() - _cache_timestamp, 0) if _cache_timestamp > 0 else None, + 'cache_ttl': CACHE_TTL, + 'audio_connected': _kiwi_client is not None and _kiwi_client.connected if _kiwi_client else False, + }) + + +# ============================================ +# KIWISDR AUDIO PROXY +# ============================================ + +_kiwi_client: Optional[KiwiSDRClient] = None +_kiwi_lock = threading.Lock() +_kiwi_audio_queue: queue.Queue = queue.Queue(maxsize=200) + + +def _disconnect_kiwi() -> None: + """Disconnect active KiwiSDR client.""" + global _kiwi_client + with _kiwi_lock: + if _kiwi_client: + _kiwi_client.disconnect() + _kiwi_client = None + # Drain audio queue + while not _kiwi_audio_queue.empty(): + try: + _kiwi_audio_queue.get_nowait() + except queue.Empty: + break + + +def _handle_kiwi_command(ws, cmd: str, data: dict) -> None: + """Handle a command from the browser client.""" + global _kiwi_client + + if cmd == 'connect': + receiver_url = data.get('url', '') + host = data.get('host', '') + port = int(data.get('port', 8073)) + freq_khz = float(data.get('freq_khz', 7000)) + mode = data.get('mode', 'am').lower() + password = data.get('password', '') + + # Parse host/port from URL if provided + if receiver_url and not host: + host, port = parse_host_port(receiver_url) + + if mode not in VALID_MODES: + ws.send(json.dumps({'type': 'error', 'message': f'Invalid mode: {mode}'})) + return + + if not host or ';' in host or '&' in host or '|' in host: + ws.send(json.dumps({'type': 'error', 'message': 'Invalid host'})) + return + + _disconnect_kiwi() + + def on_audio(pcm_bytes, smeter): + # Package: 2 bytes smeter (big-endian int16) + PCM data + header = struct.pack('>h', smeter) + try: + _kiwi_audio_queue.put_nowait(header + pcm_bytes) + except queue.Full: + try: + _kiwi_audio_queue.get_nowait() + except queue.Empty: + pass + try: + _kiwi_audio_queue.put_nowait(header + pcm_bytes) + except queue.Full: + pass + + def on_error(msg): + try: + ws.send(json.dumps({'type': 'error', 'message': msg})) + except Exception: + pass + + def on_disconnect(): + try: + ws.send(json.dumps({'type': 'disconnected'})) + except Exception: + pass + + with _kiwi_lock: + _kiwi_client = KiwiSDRClient( + host=host, port=port, + on_audio=on_audio, + on_error=on_error, + on_disconnect=on_disconnect, + password=password, + ) + success = _kiwi_client.connect(freq_khz, mode) + + if success: + ws.send(json.dumps({ + 'type': 'connected', + 'host': host, + 'port': port, + 'freq_khz': freq_khz, + 'mode': mode, + 'sample_rate': KIWI_SAMPLE_RATE, + })) + else: + ws.send(json.dumps({'type': 'error', 'message': 'Connection to KiwiSDR failed'})) + _disconnect_kiwi() + + elif cmd == 'tune': + freq_khz = float(data.get('freq_khz', 0)) + mode = data.get('mode', '').lower() or None + + with _kiwi_lock: + if _kiwi_client and _kiwi_client.connected: + success = _kiwi_client.tune( + freq_khz, + mode or _kiwi_client.mode + ) + if success: + ws.send(json.dumps({ + 'type': 'tuned', + 'freq_khz': freq_khz, + 'mode': mode or _kiwi_client.mode, + })) + else: + ws.send(json.dumps({'type': 'error', 'message': 'Retune failed'})) + else: + ws.send(json.dumps({'type': 'error', 'message': 'Not connected'})) + + elif cmd == 'disconnect': + _disconnect_kiwi() + ws.send(json.dumps({'type': 'disconnected'})) + + +def init_websdr_audio(app: Flask) -> None: + """Initialize WebSocket audio proxy for KiwiSDR. Called from app.py.""" + if not WEBSOCKET_AVAILABLE: + logger.warning("flask-sock not installed, KiwiSDR audio proxy disabled") + return + + sock = Sock(app) + + @sock.route('/ws/kiwi-audio') + def kiwi_audio_stream(ws): + """WebSocket endpoint: proxy audio between browser and KiwiSDR.""" + logger.info("KiwiSDR audio client connected") + + try: + while True: + # Check for commands from browser + try: + msg = ws.receive(timeout=0.005) + if msg: + data = json.loads(msg) + cmd = data.get('cmd', '') + _handle_kiwi_command(ws, cmd, data) + except TimeoutError: + pass + except Exception as e: + if 'closed' in str(e).lower(): + break + if 'timed out' not in str(e).lower(): + logger.error(f"KiwiSDR WS receive error: {e}") + + # Forward audio from KiwiSDR to browser + try: + audio_data = _kiwi_audio_queue.get_nowait() + ws.send(audio_data) + except queue.Empty: + time.sleep(0.005) + + except Exception as e: + logger.info(f"KiwiSDR WS closed: {e}") + finally: + _disconnect_kiwi() + logger.info("KiwiSDR audio client disconnected") diff --git a/setup.sh b/setup.sh index e09e9c7..cadde64 100755 --- a/setup.sh +++ b/setup.sh @@ -210,6 +210,10 @@ check_tools() { info "GPS:" check_required "gpsd" "GPS daemon" gpsd + echo + info "Digital Voice:" + check_optional "dsd" "Digital Speech Decoder (DMR/P25)" dsd dsd-fme + echo info "Audio:" check_required "ffmpeg" "Audio encoder/decoder" ffmpeg @@ -390,7 +394,6 @@ install_slowrx_from_source_macos() { info "slowrx not available via Homebrew. Building from source..." # Ensure build dependencies are installed - brew_install cmake brew_install fftw brew_install libsndfile brew_install gtk+3 @@ -406,13 +409,8 @@ install_slowrx_from_source_macos() { cd "$tmp_dir/slowrx" info "Compiling slowrx..." - mkdir -p build && cd build - local cmake_log make_log - cmake_log=$(cmake .. 2>&1) || { - warn "cmake failed for slowrx:" - echo "$cmake_log" | tail -20 - exit 1 - } + # slowrx uses a plain Makefile, not CMake + local make_log make_log=$(make 2>&1) || { warn "make failed for slowrx:" echo "$make_log" | tail -20 @@ -460,8 +458,192 @@ install_multimon_ng_from_source_macos() { ) } +install_dsd_from_source() { + info "Building DSD (Digital Speech Decoder) from source..." + info "This requires mbelib (vocoder library) as a prerequisite." + + if [[ "$OS" == "macos" ]]; then + brew_install cmake + brew_install libsndfile + brew_install ncurses + brew_install fftw + brew_install codec2 + brew_install librtlsdr + brew_install pulseaudio || true + else + apt_install build-essential git cmake libsndfile1-dev libpulse-dev \ + libfftw3-dev liblapack-dev libncurses-dev librtlsdr-dev libcodec2-dev + fi + + ( + tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' EXIT + + # Step 1: Build and install mbelib (required dependency) + info "Building mbelib (vocoder library)..." + git clone https://github.com/lwvmobile/mbelib.git "$tmp_dir/mbelib" >/dev/null 2>&1 \ + || { warn "Failed to clone mbelib"; exit 1; } + + cd "$tmp_dir/mbelib" + git checkout ambe_tones >/dev/null 2>&1 || true + mkdir -p build && cd build + + if cmake .. >/dev/null 2>&1 && make -j "$(nproc 2>/dev/null || sysctl -n hw.ncpu)" >/dev/null 2>&1; then + if [[ "$OS" == "macos" ]]; then + if [[ -w /usr/local/lib ]]; then + make install >/dev/null 2>&1 + else + sudo make install >/dev/null 2>&1 + fi + else + $SUDO make install >/dev/null 2>&1 + $SUDO ldconfig 2>/dev/null || true + fi + ok "mbelib installed" + else + warn "Failed to build mbelib. Cannot build DSD without it." + exit 1 + fi + + # Step 2: Build dsd-fme (or fall back to original dsd) + info "Building dsd-fme..." + git clone --depth 1 https://github.com/lwvmobile/dsd-fme.git "$tmp_dir/dsd-fme" >/dev/null 2>&1 \ + || { warn "Failed to clone dsd-fme, trying original DSD..."; + git clone --depth 1 https://github.com/szechyjs/dsd.git "$tmp_dir/dsd-fme" >/dev/null 2>&1 \ + || { warn "Failed to clone DSD"; exit 1; }; } + + cd "$tmp_dir/dsd-fme" + mkdir -p build && cd build + + # On macOS, help cmake find Homebrew ncurses + local cmake_flags="" + if [[ "$OS" == "macos" ]]; then + local ncurses_prefix + ncurses_prefix="$(brew --prefix ncurses 2>/dev/null || echo /opt/homebrew/opt/ncurses)" + cmake_flags="-DCMAKE_PREFIX_PATH=$ncurses_prefix" + fi + + info "Compiling DSD..." + if cmake .. $cmake_flags >/dev/null 2>&1 && make -j "$(nproc 2>/dev/null || sysctl -n hw.ncpu)" >/dev/null 2>&1; then + if [[ "$OS" == "macos" ]]; then + if [[ -w /usr/local/bin ]]; then + install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true + else + sudo install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || sudo install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true + fi + else + $SUDO make install >/dev/null 2>&1 \ + || $SUDO install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null \ + || $SUDO install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null \ + || true + $SUDO ldconfig 2>/dev/null || true + fi + ok "DSD installed successfully" + else + warn "Failed to build DSD from source. DMR/P25 decoding will not be available." + fi + ) +} + +install_dump1090_from_source_macos() { + info "dump1090 not available via Homebrew. Building from source..." + + brew_install cmake + brew_install librtlsdr + brew_install pkg-config + + ( + tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' EXIT + + info "Cloning FlightAware dump1090..." + git clone --depth 1 https://github.com/flightaware/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \ + || { warn "Failed to clone dump1090"; exit 1; } + + cd "$tmp_dir/dump1090" + sed -i '' 's/-Werror//g' Makefile 2>/dev/null || true + info "Compiling dump1090..." + if make BLADERF=no RTLSDR=yes 2>&1 | tail -5; then + if [[ -w /usr/local/bin ]]; then + install -m 0755 dump1090 /usr/local/bin/dump1090 + else + sudo install -m 0755 dump1090 /usr/local/bin/dump1090 + fi + ok "dump1090 installed successfully from source" + else + warn "Failed to build dump1090. ADS-B decoding will not be available." + fi + ) +} + +install_acarsdec_from_source_macos() { + info "acarsdec not available via Homebrew. Building from source..." + + brew_install cmake + brew_install librtlsdr + brew_install libsndfile + brew_install pkg-config + + ( + tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' EXIT + + info "Cloning acarsdec..." + git clone --depth 1 https://github.com/TLeconte/acarsdec.git "$tmp_dir/acarsdec" >/dev/null 2>&1 \ + || { warn "Failed to clone acarsdec"; exit 1; } + + cd "$tmp_dir/acarsdec" + mkdir -p build && cd build + + info "Compiling acarsdec..." + if cmake .. -Drtl=ON >/dev/null 2>&1 && make >/dev/null 2>&1; then + if [[ -w /usr/local/bin ]]; then + install -m 0755 acarsdec /usr/local/bin/acarsdec + else + sudo install -m 0755 acarsdec /usr/local/bin/acarsdec + fi + ok "acarsdec installed successfully from source" + else + warn "Failed to build acarsdec. ACARS decoding will not be available." + fi + ) +} + +install_aiscatcher_from_source_macos() { + info "AIS-catcher not available via Homebrew. Building from source..." + + brew_install cmake + brew_install librtlsdr + brew_install curl + brew_install pkg-config + + ( + tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' EXIT + + info "Cloning AIS-catcher..." + git clone --depth 1 https://github.com/jvde-github/AIS-catcher.git "$tmp_dir/AIS-catcher" >/dev/null 2>&1 \ + || { warn "Failed to clone AIS-catcher"; exit 1; } + + cd "$tmp_dir/AIS-catcher" + mkdir -p build && cd build + + info "Compiling AIS-catcher..." + if cmake .. >/dev/null 2>&1 && make >/dev/null 2>&1; then + if [[ -w /usr/local/bin ]]; then + install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher + else + sudo install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher + fi + ok "AIS-catcher installed successfully from source" + else + warn "Failed to build AIS-catcher. AIS vessel tracking will not be available." + fi + ) +} + install_macos_packages() { - TOTAL_STEPS=16 + TOTAL_STEPS=17 CURRENT_STEP=0 progress "Checking Homebrew" @@ -481,11 +663,20 @@ install_macos_packages() { progress "Installing direwolf (APRS decoder)" (brew_install direwolf) || warn "direwolf not available via Homebrew" - progress "Installing slowrx (SSTV decoder)" - if ! cmd_exists slowrx; then - install_slowrx_from_source_macos || warn "slowrx build failed - ISS SSTV decoding will not be available" + progress "Skipping slowrx (SSTV decoder)" + warn "slowrx requires ALSA (Linux-only) and cannot build on macOS. Skipping." + + progress "Installing DSD (Digital Speech Decoder, optional)" + if ! cmd_exists dsd && ! cmd_exists dsd-fme; then + echo + info "DSD is used for DMR, P25, NXDN, and D-STAR digital voice decoding." + if ask_yes_no "Do you want to install DSD?"; then + install_dsd_from_source || warn "DSD build failed. DMR/P25 decoding will not be available." + else + warn "Skipping DSD installation. DMR/P25 decoding will not be available." + fi else - ok "slowrx already installed" + ok "DSD already installed" fi progress "Installing ffmpeg" @@ -509,14 +700,22 @@ install_macos_packages() { fi progress "Installing dump1090" - (brew_install dump1090-mutability) || warn "dump1090 not available via Homebrew" + if ! cmd_exists dump1090; then + (brew_install dump1090-mutability) || install_dump1090_from_source_macos || warn "dump1090 not available" + else + ok "dump1090 already installed" + fi progress "Installing acarsdec" - (brew_install acarsdec) || warn "acarsdec not available via Homebrew" + if ! cmd_exists acarsdec; then + (brew_install acarsdec) || install_acarsdec_from_source_macos || warn "acarsdec not available" + else + ok "acarsdec already installed" + fi progress "Installing AIS-catcher" if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then - (brew_install aiscatcher) || warn "AIS-catcher not available via Homebrew" + (brew_install aiscatcher) || install_aiscatcher_from_source_macos || warn "AIS-catcher not available" else ok "AIS-catcher already installed" fi @@ -849,7 +1048,7 @@ install_debian_packages() { export NEEDRESTART_MODE=a fi - TOTAL_STEPS=21 + TOTAL_STEPS=22 CURRENT_STEP=0 progress "Updating APT package lists" @@ -906,7 +1105,20 @@ install_debian_packages() { apt_install direwolf || true progress "Installing slowrx (SSTV decoder)" - apt_install slowrx || cmd_exists slowrx || install_slowrx_from_source_debian + apt_install slowrx || cmd_exists slowrx || install_slowrx_from_source_debian || warn "slowrx not available. ISS SSTV decoding will not be available." + + progress "Installing DSD (Digital Speech Decoder, optional)" + if ! cmd_exists dsd && ! cmd_exists dsd-fme; then + echo + info "DSD is used for DMR, P25, NXDN, and D-STAR digital voice decoding." + if ask_yes_no "Do you want to install DSD?"; then + install_dsd_from_source || warn "DSD build failed. DMR/P25 decoding will not be available." + else + warn "Skipping DSD installation. DMR/P25 decoding will not be available." + fi + else + ok "DSD already installed" + fi progress "Installing ffmpeg" apt_install ffmpeg diff --git a/static/js/modes/dmr.js b/static/js/modes/dmr.js new file mode 100644 index 0000000..69888ae --- /dev/null +++ b/static/js/modes/dmr.js @@ -0,0 +1,200 @@ +/** + * Intercept - DMR / Digital Voice Mode + * Decoding DMR, P25, NXDN, D-STAR digital voice protocols + */ + +// ============== STATE ============== +let isDmrRunning = false; +let dmrEventSource = null; +let dmrCallCount = 0; +let dmrSyncCount = 0; +let dmrCallHistory = []; +let dmrCurrentProtocol = '--'; + +// ============== TOOLS CHECK ============== + +function checkDmrTools() { + fetch('/dmr/tools') + .then(r => r.json()) + .then(data => { + const warning = document.getElementById('dmrToolsWarning'); + const warningText = document.getElementById('dmrToolsWarningText'); + if (!warning) return; + + const missing = []; + if (!data.dsd) missing.push('dsd (Digital Speech Decoder)'); + if (!data.rtl_fm) missing.push('rtl_fm (RTL-SDR)'); + + if (missing.length > 0) { + warning.style.display = 'block'; + if (warningText) warningText.textContent = missing.join(', '); + } else { + warning.style.display = 'none'; + } + }) + .catch(() => {}); +} + +// ============== START / STOP ============== + +function startDmr() { + const frequency = parseFloat(document.getElementById('dmrFrequency')?.value || 462.5625); + const protocol = document.getElementById('dmrProtocol')?.value || 'auto'; + const gain = parseInt(document.getElementById('dmrGain')?.value || 40); + const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0; + + fetch('/dmr/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ frequency, protocol, gain, device }) + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'started') { + isDmrRunning = true; + dmrCallCount = 0; + dmrSyncCount = 0; + dmrCallHistory = []; + updateDmrUI(); + connectDmrSSE(); + const statusEl = document.getElementById('dmrStatus'); + if (statusEl) statusEl.textContent = 'DECODING'; + if (typeof showNotification === 'function') { + showNotification('DMR', `Decoding ${frequency} MHz (${protocol.toUpperCase()})`); + } + } else { + if (typeof showNotification === 'function') { + showNotification('Error', data.message || 'Failed to start DMR'); + } + } + }) + .catch(err => console.error('[DMR] Start error:', err)); +} + +function stopDmr() { + fetch('/dmr/stop', { method: 'POST' }) + .then(r => r.json()) + .then(() => { + isDmrRunning = false; + if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; } + updateDmrUI(); + const statusEl = document.getElementById('dmrStatus'); + if (statusEl) statusEl.textContent = 'IDLE'; + }) + .catch(err => console.error('[DMR] Stop error:', err)); +} + +// ============== SSE STREAMING ============== + +function connectDmrSSE() { + if (dmrEventSource) dmrEventSource.close(); + dmrEventSource = new EventSource('/dmr/stream'); + + dmrEventSource.onmessage = function(event) { + const msg = JSON.parse(event.data); + handleDmrMessage(msg); + }; + + dmrEventSource.onerror = function() { + if (isDmrRunning) { + setTimeout(connectDmrSSE, 2000); + } + }; +} + +function handleDmrMessage(msg) { + if (msg.type === 'sync') { + dmrCurrentProtocol = msg.protocol || '--'; + const protocolEl = document.getElementById('dmrActiveProtocol'); + if (protocolEl) protocolEl.textContent = dmrCurrentProtocol; + const mainProtocolEl = document.getElementById('dmrMainProtocol'); + if (mainProtocolEl) mainProtocolEl.textContent = dmrCurrentProtocol; + dmrSyncCount++; + const syncCountEl = document.getElementById('dmrSyncCount'); + if (syncCountEl) syncCountEl.textContent = dmrSyncCount; + } else if (msg.type === 'call') { + dmrCallCount++; + const countEl = document.getElementById('dmrCallCount'); + if (countEl) countEl.textContent = dmrCallCount; + const mainCountEl = document.getElementById('dmrMainCallCount'); + if (mainCountEl) mainCountEl.textContent = dmrCallCount; + + // Update current call display + const callEl = document.getElementById('dmrCurrentCall'); + if (callEl) { + callEl.innerHTML = ` +
+ Talkgroup + ${msg.talkgroup} +
+
+ Source ID + ${msg.source_id} +
+
+ Time + ${msg.timestamp} +
+ `; + } + + // Add to history + dmrCallHistory.unshift({ + talkgroup: msg.talkgroup, + source_id: msg.source_id, + protocol: dmrCurrentProtocol, + time: msg.timestamp, + }); + if (dmrCallHistory.length > 50) dmrCallHistory.length = 50; + renderDmrHistory(); + + } else if (msg.type === 'slot') { + // Update slot info in current call + } else if (msg.type === 'status') { + const statusEl = document.getElementById('dmrStatus'); + if (statusEl) { + statusEl.textContent = msg.text === 'started' ? 'DECODING' : 'IDLE'; + } + if (msg.text === 'stopped') { + isDmrRunning = false; + updateDmrUI(); + } + } +} + +// ============== UI ============== + +function updateDmrUI() { + const startBtn = document.getElementById('startDmrBtn'); + const stopBtn = document.getElementById('stopDmrBtn'); + if (startBtn) startBtn.style.display = isDmrRunning ? 'none' : 'block'; + if (stopBtn) stopBtn.style.display = isDmrRunning ? 'block' : 'none'; +} + +function renderDmrHistory() { + const container = document.getElementById('dmrHistoryBody'); + if (!container) return; + + const historyCountEl = document.getElementById('dmrHistoryCount'); + if (historyCountEl) historyCountEl.textContent = `${dmrCallHistory.length} calls`; + + if (dmrCallHistory.length === 0) { + container.innerHTML = 'No calls recorded'; + return; + } + + container.innerHTML = dmrCallHistory.slice(0, 20).map(call => ` + + ${call.time} + ${call.talkgroup} + ${call.source_id} + ${call.protocol} + + `).join(''); +} + +// ============== EXPORTS ============== + +window.startDmr = startDmr; +window.stopDmr = stopDmr; +window.checkDmrTools = checkDmrTools; diff --git a/static/js/modes/listening-post.js b/static/js/modes/listening-post.js index e703cc6..35485cc 100644 --- a/static/js/modes/listening-post.js +++ b/static/js/modes/listening-post.js @@ -830,6 +830,11 @@ function handleSignalFound(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) { @@ -2937,6 +2942,281 @@ 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 = ''; + } + } +} + +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; + +function initWaterfallCanvas() { + waterfallCanvas = document.getElementById('waterfallCanvas'); + spectrumCanvas = document.getElementById('spectrumCanvas'); + if (waterfallCanvas) waterfallCtx = waterfallCanvas.getContext('2d'); + if (spectrumCanvas) spectrumCtx = spectrumCanvas.getContext('2d'); +} + +function dBmToColor(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 `rgb(${r},${g},${b})`; +} + +function drawWaterfallRow(bins) { + if (!waterfallCtx || !waterfallCanvas) return; + const w = waterfallCanvas.width; + const h = waterfallCanvas.height; + + // Scroll existing content down by 1 pixel + const imageData = waterfallCtx.getImageData(0, 0, w, h - 1); + waterfallCtx.putImageData(imageData, 0, 1); + + // 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 + const binWidth = w / bins.length; + for (let i = 0; i < bins.length; i++) { + const normalized = (bins[i] - minVal) / range; + waterfallCtx.fillStyle = dBmToColor(normalized); + waterfallCtx.fillRect(Math.floor(i * binWidth), 0, Math.ceil(binWidth) + 1, 1); + } +} + +function drawSpectrumLine(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 + spectrumCtx.fillStyle = 'rgba(0, 200, 255, 0.5)'; + spectrumCtx.font = '9px 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; + + // 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 startWaterfall() { + const startFreq = parseFloat(document.getElementById('waterfallStartFreq')?.value || 88); + const endFreq = parseFloat(document.getElementById('waterfallEndFreq')?.value || 108); + const binSize = parseInt(document.getElementById('waterfallBinSize')?.value || 10000); + const gain = parseInt(document.getElementById('waterfallGain')?.value || 40); + const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0; + + if (startFreq >= endFreq) { + if (typeof showNotification === 'function') showNotification('Error', 'End frequency must be greater than start'); + return; + } + + waterfallStartFreq = startFreq; + waterfallEndFreq = endFreq; + + 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 }) + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'started') { + isWaterfallRunning = true; + document.getElementById('startWaterfallBtn').style.display = 'none'; + document.getElementById('stopWaterfallBtn').style.display = 'block'; + const waterfallPanel = document.getElementById('waterfallPanel'); + if (waterfallPanel) waterfallPanel.style.display = 'block'; + initWaterfallCanvas(); + connectWaterfallSSE(); + } else { + if (typeof showNotification === 'function') showNotification('Error', data.message || 'Failed to start waterfall'); + } + }) + .catch(err => console.error('[WATERFALL] Start error:', err)); +} + +function stopWaterfall() { + fetch('/listening/waterfall/stop', { method: 'POST' }) + .then(r => r.json()) + .then(() => { + isWaterfallRunning = false; + if (waterfallEventSource) { waterfallEventSource.close(); waterfallEventSource = null; } + document.getElementById('startWaterfallBtn').style.display = 'block'; + document.getElementById('stopWaterfallBtn').style.display = 'none'; + }) + .catch(err => console.error('[WATERFALL] Stop error:', err)); +} + +function connectWaterfallSSE() { + if (waterfallEventSource) waterfallEventSource.close(); + waterfallEventSource = new EventSource('/listening/waterfall/stream'); + + waterfallEventSource.onmessage = function(event) { + const msg = JSON.parse(event.data); + if (msg.type === 'waterfall_sweep') { + drawWaterfallRow(msg.bins); + drawSpectrumLine(msg.bins, msg.start_freq, msg.end_freq); + } + }; + + waterfallEventSource.onerror = function() { + if (isWaterfallRunning) { + setTimeout(connectWaterfallSSE, 2000); + } + }; +} + + window.stopDirectListen = stopDirectListen; window.toggleScanner = toggleScanner; window.startScanner = startScanner; @@ -2953,3 +3233,7 @@ window.removeBookmark = removeBookmark; window.tuneToFrequency = tuneToFrequency; window.clearScannerLog = clearScannerLog; window.exportScannerLog = exportScannerLog; +window.manualSignalGuess = manualSignalGuess; +window.guessSignal = guessSignal; +window.startWaterfall = startWaterfall; +window.stopWaterfall = stopWaterfall; diff --git a/static/js/modes/websdr.js b/static/js/modes/websdr.js new file mode 100644 index 0000000..7b67c05 --- /dev/null +++ b/static/js/modes/websdr.js @@ -0,0 +1,573 @@ +/** + * Intercept - WebSDR Mode + * HF/Shortwave KiwiSDR Network Integration with In-App Audio + */ + +// ============== STATE ============== +let websdrMap = null; +let websdrMarkers = []; +let websdrReceivers = []; +let websdrInitialized = false; +let websdrSpyStationsLoaded = false; + +// KiwiSDR audio state +let kiwiWebSocket = null; +let kiwiAudioContext = null; +let kiwiScriptProcessor = null; +let kiwiGainNode = null; +let kiwiAudioBuffer = []; +let kiwiConnected = false; +let kiwiCurrentFreq = 0; +let kiwiCurrentMode = 'am'; +let kiwiSmeter = 0; +let kiwiSmeterInterval = null; +let kiwiReceiverName = ''; + +const KIWI_SAMPLE_RATE = 12000; + +// ============== INITIALIZATION ============== + +function initWebSDR() { + if (websdrInitialized) { + if (websdrMap) { + setTimeout(() => websdrMap.invalidateSize(), 100); + } + return; + } + + const mapEl = document.getElementById('websdrMap'); + if (!mapEl || typeof L === 'undefined') return; + + websdrMap = L.map('websdrMap', { + center: [30, 0], + zoom: 2, + zoomControl: true, + }); + + L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + attribution: '© OpenStreetMap contributors © CARTO', + subdomains: 'abcd', + maxZoom: 19, + }).addTo(websdrMap); + + websdrInitialized = true; + + if (!websdrSpyStationsLoaded) { + loadSpyStationPresets(); + } + + [100, 300, 600, 1000].forEach(delay => { + setTimeout(() => { + if (websdrMap) websdrMap.invalidateSize(); + }, delay); + }); +} + +// ============== RECEIVER SEARCH ============== + +function searchReceivers(refresh) { + const freqKhz = parseFloat(document.getElementById('websdrFrequency')?.value || 0); + + let url = '/websdr/receivers?available=true'; + if (freqKhz > 0) url += `&freq_khz=${freqKhz}`; + if (refresh) url += '&refresh=true'; + + fetch(url) + .then(r => r.json()) + .then(data => { + if (data.status === 'success') { + websdrReceivers = data.receivers || []; + renderReceiverList(websdrReceivers); + plotReceiversOnMap(websdrReceivers); + + const countEl = document.getElementById('websdrReceiverCount'); + if (countEl) countEl.textContent = `${websdrReceivers.length} found`; + const sidebarCount = document.getElementById('websdrSidebarCount'); + if (sidebarCount) sidebarCount.textContent = websdrReceivers.length; + } + }) + .catch(err => console.error('[WEBSDR] Search error:', err)); +} + +// ============== MAP ============== + +function plotReceiversOnMap(receivers) { + if (!websdrMap) return; + + websdrMarkers.forEach(m => websdrMap.removeLayer(m)); + websdrMarkers = []; + + receivers.forEach((rx, idx) => { + if (rx.lat == null || rx.lon == null) return; + + const marker = L.circleMarker([rx.lat, rx.lon], { + radius: 6, + fillColor: rx.available ? '#00d4ff' : '#666', + color: rx.available ? '#00d4ff' : '#666', + weight: 1, + opacity: 0.8, + fillOpacity: 0.6, + }); + + marker.bindPopup(` +
+ ${escapeHtmlWebsdr(rx.name)}
+ ${rx.location ? `${escapeHtmlWebsdr(rx.location)}
` : ''} + Antenna: ${escapeHtmlWebsdr(rx.antenna || 'Unknown')}
+ Users: ${rx.users}/${rx.users_max}
+ +
+ `); + + marker.addTo(websdrMap); + websdrMarkers.push(marker); + }); + + if (websdrMarkers.length > 0) { + const group = L.featureGroup(websdrMarkers); + websdrMap.fitBounds(group.getBounds(), { padding: [30, 30] }); + } +} + +// ============== RECEIVER LIST ============== + +function renderReceiverList(receivers) { + const container = document.getElementById('websdrReceiverList'); + if (!container) return; + + if (receivers.length === 0) { + container.innerHTML = '
No receivers found
'; + return; + } + + container.innerHTML = receivers.slice(0, 50).map((rx, idx) => ` +
+
+ ${escapeHtmlWebsdr(rx.name)} + ${rx.users}/${rx.users_max} +
+
+ ${rx.location ? escapeHtmlWebsdr(rx.location) + ' · ' : ''}${escapeHtmlWebsdr(rx.antenna || '')} + ${rx.distance_km !== undefined ? ` · ${rx.distance_km} km` : ''} +
+
+ `).join(''); +} + +// ============== SELECT RECEIVER ============== + +function selectReceiver(index) { + const rx = websdrReceivers[index]; + if (!rx) return; + + const freqKhz = parseFloat(document.getElementById('websdrFrequency')?.value || 7000); + const mode = document.getElementById('websdrMode_select')?.value || 'am'; + + kiwiReceiverName = rx.name; + + // Connect via backend proxy + connectToReceiver(rx.url, freqKhz, mode); + + // Highlight on map + if (websdrMap && rx.lat != null && rx.lon != null) { + websdrMap.setView([rx.lat, rx.lon], 6); + } +} + +// ============== KIWISDR AUDIO CONNECTION ============== + +function connectToReceiver(receiverUrl, freqKhz, mode) { + // Disconnect if already connected + if (kiwiWebSocket) { + disconnectFromReceiver(); + } + + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${proto}//${location.host}/ws/kiwi-audio`; + + kiwiWebSocket = new WebSocket(wsUrl); + kiwiWebSocket.binaryType = 'arraybuffer'; + + kiwiWebSocket.onopen = () => { + kiwiWebSocket.send(JSON.stringify({ + cmd: 'connect', + url: receiverUrl, + freq_khz: freqKhz, + mode: mode, + })); + updateKiwiUI('connecting'); + }; + + kiwiWebSocket.onmessage = (event) => { + if (typeof event.data === 'string') { + const msg = JSON.parse(event.data); + handleKiwiStatus(msg); + } else { + handleKiwiAudio(event.data); + } + }; + + kiwiWebSocket.onclose = () => { + kiwiConnected = false; + updateKiwiUI('disconnected'); + }; + + kiwiWebSocket.onerror = () => { + updateKiwiUI('disconnected'); + }; +} + +function handleKiwiStatus(msg) { + switch (msg.type) { + case 'connected': + kiwiConnected = true; + kiwiCurrentFreq = msg.freq_khz; + kiwiCurrentMode = msg.mode; + initKiwiAudioContext(msg.sample_rate || KIWI_SAMPLE_RATE); + updateKiwiUI('connected'); + break; + case 'tuned': + kiwiCurrentFreq = msg.freq_khz; + kiwiCurrentMode = msg.mode; + updateKiwiUI('connected'); + break; + case 'error': + console.error('[KIWI] Error:', msg.message); + if (typeof showNotification === 'function') { + showNotification('WebSDR', msg.message); + } + updateKiwiUI('error'); + break; + case 'disconnected': + kiwiConnected = false; + cleanupKiwiAudio(); + updateKiwiUI('disconnected'); + break; + } +} + +function handleKiwiAudio(arrayBuffer) { + if (arrayBuffer.byteLength < 4) return; + + // First 2 bytes: S-meter (big-endian int16) + const view = new DataView(arrayBuffer); + kiwiSmeter = view.getInt16(0, false); + + // Remaining bytes: PCM 16-bit signed LE + const pcmData = new Int16Array(arrayBuffer, 2); + + // Convert to float32 [-1, 1] for Web Audio API + const float32 = new Float32Array(pcmData.length); + for (let i = 0; i < pcmData.length; i++) { + float32[i] = pcmData[i] / 32768.0; + } + + // Add to playback buffer (limit buffer size to ~2s) + kiwiAudioBuffer.push(float32); + const maxChunks = Math.ceil((KIWI_SAMPLE_RATE * 2) / 512); + while (kiwiAudioBuffer.length > maxChunks) { + kiwiAudioBuffer.shift(); + } +} + +function initKiwiAudioContext(sampleRate) { + cleanupKiwiAudio(); + + kiwiAudioContext = new (window.AudioContext || window.webkitAudioContext)({ + sampleRate: sampleRate, + }); + + // Resume if suspended (autoplay policy) + if (kiwiAudioContext.state === 'suspended') { + kiwiAudioContext.resume(); + } + + // ScriptProcessorNode: pulls audio from buffer + kiwiScriptProcessor = kiwiAudioContext.createScriptProcessor(2048, 0, 1); + kiwiScriptProcessor.onaudioprocess = (e) => { + const output = e.outputBuffer.getChannelData(0); + let offset = 0; + + while (offset < output.length && kiwiAudioBuffer.length > 0) { + const chunk = kiwiAudioBuffer[0]; + const needed = output.length - offset; + const available = chunk.length; + + if (available <= needed) { + output.set(chunk, offset); + offset += available; + kiwiAudioBuffer.shift(); + } else { + output.set(chunk.subarray(0, needed), offset); + kiwiAudioBuffer[0] = chunk.subarray(needed); + offset += needed; + } + } + + // Fill remaining with silence + while (offset < output.length) { + output[offset++] = 0; + } + }; + + // Volume control + kiwiGainNode = kiwiAudioContext.createGain(); + const savedVol = localStorage.getItem('kiwiVolume'); + kiwiGainNode.gain.value = savedVol !== null ? parseFloat(savedVol) / 100 : 0.8; + const volValue = Math.round(kiwiGainNode.gain.value * 100); + ['kiwiVolume', 'kiwiBarVolume'].forEach(id => { + const el = document.getElementById(id); + if (el) el.value = volValue; + }); + + kiwiScriptProcessor.connect(kiwiGainNode); + kiwiGainNode.connect(kiwiAudioContext.destination); + + // S-meter display updates + if (kiwiSmeterInterval) clearInterval(kiwiSmeterInterval); + kiwiSmeterInterval = setInterval(updateSmeterDisplay, 200); +} + +function disconnectFromReceiver() { + if (kiwiWebSocket && kiwiWebSocket.readyState === WebSocket.OPEN) { + kiwiWebSocket.send(JSON.stringify({ cmd: 'disconnect' })); + } + cleanupKiwiAudio(); + if (kiwiWebSocket) { + kiwiWebSocket.close(); + kiwiWebSocket = null; + } + kiwiConnected = false; + kiwiReceiverName = ''; + updateKiwiUI('disconnected'); +} + +function cleanupKiwiAudio() { + if (kiwiSmeterInterval) { + clearInterval(kiwiSmeterInterval); + kiwiSmeterInterval = null; + } + if (kiwiScriptProcessor) { + kiwiScriptProcessor.disconnect(); + kiwiScriptProcessor = null; + } + if (kiwiGainNode) { + kiwiGainNode.disconnect(); + kiwiGainNode = null; + } + if (kiwiAudioContext) { + kiwiAudioContext.close().catch(() => {}); + kiwiAudioContext = null; + } + kiwiAudioBuffer = []; + kiwiSmeter = 0; +} + +function tuneKiwi(freqKhz, mode) { + if (!kiwiWebSocket || !kiwiConnected) return; + kiwiWebSocket.send(JSON.stringify({ + cmd: 'tune', + freq_khz: freqKhz, + mode: mode || kiwiCurrentMode, + })); +} + +function tuneFromBar() { + const freq = parseFloat(document.getElementById('kiwiBarFrequency')?.value || 0); + const mode = document.getElementById('kiwiBarMode')?.value || kiwiCurrentMode; + if (freq > 0) { + tuneKiwi(freq, mode); + // Also update sidebar frequency + const freqInput = document.getElementById('websdrFrequency'); + if (freqInput) freqInput.value = freq; + } +} + +function setKiwiVolume(value) { + if (kiwiGainNode) { + kiwiGainNode.gain.value = value / 100; + localStorage.setItem('kiwiVolume', value); + } + // Sync both volume sliders + ['kiwiVolume', 'kiwiBarVolume'].forEach(id => { + const el = document.getElementById(id); + if (el && el.value !== String(value)) el.value = value; + }); +} + +// ============== S-METER ============== + +function updateSmeterDisplay() { + // KiwiSDR S-meter: value in 0.1 dBm units (e.g., -730 = -73 dBm = S9) + const dbm = kiwiSmeter / 10; + let sUnit; + if (dbm >= -73) { + const over = Math.round((dbm + 73)); + sUnit = over > 0 ? `S9+${over}` : 'S9'; + } else { + sUnit = `S${Math.max(0, Math.round((dbm + 127) / 6))}`; + } + + const pct = Math.min(100, Math.max(0, (dbm + 127) / 1.27)); + + // Update both sidebar and bar S-meter displays + ['kiwiSmeterBar', 'kiwiBarSmeter'].forEach(id => { + const el = document.getElementById(id); + if (el) el.style.width = pct + '%'; + }); + ['kiwiSmeterValue', 'kiwiBarSmeterValue'].forEach(id => { + const el = document.getElementById(id); + if (el) el.textContent = sUnit; + }); +} + +// ============== UI UPDATES ============== + +function updateKiwiUI(state) { + const statusEl = document.getElementById('kiwiStatus'); + const controlsBar = document.getElementById('kiwiAudioControls'); + const disconnectBtn = document.getElementById('kiwiDisconnectBtn'); + const receiverNameEl = document.getElementById('kiwiReceiverName'); + const freqDisplay = document.getElementById('kiwiFreqDisplay'); + const barReceiverName = document.getElementById('kiwiBarReceiverName'); + const barFreq = document.getElementById('kiwiBarFrequency'); + const barMode = document.getElementById('kiwiBarMode'); + + if (state === 'connected') { + if (statusEl) { + statusEl.textContent = 'CONNECTED'; + statusEl.style.color = 'var(--accent-green)'; + } + if (controlsBar) controlsBar.style.display = 'block'; + if (disconnectBtn) disconnectBtn.style.display = 'block'; + if (receiverNameEl) { + receiverNameEl.textContent = kiwiReceiverName; + receiverNameEl.style.display = 'block'; + } + if (freqDisplay) freqDisplay.textContent = kiwiCurrentFreq + ' kHz'; + if (barReceiverName) barReceiverName.textContent = kiwiReceiverName; + if (barFreq) barFreq.value = kiwiCurrentFreq; + if (barMode) barMode.value = kiwiCurrentMode; + } else if (state === 'connecting') { + if (statusEl) { + statusEl.textContent = 'CONNECTING...'; + statusEl.style.color = 'var(--accent-orange)'; + } + } else if (state === 'error') { + if (statusEl) { + statusEl.textContent = 'ERROR'; + statusEl.style.color = 'var(--accent-red)'; + } + } else { + // disconnected + if (statusEl) { + statusEl.textContent = 'DISCONNECTED'; + statusEl.style.color = 'var(--text-muted)'; + } + if (controlsBar) controlsBar.style.display = 'none'; + if (disconnectBtn) disconnectBtn.style.display = 'none'; + if (receiverNameEl) receiverNameEl.style.display = 'none'; + if (freqDisplay) freqDisplay.textContent = '--- kHz'; + // Reset both S-meter displays (sidebar + bar) + ['kiwiSmeterBar', 'kiwiBarSmeter'].forEach(id => { + const el = document.getElementById(id); + if (el) el.style.width = '0%'; + }); + ['kiwiSmeterValue', 'kiwiBarSmeterValue'].forEach(id => { + const el = document.getElementById(id); + if (el) el.textContent = 'S0'; + }); + } +} + +// ============== SPY STATION PRESETS ============== + +function loadSpyStationPresets() { + fetch('/spy-stations/stations') + .then(r => r.json()) + .then(data => { + websdrSpyStationsLoaded = true; + const container = document.getElementById('websdrSpyPresets'); + if (!container) return; + + const stations = data.stations || data || []; + if (!Array.isArray(stations) || stations.length === 0) { + container.innerHTML = '
No stations available
'; + return; + } + + container.innerHTML = stations.slice(0, 30).map(s => { + const primaryFreq = s.frequencies?.find(f => f.primary) || s.frequencies?.[0]; + const freqKhz = primaryFreq?.freq_khz || 0; + return ` +
+
+ ${escapeHtmlWebsdr(s.name)} + ${escapeHtmlWebsdr(s.nickname || '')} +
+ ${freqKhz} kHz +
+ `; + }).join(''); + }) + .catch(err => { + console.error('[WEBSDR] Failed to load spy station presets:', err); + }); +} + +function tuneToSpyStation(stationId, freqKhz) { + const freqInput = document.getElementById('websdrFrequency'); + if (freqInput) freqInput.value = freqKhz; + + // If already connected, just retune + if (kiwiConnected) { + const mode = document.getElementById('websdrMode_select')?.value || kiwiCurrentMode; + tuneKiwi(freqKhz, mode); + return; + } + + // Otherwise, search for receivers at this frequency + fetch(`/websdr/spy-station/${encodeURIComponent(stationId)}/receivers`) + .then(r => r.json()) + .then(data => { + if (data.status === 'success') { + websdrReceivers = data.receivers || []; + renderReceiverList(websdrReceivers); + plotReceiversOnMap(websdrReceivers); + + const countEl = document.getElementById('websdrReceiverCount'); + if (countEl) countEl.textContent = `${websdrReceivers.length} for ${data.station?.name || stationId}`; + + if (typeof showNotification === 'function' && data.station) { + showNotification('WebSDR', `Found ${websdrReceivers.length} receivers for ${data.station.name} at ${freqKhz} kHz`); + } + } + }) + .catch(err => console.error('[WEBSDR] Spy station receivers error:', err)); +} + +// ============== UTILITIES ============== + +function escapeHtmlWebsdr(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +// ============== EXPORTS ============== + +window.initWebSDR = initWebSDR; +window.searchReceivers = searchReceivers; +window.selectReceiver = selectReceiver; +window.tuneToSpyStation = tuneToSpyStation; +window.loadSpyStationPresets = loadSpyStationPresets; +window.connectToReceiver = connectToReceiver; +window.disconnectFromReceiver = disconnectFromReceiver; +window.tuneKiwi = tuneKiwi; +window.tuneFromBar = tuneFromBar; +window.setKiwiVolume = setKiwiVolume; diff --git a/static/vendor/chartjs/chartjs-adapter-date-fns.bundle.min.js b/static/vendor/chartjs/chartjs-adapter-date-fns.bundle.min.js new file mode 100644 index 0000000..f9b611c --- /dev/null +++ b/static/vendor/chartjs/chartjs-adapter-date-fns.bundle.min.js @@ -0,0 +1,124 @@ +/*! + * chartjs-adapter-date-fns v3.0.0 - Lightweight date adapter for Chart.js + * Uses native Date parsing (no external dependencies) + */ +(function() { + 'use strict'; + const FORMATS = { + datetime: 'MMM d, yyyy, h:mm:ss a', + millisecond: 'h:mm:ss.SSS a', + second: 'h:mm:ss a', + minute: 'h:mm a', + hour: 'ha', + day: 'MMM d', + week: 'PP', + month: 'MMM yyyy', + quarter: "'Q'Q - yyyy", + year: 'yyyy' + }; + + function formatDate(date, fmt) { + const d = new Date(date); + if (isNaN(d.getTime())) return ''; + const h = d.getHours(); + const m = d.getMinutes(); + const s = d.getSeconds(); + const ms = d.getMilliseconds(); + const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + const ampm = h >= 12 ? 'PM' : 'AM'; + const h12 = h % 12 || 12; + + switch(fmt) { + case 'h:mm:ss.SSS a': + return `${h12}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}.${String(ms).padStart(3,'0')} ${ampm}`; + case 'h:mm:ss a': + return `${h12}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')} ${ampm}`; + case 'h:mm a': + return `${h12}:${String(m).padStart(2,'0')} ${ampm}`; + case 'ha': + return `${h12}${ampm}`; + case 'MMM d': + return `${months[d.getMonth()]} ${d.getDate()}`; + case 'MMM yyyy': + return `${months[d.getMonth()]} ${d.getFullYear()}`; + case 'yyyy': + return `${d.getFullYear()}`; + default: + return `${months[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}, ${h12}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')} ${ampm}`; + } + } + + const UNITS = ['millisecond','second','minute','hour','day','week','month','quarter','year']; + const UNIT_MS = { + millisecond: 1, + second: 1000, + minute: 60000, + hour: 3600000, + day: 86400000, + week: 604800000, + month: 2592000000, + quarter: 7776000000, + year: 31536000000 + }; + + if (typeof Chart !== 'undefined' && Chart._adapters && Chart._adapters._date) { + const adapter = Chart._adapters._date; + adapter.override({ + _id: 'date-fns-lite', + formats: function() { return FORMATS; }, + parse: function(value) { + if (value === null || value === undefined) return null; + if (typeof value === 'number') return value; + const d = new Date(value); + return isNaN(d.getTime()) ? null : d.getTime(); + }, + format: function(time, fmt) { + return formatDate(time, fmt); + }, + add: function(time, amount, unit) { + const d = new Date(time); + switch(unit) { + case 'millisecond': d.setTime(d.getTime() + amount); break; + case 'second': d.setSeconds(d.getSeconds() + amount); break; + case 'minute': d.setMinutes(d.getMinutes() + amount); break; + case 'hour': d.setHours(d.getHours() + amount); break; + case 'day': d.setDate(d.getDate() + amount); break; + case 'week': d.setDate(d.getDate() + amount * 7); break; + case 'month': d.setMonth(d.getMonth() + amount); break; + case 'quarter': d.setMonth(d.getMonth() + amount * 3); break; + case 'year': d.setFullYear(d.getFullYear() + amount); break; + } + return d.getTime(); + }, + diff: function(max, min, unit) { + return (max - min) / (UNIT_MS[unit] || 1); + }, + startOf: function(time, unit) { + const d = new Date(time); + switch(unit) { + case 'second': d.setMilliseconds(0); break; + case 'minute': d.setSeconds(0,0); break; + case 'hour': d.setMinutes(0,0,0); break; + case 'day': d.setHours(0,0,0,0); break; + case 'week': d.setHours(0,0,0,0); d.setDate(d.getDate() - d.getDay()); break; + case 'month': d.setHours(0,0,0,0); d.setDate(1); break; + case 'quarter': d.setHours(0,0,0,0); d.setMonth(d.getMonth() - d.getMonth() % 3, 1); break; + case 'year': d.setHours(0,0,0,0); d.setMonth(0,1); break; + } + return d.getTime(); + }, + endOf: function(time, unit) { + const d = new Date(time); + switch(unit) { + case 'second': d.setMilliseconds(999); break; + case 'minute': d.setSeconds(59,999); break; + case 'hour': d.setMinutes(59,59,999); break; + case 'day': d.setHours(23,59,59,999); break; + case 'month': d.setMonth(d.getMonth()+1,0); d.setHours(23,59,59,999); break; + case 'year': d.setMonth(11,31); d.setHours(23,59,59,999); break; + } + return d.getTime(); + } + }); + } +})(); diff --git a/templates/partials/modes/dmr.html b/templates/partials/modes/dmr.html new file mode 100644 index 0000000..9374a5c --- /dev/null +++ b/templates/partials/modes/dmr.html @@ -0,0 +1,71 @@ + +
+
+

Digital Voice

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

Current Call

+
+
No active call
+
+
+ + +
+

Status

+
+
+ Status + IDLE +
+
+ Protocol + -- +
+
+ Calls + 0 +
+
+
+
diff --git a/templates/partials/modes/listening-post.html b/templates/partials/modes/listening-post.html index 97e45eb..60f24a7 100644 --- a/templates/partials/modes/listening-post.html +++ b/templates/partials/modes/listening-post.html @@ -46,4 +46,50 @@ + +
+

Signal Identification

+
+ + +
+ +
+ + +
+

Waterfall

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ diff --git a/templates/partials/modes/websdr.html b/templates/partials/modes/websdr.html new file mode 100644 index 0000000..4577e2b --- /dev/null +++ b/templates/partials/modes/websdr.html @@ -0,0 +1,78 @@ + +
+
+

WebSDR

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

Audio Player

+
+
+ Status + DISCONNECTED +
+ +
+ Frequency + --- kHz +
+ +
+ S-Meter +
+
+
+
+ S0 +
+
+ +
+ VOL + +
+ +
+
+ + +
+

Spy Station Presets

+
+
Loading...
+
+
+ + +
+
+ Receivers + 0 +
+
+
diff --git a/tests/conftest.py b/tests/conftest.py index d91c1f0..b29adfd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,11 +5,13 @@ from app import app as flask_app from routes import register_blueprints -@pytest.fixture +@pytest.fixture(scope='session') def app(): """Create application for testing.""" - register_blueprints(flask_app) flask_app.config['TESTING'] = True + # Register blueprints only if not already registered + if 'pager' not in flask_app.blueprints: + register_blueprints(flask_app) return flask_app diff --git a/tests/test_dmr.py b/tests/test_dmr.py new file mode 100644 index 0000000..f389cd5 --- /dev/null +++ b/tests/test_dmr.py @@ -0,0 +1,145 @@ +"""Tests for the DMR / Digital Voice decoding module.""" + +from unittest.mock import patch, MagicMock +import pytest +from routes.dmr import parse_dsd_output + + +# ============================================ +# parse_dsd_output() tests +# ============================================ + +def test_parse_sync_dmr(): + """Should parse DMR sync line.""" + result = parse_dsd_output('Sync: +DMR (data)') + assert result is not None + assert result['type'] == 'sync' + assert 'DMR' in result['protocol'] + + +def test_parse_sync_p25(): + """Should parse P25 sync line.""" + result = parse_dsd_output('Sync: +P25 Phase 1') + assert result is not None + assert result['type'] == 'sync' + assert 'P25' in result['protocol'] + + +def test_parse_talkgroup_and_source(): + """Should parse talkgroup and source ID.""" + result = parse_dsd_output('TG: 12345 Src: 67890') + assert result is not None + assert result['type'] == 'call' + assert result['talkgroup'] == 12345 + assert result['source_id'] == 67890 + + +def test_parse_slot(): + """Should parse slot info.""" + result = parse_dsd_output('Slot 1') + assert result is not None + assert result['type'] == 'slot' + assert result['slot'] == 1 + + +def test_parse_voice(): + """Should parse voice frame info.""" + result = parse_dsd_output('Voice Frame 1') + assert result is not None + assert result['type'] == 'voice' + + +def test_parse_nac(): + """Should parse P25 NAC.""" + result = parse_dsd_output('NAC: 293') + assert result is not None + assert result['type'] == 'nac' + assert result['nac'] == '293' + + +def test_parse_empty_line(): + """Empty lines should return None.""" + assert parse_dsd_output('') is None + assert parse_dsd_output(' ') is None + + +def test_parse_unrecognized(): + """Unrecognized lines should return None.""" + assert parse_dsd_output('some random text') is None + + +# ============================================ +# Endpoint tests +# ============================================ + +@pytest.fixture +def auth_client(client): + """Client with logged-in session.""" + with client.session_transaction() as sess: + sess['logged_in'] = True + return client + + +def test_dmr_tools(auth_client): + """Tools endpoint should return availability info.""" + resp = auth_client.get('/dmr/tools') + assert resp.status_code == 200 + data = resp.get_json() + assert 'dsd' in data + assert 'rtl_fm' in data + assert 'protocols' in data + + +def test_dmr_status(auth_client): + """Status endpoint should work.""" + resp = auth_client.get('/dmr/status') + assert resp.status_code == 200 + data = resp.get_json() + assert 'running' in data + + +def test_dmr_start_no_dsd(auth_client): + """Start should fail gracefully when dsd is not installed.""" + with patch('routes.dmr.find_dsd', return_value=None): + resp = auth_client.post('/dmr/start', json={ + 'frequency': 462.5625, + 'protocol': 'auto', + }) + assert resp.status_code == 503 + data = resp.get_json() + assert 'dsd' in data['message'] + + +def test_dmr_start_no_rtl_fm(auth_client): + """Start should fail when rtl_fm is missing.""" + with patch('routes.dmr.find_dsd', return_value='/usr/bin/dsd'), \ + patch('routes.dmr.find_rtl_fm', return_value=None): + resp = auth_client.post('/dmr/start', json={ + 'frequency': 462.5625, + }) + assert resp.status_code == 503 + + +def test_dmr_start_invalid_protocol(auth_client): + """Start should reject invalid protocol.""" + with patch('routes.dmr.find_dsd', return_value='/usr/bin/dsd'), \ + patch('routes.dmr.find_rtl_fm', return_value='/usr/bin/rtl_fm'): + resp = auth_client.post('/dmr/start', json={ + 'frequency': 462.5625, + 'protocol': 'invalid', + }) + assert resp.status_code == 400 + + +def test_dmr_stop(auth_client): + """Stop should succeed.""" + resp = auth_client.post('/dmr/stop') + assert resp.status_code == 200 + data = resp.get_json() + assert data['status'] == 'stopped' + + +def test_dmr_stream_mimetype(auth_client): + """Stream should return event-stream content type.""" + resp = auth_client.get('/dmr/stream') + assert resp.content_type.startswith('text/event-stream') diff --git a/tests/test_kiwisdr.py b/tests/test_kiwisdr.py new file mode 100644 index 0000000..ea01173 --- /dev/null +++ b/tests/test_kiwisdr.py @@ -0,0 +1,321 @@ +"""Tests for the KiwiSDR WebSocket audio client.""" + +import struct +from unittest.mock import patch, MagicMock + +import pytest + +from utils.kiwisdr import ( + KiwiSDRClient, + KIWI_SAMPLE_RATE, + KIWI_SND_HEADER_SIZE, + KIWI_DEFAULT_PORT, + MODE_FILTERS, + VALID_MODES, + parse_host_port, +) + + +# ============================================ +# parse_host_port tests +# ============================================ + +def test_parse_host_port_basic(): + """Should parse host:port from a simple URL.""" + assert parse_host_port('http://kiwi.example.com:8073') == ('kiwi.example.com', 8073) + + +def test_parse_host_port_no_port(): + """Should default to 8073 when port is missing.""" + assert parse_host_port('http://kiwi.example.com') == ('kiwi.example.com', KIWI_DEFAULT_PORT) + + +def test_parse_host_port_https(): + """Should strip https:// prefix.""" + assert parse_host_port('https://secure.kiwi.com:9090') == ('secure.kiwi.com', 9090) + + +def test_parse_host_port_ws(): + """Should strip ws:// prefix.""" + assert parse_host_port('ws://kiwi.local:8074') == ('kiwi.local', 8074) + + +def test_parse_host_port_with_path(): + """Should strip trailing path from URL.""" + assert parse_host_port('http://kiwi.com:8073/some/path') == ('kiwi.com', 8073) + + +def test_parse_host_port_bare_host(): + """Should handle bare hostname without protocol.""" + assert parse_host_port('kiwi.local') == ('kiwi.local', KIWI_DEFAULT_PORT) + + +def test_parse_host_port_bare_host_with_port(): + """Should handle bare hostname with port.""" + assert parse_host_port('kiwi.local:8074') == ('kiwi.local', 8074) + + +def test_parse_host_port_empty(): + """Should handle empty/None input.""" + assert parse_host_port('') == ('', KIWI_DEFAULT_PORT) + + +def test_parse_host_port_invalid_port(): + """Should default port for non-numeric port.""" + assert parse_host_port('http://kiwi.com:abc') == ('kiwi.com', KIWI_DEFAULT_PORT) + + +# ============================================ +# SND frame parsing tests +# ============================================ + +def _make_snd_frame(smeter_raw: int, pcm_samples: list[int]) -> bytes: + """Build a mock KiwiSDR SND binary frame.""" + header = b'SND' # 3 bytes: magic + header += b'\x00' # 1 byte: flags + header += struct.pack('>I', 42) # 4 bytes: sequence number + header += struct.pack('>h', smeter_raw) # 2 bytes: S-meter + # PCM data: 16-bit signed LE + pcm = b''.join(struct.pack(' 0 + assert high > low + + +def test_mode_filter_lsb_negative(): + """LSB filter should be in negative passband.""" + low, high = MODE_FILTERS['lsb'] + assert low < 0 + assert high < 0 + + +# ============================================ +# Connection tests with mocked WebSocket +# ============================================ + +@patch('utils.kiwisdr.WEBSOCKET_CLIENT_AVAILABLE', True) +@patch('utils.kiwisdr.websocket') +def test_client_connect_success(mock_ws_module): + """Connect should establish a WebSocket connection.""" + mock_ws = MagicMock() + mock_ws_module.WebSocket.return_value = mock_ws + + client = KiwiSDRClient(host='kiwi.local', port=8073) + result = client.connect(7000, 'am') + + assert result is True + assert client.connected is True + assert client.frequency_khz == 7000 + assert client.mode == 'am' + + # Verify WebSocket was created and connected + mock_ws_module.WebSocket.assert_called_once() + mock_ws.connect.assert_called_once() + + # Verify protocol messages were sent + calls = [str(c) for c in mock_ws.send.call_args_list] + auth_sent = any('SET auth' in c for c in calls) + compression_sent = any('SET compression=0' in c for c in calls) + mod_sent = any('SET mod=am' in c and 'freq=7000' in c for c in calls) + assert auth_sent, "Auth message not sent" + assert compression_sent, "Compression message not sent" + assert mod_sent, "Tune message not sent" + + # Cleanup + client.disconnect() + + +@patch('utils.kiwisdr.WEBSOCKET_CLIENT_AVAILABLE', True) +@patch('utils.kiwisdr.websocket') +def test_client_connect_failure(mock_ws_module): + """Connect should handle connection failures.""" + mock_ws = MagicMock() + mock_ws.connect.side_effect = ConnectionRefusedError("Connection refused") + mock_ws_module.WebSocket.return_value = mock_ws + + client = KiwiSDRClient(host='unreachable.local', port=8073) + result = client.connect(7000, 'am') + + assert result is False + assert client.connected is False + + +@patch('utils.kiwisdr.WEBSOCKET_CLIENT_AVAILABLE', True) +@patch('utils.kiwisdr.websocket') +def test_client_tune_success(mock_ws_module): + """Tune should send the correct SET mod command.""" + mock_ws = MagicMock() + mock_ws_module.WebSocket.return_value = mock_ws + + client = KiwiSDRClient(host='kiwi.local', port=8073) + client.connect(7000, 'am') + + mock_ws.send.reset_mock() + result = client.tune(14000, 'usb') + + assert result is True + assert client.frequency_khz == 14000 + assert client.mode == 'usb' + + tune_calls = [str(c) for c in mock_ws.send.call_args_list] + assert any('SET mod=usb' in c and 'freq=14000' in c for c in tune_calls) + + client.disconnect() + + +@patch('utils.kiwisdr.WEBSOCKET_CLIENT_AVAILABLE', True) +@patch('utils.kiwisdr.websocket') +def test_client_invalid_mode_fallback(mock_ws_module): + """Connect with invalid mode should fall back to AM.""" + mock_ws = MagicMock() + mock_ws_module.WebSocket.return_value = mock_ws + + client = KiwiSDRClient(host='kiwi.local', port=8073) + client.connect(7000, 'invalid_mode') + + assert client.mode == 'am' + client.disconnect() + + +@patch('utils.kiwisdr.WEBSOCKET_CLIENT_AVAILABLE', True) +@patch('utils.kiwisdr.websocket') +def test_client_ws_url_format(mock_ws_module): + """WebSocket URL should follow KiwiSDR format.""" + mock_ws = MagicMock() + mock_ws_module.WebSocket.return_value = mock_ws + + client = KiwiSDRClient(host='test.kiwi.com', port=8074) + client.connect(7000, 'am') + + ws_url = mock_ws.connect.call_args[0][0] + assert ws_url.startswith('ws://test.kiwi.com:8074/') + assert ws_url.endswith('/SND') + + client.disconnect() diff --git a/tests/test_signal_guess_api.py b/tests/test_signal_guess_api.py new file mode 100644 index 0000000..affcf61 --- /dev/null +++ b/tests/test_signal_guess_api.py @@ -0,0 +1,100 @@ +"""Tests for the Signal Identification (guess) API endpoint.""" + +import pytest + + +@pytest.fixture +def auth_client(client): + """Client with logged-in session.""" + with client.session_transaction() as sess: + sess['logged_in'] = True + return client + + +def test_signal_guess_fm_broadcast(auth_client): + """FM broadcast frequency should return a known signal type.""" + resp = auth_client.post('/listening/signal/guess', json={ + 'frequency_mhz': 98.1, + 'modulation': 'wfm', + }) + assert resp.status_code == 200 + data = resp.get_json() + assert data['status'] == 'ok' + assert data['primary_label'] + assert data['confidence'] in ('HIGH', 'MEDIUM', 'LOW') + + +def test_signal_guess_airband(auth_client): + """Airband frequency should be identified.""" + resp = auth_client.post('/listening/signal/guess', json={ + 'frequency_mhz': 121.5, + 'modulation': 'am', + }) + assert resp.status_code == 200 + data = resp.get_json() + assert data['status'] == 'ok' + assert data['primary_label'] + + +def test_signal_guess_ism_band(auth_client): + """ISM band frequency (433.92 MHz) should be identified.""" + resp = auth_client.post('/listening/signal/guess', json={ + 'frequency_mhz': 433.92, + }) + assert resp.status_code == 200 + data = resp.get_json() + assert data['status'] == 'ok' + assert data['primary_label'] + assert data['confidence'] in ('HIGH', 'MEDIUM', 'LOW') + + +def test_signal_guess_missing_frequency(auth_client): + """Missing frequency should return 400.""" + resp = auth_client.post('/listening/signal/guess', json={}) + assert resp.status_code == 400 + data = resp.get_json() + assert data['status'] == 'error' + + +def test_signal_guess_invalid_frequency(auth_client): + """Invalid frequency value should return 400.""" + resp = auth_client.post('/listening/signal/guess', json={ + 'frequency_mhz': 'abc', + }) + assert resp.status_code == 400 + + +def test_signal_guess_negative_frequency(auth_client): + """Negative frequency should return 400.""" + resp = auth_client.post('/listening/signal/guess', json={ + 'frequency_mhz': -5.0, + }) + assert resp.status_code == 400 + + +def test_signal_guess_with_region(auth_client): + """Specifying region should work.""" + resp = auth_client.post('/listening/signal/guess', json={ + 'frequency_mhz': 462.5625, + 'region': 'US', + }) + assert resp.status_code == 200 + data = resp.get_json() + assert data['status'] == 'ok' + + +def test_signal_guess_response_structure(auth_client): + """Response should have all expected fields.""" + resp = auth_client.post('/listening/signal/guess', json={ + 'frequency_mhz': 146.52, + 'modulation': 'fm', + }) + assert resp.status_code == 200 + data = resp.get_json() + assert 'primary_label' in data + assert 'confidence' in data + assert 'alternatives' in data + assert 'explanation' in data + assert 'tags' in data + assert isinstance(data['alternatives'], list) + assert isinstance(data['tags'], list) diff --git a/tests/test_waterfall.py b/tests/test_waterfall.py new file mode 100644 index 0000000..c0ced1b --- /dev/null +++ b/tests/test_waterfall.py @@ -0,0 +1,80 @@ +"""Tests for the Waterfall / Spectrogram endpoints.""" + +from unittest.mock import patch, MagicMock +import pytest + + +@pytest.fixture +def auth_client(client): + """Client with logged-in session.""" + with client.session_transaction() as sess: + sess['logged_in'] = True + return client + + +def test_waterfall_start_no_rtl_power(auth_client): + """Start should fail gracefully when rtl_power is not available.""" + with patch('routes.listening_post.find_rtl_power', return_value=None): + resp = auth_client.post('/listening/waterfall/start', json={ + 'start_freq': 88.0, + 'end_freq': 108.0, + }) + assert resp.status_code == 503 + data = resp.get_json() + assert 'rtl_power' in data['message'] + + +def test_waterfall_start_invalid_range(auth_client): + """Start should reject end <= start.""" + with patch('routes.listening_post.find_rtl_power', return_value='/usr/bin/rtl_power'): + resp = auth_client.post('/listening/waterfall/start', json={ + 'start_freq': 108.0, + 'end_freq': 88.0, + }) + assert resp.status_code == 400 + + +def test_waterfall_start_success(auth_client): + """Start should succeed with mocked rtl_power and device.""" + with patch('routes.listening_post.find_rtl_power', return_value='/usr/bin/rtl_power'), \ + patch('routes.listening_post.app_module') as mock_app: + mock_app.claim_sdr_device.return_value = None # No error, claim succeeds + resp = auth_client.post('/listening/waterfall/start', json={ + 'start_freq': 88.0, + 'end_freq': 108.0, + 'gain': 40, + 'device': 0, + }) + assert resp.status_code == 200 + data = resp.get_json() + assert data['status'] == 'started' + + # Clean up: stop waterfall + import routes.listening_post as lp + lp.waterfall_running = False + + +def test_waterfall_stop(auth_client): + """Stop should succeed.""" + resp = auth_client.post('/listening/waterfall/stop') + assert resp.status_code == 200 + data = resp.get_json() + assert data['status'] == 'stopped' + + +def test_waterfall_stream_mimetype(auth_client): + """Stream should return event-stream content type.""" + resp = auth_client.get('/listening/waterfall/stream') + assert resp.content_type.startswith('text/event-stream') + + +def test_waterfall_start_device_busy(auth_client): + """Start should fail when device is in use.""" + with patch('routes.listening_post.find_rtl_power', return_value='/usr/bin/rtl_power'), \ + patch('routes.listening_post.app_module') as mock_app: + mock_app.claim_sdr_device.return_value = 'SDR device 0 is in use by scanner' + resp = auth_client.post('/listening/waterfall/start', json={ + 'start_freq': 88.0, + 'end_freq': 108.0, + }) + assert resp.status_code == 409 diff --git a/tests/test_websdr.py b/tests/test_websdr.py new file mode 100644 index 0000000..013b255 --- /dev/null +++ b/tests/test_websdr.py @@ -0,0 +1,170 @@ +"""Tests for the HF/Shortwave WebSDR integration.""" + +from unittest.mock import patch, MagicMock +import pytest +from routes.websdr import _parse_gps_coord, _haversine +from utils.kiwisdr import parse_host_port + + +# ============================================ +# Helper function tests +# ============================================ + +def test_parse_gps_coord_float(): + """Should parse a simple float string.""" + assert _parse_gps_coord('51.5074') == pytest.approx(51.5074) + + +def test_parse_gps_coord_negative(): + """Should parse a negative coordinate.""" + assert _parse_gps_coord('-33.87') == pytest.approx(-33.87) + + +def test_parse_gps_coord_parentheses(): + """Should handle parentheses in coordinate string.""" + assert _parse_gps_coord('(-33.87)') == pytest.approx(-33.87) + + +def test_parse_gps_coord_empty(): + """Should return None for empty string.""" + assert _parse_gps_coord('') is None + assert _parse_gps_coord(None) is None + + +def test_parse_gps_coord_invalid(): + """Should return None for invalid string.""" + assert _parse_gps_coord('abc') is None + + +def test_haversine_same_point(): + """Distance between same point should be 0.""" + assert _haversine(51.5, -0.1, 51.5, -0.1) == pytest.approx(0.0, abs=0.01) + + +def test_haversine_known_distance(): + """Test with known city pair (London to Paris ~343 km).""" + dist = _haversine(51.5074, -0.1278, 48.8566, 2.3522) + assert 340 < dist < 350 + + +# ============================================ +# Endpoint tests +# ============================================ + +@pytest.fixture +def auth_client(client): + """Client with logged-in session.""" + with client.session_transaction() as sess: + sess['logged_in'] = True + return client + + +def test_websdr_status(auth_client): + """Status endpoint should return cache info.""" + resp = auth_client.get('/websdr/status') + assert resp.status_code == 200 + data = resp.get_json() + assert data['status'] == 'ok' + assert 'cached_receivers' in data + + +def test_websdr_receivers_empty_cache(auth_client): + """Receivers endpoint should work even with empty cache.""" + with patch('routes.websdr.get_receivers', return_value=[]): + resp = auth_client.get('/websdr/receivers') + assert resp.status_code == 200 + data = resp.get_json() + assert data['status'] == 'success' + assert data['receivers'] == [] + + +def test_websdr_receivers_with_data(auth_client): + """Receivers endpoint should return filtered data.""" + mock_receivers = [ + {'name': 'Test RX', 'url': 'http://test.com', 'lat': 51.5, 'lon': -0.1, + 'users': 1, 'users_max': 4, 'available': True, 'freq_lo': 0, 'freq_hi': 30000, + 'antenna': 'Dipole', 'bands': 'HF'}, + {'name': 'Full RX', 'url': 'http://full.com', 'lat': 48.8, 'lon': 2.3, + 'users': 4, 'users_max': 4, 'available': False, 'freq_lo': 0, 'freq_hi': 30000, + 'antenna': 'Loop', 'bands': 'HF'}, + ] + with patch('routes.websdr.get_receivers', return_value=mock_receivers): + # Filter available only + resp = auth_client.get('/websdr/receivers?available=true') + assert resp.status_code == 200 + data = resp.get_json() + assert len(data['receivers']) == 1 + assert data['receivers'][0]['name'] == 'Test RX' + + +def test_websdr_nearest_missing_params(auth_client): + """Nearest endpoint should require lat/lon.""" + resp = auth_client.get('/websdr/receivers/nearest') + assert resp.status_code == 400 + + +def test_websdr_nearest_with_coords(auth_client): + """Nearest endpoint should sort by distance.""" + mock_receivers = [ + {'name': 'Far RX', 'url': 'http://far.com', 'lat': -33.87, 'lon': 151.21, + 'users': 0, 'users_max': 4, 'available': True, 'freq_lo': 0, 'freq_hi': 30000, + 'antenna': 'Dipole', 'bands': 'HF'}, + {'name': 'Near RX', 'url': 'http://near.com', 'lat': 51.0, 'lon': -0.5, + 'users': 0, 'users_max': 4, 'available': True, 'freq_lo': 0, 'freq_hi': 30000, + 'antenna': 'Loop', 'bands': 'HF'}, + ] + with patch('routes.websdr.get_receivers', return_value=mock_receivers): + resp = auth_client.get('/websdr/receivers/nearest?lat=51.5&lon=-0.1') + assert resp.status_code == 200 + data = resp.get_json() + assert data['status'] == 'success' + assert len(data['receivers']) == 2 + # Near should be first + assert data['receivers'][0]['name'] == 'Near RX' + + +def test_websdr_spy_station_receivers(auth_client): + """Spy station cross-reference should find matching receivers.""" + mock_receivers = [ + {'name': 'HF RX', 'url': 'http://hf.com', 'lat': 51.5, 'lon': -0.1, + 'users': 0, 'users_max': 4, 'available': True, 'freq_lo': 0, 'freq_hi': 30000, + 'antenna': 'Dipole', 'bands': 'HF'}, + ] + with patch('routes.websdr.get_receivers', return_value=mock_receivers): + # e06 is one of the spy stations + resp = auth_client.get('/websdr/spy-station/e06/receivers') + assert resp.status_code == 200 + data = resp.get_json() + assert data['status'] == 'success' + assert 'station' in data + + +def test_websdr_spy_station_not_found(auth_client): + """Non-existent station should return 404.""" + resp = auth_client.get('/websdr/spy-station/nonexistent/receivers') + assert resp.status_code == 404 + + +# ============================================ +# parse_host_port tests (integration) +# ============================================ + +def test_parse_host_port_http_url(): + """Should parse standard KiwiSDR URL.""" + host, port = parse_host_port('http://kiwi.example.com:8073') + assert host == 'kiwi.example.com' + assert port == 8073 + + +def test_parse_host_port_no_protocol(): + """Should handle bare hostname.""" + host, port = parse_host_port('my-kiwi.local:8074') + assert host == 'my-kiwi.local' + assert port == 8074 + + +def test_parse_host_port_with_trailing_slash(): + """Should handle URL with trailing path.""" + host, port = parse_host_port('http://kiwi.com:8073/') + assert host == 'kiwi.com' + assert port == 8073 diff --git a/utils/kiwisdr.py b/utils/kiwisdr.py new file mode 100644 index 0000000..7df5210 --- /dev/null +++ b/utils/kiwisdr.py @@ -0,0 +1,288 @@ +"""KiwiSDR WebSocket audio client. + +Connects to a KiwiSDR receiver via its WebSocket API and streams +decoded PCM audio back through a callback. +""" + +from __future__ import annotations + +import struct +import threading +import time +from typing import Optional, Callable + +try: + import websocket # websocket-client library + WEBSOCKET_CLIENT_AVAILABLE = True +except ImportError: + WEBSOCKET_CLIENT_AVAILABLE = False + +from utils.logging import get_logger + +logger = get_logger('intercept.kiwisdr') + +# Protocol constants +KIWI_KEEPALIVE_INTERVAL = 5.0 +KIWI_SAMPLE_RATE = 12000 # 12 kHz mono +KIWI_SND_HEADER_SIZE = 10 # "SND"(3) + flags(1) + seq(4) + smeter(2) +KIWI_DEFAULT_PORT = 8073 + +VALID_MODES = ('am', 'usb', 'lsb', 'cw') + +# Default bandpass filters per mode (Hz) +MODE_FILTERS = { + 'am': (-4500, 4500), + 'usb': (300, 3000), + 'lsb': (-3000, -300), + 'cw': (300, 800), +} + + +def parse_host_port(url: str) -> tuple[str, int]: + """Extract host and port from a KiwiSDR URL like 'http://host:port'. + + Returns (host, port) tuple. Defaults to port 8073 if not specified. + """ + if not url: + return ('', KIWI_DEFAULT_PORT) + + # Strip protocol + cleaned = url + for prefix in ('http://', 'https://', 'ws://', 'wss://'): + if cleaned.lower().startswith(prefix): + cleaned = cleaned[len(prefix):] + break + + # Strip path + cleaned = cleaned.split('/')[0] + + # Split host:port + if ':' in cleaned: + parts = cleaned.rsplit(':', 1) + host = parts[0] + try: + port = int(parts[1]) + except ValueError: + port = KIWI_DEFAULT_PORT + else: + host = cleaned + port = KIWI_DEFAULT_PORT + + return (host, port) + + +class KiwiSDRClient: + """Manages a WebSocket connection to a single KiwiSDR receiver.""" + + def __init__( + self, + host: str, + port: int = KIWI_DEFAULT_PORT, + on_audio: Optional[Callable[[bytes, int], None]] = None, + on_error: Optional[Callable[[str], None]] = None, + on_disconnect: Optional[Callable[[], None]] = None, + password: str = '', + ): + self.host = host + self.port = port + self.password = password + self._on_audio = on_audio + self._on_error = on_error + self._on_disconnect = on_disconnect + + self._ws = None + self._connected = False + self._stopping = False + self._receive_thread: Optional[threading.Thread] = None + self._keepalive_thread: Optional[threading.Thread] = None + self._send_lock = threading.Lock() + + self.frequency_khz: float = 0 + self.mode: str = 'am' + self.last_smeter: int = 0 + + @property + def connected(self) -> bool: + return self._connected + + def connect(self, frequency_khz: float, mode: str = 'am') -> bool: + """Connect to KiwiSDR and start receiving audio.""" + if not WEBSOCKET_CLIENT_AVAILABLE: + logger.error("websocket-client not installed") + return False + + if self._connected: + self.disconnect() + + self.frequency_khz = frequency_khz + self.mode = mode if mode in VALID_MODES else 'am' + self._stopping = False + + ws_url = self._build_ws_url() + logger.info(f"Connecting to KiwiSDR: {ws_url}") + + try: + self._ws = websocket.WebSocket() + self._ws.settimeout(10) + self._ws.connect(ws_url) + + # Auth + self._send('SET auth t=kiwi p=' + self.password) + time.sleep(0.2) + + # Request uncompressed PCM + self._send('SET compression=0') + + # Set AGC + self._send('SET agc=1 hang=0 thresh=-100 slope=6 decay=1000 manGain=50') + + # Tune to frequency + self._send_tune(frequency_khz, self.mode) + + # Request audio start + self._send('SET AR OK in=12000 out=44100') + + self._connected = True + + # Start receive thread + self._receive_thread = threading.Thread( + target=self._receive_loop, daemon=True, name='kiwi-rx' + ) + self._receive_thread.start() + + # Start keepalive thread + self._keepalive_thread = threading.Thread( + target=self._keepalive_loop, daemon=True, name='kiwi-ka' + ) + self._keepalive_thread.start() + + logger.info(f"Connected to KiwiSDR {self.host}:{self.port} @ {frequency_khz} kHz {self.mode}") + return True + + except Exception as e: + logger.error(f"KiwiSDR connection failed: {e}") + self._cleanup() + return False + + def tune(self, frequency_khz: float, mode: str = 'am') -> bool: + """Retune without disconnecting.""" + if not self._connected or not self._ws: + return False + + self.frequency_khz = frequency_khz + if mode in VALID_MODES: + self.mode = mode + + try: + self._send_tune(frequency_khz, self.mode) + logger.info(f"Retuned to {frequency_khz} kHz {self.mode}") + return True + except Exception as e: + logger.error(f"Retune failed: {e}") + return False + + def disconnect(self) -> None: + """Cleanly disconnect from KiwiSDR.""" + self._stopping = True + self._connected = False + self._cleanup() + logger.info("Disconnected from KiwiSDR") + + def _build_ws_url(self) -> str: + ts = int(time.time() * 1000) + return f'ws://{self.host}:{self.port}/{ts}/SND' + + def _send(self, msg: str) -> None: + with self._send_lock: + if self._ws: + self._ws.send(msg) + + def _send_tune(self, freq_khz: float, mode: str) -> None: + low_cut, high_cut = MODE_FILTERS.get(mode, MODE_FILTERS['am']) + self._send(f'SET mod={mode} low_cut={low_cut} high_cut={high_cut} freq={freq_khz}') + + def _receive_loop(self) -> None: + """Background thread: read frames from KiwiSDR WebSocket.""" + try: + while self._connected and not self._stopping: + try: + if not self._ws: + break + self._ws.settimeout(2.0) + data = self._ws.recv() + except websocket.WebSocketTimeoutException: + continue + except Exception as e: + if not self._stopping: + logger.error(f"KiwiSDR receive error: {e}") + break + + if not data or not isinstance(data, bytes): + # Text message (status/config) — ignore + continue + + self._parse_snd_frame(data) + + except Exception as e: + if not self._stopping: + logger.error(f"KiwiSDR receive loop error: {e}") + finally: + if not self._stopping: + self._connected = False + if self._on_disconnect: + try: + self._on_disconnect() + except Exception: + pass + + def _parse_snd_frame(self, data: bytes) -> None: + """Parse a KiwiSDR SND binary frame.""" + if len(data) < KIWI_SND_HEADER_SIZE: + return + + # Check header magic + if data[:3] != b'SND': + return + + # flags = data[3] + # seq = struct.unpack('>I', data[4:8])[0] + + # S-meter: big-endian int16 at offset 8 + smeter_raw = struct.unpack('>h', data[8:10])[0] + self.last_smeter = smeter_raw + + # PCM audio data starts at offset 10 + pcm_data = data[KIWI_SND_HEADER_SIZE:] + + if pcm_data and self._on_audio: + try: + self._on_audio(pcm_data, smeter_raw) + except Exception: + pass + + def _keepalive_loop(self) -> None: + """Background thread: send keepalive every 5 seconds.""" + while self._connected and not self._stopping: + time.sleep(KIWI_KEEPALIVE_INTERVAL) + if self._connected and not self._stopping: + try: + self._send('SET keepalive') + except Exception: + break + + def _cleanup(self) -> None: + """Close WebSocket and join threads.""" + if self._ws: + try: + self._ws.close() + except Exception: + pass + self._ws = None + + if self._receive_thread and self._receive_thread.is_alive(): + self._receive_thread.join(timeout=3.0) + if self._keepalive_thread and self._keepalive_thread.is_alive(): + self._keepalive_thread.join(timeout=3.0) + + self._receive_thread = None + self._keepalive_thread = None