"""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 from utils.responses import api_success, api_error 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 api_success(data={ '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 api_error('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 api_success(data={'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 api_error('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 api_error('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 api_error('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 api_success(data={ '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")