feat: BladeRF and HydraSDR RFOne support (closes #121, closes #203)

- BladeRF 2.0 micro / x40 / x115 detected via SoapyBladeRF (47 MHz–6 GHz,
  TX capable, gain 0–66 dB)
- HydraSDR RFOne detected via SoapyHydraSDR (24 MHz–1800 MHz, RX only,
  linear gain 0–21)
- Both follow the established SoapySDR command-builder pattern and support
  FM demod, ADS-B, ISM, AIS, and I/Q capture
- Bump version to 2.31.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
James Smith
2026-07-07 12:08:03 +01:00
parent dfab714104
commit 1faa390ea7
8 changed files with 342 additions and 6 deletions
+6
View File
@@ -27,8 +27,10 @@ from typing import Optional
from .airspy import AirspyCommandBuilder
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
from .bladerf import BladeRFCommandBuilder
from .detection import detect_all_devices, invalidate_device_cache, probe_rtlsdr_device
from .hackrf import HackRFCommandBuilder
from .hydrasdr import HydraSDRCommandBuilder
from .limesdr import LimeSDRCommandBuilder
from .rtlsdr import RTLSDRCommandBuilder
from .sdrplay import SDRPlayCommandBuilder
@@ -55,6 +57,8 @@ class SDRFactory:
SDRType.AIRSPY: AirspyCommandBuilder,
SDRType.SDRPLAY: SDRPlayCommandBuilder,
SDRType.USRP: USRPCommandBuilder,
SDRType.BLADE_RF: BladeRFCommandBuilder,
SDRType.HYDRA_SDR: HydraSDRCommandBuilder,
}
@classmethod
@@ -214,6 +218,8 @@ __all__ = [
"AirspyCommandBuilder",
"SDRPlayCommandBuilder",
"USRPCommandBuilder",
"BladeRFCommandBuilder",
"HydraSDRCommandBuilder",
# Validation
"SDRValidationError",
"validate_frequency",
+2 -2
View File
@@ -21,8 +21,8 @@ class SDRType(Enum):
AIRSPY = "airspy"
SDRPLAY = "sdrplay"
USRP = "usrp"
# Future support
# BLADE_RF = "bladerf"
BLADE_RF = "bladerf"
HYDRA_SDR = "hydrasdr"
@dataclass
+153
View File
@@ -0,0 +1,153 @@
"""
BladeRF command builder implementation.
Uses SoapySDR (via SoapyBladeRF) for all signal processing tasks.
Targets bladeRF 2.0 micro (47 MHz 6 GHz) but is compatible with
bladeRF x40/x115 (300 MHz 3.8 GHz) — the SoapySDR layer handles
per-device frequency clamping transparently.
Requires: libbladeRF, SoapySDR, SoapyBladeRF module installed.
"""
from __future__ import annotations
from utils.dependencies import get_tool_path
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
class BladeRFCommandBuilder(CommandBuilder):
"""BladeRF command builder using SoapySDR / SoapyBladeRF."""
CAPABILITIES = SDRCapabilities(
sdr_type=SDRType.BLADE_RF,
freq_min_mhz=47.0, # bladeRF 2.0 micro lower bound
freq_max_mhz=6000.0,
gain_min=0.0,
gain_max=66.0, # LNA (0/3/6) + RXVGA1 (5-30) + RXVGA2 (0-30)
sample_rates=[1000000, 2000000, 4000000, 8000000, 10000000, 20000000, 40000000],
supports_bias_t=False,
supports_ppm=False,
tx_capable=True,
supports_iq_capture=True,
)
def _build_device_string(self, device: SDRDevice) -> str:
"""Build SoapySDR device string for BladeRF."""
if device.serial and device.serial not in ("N/A", "Unknown"):
return f"driver=bladerf,serial={device.serial}"
return "driver=bladerf"
def build_fm_demod_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 22050,
gain: float | None = None,
ppm: int | None = None,
modulation: str = "fm",
squelch: int | None = None,
bias_t: bool = False,
) -> list[str]:
device_str = self._build_device_string(device)
rx_fm_path = get_tool_path("rx_fm") or "rx_fm"
cmd = [
rx_fm_path,
"-d",
device_str,
"-f",
f"{frequency_mhz}M",
"-M",
modulation,
"-s",
str(sample_rate),
]
if gain is not None and gain > 0:
cmd.extend(["-g", str(int(gain))])
if squelch is not None and squelch > 0:
cmd.extend(["-l", str(squelch)])
cmd.append("-")
return cmd
def build_adsb_command(self, device: SDRDevice, gain: float | None = None, bias_t: bool = False) -> list[str]:
device_str = self._build_device_string(device)
cmd = ["readsb", "--net", "--device-type", "soapysdr", "--device", device_str, "--quiet"]
if gain is not None:
cmd.extend(["--gain", str(int(gain))])
return cmd
def build_ism_command(
self,
device: SDRDevice,
frequency_mhz: float = 433.92,
gain: float | None = None,
ppm: int | None = None,
bias_t: bool = False,
) -> list[str]:
device_str = self._build_device_string(device)
cmd = ["rtl_433", "-d", device_str, "-f", f"{frequency_mhz}M", "-F", "json"]
if gain is not None and gain > 0:
cmd.extend(["-g", str(int(gain))])
return cmd
def build_ais_command(
self,
device: SDRDevice,
gain: float | None = None,
bias_t: bool = False,
tcp_port: int = 10110,
udp_host: str | None = None,
udp_port: int | None = None,
) -> list[str]:
device_str = self._build_device_string(device)
cmd = [
"AIS-catcher",
"-d",
f"soapysdr -d {device_str}",
"-S",
str(tcp_port),
"-o",
"5",
"-q",
]
if gain is not None and gain > 0:
cmd.extend(["-gr", "tuner", str(int(gain))])
if udp_host and udp_port:
cmd.extend(["-u", udp_host, str(udp_port)])
return cmd
def build_iq_capture_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 2048000,
gain: float | None = None,
ppm: int | None = None,
bias_t: bool = False,
output_format: str = "cu8",
) -> list[str]:
device_str = self._build_device_string(device)
freq_hz = int(frequency_mhz * 1e6)
rx_sdr_path = get_tool_path("rx_sdr") or "rx_sdr"
cmd = [
rx_sdr_path,
"-d",
device_str,
"-f",
str(freq_hz),
"-s",
str(sample_rate),
"-F",
"CU8",
]
if gain is not None and gain > 0:
cmd.extend(["-g", str(int(gain))])
cmd.append("-")
return cmd
def get_capabilities(self) -> SDRCapabilities:
return self.CAPABILITIES
@classmethod
def get_sdr_type(cls) -> SDRType:
return SDRType.BLADE_RF
+6 -2
View File
@@ -49,7 +49,9 @@ def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities:
"""Get default capabilities for an SDR type."""
# Import here to avoid circular imports
from .airspy import AirspyCommandBuilder
from .bladerf import BladeRFCommandBuilder
from .hackrf import HackRFCommandBuilder
from .hydrasdr import HydraSDRCommandBuilder
from .limesdr import LimeSDRCommandBuilder
from .rtlsdr import RTLSDRCommandBuilder
from .sdrplay import SDRPlayCommandBuilder
@@ -62,6 +64,8 @@ def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities:
SDRType.AIRSPY: AirspyCommandBuilder,
SDRType.SDRPLAY: SDRPlayCommandBuilder,
SDRType.USRP: USRPCommandBuilder,
SDRType.BLADE_RF: BladeRFCommandBuilder,
SDRType.HYDRA_SDR: HydraSDRCommandBuilder,
}
builder_class = builders.get(sdr_type)
@@ -93,8 +97,8 @@ def _driver_to_sdr_type(driver: str) -> SDRType | None:
"airspyhf": SDRType.AIRSPY, # Airspy HF+ uses same builder
"sdrplay": SDRType.SDRPLAY,
"uhd": SDRType.USRP,
# Future support
# 'bladerf': SDRType.BLADE_RF,
"bladerf": SDRType.BLADE_RF,
"hydrasdr": SDRType.HYDRA_SDR,
}
return mapping.get(driver.lower())
+157
View File
@@ -0,0 +1,157 @@
"""
HydraSDR RFOne command builder implementation.
Uses SoapySDR (via SoapyHydraSDR plugin) for all signal processing tasks.
The RFOne is an RX-only receiver covering 24 MHz to 1800 MHz with a
10 MHz instantaneous bandwidth.
Gain is a linear scale 021 (not dB); the SoapyHydraSDR plugin maps this
to the hardware's RF gain stages.
Requires: rfone_host library, SoapySDR, SoapyHydraSDR module installed.
Official: https://github.com/hydrasdr/SoapyHydraSDR
"""
from __future__ import annotations
from utils.dependencies import get_tool_path
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
class HydraSDRCommandBuilder(CommandBuilder):
"""HydraSDR RFOne command builder using SoapySDR / SoapyHydraSDR."""
CAPABILITIES = SDRCapabilities(
sdr_type=SDRType.HYDRA_SDR,
freq_min_mhz=24.0,
freq_max_mhz=1800.0,
gain_min=0.0,
gain_max=21.0, # linear scale; recommended starting point: 12
sample_rates=[2500000, 5000000, 10000000],
supports_bias_t=False,
supports_ppm=False,
tx_capable=False,
supports_iq_capture=True,
)
def _build_device_string(self, device: SDRDevice) -> str:
"""Build SoapySDR device string for HydraSDR RFOne."""
if device.serial and device.serial not in ("N/A", "Unknown"):
return f"driver=hydrasdr,serial={device.serial}"
return "driver=hydrasdr"
def build_fm_demod_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 22050,
gain: float | None = None,
ppm: int | None = None,
modulation: str = "fm",
squelch: int | None = None,
bias_t: bool = False,
) -> list[str]:
device_str = self._build_device_string(device)
rx_fm_path = get_tool_path("rx_fm") or "rx_fm"
cmd = [
rx_fm_path,
"-d",
device_str,
"-f",
f"{frequency_mhz}M",
"-M",
modulation,
"-s",
str(sample_rate),
]
if gain is not None and gain > 0:
cmd.extend(["-g", str(int(gain))])
if squelch is not None and squelch > 0:
cmd.extend(["-l", str(squelch)])
cmd.append("-")
return cmd
def build_adsb_command(self, device: SDRDevice, gain: float | None = None, bias_t: bool = False) -> list[str]:
# 1090 MHz is within the RFOne's range (241800 MHz)
device_str = self._build_device_string(device)
cmd = ["readsb", "--net", "--device-type", "soapysdr", "--device", device_str, "--quiet"]
if gain is not None:
cmd.extend(["--gain", str(int(gain))])
return cmd
def build_ism_command(
self,
device: SDRDevice,
frequency_mhz: float = 433.92,
gain: float | None = None,
ppm: int | None = None,
bias_t: bool = False,
) -> list[str]:
device_str = self._build_device_string(device)
cmd = ["rtl_433", "-d", device_str, "-f", f"{frequency_mhz}M", "-F", "json"]
if gain is not None and gain > 0:
cmd.extend(["-g", str(int(gain))])
return cmd
def build_ais_command(
self,
device: SDRDevice,
gain: float | None = None,
bias_t: bool = False,
tcp_port: int = 10110,
udp_host: str | None = None,
udp_port: int | None = None,
) -> list[str]:
device_str = self._build_device_string(device)
cmd = [
"AIS-catcher",
"-d",
f"soapysdr -d {device_str}",
"-S",
str(tcp_port),
"-o",
"5",
"-q",
]
if gain is not None and gain > 0:
cmd.extend(["-gr", "tuner", str(int(gain))])
if udp_host and udp_port:
cmd.extend(["-u", udp_host, str(udp_port)])
return cmd
def build_iq_capture_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 2048000,
gain: float | None = None,
ppm: int | None = None,
bias_t: bool = False,
output_format: str = "cu8",
) -> list[str]:
device_str = self._build_device_string(device)
freq_hz = int(frequency_mhz * 1e6)
rx_sdr_path = get_tool_path("rx_sdr") or "rx_sdr"
cmd = [
rx_sdr_path,
"-d",
device_str,
"-f",
str(freq_hz),
"-s",
str(sample_rate),
"-F",
"CU8",
]
if gain is not None and gain > 0:
cmd.extend(["-g", str(int(gain))])
cmd.append("-")
return cmd
def get_capabilities(self) -> SDRCapabilities:
return self.CAPABILITIES
@classmethod
def get_sdr_type(cls) -> SDRType:
return SDRType.HYDRA_SDR