Files
intercept/routes/websdr.py
T
James Smith 96172ca593 style: apply ruff-format to entire codebase
First-time run of ruff-format via pre-commit hook normalises quote
style, trailing commas, and whitespace across 188 Python files.
No logic changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 14:48:11 +01:00

505 lines
16 KiB
Python

"""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/<station_id>/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")