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 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-06 13:50:09 +00:00
parent b4742f205a
commit 8fca54e523
+59 -5
View File
@@ -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'})