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:
Smittix
2026-01-13 23:25:15 +00:00
parent c30e5800df
commit 3263638c57
5 changed files with 923 additions and 2 deletions

15
app.py
View File

@@ -108,6 +108,12 @@ acars_process = None
acars_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
acars_lock = threading.Lock()
# APRS amateur radio tracking
aprs_process = None
aprs_rtl_process = None
aprs_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
aprs_lock = threading.Lock()
# ============================================
# GLOBAL STATE DICTIONARIES
# ============================================
@@ -422,6 +428,7 @@ def health_check() -> Response:
'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False),
'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False),
'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False),
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
},
@@ -438,6 +445,7 @@ def health_check() -> Response:
def kill_all() -> Response:
"""Kill all decoder and WiFi processes."""
global current_process, sensor_process, wifi_process, adsb_process, acars_process
global aprs_process, aprs_rtl_process
# Import adsb module to reset its state
from routes import adsb as adsb_module
@@ -446,7 +454,7 @@ def kill_all() -> Response:
processes_to_kill = [
'rtl_fm', 'multimon-ng', 'rtl_433',
'airodump-ng', 'aireplay-ng', 'airmon-ng',
'dump1090', 'acarsdec'
'dump1090', 'acarsdec', 'direwolf'
]
for proc in processes_to_kill:
@@ -475,6 +483,11 @@ def kill_all() -> Response:
with acars_lock:
acars_process = None
# Reset APRS state
with aprs_lock:
aprs_process = None
aprs_rtl_process = None
return jsonify({'status': 'killed', 'processes': killed})

View File

@@ -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
routes/aprs.py Normal file
View File

@@ -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)

View File

@@ -292,6 +292,7 @@
<button class="mode-nav-btn active" onclick="switchMode('pager')"><span class="nav-icon">📟</span><span class="nav-label">Pager</span></button>
<button class="mode-nav-btn" onclick="switchMode('sensor')"><span class="nav-icon">📡</span><span class="nav-label">433MHz</span></button>
<button class="mode-nav-btn" onclick="switchMode('aircraft')"><span class="nav-icon">✈️</span><span class="nav-label">Aircraft</span></button>
<button class="mode-nav-btn" onclick="switchMode('aprs')"><span class="nav-icon">📍</span><span class="nav-label">APRS</span></button>
<button class="mode-nav-btn" onclick="switchMode('satellite')"><span class="nav-icon">🛰️</span><span class="nav-label">Satellite</span></button>
<button class="mode-nav-btn" onclick="switchMode('listening')"><span class="nav-icon">📻</span><span class="nav-label">Listening Post</span></button>
</div>
@@ -860,6 +861,53 @@
</div>
<!-- APRS MODE -->
<div id="aprsMode" class="mode-content" style="display: none;">
<div class="section">
<h3>APRS Tracking</h3>
<p style="color: var(--text-secondary); font-size: 11px; line-height: 1.5; margin-bottom: 15px;">
Decode APRS (Automatic Packet Reporting System) amateur radio position reports on VHF.
</p>
<div style="background: rgba(255,193,7,0.1); border: 1px solid var(--accent-yellow); border-radius: 4px; padding: 8px; margin-bottom: 10px; font-size: 10px;">
<strong style="color: var(--accent-yellow);">Amateur Radio</strong><br>
<span style="color: var(--text-secondary);">APRS operates on 144.390 MHz (N. America) or 144.800 MHz (Europe). Decodes position, weather, and messages from ham radio operators.</span>
</div>
</div>
<div class="section">
<h3>Configuration</h3>
<div class="form-group">
<label>Region</label>
<select id="aprsRegion">
<option value="north_america">North America (144.390)</option>
<option value="europe">Europe (144.800)</option>
<option value="australia">Australia (145.175)</option>
<option value="japan">Japan (144.640)</option>
</select>
</div>
<div class="form-group">
<label>SDR Device</label>
<select id="aprsDevice">
<option value="0">Device 0</option>
<option value="1">Device 1</option>
</select>
</div>
<div class="form-group">
<label>Gain (dB)</label>
<input type="text" id="aprsGain" value="40" placeholder="40">
</div>
<div class="info-text" style="margin-top: 8px; display: grid; grid-template-columns: auto auto; gap: 4px 8px; align-items: center;">
<span>direwolf:</span><span class="tool-status" id="direwolfStatus">Checking...</span>
<span>multimon-ng:</span><span class="tool-status" id="aprsMultimonStatus">Checking...</span>
</div>
</div>
<button class="run-btn" id="startAprsBtn" onclick="startAprs()">
Start APRS
</button>
<button class="stop-btn" id="stopAprsBtn" onclick="stopAprs()" style="display: none;">
Stop APRS
</button>
</div>
<!-- SATELLITE MODE -->
<div id="satelliteMode" class="mode-content">
<div class="section">
@@ -1291,6 +1339,40 @@
</div>
</div>
<!-- APRS Visualizations -->
<div class="wifi-visuals" id="aprsVisuals" style="display: none;">
<div class="wifi-visual-panel" style="grid-column: span 2;">
<h5 style="color: var(--accent-cyan); text-shadow: 0 0 10px var(--accent-cyan); padding: 0 10px;">APRS STATION MAP</h5>
<div class="aircraft-map-container" style="flex: 1;">
<div class="map-header">
<span id="aprsMapTime">--:--:--</span>
<span id="aprsMapStatus">STANDBY</span>
</div>
<div id="aprsMap" style="height: 400px;"></div>
<div class="map-footer">
<span>STATIONS: <span id="aprsStationCount">0</span></span>
<span>PACKETS: <span id="aprsPacketCount">0</span></span>
</div>
</div>
</div>
<div class="wifi-visual-panel" style="display: flex; flex-direction: column; gap: 10px;">
<h5 style="color: var(--accent-green); text-shadow: 0 0 10px var(--accent-green);">STATION LIST</h5>
<div id="aprsStationList" style="flex: 1; overflow-y: auto; max-height: 350px; font-size: 11px;">
<div style="padding: 20px; text-align: center; color: var(--text-muted);">
No stations received yet
</div>
</div>
</div>
<div class="wifi-visual-panel" style="grid-column: span 2; display: flex; flex-direction: column;">
<h5 style="color: var(--accent-orange); text-shadow: 0 0 10px var(--accent-orange);">PACKET LOG</h5>
<div id="aprsPacketLog" style="flex: 1; overflow-y: auto; max-height: 200px; font-family: 'JetBrains Mono', monospace; font-size: 10px; background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
<div style="color: var(--text-muted);">Waiting for packets...</div>
</div>
</div>
</div>
<!-- Listening Post Visualizations - Professional Ham Radio Scanner -->
<div class="wifi-visuals" id="listeningPostVisuals" style="display: none;">
@@ -2352,7 +2434,7 @@
document.getElementById('pagerWaterfallPanel').style.display = (mode === 'pager') ? 'block' : 'none';
// Sensor waterfall: show only for sensor (433MHz) mode
document.getElementById('sensorWaterfallPanel').style.display = (mode === 'sensor') ? 'block' : 'none';
document.getElementById('output').style.display = (mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening') ? 'none' : 'block';
document.getElementById('output').style.display = (mode === 'satellite' || mode === 'aircraft' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening') ? 'none' : 'block';
document.querySelector('.status-bar').style.display = (mode === 'satellite') ? 'none' : 'flex';
// Load interfaces and initialize visualizations when switching modes
@@ -2366,6 +2448,9 @@
} else if (mode === 'aircraft') {
checkAdsbTools();
initAircraftRadar();
} else if (mode === 'aprs') {
checkAprsTools();
initAprsMap();
} else if (mode === 'satellite') {
initPolarPlot();
initSatelliteList();
@@ -7471,6 +7556,243 @@
}
}
// ============================================
// APRS Functions
// ============================================
let aprsMap = null;
let aprsMarkers = {};
let aprsEventSource = null;
let isAprsRunning = false;
let aprsPacketCount = 0;
let aprsStationCount = 0;
function checkAprsTools() {
fetch('/aprs/tools')
.then(r => r.json())
.then(data => {
const direwolfStatus = document.getElementById('direwolfStatus');
const multimonStatus = document.getElementById('aprsMultimonStatus');
if (direwolfStatus) {
direwolfStatus.textContent = data.direwolf ? 'OK' : 'Missing';
direwolfStatus.className = 'tool-status ' + (data.direwolf ? 'ok' : 'missing');
}
if (multimonStatus) {
multimonStatus.textContent = data.multimon_ng ? 'OK' : 'Missing';
multimonStatus.className = 'tool-status ' + (data.multimon_ng ? 'ok' : 'missing');
}
})
.catch(() => {
const direwolfStatus = document.getElementById('direwolfStatus');
const multimonStatus = document.getElementById('aprsMultimonStatus');
if (direwolfStatus) {
direwolfStatus.textContent = 'Error';
direwolfStatus.className = 'tool-status missing';
}
if (multimonStatus) {
multimonStatus.textContent = 'Error';
multimonStatus.className = 'tool-status missing';
}
});
}
function initAprsMap() {
if (aprsMap) return;
const mapContainer = document.getElementById('aprsMap');
if (!mapContainer) return;
aprsMap = L.map('aprsMap').setView([39.8283, -98.5795], 4);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; OpenStreetMap contributors &copy; CARTO',
maxZoom: 19
}).addTo(aprsMap);
// Update time display
setInterval(() => {
const timeEl = document.getElementById('aprsMapTime');
if (timeEl) {
timeEl.textContent = new Date().toLocaleTimeString('en-US', {hour12: false});
}
}, 1000);
}
function startAprs() {
const region = document.getElementById('aprsRegion').value;
const device = document.getElementById('aprsDevice').value;
const gain = document.getElementById('aprsGain').value;
fetch('/aprs/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ region, device: parseInt(device), gain: parseInt(gain) })
})
.then(r => r.json())
.then(data => {
if (data.status === 'started') {
isAprsRunning = true;
aprsPacketCount = 0;
aprsStationCount = 0;
document.getElementById('startAprsBtn').style.display = 'none';
document.getElementById('stopAprsBtn').style.display = 'block';
document.getElementById('aprsMapStatus').textContent = 'TRACKING';
document.getElementById('aprsMapStatus').style.color = 'var(--accent-green)';
startAprsStream();
} else {
alert('APRS Error: ' + data.message);
}
})
.catch(err => alert('APRS Error: ' + err));
}
function stopAprs() {
fetch('/aprs/stop', { method: 'POST' })
.then(r => r.json())
.then(data => {
isAprsRunning = false;
document.getElementById('startAprsBtn').style.display = 'block';
document.getElementById('stopAprsBtn').style.display = 'none';
document.getElementById('aprsMapStatus').textContent = 'STANDBY';
document.getElementById('aprsMapStatus').style.color = '';
if (aprsEventSource) {
aprsEventSource.close();
aprsEventSource = null;
}
});
}
function startAprsStream() {
if (aprsEventSource) aprsEventSource.close();
aprsEventSource = new EventSource('/aprs/stream');
aprsEventSource.onmessage = function(e) {
const data = JSON.parse(e.data);
if (data.type === 'aprs') {
aprsPacketCount++;
document.getElementById('aprsPacketCount').textContent = aprsPacketCount;
processAprsPacket(data);
}
};
aprsEventSource.onerror = function() {
console.error('APRS stream error');
};
}
function processAprsPacket(packet) {
// Update packet log
const logEl = document.getElementById('aprsPacketLog');
const logEntry = document.createElement('div');
logEntry.style.cssText = 'padding: 3px 0; border-bottom: 1px solid var(--border-color);';
const time = new Date().toLocaleTimeString('en-US', {hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit'});
const callsign = packet.callsign || 'UNKNOWN';
const packetType = packet.packet_type || 'unknown';
logEntry.innerHTML = `<span style="color: var(--text-muted);">${time}</span> <span style="color: var(--accent-cyan); font-weight: bold;">${callsign}</span> <span style="color: var(--accent-green);">[${packetType}]</span>`;
// Remove placeholder if present
const placeholder = logEl.querySelector('div[style*="color: var(--text-muted)"]');
if (placeholder && placeholder.textContent.includes('Waiting')) {
placeholder.remove();
}
logEl.insertBefore(logEntry, logEl.firstChild);
// Keep log manageable
while (logEl.children.length > 100) {
logEl.removeChild(logEl.lastChild);
}
// Update map if position data
if (packet.lat && packet.lon && aprsMap) {
updateAprsMarker(packet);
}
// Update station list
updateAprsStationList(packet);
}
function updateAprsMarker(packet) {
const callsign = packet.callsign;
if (aprsMarkers[callsign]) {
// Update existing marker
aprsMarkers[callsign].setLatLng([packet.lat, packet.lon]);
} else {
// Create new marker
aprsStationCount++;
document.getElementById('aprsStationCount').textContent = aprsStationCount;
const icon = L.divIcon({
className: 'aprs-marker',
html: `<div style="background: var(--accent-cyan); color: #000; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: bold; white-space: nowrap;">${callsign}</div>`,
iconSize: [80, 20],
iconAnchor: [40, 10]
});
const marker = L.marker([packet.lat, packet.lon], { icon: icon }).addTo(aprsMap);
marker.bindPopup(`
<div style="font-family: monospace;">
<strong>${callsign}</strong><br>
Position: ${packet.lat.toFixed(4)}, ${packet.lon.toFixed(4)}<br>
${packet.altitude ? `Altitude: ${packet.altitude} ft<br>` : ''}
${packet.speed ? `Speed: ${packet.speed} kts<br>` : ''}
${packet.course ? `Course: ${packet.course}°<br>` : ''}
</div>
`);
aprsMarkers[callsign] = marker;
}
}
function updateAprsStationList(packet) {
const listEl = document.getElementById('aprsStationList');
const callsign = packet.callsign;
// Remove placeholder if present
const placeholder = listEl.querySelector('div[style*="text-align: center"]');
if (placeholder && placeholder.textContent.includes('No stations')) {
placeholder.remove();
}
// Check if station already exists
let stationEl = listEl.querySelector(`[data-callsign="${callsign}"]`);
if (!stationEl) {
stationEl = document.createElement('div');
stationEl.dataset.callsign = callsign;
stationEl.style.cssText = 'padding: 6px 8px; border-bottom: 1px solid var(--border-color); cursor: pointer;';
stationEl.onclick = () => {
if (aprsMarkers[callsign] && aprsMap) {
aprsMap.setView(aprsMarkers[callsign].getLatLng(), 10);
aprsMarkers[callsign].openPopup();
}
};
listEl.insertBefore(stationEl, listEl.firstChild);
}
const time = new Date().toLocaleTimeString('en-US', {hour12: false, hour: '2-digit', minute: '2-digit'});
const hasPos = packet.lat && packet.lon;
stationEl.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="color: var(--accent-cyan); font-weight: bold;">${callsign}</span>
<span style="font-size: 9px; color: var(--text-muted);">${time}</span>
</div>
<div style="font-size: 9px; color: var(--text-secondary); margin-top: 2px;">
${packet.packet_type || 'unknown'} ${hasPos ? `| ${packet.lat.toFixed(2)}, ${packet.lon.toFixed(2)}` : ''}
</div>
`;
// Keep list manageable
while (listEl.children.length > 50) {
listEl.removeChild(listEl.lastChild);
}
}
// Batching state for aircraft updates to prevent browser freeze
let pendingAircraftUpdate = false;
let pendingAircraftData = [];

View File

@@ -209,6 +209,29 @@ TOOL_DEPENDENCIES = {
}
}
},
'aprs': {
'name': 'APRS Tracking',
'tools': {
'direwolf': {
'required': False,
'description': 'APRS/packet radio decoder (preferred)',
'install': {
'apt': 'sudo apt install direwolf',
'brew': 'brew install direwolf',
'manual': 'https://github.com/wb2osz/direwolf'
}
},
'multimon-ng': {
'required': False,
'description': 'Alternative AFSK1200 decoder',
'install': {
'apt': 'sudo apt install multimon-ng',
'brew': 'brew install multimon-ng',
'manual': 'https://github.com/EliasOenal/multimon-ng'
}
}
}
},
'satellite': {
'name': 'Satellite Tracking',
'tools': {