mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 22:59:59 -07:00
- Fix SSE fanout thread AttributeError when source queue is None during interpreter shutdown by snapshotting to local variable with null guard - Fix branded "i" logo rendering oversized on first page load (FOUC) by adding inline width/height to SVG elements across 10 templates - Bump version to 2.26.0 in config.py, pyproject.toml, and CHANGELOG.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
495 lines
15 KiB
Python
495 lines
15 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")
|