"""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 flask import Blueprint, Flask, Response, jsonify, request from utils.responses import api_error, api_success try: from flask_sock import Sock WEBSOCKET_AVAILABLE = True except ImportError: WEBSOCKET_AVAILABLE = False import contextlib from utils.kiwisdr import KIWI_SAMPLE_RATE, VALID_MODES, KiwiSDRClient, 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) -> float | None: """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 json import urllib.request 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: KiwiSDRClient | None = 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: with contextlib.suppress(queue.Empty): _kiwi_audio_queue.get_nowait() with contextlib.suppress(queue.Full): _kiwi_audio_queue.put_nowait(header + pcm_bytes) def on_error(msg): with contextlib.suppress(Exception): ws.send(json.dumps({"type": "error", "message": msg})) def on_disconnect(): with contextlib.suppress(Exception): ws.send(json.dumps({"type": "disconnected"})) 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")