Compare commits

..

3 Commits

Author SHA1 Message Date
James Smith 1faa390ea7 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>
2026-07-07 12:08:03 +01:00
James Smith dfab714104 feat: APRS export, USRP support, and MQTT data export (closes #222)
- GET /aprs/export?format=json|csv downloads all tracked APRS stations
- USRP (Ettus N200/B200/B210) detected via SoapyUHD and supported across
  all signal modes (FM demod, ADS-B, ISM, AIS, I/Q capture)
- Optional MQTT publisher broadcasts decoded events from every module;
  enabled by setting INTERCEPT_MQTT_BROKER, disabled by default
- paho-mqtt added to requirements.txt (optional dep)
- .gitignore: add *.kismet, aircraft_db.json/meta, SoapySDR/
- Bump version to 2.30.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 11:47:21 +01:00
James Smith 6d0b0437b3 fix: hide feed column in sensor dashboard mode, not just #output
The feed column (.pdir-feed-col) was left as a flex sibling with flex:1
while only its child #output was hidden, causing the sensor dashboard to
occupy ~50% width. Hide/restore the column itself in applyViewState().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 10:55:39 +01:00
15 changed files with 706 additions and 13 deletions
+11
View File
@@ -60,6 +60,17 @@ data/radiosonde/
# SDR capture files (large IQ recordings)
data/subghz/captures/
# Kismet capture files
*.kismet
*.kismet.json
# ADS-B aircraft database (downloaded at runtime)
aircraft_db.json
aircraft_db_meta.json
# SoapySDR build directory (compiled during setup, contains its own .git)
SoapySDR/
# Env files
.env
.env.*
+17
View File
@@ -2,6 +2,23 @@
All notable changes to iNTERCEPT will be documented in this file.
## [2.31.0] - 2026-07-07
### Added
- **BladeRF support** — bladeRF 2.0 micro (47 MHz 6 GHz) and bladeRF x40/x115 (300 MHz 3.8 GHz) detected and usable via SoapyBladeRF. TX capable. Supports FM demod, ADS-B, ISM, AIS, and I/Q capture.
- **HydraSDR RFOne support** — Detected and usable via SoapyHydraSDR (24 MHz 1800 MHz, RX only, 10 MHz instantaneous bandwidth). Supports FM demod, ADS-B, ISM, AIS, and I/Q capture.
---
## [2.30.0] - 2026-07-07
### Added
- **APRS station export** — `GET /aprs/export?format=json|csv` downloads all currently tracked APRS stations, consistent with the existing WiFi/Bluetooth/AIS export pattern.
- **USRP support** — Ettus USRP devices (N200, B200, B210) are now detected and usable via the SoapyUHD bridge. FM demod, ADS-B, ISM, AIS, and I/Q capture all supported through the existing SoapySDR toolchain.
- **MQTT data export** — Optional MQTT publisher that broadcasts decoded events from every module to a configurable broker. Disabled by default; set `INTERCEPT_MQTT_BROKER` to enable. Topics follow the pattern `<prefix>/<module>/<event_type>` (e.g. `intercept/aprs/packet`). Configure with `INTERCEPT_MQTT_PORT`, `INTERCEPT_MQTT_USER`, `INTERCEPT_MQTT_PASSWORD`, `INTERCEPT_MQTT_TOPIC_PREFIX`, `INTERCEPT_MQTT_RETAIN`.
---
## [2.29.0] - 2026-07-05
### Added
+26 -1
View File
@@ -7,10 +7,27 @@ import os
import sys
# Application version
VERSION = "2.29.0"
VERSION = "2.31.0"
# Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [
{
"version": "2.31.0",
"date": "July 2026",
"highlights": [
"Feat: BladeRF support — bladeRF 2.0 micro and x40/x115 detected and usable via SoapyBladeRF (47 MHz 6 GHz, TX capable)",
"Feat: HydraSDR RFOne support — detected and usable via SoapyHydraSDR (24 MHz 1800 MHz, RX only)",
],
},
{
"version": "2.30.0",
"date": "July 2026",
"highlights": [
"Feat: APRS station export — download all tracked stations as JSON or CSV via /aprs/export",
"Feat: USRP support — Ettus USRP N200/B200/B210 detected and usable via SoapyUHD bridge",
"Feat: MQTT data export — publish decoded events from all modules to a configurable broker (set INTERCEPT_MQTT_BROKER to enable)",
],
},
{
"version": "2.29.0",
"date": "July 2026",
@@ -534,6 +551,14 @@ ALERT_WEBHOOK_URL = _get_env("ALERT_WEBHOOK_URL", "")
ALERT_WEBHOOK_SECRET = _get_env("ALERT_WEBHOOK_SECRET", "")
ALERT_WEBHOOK_TIMEOUT = _get_env_int("ALERT_WEBHOOK_TIMEOUT", 5)
# MQTT data export (optional — disabled when MQTT_BROKER is empty)
MQTT_BROKER = _get_env("MQTT_BROKER", "")
MQTT_PORT = _get_env_int("MQTT_PORT", 1883)
MQTT_USER = _get_env("MQTT_USER", "")
MQTT_PASSWORD = _get_env("MQTT_PASSWORD", "")
MQTT_TOPIC_PREFIX = _get_env("MQTT_TOPIC_PREFIX", "intercept")
MQTT_RETAIN = _get_env_bool("MQTT_RETAIN", False)
# Admin credentials
ADMIN_USERNAME = _get_env("ADMIN_USERNAME", "admin")
ADMIN_PASSWORD = _get_env("ADMIN_PASSWORD", "admin")
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "intercept"
version = "2.27.0"
version = "2.31.0"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md"
requires-python = ">=3.9"
+3
View File
@@ -53,6 +53,9 @@ websocket-client>=1.6.0
# System health monitoring (optional - graceful fallback if unavailable)
psutil>=5.9.0
# MQTT data export (optional - only needed if INTERCEPT_MQTT_BROKER is set)
paho-mqtt>=1.6.0
# Production WSGI server (optional - falls back to Flask dev server)
gunicorn>=21.2.0
gevent>=23.9.0
+42
View File
@@ -1653,6 +1653,48 @@ def get_stations() -> Response:
return jsonify({"stations": list(aprs_stations.values()), "count": len(aprs_stations)})
@aprs_bp.route("/export")
def export_data() -> Response:
"""Export tracked APRS stations as JSON or CSV.
Query params:
format: 'json' or 'csv' (default: json)
"""
export_format = request.args.get("format", "json").lower()
stations = list(aprs_stations.values())
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
if export_format == "csv":
import io
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["Callsign", "Latitude", "Longitude", "Symbol", "Packet Type", "Last Seen"])
for s in stations:
writer.writerow(
[
s.get("callsign", ""),
s.get("lat", ""),
s.get("lon", ""),
s.get("symbol", ""),
s.get("packet_type", ""),
s.get("last_seen", ""),
]
)
response = Response(output.getvalue(), mimetype="text/csv")
response.headers["Content-Disposition"] = f"attachment; filename=aprs_stations_{timestamp}.csv"
return response
data = {
"stations": stations,
"count": len(stations),
"exported_at": datetime.now().isoformat(),
}
response = Response(json.dumps(data, indent=2), mimetype="application/json")
response.headers["Content-Disposition"] = f"attachment; filename=aprs_stations_{timestamp}.json"
return response
@aprs_bp.route("/data")
def aprs_data() -> Response:
"""Get APRS data snapshot for remote controller polling compatibility."""
+8 -5
View File
@@ -162,17 +162,20 @@ const SensorDashboard = (function () {
// ---- Show / hide / reset ----
function applyViewState(mode) {
const view = document.getElementById('sensorDashboardView');
const output = document.getElementById('output');
const view = document.getElementById('sensorDashboardView');
const output = document.getElementById('output');
const feedCol = document.querySelector('.pdir-feed-col');
if (mode === 'sensor') {
const saved = localStorage.getItem(STORAGE_KEY) || 'dashboard';
const isDash = saved === 'dashboard';
if (view) view.style.display = isDash ? 'block' : 'none';
if (output) output.style.display = isDash ? 'none' : '';
if (view) view.style.display = isDash ? 'block' : 'none';
if (output) output.style.display = isDash ? 'none' : '';
if (feedCol) feedCol.style.display = isDash ? 'none' : '';
_updateToggle(isDash);
} else {
if (view) view.style.display = 'none';
if (view) view.style.display = 'none';
if (feedCol) feedCol.style.display = '';
}
}
+7
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from typing import Any
from utils.alerts import get_alert_manager
from utils.mqtt import publish as mqtt_publish
from utils.recording import get_recording_manager
from utils.temporal_patterns import get_pattern_detector
@@ -54,6 +55,12 @@ def process_event(mode: str, event: dict | Any, event_type: str | None = None) -
# Alert failures should never break streaming
pass
try:
mqtt_publish(mode, event, event_type)
except Exception:
# MQTT failures should never break streaming
pass
def _extract_device_id(event: dict) -> str | None:
for field in DEVICE_ID_FIELDS:
+107
View File
@@ -0,0 +1,107 @@
"""
Optional MQTT data export.
Publishes decoded events from all modules to a configurable MQTT broker.
Disabled when INTERCEPT_MQTT_BROKER is not set — no broker, no connection,
no error.
Topics follow the pattern:
<prefix>/<module>/<event_type>
Payload is JSON, matching the SSE event structure already used by the
frontend. Configure via environment variables:
INTERCEPT_MQTT_BROKER broker hostname / IP (required to enable)
INTERCEPT_MQTT_PORT port (default 1883)
INTERCEPT_MQTT_USER username (optional)
INTERCEPT_MQTT_PASSWORD password (optional)
INTERCEPT_MQTT_TOPIC_PREFIX topic prefix (default "intercept")
INTERCEPT_MQTT_RETAIN retain flag (default false)
"""
from __future__ import annotations
import json
import logging
import threading
from typing import Any
logger = logging.getLogger(__name__)
_client = None
_lock = threading.Lock()
_enabled: bool | None = None # None = not yet initialised
def _get_config():
import config
return config
def _is_enabled() -> bool:
global _enabled
if _enabled is None:
cfg = _get_config()
_enabled = bool(cfg.MQTT_BROKER)
return _enabled
def _get_client():
"""Return a connected paho MQTT client, creating it on first call."""
global _client
if _client is not None:
return _client
with _lock:
if _client is not None:
return _client
try:
import paho.mqtt.client as mqtt
except ImportError:
logger.warning("paho-mqtt not installed; MQTT export disabled. Install with: pip install paho-mqtt")
return None
cfg = _get_config()
client = mqtt.Client(client_id="intercept", clean_session=True)
if cfg.MQTT_USER:
client.username_pw_set(cfg.MQTT_USER, cfg.MQTT_PASSWORD or None)
try:
client.connect(cfg.MQTT_BROKER, cfg.MQTT_PORT, keepalive=60)
client.loop_start()
logger.info("MQTT connected to %s:%d (prefix=%s)", cfg.MQTT_BROKER, cfg.MQTT_PORT, cfg.MQTT_TOPIC_PREFIX)
_client = client
except Exception as exc:
logger.warning("MQTT connect failed: %s — export disabled until restart", exc)
return None
return _client
def publish(mode: str, event: dict[str, Any], event_type: str | None = None) -> None:
"""Publish a decoded event to the MQTT broker.
Args:
mode: Source module name (e.g. 'aprs', 'adsb', 'pager').
event: The decoded event dict.
event_type: Optional sub-type string (e.g. 'packet', 'aircraft').
"""
if not _is_enabled():
return
client = _get_client()
if client is None:
return
cfg = _get_config()
subtopic = event_type or mode
topic = f"{cfg.MQTT_TOPIC_PREFIX}/{mode}/{subtopic}"
try:
payload = json.dumps(event, default=str)
client.publish(topic, payload, retain=cfg.MQTT_RETAIN)
except Exception as exc:
logger.debug("MQTT publish error on %s: %s", topic, exc)
+9
View File
@@ -27,11 +27,14 @@ 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
from .usrp import USRPCommandBuilder
from .validation import (
SDRValidationError,
get_capabilities_for_type,
@@ -53,6 +56,9 @@ class SDRFactory:
SDRType.HACKRF: HackRFCommandBuilder,
SDRType.AIRSPY: AirspyCommandBuilder,
SDRType.SDRPLAY: SDRPlayCommandBuilder,
SDRType.USRP: USRPCommandBuilder,
SDRType.BLADE_RF: BladeRFCommandBuilder,
SDRType.HYDRA_SDR: HydraSDRCommandBuilder,
}
@classmethod
@@ -211,6 +217,9 @@ __all__ = [
"HackRFCommandBuilder",
"AirspyCommandBuilder",
"SDRPlayCommandBuilder",
"USRPCommandBuilder",
"BladeRFCommandBuilder",
"HydraSDRCommandBuilder",
# Validation
"SDRValidationError",
"validate_frequency",
+3 -3
View File
@@ -20,9 +20,9 @@ class SDRType(Enum):
HACKRF = "hackrf"
AIRSPY = "airspy"
SDRPLAY = "sdrplay"
# Future support
# USRP = "usrp"
# BLADE_RF = "bladerf"
USRP = "usrp"
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
+9 -3
View File
@@ -49,10 +49,13 @@ 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
from .usrp import USRPCommandBuilder
builders = {
SDRType.RTL_SDR: RTLSDRCommandBuilder,
@@ -60,6 +63,9 @@ def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities:
SDRType.HACKRF: HackRFCommandBuilder,
SDRType.AIRSPY: AirspyCommandBuilder,
SDRType.SDRPLAY: SDRPlayCommandBuilder,
SDRType.USRP: USRPCommandBuilder,
SDRType.BLADE_RF: BladeRFCommandBuilder,
SDRType.HYDRA_SDR: HydraSDRCommandBuilder,
}
builder_class = builders.get(sdr_type)
@@ -90,9 +96,9 @@ def _driver_to_sdr_type(driver: str) -> SDRType | None:
"airspy": SDRType.AIRSPY,
"airspyhf": SDRType.AIRSPY, # Airspy HF+ uses same builder
"sdrplay": SDRType.SDRPLAY,
# Future support
# 'uhd': SDRType.USRP,
# 'bladerf': SDRType.BLADE_RF,
"uhd": SDRType.USRP,
"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
+153
View File
@@ -0,0 +1,153 @@
"""
USRP command builder implementation.
Uses SoapySDR (via the UHD driver / SoapyUHD bridge) for all signal
processing tasks. The same rx_fm / rx_sdr / rtl_433 / AIS-catcher
toolchain used by HackRF applies here because they all speak SoapySDR.
Tested target: USRP N200 / B200 / B210 (UHD driver, SoapyUHD bridge).
Requires: uhd, SoapySDR, SoapyUHD installed on the host or in Docker.
"""
from __future__ import annotations
from utils.dependencies import get_tool_path
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
class USRPCommandBuilder(CommandBuilder):
"""USRP command builder using SoapySDR / UHD."""
CAPABILITIES = SDRCapabilities(
sdr_type=SDRType.USRP,
freq_min_mhz=1.0,
freq_max_mhz=6000.0,
gain_min=0.0,
gain_max=76.0, # typical UHD normalised gain range
sample_rates=[1000000, 2000000, 4000000, 8000000, 16000000, 25000000],
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 USRP."""
if device.serial and device.serial not in ("N/A", "Unknown"):
return f"driver=uhd,serial={device.serial}"
return "driver=uhd"
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.USRP