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}")