From 29025059afb623e317b9fddc3f2ef2518dfb37ab Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 8 Jan 2026 21:20:26 +0000 Subject: [PATCH] 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 --- routes/listening_post.py | 128 ++++++++++++++++++++++++++++++--------- 1 file changed, 100 insertions(+), 28 deletions(-) diff --git a/routes/listening_post.py b/routes/listening_post.py index 88a07f0..6009004 100644 --- a/routes/listening_post.py +++ b/routes/listening_post.py @@ -21,6 +21,7 @@ from utils.constants import ( SSE_KEEPALIVE_INTERVAL, PROCESS_TERMINATE_TIMEOUT, ) +from utils.sdr import SDRFactory, SDRType logger = get_logger('intercept.listening_post') @@ -55,6 +56,7 @@ scanner_config = { 'device': 0, 'gain': 40, 'bias_t': False, # Bias-T power for external LNA + 'sdr_type': 'rtlsdr', # SDR type: rtlsdr, hackrf, airspy, limesdr, sdrplay } # Activity log @@ -75,6 +77,11 @@ def find_rtl_fm() -> str | None: 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: """Find ffmpeg for audio encoding.""" return shutil.which('ffmpeg') @@ -341,14 +348,19 @@ def _start_audio_stream(frequency: float, modulation: str): # Stop any existing stream _stop_audio_stream_internal() - rtl_fm_path = find_rtl_fm() ffmpeg_path = find_ffmpeg() - - if not rtl_fm_path or not ffmpeg_path: + if not ffmpeg_path: + logger.error("ffmpeg not found") 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': sample_rate = 170000 resample_rate = 32000 @@ -359,19 +371,50 @@ def _start_audio_stream(frequency: float, modulation: str): sample_rate = 24000 resample_rate = 24000 - rtl_cmd = [ - rtl_fm_path, - '-M', modulation, - '-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']), - ] - # Add bias-t flag if enabled (for external LNA power) - if scanner_config.get('bias_t', False): - rtl_cmd.append('-T') + # Build the SDR command based on device type + if sdr_type == SDRType.RTL_SDR: + # Use rtl_fm for RTL-SDR devices + rtl_fm_path = find_rtl_fm() + if not rtl_fm_path: + logger.error("rtl_fm not found") + return + + freq_hz = int(frequency * 1e6) + sdr_cmd = [ + rtl_fm_path, + '-M', modulation, + '-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 = [ ffmpeg_path, @@ -392,9 +435,9 @@ def _start_audio_stream(frequency: float, modulation: str): ] 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( - rtl_cmd, + sdr_cmd, stdout=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: 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 if audio_process.poll() is not None: @@ -426,7 +469,7 @@ def _start_audio_stream(frequency: float, modulation: str): audio_running = True audio_frequency = frequency 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: logger.error(f"Failed to start audio stream: {e}") @@ -476,12 +519,23 @@ def _stop_audio_stream_internal(): def check_tools() -> Response: """Check for required tools.""" rtl_fm = find_rtl_fm() + rx_fm = find_rx_fm() 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({ 'rtl_fm': rtl_fm is not None, + 'rx_fm': rx_fm 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['gain'] = int(data.get('gain', 40)) 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: return jsonify({ 'status': 'error', @@ -524,12 +579,20 @@ def start_scanner() -> Response: 'message': 'start_freq must be less than end_freq' }), 400 - # Check tools - if not find_rtl_fm(): - return jsonify({ - 'status': 'error', - 'message': 'rtl_fm not found. Install rtl-sdr tools.' - }), 503 + # Check tools based on SDR type + sdr_type = scanner_config['sdr_type'] + if sdr_type == 'rtlsdr': + if not find_rtl_fm(): + return jsonify({ + '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 scanner_running = True @@ -688,6 +751,7 @@ def start_audio() -> Response: squelch = int(data.get('squelch', 0)) gain = int(data.get('gain', 40)) device = int(data.get('device', 0)) + sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower() except (ValueError, TypeError) as e: return jsonify({ 'status': 'error', @@ -707,10 +771,18 @@ def start_audio() -> Response: 'message': f'Invalid modulation. Use: {", ".join(valid_mods)}' }), 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 scanner_config['squelch'] = squelch scanner_config['gain'] = gain scanner_config['device'] = device + scanner_config['sdr_type'] = sdr_type _start_audio_stream(frequency, modulation)