mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Fix APRS map ingestion and parser compatibility
This commit is contained in:
169
routes/aprs.py
169
routes/aprs.py
@@ -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'])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user