mirror of
https://github.com/smittix/intercept.git
synced 2026-07-08 01:28:13 -07:00
Add APRS amateur radio tracking feature
- Create routes/aprs.py with start/stop/stream endpoints for APRS decoding - Support multiple regional frequencies (North America, Europe, Australia, etc.) - Use direwolf (preferred) or multimon-ng as AFSK1200 decoder - Parse APRS packets for position, weather, messages, and telemetry - Add APRS visualization panel with Leaflet map and station tracking - Include station list with callsigns, distance, and last heard time - Add packet log with raw APRS data display - Register APRS blueprint and add global state management - Add direwolf and multimon-ng to dependency definitions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ def register_blueprints(app):
|
||||
from .bluetooth import bluetooth_bp
|
||||
from .adsb import adsb_bp
|
||||
from .acars import acars_bp
|
||||
from .aprs import aprs_bp
|
||||
from .satellite import satellite_bp
|
||||
from .gps import gps_bp
|
||||
from .settings import settings_bp
|
||||
@@ -20,6 +21,7 @@ def register_blueprints(app):
|
||||
app.register_blueprint(bluetooth_bp)
|
||||
app.register_blueprint(adsb_bp)
|
||||
app.register_blueprint(acars_bp)
|
||||
app.register_blueprint(aprs_bp)
|
||||
app.register_blueprint(satellite_bp)
|
||||
app.register_blueprint(gps_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
|
||||
+561
@@ -0,0 +1,561 @@
|
||||
"""APRS amateur radio position reporting routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
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',
|
||||
'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
|
||||
|
||||
|
||||
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 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
|
||||
packet['packet_type'] = 'object'
|
||||
|
||||
elif data.startswith(')'):
|
||||
# Item
|
||||
packet['packet_type'] = 'item'
|
||||
|
||||
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_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 stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subprocess.Popen) -> None:
|
||||
"""Stream decoded APRS packets to queue."""
|
||||
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
||||
|
||||
try:
|
||||
app_module.aprs_queue.put({'type': 'status', 'status': 'started'})
|
||||
|
||||
for line in iter(decoder_process.stdout.readline, b''):
|
||||
line = line.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# direwolf outputs decoded packets, multimon-ng outputs "AFSK1200: ..."
|
||||
if line.startswith('AFSK1200:'):
|
||||
line = line[9:].strip()
|
||||
|
||||
# Skip non-packet lines
|
||||
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
|
||||
freq_hz = f"{float(frequency)}M"
|
||||
rtl_cmd = [
|
||||
rtl_fm_path,
|
||||
'-f', freq_hz,
|
||||
'-s', '22050', # Sample rate for AFSK1200
|
||||
'-d', str(device),
|
||||
]
|
||||
|
||||
if gain and str(gain) != '0':
|
||||
rtl_cmd.extend(['-g', str(gain)])
|
||||
if ppm and str(ppm) != '0':
|
||||
rtl_cmd.extend(['-p', str(ppm)])
|
||||
|
||||
# Build decoder command
|
||||
if direwolf_path:
|
||||
decoder_cmd = [direwolf_path, '-r', '22050', '-D', '1', '-']
|
||||
decoder_name = 'direwolf'
|
||||
else:
|
||||
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
|
||||
rtl_process = subprocess.Popen(
|
||||
rtl_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True
|
||||
)
|
||||
|
||||
# Start decoder with rtl_fm output
|
||||
decoder_process = subprocess.Popen(
|
||||
decoder_cmd,
|
||||
stdin=rtl_process.stdout,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True
|
||||
)
|
||||
|
||||
# Allow rtl_fm stdout to be consumed by decoder
|
||||
rtl_process.stdout.close()
|
||||
|
||||
# Wait briefly to check if processes started
|
||||
time.sleep(PROCESS_START_WAIT)
|
||||
|
||||
if rtl_process.poll() is not None:
|
||||
stderr = rtl_process.stderr.read().decode('utf-8', errors='replace') if rtl_process.stderr else ''
|
||||
error_msg = f'rtl_fm failed to start'
|
||||
if stderr:
|
||||
error_msg += f': {stderr[:200]}'
|
||||
logger.error(error_msg)
|
||||
decoder_process.kill()
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
|
||||
# Store reference to decoder process (for status checks)
|
||||
app_module.aprs_process = decoder_process
|
||||
app_module.aprs_rtl_process = rtl_process
|
||||
|
||||
# Start output streaming thread
|
||||
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)
|
||||
Reference in New Issue
Block a user