mirror of
https://github.com/smittix/intercept.git
synced 2026-07-07 09:08:12 -07:00
96172ca593
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>
505 lines
16 KiB
Python
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")
|