mirror of
https://github.com/smittix/intercept.git
synced 2026-07-08 09:38:12 -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:
@@ -108,6 +108,12 @@ acars_process = None
|
|||||||
acars_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
acars_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
acars_lock = threading.Lock()
|
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
|
# 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),
|
'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),
|
'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),
|
'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),
|
'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),
|
'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:
|
def kill_all() -> Response:
|
||||||
"""Kill all decoder and WiFi processes."""
|
"""Kill all decoder and WiFi processes."""
|
||||||
global current_process, sensor_process, wifi_process, adsb_process, acars_process
|
global current_process, sensor_process, wifi_process, adsb_process, acars_process
|
||||||
|
global aprs_process, aprs_rtl_process
|
||||||
|
|
||||||
# Import adsb module to reset its state
|
# Import adsb module to reset its state
|
||||||
from routes import adsb as adsb_module
|
from routes import adsb as adsb_module
|
||||||
@@ -446,7 +454,7 @@ def kill_all() -> Response:
|
|||||||
processes_to_kill = [
|
processes_to_kill = [
|
||||||
'rtl_fm', 'multimon-ng', 'rtl_433',
|
'rtl_fm', 'multimon-ng', 'rtl_433',
|
||||||
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
||||||
'dump1090', 'acarsdec'
|
'dump1090', 'acarsdec', 'direwolf'
|
||||||
]
|
]
|
||||||
|
|
||||||
for proc in processes_to_kill:
|
for proc in processes_to_kill:
|
||||||
@@ -475,6 +483,11 @@ def kill_all() -> Response:
|
|||||||
with acars_lock:
|
with acars_lock:
|
||||||
acars_process = None
|
acars_process = None
|
||||||
|
|
||||||
|
# Reset APRS state
|
||||||
|
with aprs_lock:
|
||||||
|
aprs_process = None
|
||||||
|
aprs_rtl_process = None
|
||||||
|
|
||||||
return jsonify({'status': 'killed', 'processes': killed})
|
return jsonify({'status': 'killed', 'processes': killed})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ def register_blueprints(app):
|
|||||||
from .bluetooth import bluetooth_bp
|
from .bluetooth import bluetooth_bp
|
||||||
from .adsb import adsb_bp
|
from .adsb import adsb_bp
|
||||||
from .acars import acars_bp
|
from .acars import acars_bp
|
||||||
|
from .aprs import aprs_bp
|
||||||
from .satellite import satellite_bp
|
from .satellite import satellite_bp
|
||||||
from .gps import gps_bp
|
from .gps import gps_bp
|
||||||
from .settings import settings_bp
|
from .settings import settings_bp
|
||||||
@@ -20,6 +21,7 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(bluetooth_bp)
|
app.register_blueprint(bluetooth_bp)
|
||||||
app.register_blueprint(adsb_bp)
|
app.register_blueprint(adsb_bp)
|
||||||
app.register_blueprint(acars_bp)
|
app.register_blueprint(acars_bp)
|
||||||
|
app.register_blueprint(aprs_bp)
|
||||||
app.register_blueprint(satellite_bp)
|
app.register_blueprint(satellite_bp)
|
||||||
app.register_blueprint(gps_bp)
|
app.register_blueprint(gps_bp)
|
||||||
app.register_blueprint(settings_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)
|
||||||
+323
-1
@@ -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 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('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('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('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>
|
<button class="mode-nav-btn" onclick="switchMode('listening')"><span class="nav-icon">📻</span><span class="nav-label">Listening Post</span></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -860,6 +861,53 @@
|
|||||||
|
|
||||||
</div>
|
</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 -->
|
<!-- SATELLITE MODE -->
|
||||||
<div id="satelliteMode" class="mode-content">
|
<div id="satelliteMode" class="mode-content">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
@@ -1291,6 +1339,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Listening Post Visualizations - Professional Ham Radio Scanner -->
|
||||||
<div class="wifi-visuals" id="listeningPostVisuals" style="display: none;">
|
<div class="wifi-visuals" id="listeningPostVisuals" style="display: none;">
|
||||||
|
|
||||||
@@ -2352,7 +2434,7 @@
|
|||||||
document.getElementById('pagerWaterfallPanel').style.display = (mode === 'pager') ? 'block' : 'none';
|
document.getElementById('pagerWaterfallPanel').style.display = (mode === 'pager') ? 'block' : 'none';
|
||||||
// Sensor waterfall: show only for sensor (433MHz) mode
|
// Sensor waterfall: show only for sensor (433MHz) mode
|
||||||
document.getElementById('sensorWaterfallPanel').style.display = (mode === 'sensor') ? 'block' : 'none';
|
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';
|
document.querySelector('.status-bar').style.display = (mode === 'satellite') ? 'none' : 'flex';
|
||||||
|
|
||||||
// Load interfaces and initialize visualizations when switching modes
|
// Load interfaces and initialize visualizations when switching modes
|
||||||
@@ -2366,6 +2448,9 @@
|
|||||||
} else if (mode === 'aircraft') {
|
} else if (mode === 'aircraft') {
|
||||||
checkAdsbTools();
|
checkAdsbTools();
|
||||||
initAircraftRadar();
|
initAircraftRadar();
|
||||||
|
} else if (mode === 'aprs') {
|
||||||
|
checkAprsTools();
|
||||||
|
initAprsMap();
|
||||||
} else if (mode === 'satellite') {
|
} else if (mode === 'satellite') {
|
||||||
initPolarPlot();
|
initPolarPlot();
|
||||||
initSatelliteList();
|
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: '© OpenStreetMap contributors © 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
|
// Batching state for aircraft updates to prevent browser freeze
|
||||||
let pendingAircraftUpdate = false;
|
let pendingAircraftUpdate = false;
|
||||||
let pendingAircraftData = [];
|
let pendingAircraftData = [];
|
||||||
|
|||||||
@@ -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': {
|
'satellite': {
|
||||||
'name': 'Satellite Tracking',
|
'name': 'Satellite Tracking',
|
||||||
'tools': {
|
'tools': {
|
||||||
|
|||||||
Reference in New Issue
Block a user