Files
intercept/routes/websdr.py
Smittix 90281b1535 fix(modes): deep-linked mode scripts fail when body not yet parsed
ensureModeScript() used document.body.appendChild() to load lazy mode
scripts, but the preload for ?mode= query params runs in <head> before
<body> exists, causing all deep-linked modes to silently fail.

Also fix cross-mode handoffs (BT→BT Locate, WiFi→WiFi Locate,
Spy Stations→Waterfall) that assumed target module was already loaded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:49:08 +00:00

502 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 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/<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: 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")