Robust APRS decoder with deadlock fixes and spectrum scanning

Major improvements to APRS decoding reliability:

Process piping fixes (prevent deadlocks):
- rtl_fm stderr -> DEVNULL (was blocking on unbuffered stderr)
- decoder stderr -> STDOUT (merged, single stream to read)
- decoder uses text=True, bufsize=1 for line-buffered reading
- Proper EOF detection in stream thread

rtl_fm command improvements:
- Use -M nfm (narrowband FM) for APRS
- Add -E dc (DC blocking filter) for cleaner audio
- Add -A fast (fast AGC) for packet bursts
- Sample rate 22050 Hz matches direwolf -r 22050

Parsing robustness:
- Strip direwolf bracket prefixes like "[0.4] " before parsing
- Handle multimon-ng "AFSK1200:" prefix
- Better error handling for early process exit

New /aprs/spectrum endpoint:
- Runs rtl_power to scan around APRS frequency
- Returns peak detection, noise floor, signal analysis
- Provides advice for antenna/signal debugging
- Supports region selection and custom frequency

Also added UK to region list (same freq as Europe: 144.800 MHz)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-01-15 21:47:24 +00:00
parent af4a16bb88
commit 371fe5dd21
+279 -30
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import csv
import json import json
import os import os
import queue import queue
@@ -12,6 +13,7 @@ import tempfile
import threading import threading
import time import time
from datetime import datetime from datetime import datetime
from subprocess import DEVNULL, PIPE, STDOUT
from typing import Generator, Optional from typing import Generator, Optional
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, jsonify, request, Response
@@ -33,6 +35,7 @@ aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
APRS_FREQUENCIES = { APRS_FREQUENCIES = {
'north_america': '144.390', 'north_america': '144.390',
'europe': '144.800', 'europe': '144.800',
'uk': '144.800',
'australia': '145.175', 'australia': '145.175',
'new_zealand': '144.575', 'new_zealand': '144.575',
'argentina': '144.930', 'argentina': '144.930',
@@ -63,6 +66,11 @@ def find_rtl_fm() -> Optional[str]:
return shutil.which('rtl_fm') return shutil.which('rtl_fm')
def find_rtl_power() -> Optional[str]:
"""Find rtl_power binary for spectrum scanning."""
return shutil.which('rtl_power')
# Path to direwolf config file # Path to direwolf config file
DIREWOLF_CONFIG_PATH = os.path.join(tempfile.gettempdir(), 'intercept_direwolf.conf') DIREWOLF_CONFIG_PATH = os.path.join(tempfile.gettempdir(), 'intercept_direwolf.conf')
@@ -268,22 +276,32 @@ def parse_weather(data: str) -> dict:
def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subprocess.Popen) -> None: def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subprocess.Popen) -> None:
"""Stream decoded APRS packets to queue.""" """Stream decoded APRS packets to queue.
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.
"""
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
try: try:
app_module.aprs_queue.put({'type': 'status', 'status': 'started'}) app_module.aprs_queue.put({'type': 'status', 'status': 'started'})
for line in iter(decoder_process.stdout.readline, b''): # Read line-by-line in text mode. Empty string '' signals EOF.
line = line.decode('utf-8', errors='replace').strip() for line in iter(decoder_process.stdout.readline, ''):
line = line.strip()
if not line: if not line:
continue continue
# direwolf outputs decoded packets, multimon-ng outputs "AFSK1200: ..." # multimon-ng prefixes decoded packets with "AFSK1200: "
if line.startswith('AFSK1200:'): if line.startswith('AFSK1200:'):
line = line[9:].strip() line = line[9:].strip()
# Skip non-packet lines # direwolf often prefixes packets with "[0.4] " or similar audio level indicator
# Strip any leading bracket prefix like "[0.4] " before parsing
line = re.sub(r'^\[\d+\.\d+\]\s*', '', line)
# Skip non-packet lines (APRS format: CALL>PATH:DATA)
if '>' not in line or ':' not in line: if '>' not in line or ':' not in line:
continue continue
@@ -437,22 +455,27 @@ def start_aprs() -> Response:
aprs_last_packet_time = None aprs_last_packet_time = None
aprs_stations = {} aprs_stations = {}
# Build rtl_fm command # Build rtl_fm command for APRS (narrowband FM at 22050 Hz for AFSK1200)
freq_hz = f"{float(frequency)}M" freq_hz = f"{float(frequency)}M"
rtl_cmd = [ rtl_cmd = [
rtl_fm_path, rtl_fm_path,
'-f', freq_hz, '-f', freq_hz,
'-M', 'fm', # FM demodulation '-M', 'nfm', # Narrowband FM for APRS
'-s', '22050', # Sample rate for AFSK1200 '-s', '22050', # Sample rate matching direwolf -r 22050
'-E', 'dc', # Enable DC blocking filter for cleaner audio
'-A', 'fast', # Fast AGC for packet bursts
'-d', str(device), '-d', str(device),
] ]
# Gain: 0 means auto, otherwise set specific gain
if gain and str(gain) != '0': if gain and str(gain) != '0':
rtl_cmd.extend(['-g', str(gain)]) rtl_cmd.extend(['-g', str(gain)])
# PPM frequency correction
if ppm and str(ppm) != '0': if ppm and str(ppm) != '0':
rtl_cmd.extend(['-p', str(ppm)]) rtl_cmd.extend(['-p', str(ppm)])
# Explicitly output to stdout # Output raw audio to stdout
rtl_cmd.append('-') rtl_cmd.append('-')
# Build decoder command # Build decoder command
@@ -461,59 +484,90 @@ def start_aprs() -> Response:
config_path = create_direwolf_config() config_path = create_direwolf_config()
# direwolf flags for receiving AFSK1200 from stdin: # direwolf flags for receiving AFSK1200 from stdin:
# -c config = config file path # -c config = config file path (must come before other options)
# -n 1 = mono audio channel # -n 1 = mono audio channel
# -r 22050 = sample rate # -r 22050 = sample rate (must match rtl_fm -s)
# -b 16 = 16-bit samples # -b 16 = 16-bit signed samples
# -t 0 = disable text colors (for cleaner parsing) # -t 0 = disable text colors (for cleaner parsing)
# -q h = quiet mode - suppress audio level heard line (keeps packet output) # -q h = quiet: suppress audio level heard line (keeps packet output)
# - = read from stdin # - = read audio from stdin (must be last argument)
decoder_cmd = [direwolf_path, '-c', config_path, '-n', '1', '-r', '22050', '-b', '16', '-t', '0', '-q', 'h', '-'] decoder_cmd = [
direwolf_path,
'-c', config_path,
'-n', '1',
'-r', '22050',
'-b', '16',
'-t', '0',
'-q', 'h',
'-'
]
decoder_name = 'direwolf' decoder_name = 'direwolf'
else: else:
# Fallback to multimon-ng
decoder_cmd = [multimon_path, '-t', 'raw', '-a', 'AFSK1200', '-'] decoder_cmd = [multimon_path, '-t', 'raw', '-a', 'AFSK1200', '-']
decoder_name = 'multimon-ng' decoder_name = 'multimon-ng'
logger.info(f"Starting APRS decoder: {' '.join(rtl_cmd)} | {' '.join(decoder_cmd)}") logger.info(f"Starting APRS decoder: {' '.join(rtl_cmd)} | {' '.join(decoder_cmd)}")
try: try:
# Start rtl_fm # Start rtl_fm with stdout piped to decoder.
# stderr goes to DEVNULL to prevent blocking (rtl_fm logs to stderr).
# NOTE: RTL-SDR Blog V4 may show offset-tuned frequency in logs - this is normal.
rtl_process = subprocess.Popen( rtl_process = subprocess.Popen(
rtl_cmd, rtl_cmd,
stdout=subprocess.PIPE, stdout=PIPE,
stderr=subprocess.PIPE, stderr=DEVNULL,
start_new_session=True start_new_session=True
) )
# Start decoder with rtl_fm output # 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.
decoder_process = subprocess.Popen( decoder_process = subprocess.Popen(
decoder_cmd, decoder_cmd,
stdin=rtl_process.stdout, stdin=rtl_process.stdout,
stdout=subprocess.PIPE, stdout=PIPE,
stderr=subprocess.PIPE, stderr=STDOUT,
text=True,
bufsize=1,
start_new_session=True start_new_session=True
) )
# Allow rtl_fm stdout to be consumed by decoder # Close rtl_fm's stdout in parent so decoder owns it exclusively.
# This ensures proper EOF propagation when rtl_fm terminates.
rtl_process.stdout.close() rtl_process.stdout.close()
# Wait briefly to check if processes started # Wait briefly to check if processes started successfully
time.sleep(PROCESS_START_WAIT) time.sleep(PROCESS_START_WAIT)
if rtl_process.poll() is not None: if rtl_process.poll() is not None:
stderr = rtl_process.stderr.read().decode('utf-8', errors='replace') if rtl_process.stderr else '' # rtl_fm exited early - something went wrong
error_msg = f'rtl_fm failed to start' error_msg = f'rtl_fm failed to start (exit code {rtl_process.returncode})'
if stderr:
error_msg += f': {stderr[:200]}'
logger.error(error_msg) logger.error(error_msg)
decoder_process.kill() try:
decoder_process.kill()
except Exception:
pass
return jsonify({'status': 'error', 'message': error_msg}), 500 return jsonify({'status': 'error', 'message': error_msg}), 500
# Store reference to decoder process (for status checks) if decoder_process.poll() is not None:
# Decoder exited early - capture any output
error_output = decoder_process.stdout.read()[:500] if decoder_process.stdout else ''
error_msg = f'{decoder_name} failed to start'
if error_output:
error_msg += f': {error_output}'
logger.error(error_msg)
try:
rtl_process.kill()
except Exception:
pass
return jsonify({'status': 'error', 'message': error_msg}), 500
# Store references for status checks and cleanup
app_module.aprs_process = decoder_process app_module.aprs_process = decoder_process
app_module.aprs_rtl_process = rtl_process app_module.aprs_rtl_process = rtl_process
# Start output streaming thread # Start background thread to read decoder output and push to queue
thread = threading.Thread( thread = threading.Thread(
target=stream_aprs_output, target=stream_aprs_output,
args=(rtl_process, decoder_process), args=(rtl_process, decoder_process),
@@ -595,3 +649,198 @@ def stream_aprs() -> Response:
def get_frequencies() -> Response: def get_frequencies() -> Response:
"""Get APRS frequencies by region.""" """Get APRS frequencies by region."""
return jsonify(APRS_FREQUENCIES) return jsonify(APRS_FREQUENCIES)
@aprs_bp.route('/spectrum', methods=['GET', 'POST'])
def scan_aprs_spectrum() -> Response:
"""Scan spectrum around APRS frequency for signal visibility debugging.
This endpoint runs rtl_power briefly to detect signal activity near the
APRS frequency. Useful for headless/remote debugging to verify antenna
and SDR are receiving signals.
Query params or JSON body:
device: SDR device index (default: 0)
gain: Gain in dB, 0=auto (default: 0)
region: Region for frequency lookup (default: europe)
frequency: Override frequency in MHz (optional)
duration: Scan duration in seconds (default: 10, max: 60)
Returns JSON with peak detection and signal analysis.
"""
rtl_power_path = find_rtl_power()
if not rtl_power_path:
return jsonify({
'status': 'error',
'message': 'rtl_power not found. Install with: sudo apt install rtl-sdr'
}), 400
# Get parameters from JSON body or query args
if request.is_json:
data = request.json or {}
else:
data = {}
device = data.get('device', request.args.get('device', '0'))
gain = data.get('gain', request.args.get('gain', '0'))
region = data.get('region', request.args.get('region', 'europe'))
frequency = data.get('frequency', request.args.get('frequency'))
duration = data.get('duration', request.args.get('duration', '10'))
# Validate inputs
try:
device = validate_device_index(device)
gain = validate_gain(gain)
duration = min(max(int(duration), 5), 60) # Clamp 5-60 seconds
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
# Get center frequency
if frequency:
center_freq_mhz = float(frequency)
else:
center_freq_mhz = float(APRS_FREQUENCIES.get(region, '144.800'))
# Scan 20 kHz around center frequency (±10 kHz)
start_freq_mhz = center_freq_mhz - 0.010
end_freq_mhz = center_freq_mhz + 0.010
bin_size_hz = 200 # 200 Hz bins for good resolution
# Create temp file for rtl_power output
tmp_file = os.path.join(tempfile.gettempdir(), f'intercept_rtl_power_{os.getpid()}.csv')
try:
# Build rtl_power command
# Format: rtl_power -f start:end:bin_size -d device -g gain -i interval -e duration output_file
rtl_power_cmd = [
rtl_power_path,
'-f', f'{start_freq_mhz}M:{end_freq_mhz}M:{bin_size_hz}',
'-d', str(device),
'-i', '1', # 1 second integration
'-e', f'{duration}s',
]
# Gain: 0 means auto
if gain and str(gain) != '0':
rtl_power_cmd.extend(['-g', str(gain)])
rtl_power_cmd.append(tmp_file)
logger.info(f"Running spectrum scan: {' '.join(rtl_power_cmd)}")
# Run rtl_power with timeout
result = subprocess.run(
rtl_power_cmd,
capture_output=True,
text=True,
timeout=duration + 15 # Allow extra time for startup/shutdown
)
if result.returncode != 0:
error_msg = result.stderr[:200] if result.stderr else f'Exit code {result.returncode}'
return jsonify({
'status': 'error',
'message': f'rtl_power failed: {error_msg}'
}), 500
# Parse rtl_power CSV output
# Format: date, time, start_hz, end_hz, step_hz, samples, db1, db2, db3, ...
if not os.path.exists(tmp_file):
return jsonify({
'status': 'error',
'message': 'rtl_power did not produce output file'
}), 500
bins = []
with open(tmp_file, 'r') as f:
reader = csv.reader(f)
for row in reader:
if len(row) < 7:
continue
try:
row_start_hz = float(row[2])
row_step_hz = float(row[4])
# dB values start at column 6
for i, db_str in enumerate(row[6:]):
db_val = float(db_str.strip())
freq_hz = row_start_hz + (i * row_step_hz)
bins.append({'freq_hz': freq_hz, 'db': db_val})
except (ValueError, IndexError):
continue
if not bins:
return jsonify({
'status': 'error',
'message': 'No spectrum data collected. Check SDR connection and antenna.'
}), 500
# Calculate statistics
db_values = [b['db'] for b in bins]
avg_db = sum(db_values) / len(db_values)
max_bin = max(bins, key=lambda x: x['db'])
min_db = min(db_values)
# Find peak near center frequency (within 5 kHz)
center_hz = center_freq_mhz * 1e6
near_center_bins = [b for b in bins if abs(b['freq_hz'] - center_hz) < 5000]
if near_center_bins:
peak_near_center = max(near_center_bins, key=lambda x: x['db'])
else:
peak_near_center = max_bin
# Signal analysis
peak_above_noise = peak_near_center['db'] - avg_db
signal_detected = peak_above_noise > 3 # 3 dB above noise floor
# Generate advice
if peak_above_noise < 1:
advice = "No signal detected near APRS frequency. Check antenna connection and orientation."
elif peak_above_noise < 3:
advice = "Weak signal detected. Consider improving antenna or reducing noise sources."
elif peak_above_noise < 6:
advice = "Moderate signal detected. Decoding should work for strong stations."
else:
advice = "Good signal detected. Decoding should work well."
return jsonify({
'status': 'success',
'scan_params': {
'center_freq_mhz': center_freq_mhz,
'start_freq_mhz': start_freq_mhz,
'end_freq_mhz': end_freq_mhz,
'bin_size_hz': bin_size_hz,
'duration_seconds': duration,
'device': device,
'gain': gain,
'region': region,
},
'results': {
'total_bins': len(bins),
'noise_floor_db': round(avg_db, 1),
'min_db': round(min_db, 1),
'peak_freq_mhz': round(max_bin['freq_hz'] / 1e6, 6),
'peak_db': round(max_bin['db'], 1),
'peak_near_aprs_freq_mhz': round(peak_near_center['freq_hz'] / 1e6, 6),
'peak_near_aprs_db': round(peak_near_center['db'], 1),
'signal_above_noise_db': round(peak_above_noise, 1),
'signal_detected': signal_detected,
},
'advice': advice,
})
except subprocess.TimeoutExpired:
return jsonify({
'status': 'error',
'message': f'Spectrum scan timed out after {duration + 15} seconds'
}), 500
except Exception as e:
logger.error(f"Spectrum scan error: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
finally:
# Cleanup temp file
try:
if os.path.exists(tmp_file):
os.remove(tmp_file)
except Exception:
pass