From 5ed9674e1f8aeb1821bbe30bf703e3036e6a6c01 Mon Sep 17 00:00:00 2001 From: James Smith Date: Fri, 2 Jan 2026 14:23:51 +0000 Subject: [PATCH] Add multi-SDR hardware support (LimeSDR, HackRF) and setup script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SDR hardware abstraction layer (utils/sdr/) with support for: - RTL-SDR (existing, using native rtl_* tools) - LimeSDR (via SoapySDR) - HackRF (via SoapySDR) - Add hardware type selector to UI with capabilities display - Add automatic device detection across all supported hardware - Add hardware-specific parameter validation (frequency/gain ranges) - Add setup.sh script for automated dependency installation - Update README with multi-SDR docs, installation guide, troubleshooting - Add SoapySDR/LimeSDR/HackRF to dependency definitions - Fix dump1090 detection for Homebrew on Apple Silicon Macs - Remove defunct NOAA-15/18/19 satellites, add NOAA-21 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- README.md | 151 +++++++++++++++++--- app.py | 9 +- data/satellites.py | 12 +- routes/adsb.py | 41 +++++- routes/iridium.py | 51 +++++-- routes/pager.py | 37 ++--- routes/sensor.py | 28 ++-- setup.sh | 247 ++++++++++++++++++++++++++++++++ templates/index.html | 94 ++++++++++-- utils/dependencies.py | 66 +++++++++ utils/sdr/__init__.py | 196 +++++++++++++++++++++++++ utils/sdr/base.py | 149 +++++++++++++++++++ utils/sdr/detection.py | 306 ++++++++++++++++++++++++++++++++++++++++ utils/sdr/hackrf.py | 148 +++++++++++++++++++ utils/sdr/limesdr.py | 136 ++++++++++++++++++ utils/sdr/rtlsdr.py | 121 ++++++++++++++++ utils/sdr/validation.py | 257 +++++++++++++++++++++++++++++++++ 17 files changed, 1957 insertions(+), 92 deletions(-) create mode 100755 setup.sh create mode 100644 utils/sdr/__init__.py create mode 100644 utils/sdr/base.py create mode 100644 utils/sdr/detection.py create mode 100644 utils/sdr/hackrf.py create mode 100644 utils/sdr/limesdr.py create mode 100644 utils/sdr/rtlsdr.py create mode 100644 utils/sdr/validation.py diff --git a/README.md b/README.md index 154fb2d..a9e3dcf 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,9 @@ Instead of running command-line tools manually, INTERCEPT handles the process ma - **Message export** to CSV/JSON - **Signal activity meter** and waterfall display - **Message logging** to file with timestamps -- **RTL-SDR device detection** and selection +- **Multi-SDR hardware support** - RTL-SDR, LimeSDR, HackRF +- **Automatic device detection** across all supported hardware +- **Hardware-specific validation** - frequency/gain ranges per device type - **Configurable gain and PPM correction** - **Device intelligence** dashboard with tracking - **Disclaimer acceptance** on first use @@ -183,7 +185,10 @@ Instead of running command-line tools manually, INTERCEPT handles the process ma ## Requirements ### Hardware -- RTL-SDR compatible dongle (RTL2832U based) +- **SDR Device** (one of the following): + - RTL-SDR compatible dongle (RTL2832U based) - most common, budget-friendly + - LimeSDR / LimeSDR Mini - wider frequency range (100 kHz - 3.8 GHz) + - HackRF One - ultra-wide frequency range (1 MHz - 6 GHz) - WiFi adapter capable of monitor mode (for WiFi features) - Bluetooth adapter (for Bluetooth features) @@ -203,17 +208,41 @@ Instead of running command-line tools manually, INTERCEPT handles the process ma Install the tools for the features you need: +#### Core SDR Tools + | Tool | macOS | Ubuntu/Debian | Purpose | |------|-------|---------------|---------| -| rtl-sdr | `brew install rtl-sdr` | `sudo apt install rtl-sdr` | Required for all SDR features | -| multimon-ng | `Use MacPorts for now sudo ports install multimon-ng` | `sudo apt install multimon-ng` | Pager decoding | +| rtl-sdr | `brew install librtlsdr` | `sudo apt install rtl-sdr` | RTL-SDR support | +| multimon-ng | `sudo port install multimon-ng` | `sudo apt install multimon-ng` | Pager decoding | | rtl_433 | `brew install rtl_433` | `sudo apt install rtl-433` | 433MHz sensors | | dump1090 | `brew install dump1090-mutability` | `sudo apt install dump1090-mutability` | ADS-B aircraft | | aircrack-ng | `brew install aircrack-ng` | `sudo apt install aircrack-ng` | WiFi reconnaissance | | bluez | Built-in (limited) | `sudo apt install bluez bluetooth` | Bluetooth scanning | +#### Additional SDR Hardware (Optional) + +For LimeSDR or HackRF support, install SoapySDR and the appropriate driver: + +| Tool | macOS | Ubuntu/Debian | Purpose | +|------|-------|---------------|---------| +| SoapySDR | `brew install soapysdr` | `sudo apt install soapysdr-tools` | Universal SDR abstraction | +| LimeSDR | `brew install limesuite soapylms7` | `sudo apt install limesuite soapysdr-module-lms7` | LimeSDR support | +| HackRF | `brew install hackrf soapyhackrf` | `sudo apt install hackrf soapysdr-module-hackrf` | HackRF support | +| readsb | Build from source | Build from source | ADS-B with SoapySDR | + +> **Note:** RTL-SDR works out of the box. LimeSDR and HackRF require SoapySDR plus the hardware-specific driver. + ### Install and run +**Option 1: Automated setup (recommended)** +```bash +git clone https://github.com/smittix/intercept.git +cd intercept +./setup.sh # Installs Python deps and checks for external tools +sudo python3 intercept.py +``` + +**Option 2: Manual setup** ```bash git clone https://github.com/smittix/intercept.git cd intercept @@ -221,10 +250,30 @@ pip install -r requirements.txt sudo python3 intercept.py ``` +**Option 3: Using a virtual environment** +```bash +git clone https://github.com/smittix/intercept.git +cd intercept +python3 -m venv venv +source venv/bin/activate # On Linux/macOS +pip install -r requirements.txt +sudo venv/bin/python intercept.py +``` + Open `http://localhost:5050` in your browser. > **Note:** Running as root/sudo is recommended for full functionality (monitor mode, raw sockets, etc.) +### Check dependencies + +Run the built-in dependency checker to see what's installed and what's missing: + +```bash +python3 intercept.py --check-deps +``` + +This will show the status of all required tools and provide installation instructions for your platform. + ### Command-line options ``` @@ -241,11 +290,12 @@ python3 intercept.py --help ## Usage ### Pager Mode -1. **Select Device** - Choose your RTL-SDR device from the dropdown -2. **Set Frequency** - Enter a frequency in MHz or use a preset -3. **Choose Protocols** - Select which protocols to decode (POCSAG/FLEX) -4. **Adjust Settings** - Set gain, squelch, and PPM correction as needed -5. **Start Decoding** - Click the green "Start Decoding" button +1. **Select Hardware** - Choose your SDR type (RTL-SDR, LimeSDR, or HackRF) +2. **Select Device** - Choose your SDR device from the dropdown +3. **Set Frequency** - Enter a frequency in MHz or use a preset +4. **Choose Protocols** - Select which protocols to decode (POCSAG/FLEX) +5. **Adjust Settings** - Set gain, squelch, and PPM correction as needed +6. **Start Decoding** - Click the green "Start Decoding" button ### WiFi Mode 1. **Select Interface** - Choose a WiFi adapter capable of monitor mode @@ -262,14 +312,15 @@ python3 intercept.py --help 4. **View Devices** - Devices appear with name, address, and classification ### Aircraft Mode -1. **Check Tools** - Ensure dump1090 or rtl_adsb is installed -2. **Set Location** - Enter observer coordinates or click "Use GPS Location" -3. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception -4. **View Map** - Aircraft appear on the interactive Leaflet map -5. **Click Aircraft** - Click markers for detailed information -6. **Display Options** - Toggle callsigns, altitude, trails, range rings, clustering -7. **Filter Aircraft** - Use dropdown to show all, military, civil, or emergency only -8. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view +1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb) +2. **Check Tools** - Ensure dump1090 or readsb is installed +3. **Set Location** - Enter observer coordinates or click "Use GPS Location" +4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception +5. **View Map** - Aircraft appear on the interactive Leaflet map +6. **Click Aircraft** - Click markers for detailed information +7. **Display Options** - Toggle callsigns, altitude, trails, range rings, clustering +8. **Filter Aircraft** - Use dropdown to show all, military, civil, or emergency only +9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view ### Satellite Mode 1. **Set Location** - Enter observer coordinates or click "Use My Location" @@ -291,10 +342,46 @@ python3 intercept.py --help ## Troubleshooting -### No devices found -- Ensure your RTL-SDR is plugged in -- Check `rtl_test` works from command line -- On Linux, you may need to blacklist the DVB-T driver +### Python/pip installation issues + +**"externally-managed-environment" error (Ubuntu 23.04+, Debian 12+):** +```bash +# Option 1: Use a virtual environment (recommended) +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt + +# Option 2: Use pipx for isolated install +pipx install flask skyfield + +# Option 3: Override the restriction (not recommended) +pip install -r requirements.txt --break-system-packages +``` + +**"pip: command not found":** +```bash +# Ubuntu/Debian +sudo apt install python3-pip + +# macOS +python3 -m ensurepip --upgrade +``` + +**Permission denied errors:** +```bash +# Install to user directory instead +pip install --user -r requirements.txt +``` + +### No SDR devices found +- Ensure your SDR device is plugged in +- Check `rtl_test` (RTL-SDR) or `SoapySDRUtil --find` (LimeSDR/HackRF) +- On Linux, you may need udev rules (see setup.sh output) +- On Linux, blacklist the DVB-T driver: + ```bash + echo "blacklist dvb_usb_rtl28xxu" | sudo tee /etc/modprobe.d/blacklist-rtl.conf + sudo modprobe -r dvb_usb_rtl28xxu + ``` ### No messages appearing - Verify the frequency is correct for your area @@ -309,7 +396,13 @@ python3 intercept.py --help ### Device busy error - Click "Kill All Processes" to stop any stale processes -- Unplug and replug the RTL-SDR device +- Unplug and replug the SDR device +- Check if another application is using the device: `lsof | grep rtl` + +### LimeSDR/HackRF not detected +- Ensure SoapySDR is installed: `SoapySDRUtil --info` +- Check the driver module is loaded: `SoapySDRUtil --find` +- Verify permissions (may need udev rules or run as root) --- @@ -337,12 +430,26 @@ MIT License - see [LICENSE](LICENSE) for details. Created by **smittix** - [GitHub](https://github.com/smittix) +## Supported SDR Hardware + +| Hardware | Frequency Range | Gain Range | TX | Notes | +|----------|-----------------|------------|-----|-------| +| **RTL-SDR** | 24 - 1766 MHz | 0 - 50 dB | No | Most common, budget-friendly (~$25) | +| **LimeSDR** | 0.1 - 3800 MHz | 0 - 73 dB | Yes | Wide range, requires SoapySDR | +| **HackRF** | 1 - 6000 MHz | 0 - 62 dB | Yes | Ultra-wide range, requires SoapySDR | + +The application automatically detects connected devices and shows hardware-specific capabilities (frequency limits, gain ranges) in the UI. + +--- + ## Acknowledgments - [rtl-sdr](https://osmocom.org/projects/rtl-sdr/wiki) - RTL-SDR drivers - [multimon-ng](https://github.com/EliasOenal/multimon-ng) - Multi-protocol pager decoder - [rtl_433](https://github.com/merbanan/rtl_433) - 433MHz sensor decoder - [dump1090](https://github.com/flightaware/dump1090) - ADS-B decoder for aircraft tracking +- [SoapySDR](https://github.com/pothosware/SoapySDR) - Universal SDR abstraction layer +- [LimeSuite](https://github.com/myriadrf/LimeSuite) - LimeSDR driver and tools - [aircrack-ng](https://www.aircrack-ng.org/) - WiFi security auditing tools - [BlueZ](http://www.bluez.org/) - Official Linux Bluetooth protocol stack - [Leaflet.js](https://leafletjs.com/) - Interactive maps for aircraft tracking diff --git a/app.py b/app.py index 74e8301..5ff505f 100644 --- a/app.py +++ b/app.py @@ -26,7 +26,8 @@ from typing import Any from flask import Flask, render_template, jsonify, send_file, Response, request from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES -from utils.process import detect_devices, cleanup_stale_processes +from utils.process import cleanup_stale_processes +from utils.sdr import SDRFactory # Create Flask app @@ -105,7 +106,7 @@ def index() -> str: 'multimon': check_tool('multimon-ng'), 'rtl_433': check_tool('rtl_433') } - devices = detect_devices() + devices = [d.to_dict() for d in SDRFactory.detect_devices()] return render_template('index.html', tools=tools, devices=devices) @@ -116,7 +117,9 @@ def favicon() -> Response: @app.route('/devices') def get_devices() -> Response: - return jsonify(detect_devices()) + """Get all detected SDR devices with hardware type info.""" + devices = SDRFactory.detect_devices() + return jsonify([d.to_dict() for d in devices]) @app.route('/dependencies') diff --git a/data/satellites.py b/data/satellites.py index ca66d20..045a1fa 100644 --- a/data/satellites.py +++ b/data/satellites.py @@ -3,18 +3,12 @@ TLE_SATELLITES = { 'ISS': ('ISS (ZARYA)', '1 25544U 98067A 24001.00000000 .00000000 00000-0 00000-0 0 0000', '2 25544 51.6400 0.0000 0000000 0.0000 0.0000 15.50000000000000'), - 'NOAA-15': ('NOAA 15', - '1 25338U 98030A 24001.00000000 .00000-0 00000-0 00000-0 0 0000', - '2 25338 98.7300 0.0000 0010000 0.0000 0.0000 14.26000000000000'), - 'NOAA-18': ('NOAA 18', - '1 28654U 05018A 24001.00000000 .00000-0 00000-0 00000-0 0 0000', - '2 28654 98.8800 0.0000 0014000 0.0000 0.0000 14.12000000000000'), - 'NOAA-19': ('NOAA 19', - '1 33591U 09005A 24001.00000000 .00000-0 00000-0 00000-0 0 0000', - '2 33591 99.1900 0.0000 0014000 0.0000 0.0000 14.12000000000000'), 'NOAA-20': ('NOAA 20 (JPSS-1)', '1 43013U 17073A 24001.00000000 .00000-0 00000-0 00000-0 0 0000', '2 43013 98.7400 0.0000 0001000 0.0000 0.0000 14.19000000000000'), + 'NOAA-21': ('NOAA 21 (JPSS-2)', + '1 54234U 22150A 24001.00000000 .00000-0 00000-0 00000-0 0 0000', + '2 54234 98.7100 0.0000 0001000 0.0000 0.0000 14.19000000000000'), 'METEOR-M2': ('METEOR-M 2', '1 40069U 14037A 24001.00000000 .00000-0 00000-0 00000-0 0 0000', '2 40069 98.5400 0.0000 0005000 0.0000 0.0000 14.21000000000000'), diff --git a/routes/adsb.py b/routes/adsb.py index ceca415..3da51a1 100644 --- a/routes/adsb.py +++ b/routes/adsb.py @@ -18,6 +18,7 @@ import app as app_module from utils.logging import adsb_logger as logger from utils.validation import validate_device_index, validate_gain from utils.sse import format_sse +from utils.sdr import SDRFactory, SDRType adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb') @@ -29,9 +30,15 @@ adsb_last_message_time = None # Common installation paths for dump1090 (when not in PATH) DUMP1090_PATHS = [ + # Homebrew on Apple Silicon (M1/M2/M3) + '/opt/homebrew/bin/dump1090', + '/opt/homebrew/bin/dump1090-fa', + '/opt/homebrew/bin/dump1090-mutability', + # Homebrew on Intel Mac '/usr/local/bin/dump1090', '/usr/local/bin/dump1090-fa', '/usr/local/bin/dump1090-mutability', + # Linux system paths '/usr/bin/dump1090', '/usr/bin/dump1090-fa', '/usr/bin/dump1090-mutability', @@ -240,11 +247,23 @@ def start_adsb(): thread.start() return jsonify({'status': 'started', 'message': 'Connected to existing dump1090 service'}) - # No existing service, need to start dump1090 ourselves - dump1090_path = find_dump1090() + # Get SDR type from request + sdr_type_str = data.get('sdr_type', 'rtlsdr') + try: + sdr_type = SDRType(sdr_type_str) + except ValueError: + sdr_type = SDRType.RTL_SDR - if not dump1090_path: - return jsonify({'status': 'error', 'message': 'dump1090 not found. Install dump1090/dump1090-fa or ensure it is in /usr/local/bin/'}) + # For RTL-SDR, use dump1090. For other hardware, need readsb with SoapySDR + if sdr_type == SDRType.RTL_SDR: + dump1090_path = find_dump1090() + if not dump1090_path: + return jsonify({'status': 'error', 'message': 'dump1090 not found. Install dump1090/dump1090-fa or ensure it is in /usr/local/bin/'}) + else: + # For LimeSDR/HackRF, check for readsb (dump1090 with SoapySDR support) + dump1090_path = shutil.which('readsb') or find_dump1090() + if not dump1090_path: + return jsonify({'status': 'error', 'message': f'readsb or dump1090 not found for {sdr_type.value}. Install readsb with SoapySDR support.'}) # Kill any stale app-started process if app_module.adsb_process: @@ -255,7 +274,19 @@ def start_adsb(): pass app_module.adsb_process = None - cmd = [dump1090_path, '--net', '--gain', str(gain), '--device-index', str(device), '--quiet'] + # Create device object and build command via abstraction layer + sdr_device = SDRFactory.create_default_device(sdr_type, index=device) + builder = SDRFactory.get_builder(sdr_type) + + # Build ADS-B decoder command + cmd = builder.build_adsb_command( + device=sdr_device, + gain=float(gain) + ) + + # For RTL-SDR, ensure we use the found dump1090 path + if sdr_type == SDRType.RTL_SDR: + cmd[0] = dump1090_path try: app_module.adsb_process = subprocess.Popen( diff --git a/routes/iridium.py b/routes/iridium.py index f751260..d219ba3 100644 --- a/routes/iridium.py +++ b/routes/iridium.py @@ -23,6 +23,7 @@ import app as app_module from utils.logging import iridium_logger as logger from utils.validation import validate_frequency, validate_device_index, validate_gain from utils.sse import format_sse +from utils.sdr import SDRFactory, SDRType iridium_bp = Blueprint('iridium', __name__, url_prefix='/iridium') @@ -103,21 +104,45 @@ def start_iridium(): except (ValueError, AttributeError): return jsonify({'status': 'error', 'message': 'Invalid sample rate format'}), 400 - if not shutil.which('iridium-extractor') and not shutil.which('rtl_fm'): - return jsonify({ - 'status': 'error', - 'message': 'Iridium tools not found. Requires rtl_fm or iridium-extractor.' - }), 503 + # Get SDR type from request + sdr_type_str = data.get('sdr_type', 'rtlsdr') + try: + sdr_type = SDRType(sdr_type_str) + except ValueError: + sdr_type = SDRType.RTL_SDR + + # Check for required tools based on SDR type + if sdr_type == SDRType.RTL_SDR: + if not shutil.which('iridium-extractor') and not shutil.which('rtl_fm'): + return jsonify({ + 'status': 'error', + 'message': 'Iridium tools not found. Requires rtl_fm or iridium-extractor.' + }), 503 + else: + if not shutil.which('rx_fm'): + return jsonify({ + 'status': 'error', + 'message': f'rx_fm not found for {sdr_type.value}. Install SoapySDR tools.' + }), 503 try: - cmd = [ - 'rtl_fm', - '-f', f'{float(freq)}M', - '-g', str(gain), - '-s', sample_rate, - '-d', str(device), - '-' - ] + # Create device object and build command via abstraction layer + sdr_device = SDRFactory.create_default_device(sdr_type, index=device) + builder = SDRFactory.get_builder(sdr_type) + + # Parse sample rate + sample_rate_hz = int(float(sample_rate)) + + # Build FM demodulation command + cmd = builder.build_fm_demod_command( + device=sdr_device, + frequency_mhz=float(freq), + sample_rate=sample_rate_hz, + gain=float(gain), + ppm=None, + modulation='fm', + squelch=None + ) app_module.satellite_process = subprocess.Popen( cmd, diff --git a/routes/pager.py b/routes/pager.py index d26dc60..7d79703 100644 --- a/routes/pager.py +++ b/routes/pager.py @@ -21,6 +21,7 @@ from utils.logging import pager_logger as logger from utils.validation import validate_frequency, validate_device_index, validate_gain, validate_ppm from utils.sse import format_sse from utils.process import safe_terminate, register_process +from utils.sdr import SDRFactory, SDRType, SDRValidationError pager_bp = Blueprint('pager', __name__) @@ -201,25 +202,27 @@ def start_decoding() -> Response: elif proto == 'FLEX': decoders.extend(['-a', 'FLEX']) - # Build rtl_fm command - rtl_cmd = [ - 'rtl_fm', - '-d', str(device), - '-f', f'{freq}M', - '-M', 'fm', - '-s', '22050', - ] + # Get SDR type and build command via abstraction layer + sdr_type_str = data.get('sdr_type', 'rtlsdr') + try: + sdr_type = SDRType(sdr_type_str) + except ValueError: + sdr_type = SDRType.RTL_SDR - if gain and gain != '0': - rtl_cmd.extend(['-g', str(gain)]) + # Create device object and get command builder + sdr_device = SDRFactory.create_default_device(sdr_type, index=device) + builder = SDRFactory.get_builder(sdr_type) - if ppm and ppm != '0': - rtl_cmd.extend(['-p', str(ppm)]) - - if squelch and squelch != '0': - rtl_cmd.extend(['-l', str(squelch)]) - - rtl_cmd.append('-') + # Build FM demodulation command + rtl_cmd = builder.build_fm_demod_command( + device=sdr_device, + frequency_mhz=freq, + sample_rate=22050, + gain=float(gain) if gain and gain != '0' else None, + ppm=int(ppm) if ppm and ppm != '0' else None, + modulation='fm', + squelch=squelch if squelch and squelch != 0 else None + ) multimon_cmd = ['multimon-ng', '-t', 'raw'] + decoders + ['-f', 'alpha', '-'] diff --git a/routes/sensor.py b/routes/sensor.py index a3e99fe..3959168 100644 --- a/routes/sensor.py +++ b/routes/sensor.py @@ -17,6 +17,7 @@ from utils.logging import sensor_logger as logger from utils.validation import validate_frequency, validate_device_index, validate_gain, validate_ppm from utils.sse import format_sse from utils.process import safe_terminate, register_process +from utils.sdr import SDRFactory, SDRType sensor_bp = Blueprint('sensor', __name__) @@ -82,19 +83,24 @@ def start_sensor() -> Response: except queue.Empty: break - # Build rtl_433 command - cmd = [ - 'rtl_433', - '-d', str(device), - '-f', f'{freq}M', - '-F', 'json' - ] + # Get SDR type and build command via abstraction layer + sdr_type_str = data.get('sdr_type', 'rtlsdr') + try: + sdr_type = SDRType(sdr_type_str) + except ValueError: + sdr_type = SDRType.RTL_SDR - if gain and gain != 0: - cmd.extend(['-g', str(int(gain))]) + # Create device object and get command builder + sdr_device = SDRFactory.create_default_device(sdr_type, index=device) + builder = SDRFactory.get_builder(sdr_type) - if ppm and ppm != 0: - cmd.extend(['-p', str(ppm)]) + # Build ISM band decoder command + cmd = builder.build_ism_command( + device=sdr_device, + frequency_mhz=freq, + gain=float(gain) if gain and gain != 0 else None, + ppm=int(ppm) if ppm and ppm != 0 else None + ) full_cmd = ' '.join(cmd) logger.info(f"Running: {full_cmd}") diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..d1e713e --- /dev/null +++ b/setup.sh @@ -0,0 +1,247 @@ +#!/bin/bash +# +# INTERCEPT Setup Script +# Installs Python dependencies and checks for external tools +# + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +echo -e "${BLUE}" +echo " ___ _ _ _____ _____ ____ ____ _____ ____ _____ " +echo " |_ _| \\ | |_ _| ____| _ \\ / ___| ____| _ \\_ _|" +echo " | || \\| | | | | _| | |_) | | | _| | |_) || | " +echo " | || |\\ | | | | |___| _ <| |___| |___| __/ | | " +echo " |___|_| \\_| |_| |_____|_| \\_\\\\____|_____|_| |_| " +echo -e "${NC}" +echo "Signal Intelligence Platform - Setup Script" +echo "============================================" +echo "" + +# Detect OS +detect_os() { + if [[ "$OSTYPE" == "darwin"* ]]; then + OS="macos" + PKG_MANAGER="brew" + elif [[ -f /etc/debian_version ]]; then + OS="debian" + PKG_MANAGER="apt" + elif [[ -f /etc/redhat-release ]]; then + OS="redhat" + PKG_MANAGER="dnf" + elif [[ -f /etc/arch-release ]]; then + OS="arch" + PKG_MANAGER="pacman" + else + OS="unknown" + PKG_MANAGER="unknown" + fi + echo -e "${BLUE}Detected OS:${NC} $OS (package manager: $PKG_MANAGER)" +} + +# Check if a command exists +check_cmd() { + command -v "$1" &> /dev/null +} + +# Install Python dependencies +install_python_deps() { + echo "" + echo -e "${BLUE}[1/3] Installing Python dependencies...${NC}" + + if ! check_cmd python3; then + echo -e "${RED}Error: Python 3 is not installed${NC}" + echo "Please install Python 3.7 or later" + exit 1 + fi + + PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') + echo "Python version: $PYTHON_VERSION" + + if check_cmd pip3; then + pip3 install -r requirements.txt + elif check_cmd pip; then + pip install -r requirements.txt + else + python3 -m pip install -r requirements.txt + fi + + echo -e "${GREEN}Python dependencies installed successfully${NC}" +} + +# Check external tools +check_tools() { + echo "" + echo -e "${BLUE}[2/3] Checking external tools...${NC}" + echo "" + + MISSING_TOOLS=() + + # Core SDR tools + echo "Core SDR Tools:" + check_tool "rtl_fm" "RTL-SDR FM demodulator" + check_tool "rtl_test" "RTL-SDR device detection" + check_tool "multimon-ng" "Pager decoder" + check_tool "rtl_433" "433MHz sensor decoder" + check_tool "dump1090" "ADS-B decoder" + + echo "" + echo "Additional SDR Hardware (optional):" + check_tool "SoapySDRUtil" "SoapySDR (for LimeSDR/HackRF)" + check_tool "LimeUtil" "LimeSDR tools" + check_tool "hackrf_info" "HackRF tools" + + echo "" + echo "WiFi Tools:" + check_tool "airmon-ng" "WiFi monitor mode" + check_tool "airodump-ng" "WiFi scanner" + + echo "" + echo "Bluetooth Tools:" + check_tool "bluetoothctl" "Bluetooth controller" + check_tool "hcitool" "Bluetooth HCI tool" + + if [ ${#MISSING_TOOLS[@]} -gt 0 ]; then + echo "" + echo -e "${YELLOW}Some tools are missing. See installation instructions below.${NC}" + fi +} + +check_tool() { + local cmd=$1 + local desc=$2 + if check_cmd "$cmd"; then + echo -e " ${GREEN}✓${NC} $cmd - $desc" + else + echo -e " ${RED}✗${NC} $cmd - $desc ${YELLOW}(not found)${NC}" + MISSING_TOOLS+=("$cmd") + fi +} + +# Show installation instructions +show_install_instructions() { + echo "" + echo -e "${BLUE}[3/3] Installation instructions for missing tools${NC}" + echo "" + + if [ ${#MISSING_TOOLS[@]} -eq 0 ]; then + echo -e "${GREEN}All tools are installed!${NC}" + return + fi + + echo "Run the following commands to install missing tools:" + echo "" + + if [[ "$OS" == "macos" ]]; then + echo -e "${YELLOW}macOS (Homebrew):${NC}" + echo "" + + # Check if Homebrew is installed + if ! check_cmd brew; then + echo "First, install Homebrew:" + echo ' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' + echo "" + fi + + echo "# Core SDR tools" + echo "brew install librtlsdr multimon-ng rtl_433 dump1090-mutability" + echo "" + echo "# LimeSDR support (optional)" + echo "brew install soapysdr limesuite soapylms7" + echo "" + echo "# HackRF support (optional)" + echo "brew install hackrf soapyhackrf" + echo "" + echo "# WiFi tools" + echo "brew install aircrack-ng" + + elif [[ "$OS" == "debian" ]]; then + echo -e "${YELLOW}Ubuntu/Debian:${NC}" + echo "" + echo "# Core SDR tools" + echo "sudo apt update" + echo "sudo apt install rtl-sdr multimon-ng rtl-433 dump1090-mutability" + echo "" + echo "# LimeSDR support (optional)" + echo "sudo apt install soapysdr-tools limesuite soapysdr-module-lms7" + echo "" + echo "# HackRF support (optional)" + echo "sudo apt install hackrf soapysdr-module-hackrf" + echo "" + echo "# WiFi tools" + echo "sudo apt install aircrack-ng" + echo "" + echo "# Bluetooth tools" + echo "sudo apt install bluez bluetooth" + + elif [[ "$OS" == "arch" ]]; then + echo -e "${YELLOW}Arch Linux:${NC}" + echo "" + echo "# Core SDR tools" + echo "sudo pacman -S rtl-sdr multimon-ng" + echo "yay -S rtl_433 dump1090" + echo "" + echo "# LimeSDR/HackRF support (optional)" + echo "sudo pacman -S soapysdr limesuite hackrf" + + elif [[ "$OS" == "redhat" ]]; then + echo -e "${YELLOW}Fedora/RHEL:${NC}" + echo "" + echo "# Core SDR tools" + echo "sudo dnf install rtl-sdr" + echo "# multimon-ng, rtl_433, dump1090 may need to be built from source" + + else + echo "Please install the following tools manually:" + for tool in "${MISSING_TOOLS[@]}"; do + echo " - $tool" + done + fi +} + +# RTL-SDR udev rules (Linux only) +setup_udev_rules() { + if [[ "$OS" != "macos" ]] && [[ "$OS" != "unknown" ]]; then + echo "" + echo -e "${BLUE}RTL-SDR udev rules (Linux only):${NC}" + echo "" + echo "If your RTL-SDR is not detected, you may need to add udev rules:" + echo "" + echo "sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF" + echo 'SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE="0666"' + echo 'SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2832", MODE="0666"' + echo "EOF'" + echo "" + echo "sudo udevadm control --reload-rules" + echo "sudo udevadm trigger" + echo "" + echo "Then unplug and replug your RTL-SDR device." + fi +} + +# Main +main() { + detect_os + install_python_deps + check_tools + show_install_instructions + setup_udev_rules + + echo "" + echo "============================================" + echo -e "${GREEN}Setup complete!${NC}" + echo "" + echo "To start INTERCEPT:" + echo " sudo python3 intercept.py" + echo "" + echo "Then open http://localhost:5050 in your browser" + echo "" +} + +main "$@" diff --git a/templates/index.html b/templates/index.html index 1106a46..e8be93b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3105,18 +3105,33 @@
-

RTL-SDR Device

+

SDR Device

+ + +
+
+
+
+
+ Freq:24-1766 MHz + Gain:0-50 dB +
+
@@ -4730,7 +4745,8 @@ frequency: freq, gain: gain, ppm: ppm, - device: device + device: device, + sdr_type: getSelectedSDRType() }; fetch('/start_sensor', { @@ -5139,19 +5155,66 @@ } } + // SDR hardware capabilities + const sdrCapabilities = { + 'rtlsdr': { name: 'RTL-SDR', freq_min: 24, freq_max: 1766, gain_min: 0, gain_max: 50 }, + 'limesdr': { name: 'LimeSDR', freq_min: 0.1, freq_max: 3800, gain_min: 0, gain_max: 73 }, + 'hackrf': { name: 'HackRF', freq_min: 1, freq_max: 6000, gain_min: 0, gain_max: 62 } + }; + + // Current device list with SDR type info + let currentDeviceList = []; + + function onSDRTypeChanged() { + const sdrType = document.getElementById('sdrTypeSelect').value; + const select = document.getElementById('deviceSelect'); + + // Filter devices by selected SDR type + const filteredDevices = currentDeviceList.filter(d => + (d.sdr_type || 'rtlsdr') === sdrType + ); + + if (filteredDevices.length === 0) { + select.innerHTML = ``; + } else { + select.innerHTML = filteredDevices.map(d => + `` + ).join(''); + } + + // Update capabilities display + updateCapabilitiesDisplay(sdrType); + } + + function updateCapabilitiesDisplay(sdrType) { + const caps = sdrCapabilities[sdrType]; + if (caps) { + document.getElementById('capFreqRange').textContent = `${caps.freq_min}-${caps.freq_max} MHz`; + document.getElementById('capGainRange').textContent = `${caps.gain_min}-${caps.gain_max} dB`; + } + } + function refreshDevices() { fetch('/devices') .then(r => r.json()) .then(devices => { + // Store full device list with SDR type info + currentDeviceList = devices; deviceList = devices; - const select = document.getElementById('deviceSelect'); - if (devices.length === 0) { - select.innerHTML = ''; - } else { - select.innerHTML = devices.map(d => - `` - ).join(''); + + // Auto-select SDR type if devices found + if (devices.length > 0) { + const firstType = devices[0].sdr_type || 'rtlsdr'; + document.getElementById('sdrTypeSelect').value = firstType; } + + // Trigger filter update + onSDRTypeChanged(); + }) + .catch(err => { + console.error('Failed to refresh devices:', err); + const select = document.getElementById('deviceSelect'); + select.innerHTML = ''; }); } @@ -5159,6 +5222,10 @@ return document.getElementById('deviceSelect').value; } + function getSelectedSDRType() { + return document.getElementById('sdrTypeSelect').value; + } + function getSelectedProtocols() { const protocols = []; if (document.getElementById('proto_pocsag512').checked) protocols.push('POCSAG512'); @@ -5187,6 +5254,7 @@ squelch: squelch, ppm: ppm, device: device, + sdr_type: getSelectedSDRType(), protocols: protocols }; @@ -8759,11 +8827,12 @@ function startAdsbScan() { const gain = document.getElementById('adsbGain').value; const device = getSelectedDevice(); + const sdr_type = getSelectedSDRType(); fetch('/adsb/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ gain, device }) + body: JSON.stringify({ gain, device, sdr_type }) }) .then(r => r.json()) .then(data => { @@ -9759,11 +9828,12 @@ const gain = document.getElementById('iridiumGain').value; const sampleRate = document.getElementById('iridiumSampleRate').value; const device = getSelectedDevice(); + const sdr_type = getSelectedSDRType(); fetch('/iridium/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ freq, gain, sampleRate, device }) + body: JSON.stringify({ freq, gain, sampleRate, device, sdr_type }) }) .then(r => r.json()) .then(data => { diff --git a/utils/dependencies.py b/utils/dependencies.py index 456e060..5f30bf3 100644 --- a/utils/dependencies.py +++ b/utils/dependencies.py @@ -200,6 +200,72 @@ TOOL_DEPENDENCIES = { } } } + }, + 'sdr_hardware': { + 'name': 'SDR Hardware Support', + 'tools': { + 'SoapySDRUtil': { + 'required': False, + 'description': 'Universal SDR abstraction (required for LimeSDR, HackRF)', + 'install': { + 'apt': 'sudo apt install soapysdr-tools', + 'brew': 'brew install soapysdr', + 'manual': 'https://github.com/pothosware/SoapySDR' + } + }, + 'rx_fm': { + 'required': False, + 'description': 'SoapySDR FM receiver (for non-RTL hardware)', + 'install': { + 'manual': 'Part of SoapySDR utilities or build from source' + } + }, + 'LimeUtil': { + 'required': False, + 'description': 'LimeSDR native utilities', + 'install': { + 'apt': 'sudo apt install limesuite', + 'brew': 'brew install limesuite', + 'manual': 'https://github.com/myriadrf/LimeSuite' + } + }, + 'SoapyLMS7': { + 'required': False, + 'description': 'SoapySDR plugin for LimeSDR', + 'install': { + 'apt': 'sudo apt install soapysdr-module-lms7', + 'brew': 'brew install soapylms7', + 'manual': 'https://github.com/myriadrf/LimeSuite' + } + }, + 'hackrf_info': { + 'required': False, + 'description': 'HackRF native utilities', + 'install': { + 'apt': 'sudo apt install hackrf', + 'brew': 'brew install hackrf', + 'manual': 'https://github.com/greatscottgadgets/hackrf' + } + }, + 'SoapyHackRF': { + 'required': False, + 'description': 'SoapySDR plugin for HackRF', + 'install': { + 'apt': 'sudo apt install soapysdr-module-hackrf', + 'brew': 'brew install soapyhackrf', + 'manual': 'https://github.com/pothosware/SoapyHackRF' + } + }, + 'readsb': { + 'required': False, + 'description': 'ADS-B decoder with SoapySDR support', + 'install': { + 'apt': 'Build from source with SoapySDR support', + 'brew': 'Build from source with SoapySDR support', + 'manual': 'https://github.com/wiedehopf/readsb' + } + } + } } } diff --git a/utils/sdr/__init__.py b/utils/sdr/__init__.py new file mode 100644 index 0000000..aa7cbcc --- /dev/null +++ b/utils/sdr/__init__.py @@ -0,0 +1,196 @@ +""" +SDR Hardware Abstraction Layer. + +This module provides a unified interface for multiple SDR hardware types +including RTL-SDR, LimeSDR, and HackRF. Use SDRFactory to detect devices +and get appropriate command builders. + +Example usage: + from utils.sdr import SDRFactory, SDRType + + # Detect all connected devices + devices = SDRFactory.detect_devices() + + # Get a command builder for a specific device + builder = SDRFactory.get_builder_for_device(devices[0]) + + # Or get a builder by type + builder = SDRFactory.get_builder(SDRType.RTL_SDR) + + # Build commands + cmd = builder.build_fm_demod_command(device, frequency_mhz=153.35) +""" + +from typing import Optional + +from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType +from .detection import detect_all_devices +from .rtlsdr import RTLSDRCommandBuilder +from .limesdr import LimeSDRCommandBuilder +from .hackrf import HackRFCommandBuilder +from .validation import ( + SDRValidationError, + validate_frequency, + validate_gain, + validate_sample_rate, + validate_ppm, + validate_device_index, + validate_squelch, + get_capabilities_for_type, +) + + +class SDRFactory: + """Factory for creating SDR command builders and detecting devices.""" + + _builders: dict[SDRType, type[CommandBuilder]] = { + SDRType.RTL_SDR: RTLSDRCommandBuilder, + SDRType.LIME_SDR: LimeSDRCommandBuilder, + SDRType.HACKRF: HackRFCommandBuilder, + } + + @classmethod + def get_builder(cls, sdr_type: SDRType) -> CommandBuilder: + """ + Get a command builder for the specified SDR type. + + Args: + sdr_type: The SDR hardware type + + Returns: + CommandBuilder instance for the specified type + + Raises: + ValueError: If the SDR type is not supported + """ + builder_class = cls._builders.get(sdr_type) + if not builder_class: + raise ValueError(f"Unsupported SDR type: {sdr_type}") + return builder_class() + + @classmethod + def get_builder_for_device(cls, device: SDRDevice) -> CommandBuilder: + """ + Get a command builder for a specific device. + + Args: + device: The SDR device + + Returns: + CommandBuilder instance for the device's type + """ + return cls.get_builder(device.sdr_type) + + @classmethod + def detect_devices(cls) -> list[SDRDevice]: + """ + Detect all available SDR devices. + + Returns: + List of detected SDR devices + """ + return detect_all_devices() + + @classmethod + def get_supported_types(cls) -> list[SDRType]: + """ + Get list of supported SDR types. + + Returns: + List of supported SDRType values + """ + return list(cls._builders.keys()) + + @classmethod + def get_capabilities(cls, sdr_type: SDRType) -> SDRCapabilities: + """ + Get capabilities for an SDR type. + + Args: + sdr_type: The SDR hardware type + + Returns: + SDRCapabilities for the specified type + """ + builder = cls.get_builder(sdr_type) + return builder.get_capabilities() + + @classmethod + def get_all_capabilities(cls) -> dict[str, dict]: + """ + Get capabilities for all supported SDR types. + + Returns: + Dictionary mapping SDR type names to capability dicts + """ + capabilities = {} + for sdr_type in cls._builders: + caps = cls.get_capabilities(sdr_type) + capabilities[sdr_type.value] = { + 'name': sdr_type.name.replace('_', ' '), + 'freq_min_mhz': caps.freq_min_mhz, + 'freq_max_mhz': caps.freq_max_mhz, + 'gain_min': caps.gain_min, + 'gain_max': caps.gain_max, + 'sample_rates': caps.sample_rates, + 'supports_bias_t': caps.supports_bias_t, + 'supports_ppm': caps.supports_ppm, + 'tx_capable': caps.tx_capable, + } + return capabilities + + @classmethod + def create_default_device( + cls, + sdr_type: SDRType, + index: int = 0, + serial: str = 'N/A' + ) -> SDRDevice: + """ + Create a default device object for a given SDR type. + + Useful when device detection didn't provide full details but + you know the hardware type. + + Args: + sdr_type: The SDR hardware type + index: Device index (default 0) + serial: Device serial (default 'N/A') + + Returns: + SDRDevice with default capabilities for the type + """ + caps = cls.get_capabilities(sdr_type) + return SDRDevice( + sdr_type=sdr_type, + index=index, + name=f'{sdr_type.name.replace("_", " ")} Device {index}', + serial=serial, + driver=sdr_type.value, + capabilities=caps + ) + + +# Export commonly used items at package level +__all__ = [ + # Factory + 'SDRFactory', + # Types and classes + 'SDRType', + 'SDRDevice', + 'SDRCapabilities', + 'CommandBuilder', + # Builders + 'RTLSDRCommandBuilder', + 'LimeSDRCommandBuilder', + 'HackRFCommandBuilder', + # Validation + 'SDRValidationError', + 'validate_frequency', + 'validate_gain', + 'validate_sample_rate', + 'validate_ppm', + 'validate_device_index', + 'validate_squelch', + 'get_capabilities_for_type', +] diff --git a/utils/sdr/base.py b/utils/sdr/base.py new file mode 100644 index 0000000..29f2f8f --- /dev/null +++ b/utils/sdr/base.py @@ -0,0 +1,149 @@ +""" +Base classes and types for SDR hardware abstraction. + +This module provides the core abstractions for supporting multiple SDR hardware +types (RTL-SDR, LimeSDR, HackRF, etc.) through a unified interface. +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional + + +class SDRType(Enum): + """Supported SDR hardware types.""" + RTL_SDR = "rtlsdr" + LIME_SDR = "limesdr" + HACKRF = "hackrf" + # Future support + # USRP = "usrp" + # BLADE_RF = "bladerf" + + +@dataclass +class SDRCapabilities: + """Hardware capabilities for an SDR device.""" + sdr_type: SDRType + freq_min_mhz: float # Minimum frequency in MHz + freq_max_mhz: float # Maximum frequency in MHz + gain_min: float # Minimum gain in dB + gain_max: float # Maximum gain in dB + sample_rates: list[int] = field(default_factory=list) # Supported sample rates + supports_bias_t: bool = False # Bias-T support + supports_ppm: bool = True # PPM correction support + tx_capable: bool = False # Can transmit + + +@dataclass +class SDRDevice: + """Detected SDR device.""" + sdr_type: SDRType + index: int + name: str + serial: str + driver: str # e.g., "rtlsdr", "lime", "hackrf" + capabilities: SDRCapabilities + + def to_dict(self) -> dict: + """Convert to dictionary for JSON serialization.""" + return { + 'index': self.index, + 'name': self.name, + 'serial': self.serial, + 'sdr_type': self.sdr_type.value, + 'driver': self.driver, + 'capabilities': { + 'freq_min_mhz': self.capabilities.freq_min_mhz, + 'freq_max_mhz': self.capabilities.freq_max_mhz, + 'gain_min': self.capabilities.gain_min, + 'gain_max': self.capabilities.gain_max, + 'sample_rates': self.capabilities.sample_rates, + 'supports_bias_t': self.capabilities.supports_bias_t, + 'supports_ppm': self.capabilities.supports_ppm, + 'tx_capable': self.capabilities.tx_capable, + } + } + + +class CommandBuilder(ABC): + """Abstract base class for building SDR commands.""" + + @abstractmethod + def build_fm_demod_command( + self, + device: SDRDevice, + frequency_mhz: float, + sample_rate: int = 22050, + gain: Optional[float] = None, + ppm: Optional[int] = None, + modulation: str = "fm", + squelch: Optional[int] = None + ) -> list[str]: + """ + Build FM demodulation command (for pager, iridium). + + Args: + device: The SDR device to use + frequency_mhz: Center frequency in MHz + sample_rate: Audio sample rate (default 22050 for pager) + gain: Gain in dB (None for auto) + ppm: PPM frequency correction + modulation: Modulation type (fm, am, etc.) + squelch: Squelch level + + Returns: + Command as list of strings for subprocess + """ + pass + + @abstractmethod + def build_adsb_command( + self, + device: SDRDevice, + gain: Optional[float] = None + ) -> list[str]: + """ + Build ADS-B decoder command. + + Args: + device: The SDR device to use + gain: Gain in dB (None for auto) + + Returns: + Command as list of strings for subprocess + """ + pass + + @abstractmethod + def build_ism_command( + self, + device: SDRDevice, + frequency_mhz: float = 433.92, + gain: Optional[float] = None, + ppm: Optional[int] = None + ) -> list[str]: + """ + Build ISM band decoder command (433MHz sensors). + + Args: + device: The SDR device to use + frequency_mhz: Center frequency in MHz (default 433.92) + gain: Gain in dB (None for auto) + ppm: PPM frequency correction + + Returns: + Command as list of strings for subprocess + """ + pass + + @abstractmethod + def get_capabilities(self) -> SDRCapabilities: + """Return hardware capabilities for this SDR type.""" + pass + + @classmethod + @abstractmethod + def get_sdr_type(cls) -> SDRType: + """Return the SDR type this builder handles.""" + pass diff --git a/utils/sdr/detection.py b/utils/sdr/detection.py new file mode 100644 index 0000000..ec0a08e --- /dev/null +++ b/utils/sdr/detection.py @@ -0,0 +1,306 @@ +""" +Multi-hardware SDR device detection. + +Detects RTL-SDR devices via rtl_test and other SDR hardware via SoapySDR. +""" + +import logging +import re +import shutil +import subprocess +from typing import Optional + +from .base import SDRCapabilities, SDRDevice, SDRType + +logger = logging.getLogger(__name__) + + +def _check_tool(name: str) -> bool: + """Check if a tool is available in PATH.""" + return shutil.which(name) is not None + + +def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities: + """Get default capabilities for an SDR type.""" + # Import here to avoid circular imports + from .rtlsdr import RTLSDRCommandBuilder + from .limesdr import LimeSDRCommandBuilder + from .hackrf import HackRFCommandBuilder + + builders = { + SDRType.RTL_SDR: RTLSDRCommandBuilder, + SDRType.LIME_SDR: LimeSDRCommandBuilder, + SDRType.HACKRF: HackRFCommandBuilder, + } + + builder_class = builders.get(sdr_type) + if builder_class: + return builder_class.CAPABILITIES + + # Fallback generic capabilities + return SDRCapabilities( + sdr_type=sdr_type, + freq_min_mhz=1.0, + freq_max_mhz=6000.0, + gain_min=0.0, + gain_max=50.0, + sample_rates=[2048000], + supports_bias_t=False, + supports_ppm=False, + tx_capable=False + ) + + +def _driver_to_sdr_type(driver: str) -> Optional[SDRType]: + """Map SoapySDR driver name to SDRType.""" + mapping = { + 'rtlsdr': SDRType.RTL_SDR, + 'lime': SDRType.LIME_SDR, + 'limesdr': SDRType.LIME_SDR, + 'hackrf': SDRType.HACKRF, + # Future support + # 'uhd': SDRType.USRP, + # 'bladerf': SDRType.BLADE_RF, + } + return mapping.get(driver.lower()) + + +def detect_rtlsdr_devices() -> list[SDRDevice]: + """ + Detect RTL-SDR devices using rtl_test. + + This uses the native rtl_test tool for best compatibility with + existing RTL-SDR installations. + """ + devices: list[SDRDevice] = [] + + if not _check_tool('rtl_test'): + logger.debug("rtl_test not found, skipping RTL-SDR detection") + return devices + + try: + result = subprocess.run( + ['rtl_test', '-t'], + capture_output=True, + text=True, + timeout=5 + ) + output = result.stderr + result.stdout + + # Parse device info from rtl_test output + # Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001" + device_pattern = r'(\d+):\s+(.+?)(?:,\s*SN:\s*(\S+))?$' + + from .rtlsdr import RTLSDRCommandBuilder + + for line in output.split('\n'): + line = line.strip() + match = re.match(device_pattern, line) + if match: + devices.append(SDRDevice( + sdr_type=SDRType.RTL_SDR, + index=int(match.group(1)), + name=match.group(2).strip().rstrip(','), + serial=match.group(3) or 'N/A', + driver='rtlsdr', + capabilities=RTLSDRCommandBuilder.CAPABILITIES + )) + + # Fallback: if we found devices but couldn't parse details + if not devices: + found_match = re.search(r'Found (\d+) device', output) + if found_match: + count = int(found_match.group(1)) + for i in range(count): + devices.append(SDRDevice( + sdr_type=SDRType.RTL_SDR, + index=i, + name=f'RTL-SDR Device {i}', + serial='Unknown', + driver='rtlsdr', + capabilities=RTLSDRCommandBuilder.CAPABILITIES + )) + + except subprocess.TimeoutExpired: + logger.warning("rtl_test timed out") + except Exception as e: + logger.debug(f"RTL-SDR detection error: {e}") + + return devices + + +def detect_soapy_devices() -> list[SDRDevice]: + """ + Detect SDR devices via SoapySDR. + + This detects LimeSDR, HackRF, USRP, BladeRF, and other SoapySDR-compatible + devices. RTL-SDR devices may also appear here but we prefer the native + detection for those. + """ + devices: list[SDRDevice] = [] + + if not _check_tool('SoapySDRUtil'): + logger.debug("SoapySDRUtil not found, skipping SoapySDR detection") + return devices + + try: + result = subprocess.run( + ['SoapySDRUtil', '--find'], + capture_output=True, + text=True, + timeout=10 + ) + + # Parse SoapySDR output + # Format varies but typically includes lines like: + # " driver = lime" + # " serial = 0009060B00123456" + # " label = LimeSDR Mini [USB 3.0] 0009060B00123456" + + current_device: dict = {} + device_counts: dict[SDRType, int] = {} + + for line in result.stdout.split('\n'): + line = line.strip() + + # Start of new device block + if line.startswith('Found device'): + if current_device.get('driver'): + _add_soapy_device(devices, current_device, device_counts) + current_device = {} + continue + + # Parse key = value pairs + if ' = ' in line: + key, value = line.split(' = ', 1) + key = key.strip() + value = value.strip() + current_device[key] = value + + # Don't forget the last device + if current_device.get('driver'): + _add_soapy_device(devices, current_device, device_counts) + + except subprocess.TimeoutExpired: + logger.warning("SoapySDRUtil timed out") + except Exception as e: + logger.debug(f"SoapySDR detection error: {e}") + + return devices + + +def _add_soapy_device( + devices: list[SDRDevice], + device_info: dict, + device_counts: dict[SDRType, int] +) -> None: + """Add a device from SoapySDR detection to the list.""" + driver = device_info.get('driver', '').lower() + sdr_type = _driver_to_sdr_type(driver) + + if not sdr_type: + logger.debug(f"Unknown SoapySDR driver: {driver}") + return + + # Skip RTL-SDR devices from SoapySDR (we use native detection) + if sdr_type == SDRType.RTL_SDR: + return + + # Track device index per type + if sdr_type not in device_counts: + device_counts[sdr_type] = 0 + + index = device_counts[sdr_type] + device_counts[sdr_type] += 1 + + devices.append(SDRDevice( + sdr_type=sdr_type, + index=index, + name=device_info.get('label', device_info.get('driver', 'Unknown')), + serial=device_info.get('serial', 'N/A'), + driver=driver, + capabilities=_get_capabilities_for_type(sdr_type) + )) + + +def detect_hackrf_devices() -> list[SDRDevice]: + """ + Detect HackRF devices using native hackrf_info tool. + + Fallback for when SoapySDR is not available. + """ + devices: list[SDRDevice] = [] + + if not _check_tool('hackrf_info'): + return devices + + try: + result = subprocess.run( + ['hackrf_info'], + capture_output=True, + text=True, + timeout=5 + ) + + # Parse hackrf_info output + # Look for "Serial number:" lines + serial_pattern = r'Serial number:\s*(\S+)' + from .hackrf import HackRFCommandBuilder + + serials_found = re.findall(serial_pattern, result.stdout) + + for i, serial in enumerate(serials_found): + devices.append(SDRDevice( + sdr_type=SDRType.HACKRF, + index=i, + name=f'HackRF One', + serial=serial, + driver='hackrf', + capabilities=HackRFCommandBuilder.CAPABILITIES + )) + + # Fallback: check if any HackRF found without serial + if not devices and 'Found HackRF' in result.stdout: + devices.append(SDRDevice( + sdr_type=SDRType.HACKRF, + index=0, + name='HackRF One', + serial='Unknown', + driver='hackrf', + capabilities=HackRFCommandBuilder.CAPABILITIES + )) + + except Exception as e: + logger.debug(f"HackRF detection error: {e}") + + return devices + + +def detect_all_devices() -> list[SDRDevice]: + """ + Detect all connected SDR devices across all supported hardware types. + + Returns a unified list of SDRDevice objects sorted by type and index. + """ + devices: list[SDRDevice] = [] + + # RTL-SDR via native tool (primary method) + devices.extend(detect_rtlsdr_devices()) + + # SoapySDR devices (LimeSDR, HackRF, etc.) + soapy_devices = detect_soapy_devices() + devices.extend(soapy_devices) + + # Native HackRF detection (fallback if SoapySDR didn't find it) + hackrf_from_soapy = any(d.sdr_type == SDRType.HACKRF for d in soapy_devices) + if not hackrf_from_soapy: + devices.extend(detect_hackrf_devices()) + + # Sort by type name, then index + devices.sort(key=lambda d: (d.sdr_type.value, d.index)) + + logger.info(f"Detected {len(devices)} SDR device(s)") + for d in devices: + logger.debug(f" {d.sdr_type.value}:{d.index} - {d.name} (serial: {d.serial})") + + return devices diff --git a/utils/sdr/hackrf.py b/utils/sdr/hackrf.py new file mode 100644 index 0000000..5fa3a07 --- /dev/null +++ b/utils/sdr/hackrf.py @@ -0,0 +1,148 @@ +""" +HackRF command builder implementation. + +Uses SoapySDR-based tools for FM demodulation and signal capture. +HackRF supports 1 MHz to 6 GHz frequency range. +""" + +from typing import Optional + +from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType + + +class HackRFCommandBuilder(CommandBuilder): + """HackRF command builder using SoapySDR tools.""" + + CAPABILITIES = SDRCapabilities( + sdr_type=SDRType.HACKRF, + freq_min_mhz=1.0, # 1 MHz + freq_max_mhz=6000.0, # 6 GHz + gain_min=0.0, + gain_max=62.0, # LNA (0-40) + VGA (0-62) + sample_rates=[2000000, 4000000, 8000000, 10000000, 20000000], + supports_bias_t=True, + supports_ppm=False, + tx_capable=True + ) + + def _build_device_string(self, device: SDRDevice) -> str: + """Build SoapySDR device string for HackRF.""" + if device.serial and device.serial != 'N/A': + return f'driver=hackrf,serial={device.serial}' + return f'driver=hackrf' + + def _split_gain(self, gain: float) -> tuple[int, int]: + """ + Split total gain into LNA and VGA components. + + HackRF has two gain stages: + - LNA: 0-40 dB (RF amplifier) + - VGA: 0-62 dB (IF amplifier) + + This function distributes the requested gain across both stages. + """ + if gain <= 40: + # All to LNA first + return int(gain), 0 + else: + # Max out LNA, rest to VGA + lna = 40 + vga = min(62, int(gain - 40)) + return lna, vga + + def build_fm_demod_command( + self, + device: SDRDevice, + frequency_mhz: float, + sample_rate: int = 22050, + gain: Optional[float] = None, + ppm: Optional[int] = None, + modulation: str = "fm", + squelch: Optional[int] = None + ) -> list[str]: + """ + Build SoapySDR rx_fm command for FM demodulation. + + For pager decoding and iridium capture with HackRF. + """ + device_str = self._build_device_string(device) + + cmd = [ + 'rx_fm', + '-d', device_str, + '-f', f'{frequency_mhz}M', + '-M', modulation, + '-s', str(sample_rate), + ] + + if gain is not None and gain > 0: + lna, vga = self._split_gain(gain) + cmd.extend(['-g', f'LNA={lna},VGA={vga}']) + + if squelch is not None and squelch > 0: + cmd.extend(['-l', str(squelch)]) + + # Output to stdout + cmd.append('-') + + return cmd + + def build_adsb_command( + self, + device: SDRDevice, + gain: Optional[float] = None + ) -> list[str]: + """ + Build dump1090/readsb command with SoapySDR support for ADS-B decoding. + + Uses readsb which has better SoapySDR support. + """ + 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: Optional[float] = None, + ppm: Optional[int] = None + ) -> list[str]: + """ + Build rtl_433 command with SoapySDR support for ISM band decoding. + + rtl_433 has native SoapySDR support via -d flag. + """ + 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 get_capabilities(self) -> SDRCapabilities: + """Return HackRF capabilities.""" + return self.CAPABILITIES + + @classmethod + def get_sdr_type(cls) -> SDRType: + """Return SDR type.""" + return SDRType.HACKRF diff --git a/utils/sdr/limesdr.py b/utils/sdr/limesdr.py new file mode 100644 index 0000000..7f7ffdd --- /dev/null +++ b/utils/sdr/limesdr.py @@ -0,0 +1,136 @@ +""" +LimeSDR command builder implementation. + +Uses SoapySDR-based tools for FM demodulation and signal capture. +LimeSDR supports 100 kHz to 3.8 GHz frequency range. +""" + +from typing import Optional + +from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType + + +class LimeSDRCommandBuilder(CommandBuilder): + """LimeSDR command builder using SoapySDR tools.""" + + CAPABILITIES = SDRCapabilities( + sdr_type=SDRType.LIME_SDR, + freq_min_mhz=0.1, # 100 kHz + freq_max_mhz=3800.0, # 3.8 GHz + gain_min=0.0, + gain_max=73.0, # Combined LNA + TIA + PGA + sample_rates=[1000000, 2000000, 4000000, 8000000, 10000000, 20000000], + supports_bias_t=False, + supports_ppm=False, # Uses TCXO, no PPM correction needed + tx_capable=True + ) + + def _build_device_string(self, device: SDRDevice) -> str: + """Build SoapySDR device string for LimeSDR.""" + if device.serial and device.serial != 'N/A': + return f'driver=lime,serial={device.serial}' + return f'driver=lime' + + def build_fm_demod_command( + self, + device: SDRDevice, + frequency_mhz: float, + sample_rate: int = 22050, + gain: Optional[float] = None, + ppm: Optional[int] = None, + modulation: str = "fm", + squelch: Optional[int] = None + ) -> list[str]: + """ + Build SoapySDR rx_fm command for FM demodulation. + + For pager decoding and iridium capture with LimeSDR. + """ + device_str = self._build_device_string(device) + + cmd = [ + 'rx_fm', + '-d', device_str, + '-f', f'{frequency_mhz}M', + '-M', modulation, + '-s', str(sample_rate), + ] + + if gain is not None and gain > 0: + # LimeSDR gain is applied to LNAH element + cmd.extend(['-g', f'LNAH={int(gain)}']) + + if squelch is not None and squelch > 0: + cmd.extend(['-l', str(squelch)]) + + # Output to stdout + cmd.append('-') + + return cmd + + def build_adsb_command( + self, + device: SDRDevice, + gain: Optional[float] = None + ) -> list[str]: + """ + Build dump1090 command with SoapySDR support for ADS-B decoding. + + Uses dump1090 compiled with SoapySDR support, or readsb as alternative. + Note: Requires dump1090 with SoapySDR support or readsb. + """ + device_str = self._build_device_string(device) + + # Try readsb first (better SoapySDR support), fallback to dump1090 + 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: Optional[float] = None, + ppm: Optional[int] = None + ) -> list[str]: + """ + Build rtl_433 command with SoapySDR support for ISM band decoding. + + rtl_433 has native SoapySDR support via -d flag. + """ + 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))]) + + # PPM not typically needed for LimeSDR (TCXO) + # but include if specified + if ppm is not None and ppm != 0: + cmd.extend(['-p', str(ppm)]) + + return cmd + + def get_capabilities(self) -> SDRCapabilities: + """Return LimeSDR capabilities.""" + return self.CAPABILITIES + + @classmethod + def get_sdr_type(cls) -> SDRType: + """Return SDR type.""" + return SDRType.LIME_SDR diff --git a/utils/sdr/rtlsdr.py b/utils/sdr/rtlsdr.py new file mode 100644 index 0000000..7cdf243 --- /dev/null +++ b/utils/sdr/rtlsdr.py @@ -0,0 +1,121 @@ +""" +RTL-SDR command builder implementation. + +Uses native rtl_* tools (rtl_fm, rtl_433) and dump1090 for maximum compatibility +with existing RTL-SDR installations. No SoapySDR dependency required. +""" + +from typing import Optional + +from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType + + +class RTLSDRCommandBuilder(CommandBuilder): + """RTL-SDR command builder using native rtl_* tools.""" + + CAPABILITIES = SDRCapabilities( + sdr_type=SDRType.RTL_SDR, + freq_min_mhz=24.0, + freq_max_mhz=1766.0, + gain_min=0.0, + gain_max=49.6, + sample_rates=[250000, 1024000, 1800000, 2048000, 2400000], + supports_bias_t=True, + supports_ppm=True, + tx_capable=False + ) + + def build_fm_demod_command( + self, + device: SDRDevice, + frequency_mhz: float, + sample_rate: int = 22050, + gain: Optional[float] = None, + ppm: Optional[int] = None, + modulation: str = "fm", + squelch: Optional[int] = None + ) -> list[str]: + """ + Build rtl_fm command for FM demodulation. + + Used for pager decoding and iridium capture. + """ + cmd = [ + 'rtl_fm', + '-d', str(device.index), + '-f', f'{frequency_mhz}M', + '-M', modulation, + '-s', str(sample_rate), + ] + + if gain is not None and gain > 0: + cmd.extend(['-g', str(gain)]) + + if ppm is not None and ppm != 0: + cmd.extend(['-p', str(ppm)]) + + if squelch is not None and squelch > 0: + cmd.extend(['-l', str(squelch)]) + + # Output to stdout for piping + cmd.append('-') + + return cmd + + def build_adsb_command( + self, + device: SDRDevice, + gain: Optional[float] = None + ) -> list[str]: + """ + Build dump1090 command for ADS-B decoding. + + Uses dump1090 with network output for SBS data streaming. + """ + cmd = [ + 'dump1090', + '--net', + '--device-index', str(device.index), + '--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: Optional[float] = None, + ppm: Optional[int] = None + ) -> list[str]: + """ + Build rtl_433 command for ISM band sensor decoding. + + Outputs JSON for easy parsing. + """ + cmd = [ + 'rtl_433', + '-d', str(device.index), + '-f', f'{frequency_mhz}M', + '-F', 'json' + ] + + if gain is not None and gain > 0: + cmd.extend(['-g', str(int(gain))]) + + if ppm is not None and ppm != 0: + cmd.extend(['-p', str(ppm)]) + + return cmd + + def get_capabilities(self) -> SDRCapabilities: + """Return RTL-SDR capabilities.""" + return self.CAPABILITIES + + @classmethod + def get_sdr_type(cls) -> SDRType: + """Return SDR type.""" + return SDRType.RTL_SDR diff --git a/utils/sdr/validation.py b/utils/sdr/validation.py new file mode 100644 index 0000000..186eea5 --- /dev/null +++ b/utils/sdr/validation.py @@ -0,0 +1,257 @@ +""" +Hardware-specific parameter validation for SDR devices. + +Validates frequency, gain, sample rate, and other parameters against +the capabilities of specific SDR hardware. +""" + +from typing import Optional + +from .base import SDRCapabilities, SDRDevice, SDRType + + +class SDRValidationError(ValueError): + """Raised when SDR parameter validation fails.""" + pass + + +def validate_frequency( + freq_mhz: float, + device: Optional[SDRDevice] = None, + capabilities: Optional[SDRCapabilities] = None +) -> float: + """ + Validate frequency against device capabilities. + + Args: + freq_mhz: Frequency in MHz + device: SDR device (optional, takes precedence) + capabilities: SDR capabilities (used if device not provided) + + Returns: + Validated frequency in MHz + + Raises: + SDRValidationError: If frequency is out of range + """ + if device: + caps = device.capabilities + elif capabilities: + caps = capabilities + else: + # Default RTL-SDR range for backwards compatibility + caps = SDRCapabilities( + sdr_type=SDRType.RTL_SDR, + freq_min_mhz=24.0, + freq_max_mhz=1766.0, + gain_min=0.0, + gain_max=50.0 + ) + + if not caps.freq_min_mhz <= freq_mhz <= caps.freq_max_mhz: + raise SDRValidationError( + f"Frequency {freq_mhz} MHz out of range for {caps.sdr_type.value}. " + f"Valid range: {caps.freq_min_mhz}-{caps.freq_max_mhz} MHz" + ) + + return freq_mhz + + +def validate_gain( + gain: float, + device: Optional[SDRDevice] = None, + capabilities: Optional[SDRCapabilities] = None +) -> float: + """ + Validate gain against device capabilities. + + Args: + gain: Gain in dB + device: SDR device (optional, takes precedence) + capabilities: SDR capabilities (used if device not provided) + + Returns: + Validated gain in dB + + Raises: + SDRValidationError: If gain is out of range + """ + if device: + caps = device.capabilities + elif capabilities: + caps = capabilities + else: + # Default range for backwards compatibility + caps = SDRCapabilities( + sdr_type=SDRType.RTL_SDR, + freq_min_mhz=24.0, + freq_max_mhz=1766.0, + gain_min=0.0, + gain_max=50.0 + ) + + # Allow 0 for auto gain + if gain == 0: + return gain + + if not caps.gain_min <= gain <= caps.gain_max: + raise SDRValidationError( + f"Gain {gain} dB out of range for {caps.sdr_type.value}. " + f"Valid range: {caps.gain_min}-{caps.gain_max} dB" + ) + + return gain + + +def validate_sample_rate( + rate: int, + device: Optional[SDRDevice] = None, + capabilities: Optional[SDRCapabilities] = None, + snap_to_nearest: bool = True +) -> int: + """ + Validate sample rate against device capabilities. + + Args: + rate: Sample rate in Hz + device: SDR device (optional, takes precedence) + capabilities: SDR capabilities (used if device not provided) + snap_to_nearest: If True, return nearest valid rate instead of raising + + Returns: + Validated sample rate in Hz + + Raises: + SDRValidationError: If rate is invalid and snap_to_nearest is False + """ + if device: + caps = device.capabilities + elif capabilities: + caps = capabilities + else: + return rate # No validation without capabilities + + if not caps.sample_rates: + return rate # No restrictions + + if rate in caps.sample_rates: + return rate + + if snap_to_nearest: + # Find closest valid rate + closest = min(caps.sample_rates, key=lambda x: abs(x - rate)) + return closest + + raise SDRValidationError( + f"Sample rate {rate} Hz not supported by {caps.sdr_type.value}. " + f"Valid rates: {caps.sample_rates}" + ) + + +def validate_ppm( + ppm: int, + device: Optional[SDRDevice] = None, + capabilities: Optional[SDRCapabilities] = None +) -> int: + """ + Validate PPM frequency correction. + + Args: + ppm: PPM correction value + device: SDR device (optional, takes precedence) + capabilities: SDR capabilities (used if device not provided) + + Returns: + Validated PPM value + + Raises: + SDRValidationError: If PPM is out of range or not supported + """ + if device: + caps = device.capabilities + elif capabilities: + caps = capabilities + else: + caps = None + + # Check if device supports PPM + if caps and not caps.supports_ppm: + if ppm != 0: + # Warn but don't fail - some hardware just ignores PPM + pass + return 0 # Return 0 to indicate no correction + + # Standard PPM range + if not -1000 <= ppm <= 1000: + raise SDRValidationError( + f"PPM correction {ppm} out of range. Valid range: -1000 to 1000" + ) + + return ppm + + +def validate_device_index(index: int) -> int: + """ + Validate device index. + + Args: + index: Device index (0-255) + + Returns: + Validated device index + + Raises: + SDRValidationError: If index is out of range + """ + if not 0 <= index <= 255: + raise SDRValidationError( + f"Device index {index} out of range. Valid range: 0-255" + ) + return index + + +def validate_squelch(squelch: int) -> int: + """ + Validate squelch level. + + Args: + squelch: Squelch level (0-1000, 0 = off) + + Returns: + Validated squelch level + + Raises: + SDRValidationError: If squelch is out of range + """ + if not 0 <= squelch <= 1000: + raise SDRValidationError( + f"Squelch {squelch} out of range. Valid range: 0-1000" + ) + return squelch + + +def get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities: + """ + Get default capabilities for an SDR type. + + Args: + sdr_type: The SDR type + + Returns: + SDRCapabilities for the specified type + """ + from .rtlsdr import RTLSDRCommandBuilder + from .limesdr import LimeSDRCommandBuilder + from .hackrf import HackRFCommandBuilder + + builders = { + SDRType.RTL_SDR: RTLSDRCommandBuilder, + SDRType.LIME_SDR: LimeSDRCommandBuilder, + SDRType.HACKRF: HackRFCommandBuilder, + } + + builder_class = builders.get(sdr_type) + if builder_class: + return builder_class.CAPABILITIES + + raise SDRValidationError(f"Unknown SDR type: {sdr_type}")