Fix APRS map ingestion and parser compatibility

This commit is contained in:
Smittix
2026-02-24 23:39:54 +00:00
parent 6384e39576
commit c4bde6c707
3 changed files with 186 additions and 69 deletions

View File

@@ -14,7 +14,7 @@ import threading
import time
from datetime import datetime
from subprocess import PIPE, STDOUT
from typing import Generator, Optional
from typing import Any, Generator, Optional
from flask import Blueprint, jsonify, request, Response
@@ -152,9 +152,10 @@ def parse_aprs_packet(raw_packet: str) -> Optional[dict]:
# 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)
# Source callsigns can include tactical suffixes like "/1" on some stations.
match = re.match(r'^([A-Z0-9/\-]+)>([^:]+):(.+)$', raw_packet, re.IGNORECASE)
if not match:
return None
return None
callsign = match.group(1).upper()
path = match.group(2)
@@ -417,16 +418,16 @@ def parse_aprs_packet(raw_packet: str) -> Optional[dict]:
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
)
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))
@@ -466,10 +467,70 @@ def parse_position(data: str) -> Optional[dict]:
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 result
# Fallback: tolerate APRS ambiguity spaces in minute fields.
# Example: 4903. N/07201. W
if len(data) >= 18:
lat_field = data[0:7]
lat_dir = data[7]
symbol_table = data[8] if len(data) > 8 else ''
lon_field = data[9:17] if len(data) >= 17 else ''
lon_dir = data[17] if len(data) > 17 else ''
symbol_code = data[18] if len(data) > 18 else ''
if (
len(lat_field) == 7
and len(lon_field) == 8
and lat_dir in ('N', 'S')
and lon_dir in ('E', 'W')
):
lat_deg_txt = lat_field[:2]
lat_min_txt = lat_field[2:].replace(' ', '0')
lon_deg_txt = lon_field[:3]
lon_min_txt = lon_field[3:].replace(' ', '0')
if (
lat_deg_txt.isdigit()
and lon_deg_txt.isdigit()
and re.match(r'^\d{2}\.\d+$', lat_min_txt)
and re.match(r'^\d{2}\.\d+$', lon_min_txt)
):
lat_deg = int(lat_deg_txt)
lon_deg = int(lon_deg_txt)
lat_min = float(lat_min_txt)
lon_min = float(lon_min_txt)
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,
}
# Keep same extension parsing behavior as primary branch.
remaining = data[19:] if len(data) > 19 else ''
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))
alt_match = re.search(r'/A=(-?\d+)', remaining)
if alt_match:
result['altitude'] = int(alt_match.group(1))
return result
except Exception as e:
logger.debug(f"Failed to parse position: {e}")
return None
@@ -1389,25 +1450,29 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
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'),
}
# Geofence check
_aprs_lat = packet.get('lat')
_aprs_lon = packet.get('lon')
if _aprs_lat and _aprs_lon:
try:
from utils.geofence import get_geofence_manager
for _gf_evt in get_geofence_manager().check_position(
callsign, 'aprs_station', _aprs_lat, _aprs_lon,
{'callsign': callsign}
# Update station data, preserving last known coordinates when
# packets do not contain position fields.
if callsign:
existing = aprs_stations.get(callsign, {})
packet_lat = packet.get('lat')
packet_lon = packet.get('lon')
aprs_stations[callsign] = {
'callsign': callsign,
'lat': packet_lat if packet_lat is not None else existing.get('lat'),
'lon': packet_lon if packet_lon is not None else existing.get('lon'),
'symbol': packet.get('symbol') or existing.get('symbol'),
'last_seen': packet.get('timestamp'),
'packet_type': packet.get('packet_type'),
}
# Geofence check
_aprs_lat = packet_lat
_aprs_lon = packet_lon
if _aprs_lat is not None and _aprs_lon is not None:
try:
from utils.geofence import get_geofence_manager
for _gf_evt in get_geofence_manager().check_position(
callsign, 'aprs_station', _aprs_lat, _aprs_lon,
{'callsign': callsign}
):
process_event('aprs', _gf_evt, 'geofence')
except Exception:
@@ -1488,13 +1553,31 @@ def aprs_status() -> Response:
})
@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('/stations')
def get_stations() -> Response:
"""Get all tracked APRS stations."""
return jsonify({
'stations': list(aprs_stations.values()),
'count': len(aprs_stations)
})
@aprs_bp.route('/data')
def aprs_data() -> Response:
"""Get APRS data snapshot for remote controller polling compatibility."""
running = False
if app_module.aprs_process:
running = app_module.aprs_process.poll() is None
return jsonify({
'status': 'success',
'running': running,
'stations': list(aprs_stations.values()),
'count': len(aprs_stations),
'packet_count': aprs_packet_count,
'station_count': aprs_station_count,
'last_packet_time': aprs_last_packet_time,
})
@aprs_bp.route('/start', methods=['POST'])

View File

@@ -9298,19 +9298,25 @@
return R * c;
}
function aprsHasValidCoordinates(lat, lon) {
return Number.isFinite(Number(lat)) && Number.isFinite(Number(lon));
}
// Update APRS user location from GPS
function updateAprsUserLocation(position) {
if (!position || !position.latitude || !position.longitude) return;
const lat = Number(position && position.latitude);
const lon = Number(position && position.longitude);
if (!aprsHasValidCoordinates(lat, lon)) return;
aprsUserLocation.lat = position.latitude;
aprsUserLocation.lon = position.longitude;
aprsUserLocation.lat = lat;
aprsUserLocation.lon = lon;
// Update user marker on map
if (aprsMap) {
if (aprsUserMarker) {
aprsUserMarker.setLatLng([position.latitude, position.longitude]);
aprsUserMarker.setLatLng([lat, lon]);
} else {
aprsUserMarker = L.marker([position.latitude, position.longitude], {
aprsUserMarker = L.marker([lat, lon], {
icon: L.divIcon({
className: 'aprs-user-marker',
html: '<div style="width: 14px; height: 14px; background: #ff0; border: 2px solid #000; border-radius: 50%; box-shadow: 0 0 10px #ff0;"></div>',
@@ -9323,7 +9329,7 @@
// Center map on first GPS fix
if (!aprsMap._gpsInitialized) {
aprsMap.setView([position.latitude, position.longitude], 8);
aprsMap.setView([lat, lon], 8);
aprsMap._gpsInitialized = true;
}
}
@@ -9338,7 +9344,7 @@
// Update distances for all stations in the list
function updateAprsStationDistances() {
if (!aprsUserLocation.lat || !aprsUserLocation.lon) return;
if (!aprsHasValidCoordinates(aprsUserLocation.lat, aprsUserLocation.lon)) return;
// Update station list items
const listEl = document.getElementById('aprsStationList');
@@ -9395,9 +9401,14 @@
if (!mapContainer) return;
// Use GPS location if available, otherwise default to center of US
const initialLat = aprsUserLocation.lat || gpsLastPosition?.latitude || 39.8283;
const initialLon = aprsUserLocation.lon || gpsLastPosition?.longitude || -98.5795;
const initialZoom = (aprsUserLocation.lat || gpsLastPosition?.latitude) ? 8 : 4;
const gpsLat = Number(gpsLastPosition && gpsLastPosition.latitude);
const gpsLon = Number(gpsLastPosition && gpsLastPosition.longitude);
const hasUserLocation = aprsHasValidCoordinates(aprsUserLocation.lat, aprsUserLocation.lon);
const hasGpsLocation = aprsHasValidCoordinates(gpsLat, gpsLon);
const initialLat = hasUserLocation ? aprsUserLocation.lat : (hasGpsLocation ? gpsLat : 39.8283);
const initialLon = hasUserLocation ? aprsUserLocation.lon : (hasGpsLocation ? gpsLon : -98.5795);
const initialZoom = (hasUserLocation || hasGpsLocation) ? 8 : 4;
aprsMap = L.map('aprsMap').setView([initialLat, initialLon], initialZoom);
window.aprsMap = aprsMap;
@@ -9418,8 +9429,8 @@
}
// Add user marker if GPS position is already available
if (gpsConnected && gpsLastPosition && gpsLastPosition.latitude && gpsLastPosition.longitude) {
updateAprsUserLocation(gpsLastPosition);
if (gpsConnected && hasGpsLocation) {
updateAprsUserLocation({ latitude: gpsLat, longitude: gpsLon });
aprsMap._gpsInitialized = true;
}
@@ -9863,7 +9874,7 @@
}
// Update map if position data
if (packet.lat && packet.lon && aprsMap) {
if (aprsHasValidCoordinates(packet.lat, packet.lon) && aprsMap) {
updateAprsMarker(packet);
}
@@ -9908,22 +9919,27 @@
function updateAprsMarker(packet) {
const callsign = packet.callsign;
const lat = Number(packet.lat);
const lon = Number(packet.lon);
if (!aprsHasValidCoordinates(lat, lon)) {
return;
}
// Calculate distance if user location available
let distStr = '';
if (aprsUserLocation.lat && aprsUserLocation.lon) {
const dist = aprsCalculateDistanceMi(aprsUserLocation.lat, aprsUserLocation.lon, packet.lat, packet.lon);
if (aprsHasValidCoordinates(aprsUserLocation.lat, aprsUserLocation.lon)) {
const dist = aprsCalculateDistanceMi(aprsUserLocation.lat, aprsUserLocation.lon, lat, lon);
distStr = `Distance: ${dist.toFixed(1)} mi<br>`;
}
if (aprsMarkers[callsign]) {
// Update existing marker position and popup
aprsMarkers[callsign].setLatLng([packet.lat, packet.lon]);
aprsMarkers[callsign].setLatLng([lat, lon]);
aprsMarkers[callsign].setIcon(buildAprsMarkerIcon(packet));
aprsMarkers[callsign].setPopupContent(`
<div style="font-family: monospace;">
<strong>${callsign}</strong><br>
Position: ${packet.lat.toFixed(4)}, ${packet.lon.toFixed(4)}<br>
Position: ${lat.toFixed(4)}, ${lon.toFixed(4)}<br>
${distStr}
${packet.altitude ? `Altitude: ${packet.altitude} ft<br>` : ''}
${packet.speed ? `Speed: ${packet.speed} kts<br>` : ''}
@@ -9937,12 +9953,12 @@
document.getElementById('aprsStationCount').textContent = aprsStationCount;
document.getElementById('aprsStripStations').textContent = aprsStationCount;
const marker = L.marker([packet.lat, packet.lon], { icon: buildAprsMarkerIcon(packet) }).addTo(aprsMap);
const marker = L.marker([lat, lon], { icon: buildAprsMarkerIcon(packet) }).addTo(aprsMap);
marker.bindPopup(`
<div style="font-family: monospace;">
<strong>${callsign}</strong><br>
Position: ${packet.lat.toFixed(4)}, ${packet.lon.toFixed(4)}<br>
Position: ${lat.toFixed(4)}, ${lon.toFixed(4)}<br>
${distStr}
${packet.altitude ? `Altitude: ${packet.altitude} ft<br>` : ''}
${packet.speed ? `Speed: ${packet.speed} kts<br>` : ''}
@@ -9966,9 +9982,11 @@
// Calculate distance if user location available
let distance = null;
const hasPos = packet.lat && packet.lon;
if (hasPos && aprsUserLocation.lat && aprsUserLocation.lon) {
distance = aprsCalculateDistanceMi(aprsUserLocation.lat, aprsUserLocation.lon, packet.lat, packet.lon);
const hasPos = aprsHasValidCoordinates(packet.lat, packet.lon);
const lat = hasPos ? Number(packet.lat) : null;
const lon = hasPos ? Number(packet.lon) : null;
if (hasPos && aprsHasValidCoordinates(aprsUserLocation.lat, aprsUserLocation.lon)) {
distance = aprsCalculateDistanceMi(aprsUserLocation.lat, aprsUserLocation.lon, lat, lon);
}
// Check if station already exists
@@ -9979,8 +9997,8 @@
const msg = {
callsign: callsign,
packet_type: packet.packet_type || 'unknown',
latitude: packet.lat,
longitude: packet.lon,
latitude: lat,
longitude: lon,
altitude: packet.altitude,
speed: packet.speed,
course: packet.course,
@@ -9998,8 +10016,8 @@
// Store position for distance updates
if (hasPos) {
newCard.dataset.lat = packet.lat;
newCard.dataset.lon = packet.lon;
newCard.dataset.lat = lat;
newCard.dataset.lon = lon;
}
// Add click handler to focus map

View File

@@ -25,3 +25,19 @@ def test_parse_aprs_packet_accepts_decoder_prefix_variants(line: str) -> None:
assert packet is not None
assert packet["callsign"] == "N0CALL-9"
assert packet["type"] == "aprs"
def test_parse_aprs_packet_accepts_callsign_with_tactical_suffix() -> None:
packet = parse_aprs_packet("CALL/1>APRS:!4903.50N/07201.75W-Test")
assert packet is not None
assert packet["callsign"] == "CALL/1"
assert packet["lat"] == pytest.approx(49.058333, rel=0, abs=1e-6)
assert packet["lon"] == pytest.approx(-72.029167, rel=0, abs=1e-6)
def test_parse_aprs_packet_handles_ambiguous_uncompressed_position() -> None:
packet = parse_aprs_packet("KJ7ABC-7>APRS,WIDE1-1:!4903. N/07201. W-Test")
assert packet is not None
assert packet["packet_type"] == "position"
assert packet["lat"] == pytest.approx(49.05, rel=0, abs=1e-6)
assert packet["lon"] == pytest.approx(-72.016667, rel=0, abs=1e-6)