Add HackRF/Airspy/LimeSDR/SDRPlay support to Listening Post

The Listening Post module now uses the SDR abstraction layer to support
non-RTL-SDR devices via rx_fm (SoapySDR). Previously only rtl_fm worked.

- Add sdr_type parameter to /audio/start and /scanner/start endpoints
- Use appropriate command builder based on SDR type
- Update /tools endpoint to report rx_fm and supported SDR types

Fixes compatibility issue reported by DragonOS users with HackRF/Airspy.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-01-08 21:20:26 +00:00
parent 6229c25872
commit 29025059af
+100 -28
View File
@@ -21,6 +21,7 @@ from utils.constants import (
SSE_KEEPALIVE_INTERVAL, SSE_KEEPALIVE_INTERVAL,
PROCESS_TERMINATE_TIMEOUT, PROCESS_TERMINATE_TIMEOUT,
) )
from utils.sdr import SDRFactory, SDRType
logger = get_logger('intercept.listening_post') logger = get_logger('intercept.listening_post')
@@ -55,6 +56,7 @@ scanner_config = {
'device': 0, 'device': 0,
'gain': 40, 'gain': 40,
'bias_t': False, # Bias-T power for external LNA 'bias_t': False, # Bias-T power for external LNA
'sdr_type': 'rtlsdr', # SDR type: rtlsdr, hackrf, airspy, limesdr, sdrplay
} }
# Activity log # Activity log
@@ -75,6 +77,11 @@ def find_rtl_fm() -> str | None:
return shutil.which('rtl_fm') return shutil.which('rtl_fm')
def find_rx_fm() -> str | None:
"""Find rx_fm binary (SoapySDR FM demodulator for HackRF/Airspy/LimeSDR)."""
return shutil.which('rx_fm')
def find_ffmpeg() -> str | None: def find_ffmpeg() -> str | None:
"""Find ffmpeg for audio encoding.""" """Find ffmpeg for audio encoding."""
return shutil.which('ffmpeg') return shutil.which('ffmpeg')
@@ -341,14 +348,19 @@ def _start_audio_stream(frequency: float, modulation: str):
# Stop any existing stream # Stop any existing stream
_stop_audio_stream_internal() _stop_audio_stream_internal()
rtl_fm_path = find_rtl_fm()
ffmpeg_path = find_ffmpeg() ffmpeg_path = find_ffmpeg()
if not ffmpeg_path:
if not rtl_fm_path or not ffmpeg_path: logger.error("ffmpeg not found")
return return
freq_hz = int(frequency * 1e6) # Determine SDR type and build appropriate command
sdr_type_str = scanner_config.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
# Set sample rates based on modulation
if modulation == 'wfm': if modulation == 'wfm':
sample_rate = 170000 sample_rate = 170000
resample_rate = 32000 resample_rate = 32000
@@ -359,19 +371,50 @@ def _start_audio_stream(frequency: float, modulation: str):
sample_rate = 24000 sample_rate = 24000
resample_rate = 24000 resample_rate = 24000
rtl_cmd = [ # Build the SDR command based on device type
rtl_fm_path, if sdr_type == SDRType.RTL_SDR:
'-M', modulation, # Use rtl_fm for RTL-SDR devices
'-f', str(freq_hz), rtl_fm_path = find_rtl_fm()
'-s', str(sample_rate), if not rtl_fm_path:
'-r', str(resample_rate), logger.error("rtl_fm not found")
'-g', str(scanner_config['gain']), return
'-d', str(scanner_config['device']),
'-l', str(scanner_config['squelch']), freq_hz = int(frequency * 1e6)
] sdr_cmd = [
# Add bias-t flag if enabled (for external LNA power) rtl_fm_path,
if scanner_config.get('bias_t', False): '-M', modulation,
rtl_cmd.append('-T') '-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(scanner_config['gain']),
'-d', str(scanner_config['device']),
'-l', str(scanner_config['squelch']),
]
if scanner_config.get('bias_t', False):
sdr_cmd.append('-T')
else:
# Use SDR abstraction layer for HackRF, Airspy, LimeSDR, SDRPlay
rx_fm_path = find_rx_fm()
if not rx_fm_path:
logger.error(f"rx_fm not found - required for {sdr_type.value}. Install SoapySDR utilities.")
return
# Create device and get command builder
device = SDRFactory.create_default_device(sdr_type, index=scanner_config['device'])
builder = SDRFactory.get_builder(sdr_type)
# Build FM demod command
sdr_cmd = builder.build_fm_demod_command(
device=device,
frequency_mhz=frequency,
sample_rate=resample_rate,
gain=float(scanner_config['gain']),
modulation=modulation,
squelch=scanner_config['squelch'],
bias_t=scanner_config.get('bias_t', False)
)
# Ensure we use the found rx_fm path
sdr_cmd[0] = rx_fm_path
encoder_cmd = [ encoder_cmd = [
ffmpeg_path, ffmpeg_path,
@@ -392,9 +435,9 @@ def _start_audio_stream(frequency: float, modulation: str):
] ]
try: try:
logger.info(f"Starting rtl_fm: {' '.join(rtl_cmd)}") logger.info(f"Starting SDR ({sdr_type.value}): {' '.join(sdr_cmd)}")
audio_rtl_process = subprocess.Popen( audio_rtl_process = subprocess.Popen(
rtl_cmd, sdr_cmd,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE stderr=subprocess.PIPE
) )
@@ -415,7 +458,7 @@ def _start_audio_stream(frequency: float, modulation: str):
if audio_rtl_process.poll() is not None: if audio_rtl_process.poll() is not None:
stderr = audio_rtl_process.stderr.read().decode() if audio_rtl_process.stderr else '' stderr = audio_rtl_process.stderr.read().decode() if audio_rtl_process.stderr else ''
logger.error(f"rtl_fm exited immediately: {stderr}") logger.error(f"SDR process exited immediately: {stderr}")
return return
if audio_process.poll() is not None: if audio_process.poll() is not None:
@@ -426,7 +469,7 @@ def _start_audio_stream(frequency: float, modulation: str):
audio_running = True audio_running = True
audio_frequency = frequency audio_frequency = frequency
audio_modulation = modulation audio_modulation = modulation
logger.info(f"Audio stream started: {frequency} MHz ({modulation})") logger.info(f"Audio stream started: {frequency} MHz ({modulation}) via {sdr_type.value}")
except Exception as e: except Exception as e:
logger.error(f"Failed to start audio stream: {e}") logger.error(f"Failed to start audio stream: {e}")
@@ -476,12 +519,23 @@ def _stop_audio_stream_internal():
def check_tools() -> Response: def check_tools() -> Response:
"""Check for required tools.""" """Check for required tools."""
rtl_fm = find_rtl_fm() rtl_fm = find_rtl_fm()
rx_fm = find_rx_fm()
ffmpeg = find_ffmpeg() ffmpeg = find_ffmpeg()
# Determine which SDR types are supported
supported_sdr_types = []
if rtl_fm:
supported_sdr_types.append('rtlsdr')
if rx_fm:
# rx_fm from SoapySDR supports these types
supported_sdr_types.extend(['hackrf', 'airspy', 'limesdr', 'sdrplay'])
return jsonify({ return jsonify({
'rtl_fm': rtl_fm is not None, 'rtl_fm': rtl_fm is not None,
'rx_fm': rx_fm is not None,
'ffmpeg': ffmpeg is not None, 'ffmpeg': ffmpeg is not None,
'available': rtl_fm is not None and ffmpeg is not None 'available': (rtl_fm is not None or rx_fm is not None) and ffmpeg is not None,
'supported_sdr_types': supported_sdr_types
}) })
@@ -511,6 +565,7 @@ def start_scanner() -> Response:
scanner_config['device'] = int(data.get('device', 0)) scanner_config['device'] = int(data.get('device', 0))
scanner_config['gain'] = int(data.get('gain', 40)) scanner_config['gain'] = int(data.get('gain', 40))
scanner_config['bias_t'] = bool(data.get('bias_t', False)) scanner_config['bias_t'] = bool(data.get('bias_t', False))
scanner_config['sdr_type'] = str(data.get('sdr_type', 'rtlsdr')).lower()
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -524,12 +579,20 @@ def start_scanner() -> Response:
'message': 'start_freq must be less than end_freq' 'message': 'start_freq must be less than end_freq'
}), 400 }), 400
# Check tools # Check tools based on SDR type
if not find_rtl_fm(): sdr_type = scanner_config['sdr_type']
return jsonify({ if sdr_type == 'rtlsdr':
'status': 'error', if not find_rtl_fm():
'message': 'rtl_fm not found. Install rtl-sdr tools.' return jsonify({
}), 503 'status': 'error',
'message': 'rtl_fm not found. Install rtl-sdr tools.'
}), 503
else:
if not find_rx_fm():
return jsonify({
'status': 'error',
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
}), 503
# Start scanner thread # Start scanner thread
scanner_running = True scanner_running = True
@@ -688,6 +751,7 @@ def start_audio() -> Response:
squelch = int(data.get('squelch', 0)) squelch = int(data.get('squelch', 0))
gain = int(data.get('gain', 40)) gain = int(data.get('gain', 40))
device = int(data.get('device', 0)) device = int(data.get('device', 0))
sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower()
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -707,10 +771,18 @@ def start_audio() -> Response:
'message': f'Invalid modulation. Use: {", ".join(valid_mods)}' 'message': f'Invalid modulation. Use: {", ".join(valid_mods)}'
}), 400 }), 400
valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay']
if sdr_type not in valid_sdr_types:
return jsonify({
'status': 'error',
'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}'
}), 400
# Update config for audio # Update config for audio
scanner_config['squelch'] = squelch scanner_config['squelch'] = squelch
scanner_config['gain'] = gain scanner_config['gain'] = gain
scanner_config['device'] = device scanner_config['device'] = device
scanner_config['sdr_type'] = sdr_type
_start_audio_stream(frequency, modulation) _start_audio_stream(frequency, modulation)