Files
intercept/utils/kiwisdr.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

282 lines
8.5 KiB
Python

"""KiwiSDR WebSocket audio client.
Connects to a KiwiSDR receiver via its WebSocket API and streams
decoded PCM audio back through a callback.
"""
from __future__ import annotations
import struct
import threading
import time
from typing import Callable
try:
import websocket # websocket-client library
WEBSOCKET_CLIENT_AVAILABLE = True
except ImportError:
WEBSOCKET_CLIENT_AVAILABLE = False
import contextlib
from utils.logging import get_logger
logger = get_logger("intercept.kiwisdr")
# Protocol constants
KIWI_KEEPALIVE_INTERVAL = 5.0
KIWI_SAMPLE_RATE = 12000 # 12 kHz mono
KIWI_SND_HEADER_SIZE = 10 # "SND"(3) + flags(1) + seq(4) + smeter(2)
KIWI_DEFAULT_PORT = 8073
VALID_MODES = ("am", "usb", "lsb", "cw")
# Default bandpass filters per mode (Hz)
MODE_FILTERS = {
"am": (-4500, 4500),
"usb": (300, 3000),
"lsb": (-3000, -300),
"cw": (300, 800),
}
def parse_host_port(url: str) -> tuple[str, int]:
"""Extract host and port from a KiwiSDR URL like 'http://host:port'.
Returns (host, port) tuple. Defaults to port 8073 if not specified.
"""
if not url:
return ("", KIWI_DEFAULT_PORT)
# Strip protocol
cleaned = url
for prefix in ("http://", "https://", "ws://", "wss://"):
if cleaned.lower().startswith(prefix):
cleaned = cleaned[len(prefix) :]
break
# Strip path
cleaned = cleaned.split("/")[0]
# Split host:port
if ":" in cleaned:
parts = cleaned.rsplit(":", 1)
host = parts[0]
try:
port = int(parts[1])
except ValueError:
port = KIWI_DEFAULT_PORT
else:
host = cleaned
port = KIWI_DEFAULT_PORT
return (host, port)
class KiwiSDRClient:
"""Manages a WebSocket connection to a single KiwiSDR receiver."""
def __init__(
self,
host: str,
port: int = KIWI_DEFAULT_PORT,
on_audio: Callable[[bytes, int], None] | None = None,
on_error: Callable[[str], None] | None = None,
on_disconnect: Callable[[], None] | None = None,
password: str = "",
):
self.host = host
self.port = port
self.password = password
self._on_audio = on_audio
self._on_error = on_error
self._on_disconnect = on_disconnect
self._ws = None
self._connected = False
self._stopping = False
self._receive_thread: threading.Thread | None = None
self._keepalive_thread: threading.Thread | None = None
self._send_lock = threading.Lock()
self.frequency_khz: float = 0
self.mode: str = "am"
self.last_smeter: int = 0
@property
def connected(self) -> bool:
return self._connected
def connect(self, frequency_khz: float, mode: str = "am") -> bool:
"""Connect to KiwiSDR and start receiving audio."""
if not WEBSOCKET_CLIENT_AVAILABLE:
logger.error("websocket-client not installed")
return False
if self._connected:
self.disconnect()
self.frequency_khz = frequency_khz
self.mode = mode if mode in VALID_MODES else "am"
self._stopping = False
ws_url = self._build_ws_url()
logger.info(f"Connecting to KiwiSDR: {ws_url}")
try:
self._ws = websocket.WebSocket()
self._ws.settimeout(10)
self._ws.connect(ws_url)
# Auth
self._send("SET auth t=kiwi p=" + self.password)
time.sleep(0.2)
# Request uncompressed PCM
self._send("SET compression=0")
# Set AGC
self._send("SET agc=1 hang=0 thresh=-100 slope=6 decay=1000 manGain=50")
# Tune to frequency
self._send_tune(frequency_khz, self.mode)
# Request audio start
self._send("SET AR OK in=12000 out=44100")
self._connected = True
# Start receive thread
self._receive_thread = threading.Thread(target=self._receive_loop, daemon=True, name="kiwi-rx")
self._receive_thread.start()
# Start keepalive thread
self._keepalive_thread = threading.Thread(target=self._keepalive_loop, daemon=True, name="kiwi-ka")
self._keepalive_thread.start()
logger.info(f"Connected to KiwiSDR {self.host}:{self.port} @ {frequency_khz} kHz {self.mode}")
return True
except Exception as e:
logger.error(f"KiwiSDR connection failed: {e}")
self._cleanup()
return False
def tune(self, frequency_khz: float, mode: str = "am") -> bool:
"""Retune without disconnecting."""
if not self._connected or not self._ws:
return False
self.frequency_khz = frequency_khz
if mode in VALID_MODES:
self.mode = mode
try:
self._send_tune(frequency_khz, self.mode)
logger.info(f"Retuned to {frequency_khz} kHz {self.mode}")
return True
except Exception as e:
logger.error(f"Retune failed: {e}")
return False
def disconnect(self) -> None:
"""Cleanly disconnect from KiwiSDR."""
self._stopping = True
self._connected = False
self._cleanup()
logger.info("Disconnected from KiwiSDR")
def _build_ws_url(self) -> str:
ts = int(time.time() * 1000)
return f"ws://{self.host}:{self.port}/{ts}/SND"
def _send(self, msg: str) -> None:
with self._send_lock:
if self._ws:
self._ws.send(msg)
def _send_tune(self, freq_khz: float, mode: str) -> None:
low_cut, high_cut = MODE_FILTERS.get(mode, MODE_FILTERS["am"])
self._send(f"SET mod={mode} low_cut={low_cut} high_cut={high_cut} freq={freq_khz}")
def _receive_loop(self) -> None:
"""Background thread: read frames from KiwiSDR WebSocket."""
try:
while self._connected and not self._stopping:
try:
if not self._ws:
break
self._ws.settimeout(2.0)
data = self._ws.recv()
except websocket.WebSocketTimeoutException:
continue
except Exception as e:
if not self._stopping:
logger.error(f"KiwiSDR receive error: {e}")
break
if not data or not isinstance(data, bytes):
# Text message (status/config) — ignore
continue
self._parse_snd_frame(data)
except Exception as e:
if not self._stopping:
logger.error(f"KiwiSDR receive loop error: {e}")
finally:
if not self._stopping:
self._connected = False
if self._on_disconnect:
with contextlib.suppress(Exception):
self._on_disconnect()
def _parse_snd_frame(self, data: bytes) -> None:
"""Parse a KiwiSDR SND binary frame."""
if len(data) < KIWI_SND_HEADER_SIZE:
return
# Check header magic
if data[:3] != b"SND":
return
# flags = data[3]
# seq = struct.unpack('>I', data[4:8])[0]
# S-meter: big-endian int16 at offset 8
smeter_raw = struct.unpack(">h", data[8:10])[0]
self.last_smeter = smeter_raw
# PCM audio data starts at offset 10
pcm_data = data[KIWI_SND_HEADER_SIZE:]
if pcm_data and self._on_audio:
with contextlib.suppress(Exception):
self._on_audio(pcm_data, smeter_raw)
def _keepalive_loop(self) -> None:
"""Background thread: send keepalive every 5 seconds."""
while self._connected and not self._stopping:
time.sleep(KIWI_KEEPALIVE_INTERVAL)
if self._connected and not self._stopping:
try:
self._send("SET keepalive")
except Exception:
break
def _cleanup(self) -> None:
"""Close WebSocket and join threads."""
if self._ws:
with contextlib.suppress(Exception):
self._ws.close()
self._ws = None
if self._receive_thread and self._receive_thread.is_alive():
self._receive_thread.join(timeout=3.0)
if self._keepalive_thread and self._keepalive_thread.is_alive():
self._keepalive_thread.join(timeout=3.0)
self._receive_thread = None
self._keepalive_thread = None