From 8fca54e52339edd521dfb5fb744d88816474044d Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 6 Feb 2026 13:50:09 +0000 Subject: [PATCH] Fix APRS rtl_fm startup failure and SDR device conflicts (#122) Add SDR device reservation to prevent conflicts with other modes, and capture rtl_fm stderr so actual error messages are reported to the user instead of a generic exit code. Co-Authored-By: Claude Opus 4.6 --- routes/aprs.py | 64 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/routes/aprs.py b/routes/aprs.py index 41bb886..cf7fe61 100644 --- a/routes/aprs.py +++ b/routes/aprs.py @@ -13,7 +13,7 @@ import tempfile import threading import time from datetime import datetime -from subprocess import DEVNULL, PIPE, STDOUT +from subprocess import PIPE, STDOUT from typing import Generator, Optional from flask import Blueprint, jsonify, request, Response @@ -31,6 +31,9 @@ from utils.constants import ( aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs') +# Track which SDR device is being used +aprs_active_device: int | None = None + # APRS frequencies by region (MHz) APRS_FREQUENCIES = { 'north_america': '144.390', @@ -1301,7 +1304,7 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces This function reads from the decoder's stdout (text mode, line-buffered). The decoder's stderr is merged into stdout (STDOUT) to avoid deadlocks. - rtl_fm's stderr is sent to DEVNULL for the same reason. + rtl_fm's stderr is captured via PIPE with a monitor thread. Outputs two types of messages to the queue: - type='aprs': Decoded APRS packets @@ -1383,6 +1386,7 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces logger.error(f"APRS stream error: {e}") app_module.aprs_queue.put({'type': 'error', 'message': str(e)}) finally: + global aprs_active_device app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'}) # Cleanup processes for proc in [rtl_process, decoder_process]: @@ -1394,6 +1398,10 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces proc.kill() except Exception: pass + # Release SDR device + if aprs_active_device is not None: + app_module.release_sdr_device(aprs_active_device) + aprs_active_device = None @aprs_bp.route('/tools') @@ -1441,6 +1449,7 @@ def get_stations() -> Response: def start_aprs() -> Response: """Start APRS decoder.""" global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations + global aprs_active_device with app_module.aprs_lock: if app_module.aprs_process and app_module.aprs_process.poll() is None: @@ -1477,6 +1486,16 @@ def start_aprs() -> Response: except ValueError as e: return jsonify({'status': 'error', 'message': str(e)}), 400 + # Reserve SDR device to prevent conflicts with other modes + error = app_module.reserve_sdr_device(device, 'APRS') + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error + }), 409 + aprs_active_device = device + # Get frequency for region region = data.get('region', 'north_america') frequency = APRS_FREQUENCIES.get(region, '144.390') @@ -1552,15 +1571,25 @@ def start_aprs() -> Response: try: # Start rtl_fm with stdout piped to decoder. - # stderr goes to DEVNULL to prevent blocking (rtl_fm logs to stderr). + # stderr is captured via PIPE so errors are reported to the user. # NOTE: RTL-SDR Blog V4 may show offset-tuned frequency in logs - this is normal. rtl_process = subprocess.Popen( rtl_cmd, stdout=PIPE, - stderr=DEVNULL, + stderr=PIPE, start_new_session=True ) + # Start a thread to monitor rtl_fm stderr for errors + def monitor_rtl_stderr(): + for line in rtl_process.stderr: + err_text = line.decode('utf-8', errors='replace').strip() + if err_text: + logger.debug(f"[RTL_FM] {err_text}") + + rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr, daemon=True) + rtl_stderr_thread.start() + # Start decoder with stdin wired to rtl_fm's stdout. # Use text mode with line buffering for reliable line-by-line reading. # Merge stderr into stdout to avoid blocking on unbuffered stderr. @@ -1582,13 +1611,25 @@ def start_aprs() -> Response: time.sleep(PROCESS_START_WAIT) if rtl_process.poll() is not None: - # rtl_fm exited early - something went wrong + # rtl_fm exited early - capture stderr for diagnostics + stderr_output = '' + try: + remaining = rtl_process.stderr.read() + if remaining: + stderr_output = remaining.decode('utf-8', errors='replace').strip() + except Exception: + pass error_msg = f'rtl_fm failed to start (exit code {rtl_process.returncode})' + if stderr_output: + error_msg += f': {stderr_output[:200]}' logger.error(error_msg) try: decoder_process.kill() except Exception: pass + if aprs_active_device is not None: + app_module.release_sdr_device(aprs_active_device) + aprs_active_device = None return jsonify({'status': 'error', 'message': error_msg}), 500 if decoder_process.poll() is not None: @@ -1602,6 +1643,9 @@ def start_aprs() -> Response: rtl_process.kill() except Exception: pass + if aprs_active_device is not None: + app_module.release_sdr_device(aprs_active_device) + aprs_active_device = None return jsonify({'status': 'error', 'message': error_msg}), 500 # Store references for status checks and cleanup @@ -1626,12 +1670,17 @@ def start_aprs() -> Response: except Exception as e: logger.error(f"Failed to start APRS decoder: {e}") + if aprs_active_device is not None: + app_module.release_sdr_device(aprs_active_device) + aprs_active_device = None return jsonify({'status': 'error', 'message': str(e)}), 500 @aprs_bp.route('/stop', methods=['POST']) def stop_aprs() -> Response: """Stop APRS decoder.""" + global aprs_active_device + with app_module.aprs_lock: processes_to_stop = [] @@ -1660,6 +1709,11 @@ def stop_aprs() -> Response: if hasattr(app_module, 'aprs_rtl_process'): app_module.aprs_rtl_process = None + # Release SDR device + if aprs_active_device is not None: + app_module.release_sdr_device(aprs_active_device) + aprs_active_device = None + return jsonify({'status': 'stopped'})