Files
intercept/routes/aprs.py
Smittix fb23766ed3 Fix APRS object and item position decoding
Objects (;) and items ()) were identified but position data was never
extracted, causing them to appear without location on the map. Added
parse_object() and parse_item() functions to properly extract name,
status, and coordinates.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 12:39:15 +00:00

1030 lines
34 KiB
Python

"""APRS amateur radio position reporting routes."""
from __future__ import annotations
import csv
import json
import os
import queue
import re
import shutil
import subprocess
import tempfile
import threading
import time
from datetime import datetime
from subprocess import DEVNULL, PIPE, STDOUT
from typing import Generator, Optional
from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.logging import sensor_logger as logger
from utils.validation import validate_device_index, validate_gain, validate_ppm
from utils.sse import format_sse
from utils.constants import (
PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
PROCESS_START_WAIT,
)
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
# APRS frequencies by region (MHz)
APRS_FREQUENCIES = {
'north_america': '144.390',
'europe': '144.800',
'uk': '144.800',
'australia': '145.175',
'new_zealand': '144.575',
'argentina': '144.930',
'brazil': '145.570',
'japan': '144.640',
'china': '144.640',
}
# Statistics
aprs_packet_count = 0
aprs_station_count = 0
aprs_last_packet_time = None
aprs_stations = {} # callsign -> station data
# Meter rate limiting
_last_meter_time = 0.0
_last_meter_level = -1
METER_MIN_INTERVAL = 0.1 # Max 10 updates/sec
METER_MIN_CHANGE = 2 # Only send if level changes by at least this much
def find_direwolf() -> Optional[str]:
"""Find direwolf binary."""
return shutil.which('direwolf')
def find_multimon_ng() -> Optional[str]:
"""Find multimon-ng binary."""
return shutil.which('multimon-ng')
def find_rtl_fm() -> Optional[str]:
"""Find rtl_fm binary."""
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
DIREWOLF_CONFIG_PATH = os.path.join(tempfile.gettempdir(), 'intercept_direwolf.conf')
def create_direwolf_config() -> str:
"""Create a minimal direwolf config for receive-only operation."""
config = """# Minimal direwolf config for INTERCEPT (receive-only)
# Audio input is handled via stdin
ADEVICE stdin null
CHANNEL 0
MYCALL N0CALL
MODEM 1200
"""
with open(DIREWOLF_CONFIG_PATH, 'w') as f:
f.write(config)
return DIREWOLF_CONFIG_PATH
def parse_aprs_packet(raw_packet: str) -> Optional[dict]:
"""Parse APRS packet into structured data."""
try:
# Basic APRS packet format: CALLSIGN>PATH:DATA
# Example: N0CALL-9>APRS,TCPIP*:@092345z4903.50N/07201.75W_090/000g005t077
match = re.match(r'^([A-Z0-9-]+)>([^:]+):(.+)$', raw_packet, re.IGNORECASE)
if not match:
return None
callsign = match.group(1).upper()
path = match.group(2)
data = match.group(3)
packet = {
'type': 'aprs',
'callsign': callsign,
'path': path,
'raw': raw_packet,
'timestamp': datetime.utcnow().isoformat() + 'Z',
}
# Determine packet type and parse accordingly
if data.startswith('!') or data.startswith('='):
# Position without timestamp
packet['packet_type'] = 'position'
pos = parse_position(data[1:])
if pos:
packet.update(pos)
elif data.startswith('/') or data.startswith('@'):
# Position with timestamp
packet['packet_type'] = 'position'
# Skip timestamp (7 chars) and parse position
if len(data) > 8:
pos = parse_position(data[8:])
if pos:
packet.update(pos)
elif data.startswith('>'):
# Status message
packet['packet_type'] = 'status'
packet['status'] = data[1:]
elif data.startswith(':'):
# Message
packet['packet_type'] = 'message'
msg_match = re.match(r'^:([A-Z0-9 -]{9}):(.*)$', data, re.IGNORECASE)
if msg_match:
packet['addressee'] = msg_match.group(1).strip()
packet['message'] = msg_match.group(2)
elif data.startswith('_'):
# Weather report (Positionless)
packet['packet_type'] = 'weather'
packet['weather'] = parse_weather(data)
elif data.startswith(';'):
# Object format: ;OBJECTNAME*DDHHMMzPOSITION or ;OBJECTNAME_DDHHMMzPOSITION
# Name is exactly 9 chars, then * (live) or _ (killed), then 7-char timestamp, then position
packet['packet_type'] = 'object'
obj_data = parse_object(data)
if obj_data:
packet.update(obj_data)
elif data.startswith(')'):
# Item format: )ITEMNAME!POSITION or )ITEMNAME_POSITION
# Name is 3-9 chars, terminated by ! (live) or _ (killed), then position
packet['packet_type'] = 'item'
item_data = parse_item(data)
if item_data:
packet.update(item_data)
elif data.startswith('T'):
# Telemetry
packet['packet_type'] = 'telemetry'
else:
packet['packet_type'] = 'other'
packet['data'] = data
return packet
except Exception as e:
logger.debug(f"Failed to parse APRS packet: {e}")
return None
def parse_position(data: str) -> Optional[dict]:
"""Parse APRS position data."""
try:
# Format: DDMM.mmN/DDDMM.mmW (or similar with symbols)
# Example: 4903.50N/07201.75W
pos_match = re.match(
r'^(\d{2})(\d{2}\.\d+)([NS])(.)(\d{3})(\d{2}\.\d+)([EW])(.)?',
data
)
if pos_match:
lat_deg = int(pos_match.group(1))
lat_min = float(pos_match.group(2))
lat_dir = pos_match.group(3)
symbol_table = pos_match.group(4)
lon_deg = int(pos_match.group(5))
lon_min = float(pos_match.group(6))
lon_dir = pos_match.group(7)
symbol_code = pos_match.group(8) or ''
lat = lat_deg + lat_min / 60.0
if lat_dir == 'S':
lat = -lat
lon = lon_deg + lon_min / 60.0
if lon_dir == 'W':
lon = -lon
result = {
'lat': round(lat, 6),
'lon': round(lon, 6),
'symbol': symbol_table + symbol_code,
}
# Parse additional data after position (course/speed, altitude, etc.)
remaining = data[18:] if len(data) > 18 else ''
# Course/Speed: CCC/SSS
cs_match = re.search(r'(\d{3})/(\d{3})', remaining)
if cs_match:
result['course'] = int(cs_match.group(1))
result['speed'] = int(cs_match.group(2)) # knots
# Altitude: /A=NNNNNN
alt_match = re.search(r'/A=(-?\d+)', remaining)
if alt_match:
result['altitude'] = int(alt_match.group(1)) # feet
return result
except Exception as e:
logger.debug(f"Failed to parse position: {e}")
return None
def parse_object(data: str) -> Optional[dict]:
"""Parse APRS object data.
Object format: ;OBJECTNAME*DDHHMMzPOSITION or ;OBJECTNAME_DDHHMMzPOSITION
- ; is the object marker
- OBJECTNAME is exactly 9 characters (padded with spaces if needed)
- * means object is live, _ means object is killed/deleted
- DDHHMMz is the timestamp (day/hour/minute zulu) - 7 chars
- Position follows in standard APRS format
Some implementations have whitespace variations, so we search for the status
character rather than assuming exact position.
"""
try:
if not data.startswith(';') or len(data) < 18:
return None
# Find the status character (* or _) which marks end of object name
# It should be around position 10, but allow some flexibility
status_pos = -1
for i in range(10, min(13, len(data))):
if data[i] in '*_':
status_pos = i
break
if status_pos == -1:
# Fallback: assume standard position
status_pos = 10
# Extract object name (chars between ; and status)
obj_name = data[1:status_pos].strip()
# Get status character
status_char = data[status_pos] if status_pos < len(data) else '*'
is_live = status_char == '*'
# Timestamp is 7 chars after status, position follows
pos_start = status_pos + 8 # status + 7 char timestamp
if len(data) > pos_start:
pos = parse_position(data[pos_start:])
else:
pos = None
result = {
'object_name': obj_name,
'object_live': is_live,
}
if pos:
result.update(pos)
return result
except Exception as e:
logger.debug(f"Failed to parse object: {e}")
return None
def parse_item(data: str) -> Optional[dict]:
"""Parse APRS item data.
Item format: )ITEMNAME!POSITION or )ITEMNAME_POSITION
- ) is the item marker
- ITEMNAME is 3-9 characters
- ! means item is live, _ means item is killed/deleted
- Position follows immediately in standard APRS format
"""
try:
if not data.startswith(')') or len(data) < 5:
return None
# Find the status delimiter (! or _) which terminates the name
# Item name is 3-9 chars, so check positions 4-10 (1-based: chars 4-10 after ')')
status_pos = -1
for i in range(4, min(11, len(data))):
if data[i] in '!_':
status_pos = i
break
if status_pos == -1:
return None
# Extract item name and status
item_name = data[1:status_pos].strip()
status_char = data[status_pos]
is_live = status_char == '!'
# Parse position after status character
if len(data) > status_pos + 1:
pos = parse_position(data[status_pos + 1:])
else:
pos = None
result = {
'item_name': item_name,
'item_live': is_live,
}
if pos:
result.update(pos)
return result
except Exception as e:
logger.debug(f"Failed to parse item: {e}")
return None
def parse_weather(data: str) -> dict:
"""Parse APRS weather data."""
weather = {}
# Wind direction: cCCC
match = re.search(r'c(\d{3})', data)
if match:
weather['wind_direction'] = int(match.group(1))
# Wind speed: sSSS (mph)
match = re.search(r's(\d{3})', data)
if match:
weather['wind_speed'] = int(match.group(1))
# Wind gust: gGGG (mph)
match = re.search(r'g(\d{3})', data)
if match:
weather['wind_gust'] = int(match.group(1))
# Temperature: tTTT (Fahrenheit)
match = re.search(r't(-?\d{2,3})', data)
if match:
weather['temperature'] = int(match.group(1))
# Rain last hour: rRRR (hundredths of inch)
match = re.search(r'r(\d{3})', data)
if match:
weather['rain_1h'] = int(match.group(1)) / 100.0
# Rain last 24h: pPPP
match = re.search(r'p(\d{3})', data)
if match:
weather['rain_24h'] = int(match.group(1)) / 100.0
# Humidity: hHH (%)
match = re.search(r'h(\d{2})', data)
if match:
h = int(match.group(1))
weather['humidity'] = 100 if h == 0 else h
# Barometric pressure: bBBBBB (tenths of millibars)
match = re.search(r'b(\d{5})', data)
if match:
weather['pressure'] = int(match.group(1)) / 10.0
return weather
def parse_audio_level(line: str) -> Optional[int]:
"""Parse direwolf audio level line and return normalized level (0-100).
Direwolf outputs lines like:
Audio level = 34(18/16) [NONE] __||||||______
[0.4] Audio level = 57(34/32) [NONE] __||||||||||||______
The first number after "Audio level = " is the main level indicator.
We normalize it to 0-100 scale (direwolf typically outputs 0-100+).
"""
# Match "Audio level = NN" pattern
match = re.search(r'Audio level\s*=\s*(\d+)', line, re.IGNORECASE)
if match:
raw_level = int(match.group(1))
# Normalize: direwolf levels are typically 0-100, but can go higher
# Clamp to 0-100 range
normalized = min(max(raw_level, 0), 100)
return normalized
return None
def should_send_meter_update(level: int) -> bool:
"""Rate-limit meter updates to avoid spamming SSE.
Only send if:
- At least METER_MIN_INTERVAL seconds have passed, OR
- Level changed by at least METER_MIN_CHANGE
"""
global _last_meter_time, _last_meter_level
now = time.time()
time_ok = (now - _last_meter_time) >= METER_MIN_INTERVAL
change_ok = abs(level - _last_meter_level) >= METER_MIN_CHANGE
if time_ok or change_ok:
_last_meter_time = now
_last_meter_level = level
return True
return False
def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subprocess.Popen) -> None:
"""Stream decoded APRS packets and audio level meter 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.
Outputs two types of messages to the queue:
- type='aprs': Decoded APRS packets
- type='meter': Audio level meter readings (rate-limited)
"""
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
global _last_meter_time, _last_meter_level
# Reset meter state
_last_meter_time = 0.0
_last_meter_level = -1
try:
app_module.aprs_queue.put({'type': 'status', 'status': 'started'})
# Read line-by-line in text mode. Empty string '' signals EOF.
for line in iter(decoder_process.stdout.readline, ''):
line = line.strip()
if not line:
continue
# Check for audio level line first (for signal meter)
audio_level = parse_audio_level(line)
if audio_level is not None:
if should_send_meter_update(audio_level):
meter_msg = {
'type': 'meter',
'level': audio_level,
'ts': datetime.utcnow().isoformat() + 'Z'
}
app_module.aprs_queue.put(meter_msg)
continue # Audio level lines are not packets
# multimon-ng prefixes decoded packets with "AFSK1200: "
if line.startswith('AFSK1200:'):
line = line[9:].strip()
# 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:
continue
packet = parse_aprs_packet(line)
if packet:
aprs_packet_count += 1
aprs_last_packet_time = time.time()
# Track unique stations
callsign = packet.get('callsign')
if callsign and callsign not in aprs_stations:
aprs_station_count += 1
# Update station data
if callsign:
aprs_stations[callsign] = {
'callsign': callsign,
'lat': packet.get('lat'),
'lon': packet.get('lon'),
'symbol': packet.get('symbol'),
'last_seen': packet.get('timestamp'),
'packet_type': packet.get('packet_type'),
}
app_module.aprs_queue.put(packet)
# Log if enabled
if app_module.logging_enabled:
try:
with open(app_module.log_file_path, 'a') as f:
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
f.write(f"{ts} | APRS | {json.dumps(packet)}\n")
except Exception:
pass
except Exception as e:
logger.error(f"APRS stream error: {e}")
app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
finally:
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
# Cleanup processes
for proc in [rtl_process, decoder_process]:
try:
proc.terminate()
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
@aprs_bp.route('/tools')
def check_aprs_tools() -> Response:
"""Check for APRS decoding tools."""
has_rtl_fm = find_rtl_fm() is not None
has_direwolf = find_direwolf() is not None
has_multimon = find_multimon_ng() is not None
return jsonify({
'rtl_fm': has_rtl_fm,
'direwolf': has_direwolf,
'multimon_ng': has_multimon,
'ready': has_rtl_fm and (has_direwolf or has_multimon),
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
})
@aprs_bp.route('/status')
def aprs_status() -> Response:
"""Get APRS decoder status."""
running = False
if app_module.aprs_process:
running = app_module.aprs_process.poll() is None
return jsonify({
'running': running,
'packet_count': aprs_packet_count,
'station_count': aprs_station_count,
'last_packet_time': aprs_last_packet_time,
'queue_size': app_module.aprs_queue.qsize()
})
@aprs_bp.route('/stations')
def get_stations() -> Response:
"""Get all tracked APRS stations."""
return jsonify({
'stations': list(aprs_stations.values()),
'count': len(aprs_stations)
})
@aprs_bp.route('/start', methods=['POST'])
def start_aprs() -> Response:
"""Start APRS decoder."""
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
with app_module.aprs_lock:
if app_module.aprs_process and app_module.aprs_process.poll() is None:
return jsonify({
'status': 'error',
'message': 'APRS decoder already running'
}), 409
# Check for required tools
rtl_fm_path = find_rtl_fm()
if not rtl_fm_path:
return jsonify({
'status': 'error',
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
}), 400
# Check for decoder (prefer direwolf, fallback to multimon-ng)
direwolf_path = find_direwolf()
multimon_path = find_multimon_ng()
if not direwolf_path and not multimon_path:
return jsonify({
'status': 'error',
'message': 'No APRS decoder found. Install direwolf or multimon-ng'
}), 400
data = request.json or {}
# Validate inputs
try:
device = validate_device_index(data.get('device', '0'))
gain = validate_gain(data.get('gain', '40'))
ppm = validate_ppm(data.get('ppm', '0'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
# Get frequency for region
region = data.get('region', 'north_america')
frequency = APRS_FREQUENCIES.get(region, '144.390')
# Allow custom frequency override
if data.get('frequency'):
frequency = data.get('frequency')
# Clear queue and reset stats
while not app_module.aprs_queue.empty():
try:
app_module.aprs_queue.get_nowait()
except queue.Empty:
break
aprs_packet_count = 0
aprs_station_count = 0
aprs_last_packet_time = None
aprs_stations = {}
# Build rtl_fm command for APRS (narrowband FM at 22050 Hz for AFSK1200)
freq_hz = f"{float(frequency)}M"
rtl_cmd = [
rtl_fm_path,
'-f', freq_hz,
'-M', 'nfm', # Narrowband FM for APRS
'-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),
]
# Gain: 0 means auto, otherwise set specific gain
if gain and str(gain) != '0':
rtl_cmd.extend(['-g', str(gain)])
# PPM frequency correction
if ppm and str(ppm) != '0':
rtl_cmd.extend(['-p', str(ppm)])
# Output raw audio to stdout
rtl_cmd.append('-')
# Build decoder command
if direwolf_path:
# Create minimal config file for direwolf
config_path = create_direwolf_config()
# direwolf flags for receiving AFSK1200 from stdin:
# -c config = config file path (must come before other options)
# -n 1 = mono audio channel
# -r 22050 = sample rate (must match rtl_fm -s)
# -b 16 = 16-bit signed samples
# -t 0 = disable text colors (for cleaner parsing)
# NOTE: We do NOT use -q h here so we get audio level lines for the signal meter
# - = read audio from stdin (must be last argument)
decoder_cmd = [
direwolf_path,
'-c', config_path,
'-n', '1',
'-r', '22050',
'-b', '16',
'-t', '0',
'-'
]
decoder_name = 'direwolf'
else:
# Fallback to multimon-ng
decoder_cmd = [multimon_path, '-t', 'raw', '-a', 'AFSK1200', '-']
decoder_name = 'multimon-ng'
logger.info(f"Starting APRS decoder: {' '.join(rtl_cmd)} | {' '.join(decoder_cmd)}")
try:
# 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_cmd,
stdout=PIPE,
stderr=DEVNULL,
start_new_session=True
)
# 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_cmd,
stdin=rtl_process.stdout,
stdout=PIPE,
stderr=STDOUT,
text=True,
bufsize=1,
start_new_session=True
)
# 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()
# Wait briefly to check if processes started successfully
time.sleep(PROCESS_START_WAIT)
if rtl_process.poll() is not None:
# rtl_fm exited early - something went wrong
error_msg = f'rtl_fm failed to start (exit code {rtl_process.returncode})'
logger.error(error_msg)
try:
decoder_process.kill()
except Exception:
pass
return jsonify({'status': 'error', 'message': error_msg}), 500
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_rtl_process = rtl_process
# Start background thread to read decoder output and push to queue
thread = threading.Thread(
target=stream_aprs_output,
args=(rtl_process, decoder_process),
daemon=True
)
thread.start()
return jsonify({
'status': 'started',
'frequency': frequency,
'region': region,
'device': device,
'decoder': decoder_name
})
except Exception as e:
logger.error(f"Failed to start APRS decoder: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@aprs_bp.route('/stop', methods=['POST'])
def stop_aprs() -> Response:
"""Stop APRS decoder."""
with app_module.aprs_lock:
processes_to_stop = []
if hasattr(app_module, 'aprs_rtl_process') and app_module.aprs_rtl_process:
processes_to_stop.append(app_module.aprs_rtl_process)
if app_module.aprs_process:
processes_to_stop.append(app_module.aprs_process)
if not processes_to_stop:
return jsonify({
'status': 'error',
'message': 'APRS decoder not running'
}), 400
for proc in processes_to_stop:
try:
proc.terminate()
proc.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
except subprocess.TimeoutExpired:
proc.kill()
except Exception as e:
logger.error(f"Error stopping APRS process: {e}")
app_module.aprs_process = None
if hasattr(app_module, 'aprs_rtl_process'):
app_module.aprs_rtl_process = None
return jsonify({'status': 'stopped'})
@aprs_bp.route('/stream')
def stream_aprs() -> Response:
"""SSE stream for APRS packets."""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
while True:
try:
msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
@aprs_bp.route('/frequencies')
def get_frequencies() -> Response:
"""Get APRS frequencies by region."""
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