diff --git a/routes/aprs.py b/routes/aprs.py index 3ba3eb6..eb14c97 100644 --- a/routes/aprs.py +++ b/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']) diff --git a/templates/index.html b/templates/index.html index b9c01f8..ccaaaa8 100644 --- a/templates/index.html +++ b/templates/index.html @@ -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: '
', @@ -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