Sweeps any set of frequency ranges, records what it finds, and works out what kind of signal it was. - Frequency ranges entered by hand or picked from a 135-entry US band plan, including whole-band and all-CW sweeps that resolve the demodulator per segment. - Detection calibrated against the peak-hold detector's own noise statistics, so the threshold means real margin over static rather than over the floor. - A content gate: captures are kept only if they carry voice, decodable CW, or an identified digital keying scheme. Speech is recognised by a pitch track that drifts, which static cannot imitate. - Identification of NFM/WFM/AM/SSB, CW with Morse decoded to text, P25, DMR, NXDN, D-STAR, POCSAG, FLEX, ACARS, AIS, APRS, n-FSK and n-PSK. - Gapless streaming capture, with the signal path fast enough to keep up in real time, so recordings play back at the right speed. - Optional one-file-per-frequency recording with spoken timestamps, and speech-to-text transcription. - Menus and command line generated from one settings table, so neither can offer something the other cannot; settings persist in ~/.config. 367 tests, run against synthetic signals, a built-in receiver simulator, and real hardware. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
338 lines
11 KiB
Python
Executable file
338 lines
11 KiB
Python
Executable file
"""Minimal ctypes binding to librtlsdr.
|
|
|
|
Only the subset of the API that the scanner needs is bound. Kept separate from
|
|
``device.py`` so that the raw C surface stays easy to audit.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ctypes
|
|
import ctypes.util
|
|
import os
|
|
|
|
__all__ = ["LibRtlSdr", "RtlSdrError", "load", "is_available", "load_error"]
|
|
|
|
|
|
class RtlSdrError(RuntimeError):
|
|
"""Raised when librtlsdr reports a failure or is unusable."""
|
|
|
|
|
|
# librtlsdr returns negative errno-ish codes; these are the ones worth naming.
|
|
_ERRNO_TEXT = {
|
|
-1: "device handle is invalid",
|
|
-2: "device not found or already claimed",
|
|
-3: "operation not supported by this tuner",
|
|
-5: "USB transfer error (cable, power, or driver problem)",
|
|
-6: "device is busy",
|
|
-12: "out of memory",
|
|
}
|
|
|
|
|
|
def _describe(code: int) -> str:
|
|
return _ERRNO_TEXT.get(code, f"librtlsdr error {code}")
|
|
|
|
|
|
_CANDIDATES = (
|
|
"librtlsdr.so.2",
|
|
"librtlsdr.so.0",
|
|
"librtlsdr.so",
|
|
"librtlsdr.dylib",
|
|
"rtlsdr.dll",
|
|
"librtlsdr.dll",
|
|
)
|
|
|
|
_lib = None
|
|
_load_error: str | None = None
|
|
|
|
|
|
def _try_load():
|
|
"""Locate and dlopen librtlsdr, returning (handle, error_message)."""
|
|
tried = []
|
|
|
|
env = os.environ.get("BANDSAUNTER_LIBRTLSDR")
|
|
names = ([env] if env else []) + list(_CANDIDATES)
|
|
|
|
found = ctypes.util.find_library("rtlsdr")
|
|
if found:
|
|
names.append(found)
|
|
|
|
for name in names:
|
|
try:
|
|
return ctypes.CDLL(name), None
|
|
except OSError as exc: # pragma: no cover - platform dependent
|
|
tried.append(f"{name}: {exc}")
|
|
|
|
hint = (
|
|
"librtlsdr was not found. On Debian/Ubuntu install it with:\n"
|
|
" sudo apt install rtl-sdr librtlsdr0\n"
|
|
"On Fedora: sudo dnf install rtl-sdr\n"
|
|
"On macOS: brew install librtlsdr\n"
|
|
"Or set BANDSAUNTER_LIBRTLSDR=/path/to/librtlsdr.so"
|
|
)
|
|
return None, hint + "\n\nTried:\n " + "\n ".join(tried)
|
|
|
|
|
|
# Callback librtlsdr invokes from its own thread for each filled buffer.
|
|
READ_ASYNC_CB = ctypes.CFUNCTYPE(None, ctypes.POINTER(ctypes.c_ubyte),
|
|
ctypes.c_uint32, ctypes.c_void_p)
|
|
|
|
|
|
def _bind(lib):
|
|
c = ctypes.c_int
|
|
u32 = ctypes.c_uint32
|
|
p = ctypes.c_void_p
|
|
cp = ctypes.c_char_p
|
|
|
|
sig = {
|
|
"rtlsdr_get_device_count": ([], ctypes.c_uint32),
|
|
"rtlsdr_get_device_name": ([u32], cp),
|
|
"rtlsdr_get_device_usb_strings": ([u32, cp, cp, cp], c),
|
|
"rtlsdr_get_index_by_serial": ([cp], c),
|
|
"rtlsdr_open": ([ctypes.POINTER(p), u32], c),
|
|
"rtlsdr_close": ([p], c),
|
|
"rtlsdr_set_center_freq": ([p, u32], c),
|
|
"rtlsdr_get_center_freq": ([p], u32),
|
|
"rtlsdr_set_freq_correction": ([p, c], c),
|
|
"rtlsdr_get_freq_correction": ([p], c),
|
|
"rtlsdr_get_tuner_type": ([p], c),
|
|
"rtlsdr_get_tuner_gains": ([p, ctypes.POINTER(c)], c),
|
|
"rtlsdr_set_tuner_gain": ([p, c], c),
|
|
"rtlsdr_get_tuner_gain": ([p], c),
|
|
"rtlsdr_set_tuner_gain_mode": ([p, c], c),
|
|
"rtlsdr_set_tuner_bandwidth": ([p, u32], c),
|
|
"rtlsdr_set_sample_rate": ([p, u32], c),
|
|
"rtlsdr_get_sample_rate": ([p], u32),
|
|
"rtlsdr_set_agc_mode": ([p, c], c),
|
|
"rtlsdr_set_direct_sampling": ([p, c], c),
|
|
"rtlsdr_get_direct_sampling": ([p], c),
|
|
"rtlsdr_set_offset_tuning": ([p, c], c),
|
|
"rtlsdr_get_offset_tuning": ([p], c),
|
|
"rtlsdr_set_bias_tee": ([p, c], c),
|
|
"rtlsdr_reset_buffer": ([p], c),
|
|
"rtlsdr_read_sync": ([p, p, c, ctypes.POINTER(c)], c),
|
|
"rtlsdr_read_async": ([p, READ_ASYNC_CB, p, u32, u32], c),
|
|
"rtlsdr_cancel_async": ([p], c),
|
|
}
|
|
|
|
missing = []
|
|
for name, (argtypes, restype) in sig.items():
|
|
fn = getattr(lib, name, None)
|
|
if fn is None:
|
|
missing.append(name)
|
|
continue
|
|
fn.argtypes = argtypes
|
|
fn.restype = restype
|
|
|
|
# A few entry points only exist in newer librtlsdr; the wrapper degrades
|
|
# gracefully for those rather than refusing to run.
|
|
hard_required = {
|
|
"rtlsdr_open",
|
|
"rtlsdr_close",
|
|
"rtlsdr_set_center_freq",
|
|
"rtlsdr_set_sample_rate",
|
|
"rtlsdr_read_sync",
|
|
"rtlsdr_reset_buffer",
|
|
}
|
|
fatal = hard_required.intersection(missing)
|
|
if fatal:
|
|
raise RtlSdrError(
|
|
"librtlsdr is missing required symbols: " + ", ".join(sorted(fatal))
|
|
)
|
|
return lib
|
|
|
|
|
|
def load():
|
|
"""Return the loaded librtlsdr handle, raising RtlSdrError if unavailable."""
|
|
global _lib, _load_error
|
|
if _lib is not None:
|
|
return _lib
|
|
if _load_error is not None:
|
|
raise RtlSdrError(_load_error)
|
|
lib, err = _try_load()
|
|
if lib is None:
|
|
_load_error = err
|
|
raise RtlSdrError(err)
|
|
_lib = _bind(lib)
|
|
return _lib
|
|
|
|
|
|
def is_available() -> bool:
|
|
try:
|
|
load()
|
|
return True
|
|
except RtlSdrError:
|
|
return False
|
|
|
|
|
|
def load_error() -> str | None:
|
|
"""The dlopen failure message, or None if the library loaded fine."""
|
|
try:
|
|
load()
|
|
return None
|
|
except RtlSdrError as exc:
|
|
return str(exc)
|
|
|
|
|
|
class LibRtlSdr:
|
|
"""Thin, checked wrapper around the C calls.
|
|
|
|
Every method raises :class:`RtlSdrError` on a negative return code so the
|
|
layers above can assume success.
|
|
"""
|
|
|
|
TUNER_NAMES = {
|
|
0: "unknown",
|
|
1: "Elonics E4000",
|
|
2: "Fitipower FC0012",
|
|
3: "Fitipower FC0013",
|
|
4: "FCI FC2580",
|
|
5: "Rafael Micro R820T/R820T2",
|
|
6: "Rafael Micro R828D",
|
|
}
|
|
|
|
def __init__(self):
|
|
self.lib = load()
|
|
|
|
# -- enumeration ---------------------------------------------------
|
|
def device_count(self) -> int:
|
|
return int(self.lib.rtlsdr_get_device_count())
|
|
|
|
def device_name(self, index: int) -> str:
|
|
name = self.lib.rtlsdr_get_device_name(index)
|
|
return name.decode("utf-8", "replace") if name else "?"
|
|
|
|
def usb_strings(self, index: int):
|
|
buf = [ctypes.create_string_buffer(256) for _ in range(3)]
|
|
rc = self.lib.rtlsdr_get_device_usb_strings(index, *buf)
|
|
if rc < 0:
|
|
return ("?", "?", "?")
|
|
return tuple(b.value.decode("utf-8", "replace") for b in buf)
|
|
|
|
# -- lifecycle -----------------------------------------------------
|
|
def open(self, index: int) -> ctypes.c_void_p:
|
|
handle = ctypes.c_void_p()
|
|
rc = self.lib.rtlsdr_open(ctypes.byref(handle), index)
|
|
if rc < 0:
|
|
extra = ""
|
|
if rc == -2 or rc == -6:
|
|
extra = (
|
|
"\nThe DVB-T kernel driver may have claimed it. Blacklist it:\n"
|
|
" echo 'blacklist dvb_usb_rtl28xxu' | "
|
|
"sudo tee /etc/modprobe.d/blacklist-rtl.conf\n"
|
|
" sudo rmmod dvb_usb_rtl28xxu\n"
|
|
"Also make sure your user can access the USB device "
|
|
"(udev rule / plugdev group)."
|
|
)
|
|
raise RtlSdrError(f"could not open device {index}: {_describe(rc)}{extra}")
|
|
return handle
|
|
|
|
def close(self, dev) -> None:
|
|
if dev:
|
|
self.lib.rtlsdr_close(dev)
|
|
|
|
# -- checked setters -----------------------------------------------
|
|
def _check(self, rc: int, what: str, soft: bool = False) -> bool:
|
|
if rc < 0:
|
|
if soft:
|
|
return False
|
|
raise RtlSdrError(f"{what} failed: {_describe(rc)}")
|
|
return True
|
|
|
|
def set_center_freq(self, dev, hz: int) -> None:
|
|
self._check(self.lib.rtlsdr_set_center_freq(dev, int(hz)),
|
|
f"tuning to {hz/1e6:.6f} MHz")
|
|
|
|
def get_center_freq(self, dev) -> int:
|
|
return int(self.lib.rtlsdr_get_center_freq(dev))
|
|
|
|
def set_sample_rate(self, dev, hz: int) -> None:
|
|
self._check(self.lib.rtlsdr_set_sample_rate(dev, int(hz)),
|
|
f"setting sample rate {hz}")
|
|
|
|
def get_sample_rate(self, dev) -> int:
|
|
return int(self.lib.rtlsdr_get_sample_rate(dev))
|
|
|
|
def set_freq_correction(self, dev, ppm: int) -> bool:
|
|
# librtlsdr returns -2 when the value is already set; that is benign.
|
|
rc = self.lib.rtlsdr_set_freq_correction(dev, int(ppm))
|
|
return rc >= 0 or rc == -2
|
|
|
|
def get_tuner_type(self, dev) -> str:
|
|
try:
|
|
return self.TUNER_NAMES.get(int(self.lib.rtlsdr_get_tuner_type(dev)),
|
|
"unknown")
|
|
except Exception:
|
|
return "unknown"
|
|
|
|
def get_tuner_gains(self, dev) -> list[float]:
|
|
n = self.lib.rtlsdr_get_tuner_gains(dev, None)
|
|
if n <= 0:
|
|
return []
|
|
arr = (ctypes.c_int * n)()
|
|
self.lib.rtlsdr_get_tuner_gains(dev, arr)
|
|
return [v / 10.0 for v in arr]
|
|
|
|
def set_tuner_gain_mode(self, dev, manual: bool) -> None:
|
|
self._check(self.lib.rtlsdr_set_tuner_gain_mode(dev, 1 if manual else 0),
|
|
"setting tuner gain mode")
|
|
|
|
def set_tuner_gain(self, dev, db: float) -> None:
|
|
self._check(self.lib.rtlsdr_set_tuner_gain(dev, int(round(db * 10))),
|
|
f"setting tuner gain {db} dB")
|
|
|
|
def get_tuner_gain(self, dev) -> float:
|
|
return self.lib.rtlsdr_get_tuner_gain(dev) / 10.0
|
|
|
|
def set_tuner_bandwidth(self, dev, hz: int) -> bool:
|
|
fn = getattr(self.lib, "rtlsdr_set_tuner_bandwidth", None)
|
|
if fn is None:
|
|
return False
|
|
return self._check(fn(dev, int(hz)), "setting tuner bandwidth", soft=True)
|
|
|
|
def set_agc_mode(self, dev, on: bool) -> bool:
|
|
return self._check(self.lib.rtlsdr_set_agc_mode(dev, 1 if on else 0),
|
|
"setting RTL2832 AGC", soft=True)
|
|
|
|
def set_direct_sampling(self, dev, mode: int) -> bool:
|
|
"""0 = off, 1 = I branch, 2 = Q branch."""
|
|
return self._check(self.lib.rtlsdr_set_direct_sampling(dev, int(mode)),
|
|
"setting direct sampling", soft=True)
|
|
|
|
def get_direct_sampling(self, dev) -> int:
|
|
try:
|
|
return int(self.lib.rtlsdr_get_direct_sampling(dev))
|
|
except Exception:
|
|
return 0
|
|
|
|
def set_offset_tuning(self, dev, on: bool) -> bool:
|
|
return self._check(self.lib.rtlsdr_set_offset_tuning(dev, 1 if on else 0),
|
|
"setting offset tuning", soft=True)
|
|
|
|
def set_bias_tee(self, dev, on: bool) -> bool:
|
|
fn = getattr(self.lib, "rtlsdr_set_bias_tee", None)
|
|
if fn is None:
|
|
return False
|
|
return self._check(fn(dev, 1 if on else 0), "setting bias tee", soft=True)
|
|
|
|
def reset_buffer(self, dev) -> None:
|
|
self._check(self.lib.rtlsdr_reset_buffer(dev), "resetting USB buffer")
|
|
|
|
def read_async(self, dev, callback, buf_num: int = 15,
|
|
buf_len: int = 262144) -> None:
|
|
"""Stream continuously until :meth:`cancel_async`. Blocks the caller."""
|
|
rc = self.lib.rtlsdr_read_async(dev, callback, None,
|
|
int(buf_num), int(buf_len))
|
|
if rc < 0:
|
|
raise RtlSdrError(f"async read failed: {_describe(rc)}")
|
|
|
|
def cancel_async(self, dev) -> None:
|
|
self.lib.rtlsdr_cancel_async(dev)
|
|
|
|
def read_sync(self, dev, buf, count: int) -> int:
|
|
n_read = ctypes.c_int(0)
|
|
rc = self.lib.rtlsdr_read_sync(
|
|
dev, ctypes.cast(buf, ctypes.c_void_p), count, ctypes.byref(n_read)
|
|
)
|
|
if rc < 0:
|
|
raise RtlSdrError(f"USB read failed: {_describe(rc)}")
|
|
return int(n_read.value)
|