diff --git a/routes/aprs.py b/routes/aprs.py index 8611f37..a6e29d6 100644 --- a/routes/aprs.py +++ b/routes/aprs.py @@ -14,14 +14,14 @@ import threading import time from datetime import datetime from subprocess import PIPE, STDOUT -from typing import Any, Generator, Optional +from typing import Any, 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 sse_stream_fanout +from utils.sse import sse_stream_fanout from utils.event_pipeline import process_event from utils.sdr import SDRFactory, SDRType from utils.constants import ( @@ -94,7 +94,7 @@ def find_rtl_power() -> Optional[str]: DIREWOLF_CONFIG_PATH = os.path.join(tempfile.gettempdir(), 'intercept_direwolf.conf') -def create_direwolf_config() -> str: +def create_direwolf_config() -> str: """Create a minimal direwolf config for receive-only operation.""" config = """# Minimal direwolf config for INTERCEPT (receive-only) # Audio input is handled via stdin @@ -104,32 +104,32 @@ CHANNEL 0 MYCALL N0CALL MODEM 1200 """ - with open(DIREWOLF_CONFIG_PATH, 'w') as f: - f.write(config) - return DIREWOLF_CONFIG_PATH - - -def normalize_aprs_output_line(line: str) -> str: - """Normalize a decoder output line to raw APRS packet format. - - Handles common decoder prefixes: - - multimon-ng: ``AFSK1200: ...`` - - direwolf tags: ``[0.4] ...``, ``[0L] ...``, etc. - """ - if not line: - return '' - - normalized = line.strip() - if normalized.startswith('AFSK1200:'): - normalized = normalized[9:].strip() - - # Strip one or more leading bracket tags emitted by decoders. - # Examples: [0.4], [0L], [NONE] - normalized = re.sub(r'^(?:\[[^\]]+\]\s*)+', '', normalized) - return normalized - - -def parse_aprs_packet(raw_packet: str) -> Optional[dict]: + with open(DIREWOLF_CONFIG_PATH, 'w') as f: + f.write(config) + return DIREWOLF_CONFIG_PATH + + +def normalize_aprs_output_line(line: str) -> str: + """Normalize a decoder output line to raw APRS packet format. + + Handles common decoder prefixes: + - multimon-ng: ``AFSK1200: ...`` + - direwolf tags: ``[0.4] ...``, ``[0L] ...``, etc. + """ + if not line: + return '' + + normalized = line.strip() + if normalized.startswith('AFSK1200:'): + normalized = normalized[9:].strip() + + # Strip one or more leading bracket tags emitted by decoders. + # Examples: [0.4], [0L], [NONE] + normalized = re.sub(r'^(?:\[[^\]]+\]\s*)+', '', normalized) + return normalized + + +def parse_aprs_packet(raw_packet: str) -> Optional[dict]: """Parse APRS packet into structured data. Supports all major APRS packet types: @@ -143,19 +143,19 @@ def parse_aprs_packet(raw_packet: str) -> Optional[dict]: - Third-party traffic - Raw GPS/NMEA data - User-defined formats - """ - try: - raw_packet = normalize_aprs_output_line(raw_packet) - if not raw_packet: - return None - - # Basic APRS packet format: CALLSIGN>PATH:DATA - # Example: N0CALL-9>APRS,TCPIP*:@092345z4903.50N/07201.75W_090/000g005t077 - - # 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 + """ + try: + raw_packet = normalize_aprs_output_line(raw_packet) + if not raw_packet: + return None + + # Basic APRS packet format: CALLSIGN>PATH:DATA + # Example: N0CALL-9>APRS,TCPIP*:@092345z4903.50N/07201.75W_090/000g005t077 + + # 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 callsign = match.group(1).upper() path = match.group(2) @@ -418,18 +418,18 @@ 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 - if pos_match: + 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) @@ -467,113 +467,113 @@ def parse_position(data: str) -> Optional[dict]: if alt_match: result['altitude'] = int(alt_match.group(1)) # feet - return result - - # Legacy/no-decimal variant occasionally seen in degraded decodes: - # DDMMN/DDDMMW (symbol chars still present between/after coords). - nodot_match = re.match( - r'^(\d{2})(\d{2})([NS])(.)(\d{3})(\d{2})([EW])(.)?', - data - ) - if nodot_match: - lat_deg = int(nodot_match.group(1)) - lat_min = float(nodot_match.group(2)) - lat_dir = nodot_match.group(3) - symbol_table = nodot_match.group(4) - lon_deg = int(nodot_match.group(5)) - lon_min = float(nodot_match.group(6)) - lon_dir = nodot_match.group(7) - symbol_code = nodot_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, - } - - remaining = data[13:] if len(data) > 13 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 - - # 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 result + + # Legacy/no-decimal variant occasionally seen in degraded decodes: + # DDMMN/DDDMMW (symbol chars still present between/after coords). + nodot_match = re.match( + r'^(\d{2})(\d{2})([NS])(.)(\d{3})(\d{2})([EW])(.)?', + data + ) + if nodot_match: + lat_deg = int(nodot_match.group(1)) + lat_min = float(nodot_match.group(2)) + lat_dir = nodot_match.group(3) + symbol_table = nodot_match.group(4) + lon_deg = int(nodot_match.group(5)) + lon_min = float(nodot_match.group(6)) + lon_dir = nodot_match.group(7) + symbol_code = nodot_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, + } + + remaining = data[13:] if len(data) > 13 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 + + # 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 @@ -1449,7 +1449,11 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces - type='meter': Audio level meter readings (rate-limited) """ global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations - global _last_meter_time, _last_meter_level + global _last_meter_time, _last_meter_level, aprs_active_device + + # Capture the device claimed by THIS session so the finally block only + # releases our own device, not one claimed by a subsequent start. + my_device = aprs_active_device # Reset meter state _last_meter_time = 0.0 @@ -1476,12 +1480,12 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces app_module.aprs_queue.put(meter_msg) continue # Audio level lines are not packets - # Normalize decoder prefixes (multimon/direwolf) before parsing. - line = normalize_aprs_output_line(line) - - # Skip non-packet lines (APRS format: CALL>PATH:DATA) - if '>' not in line or ':' not in line: - continue + # Normalize decoder prefixes (multimon/direwolf) before parsing. + line = normalize_aprs_output_line(line) + + # Skip non-packet lines (APRS format: CALL>PATH:DATA) + if '>' not in line or ':' not in line: + continue packet = parse_aprs_packet(line) if packet: @@ -1493,29 +1497,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, 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} + # 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: @@ -1543,7 +1547,6 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces logger.error(f"APRS stream error: {e}") app_module.aprs_queue.put({'type': 'error', 'message': str(e)}) finally: - global aprs_active_device app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'}) # Cleanup processes for proc in [rtl_process, decoder_process]: @@ -1555,9 +1558,9 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces proc.kill() except Exception: pass - # Release SDR device - if aprs_active_device is not None: - app_module.release_sdr_device(aprs_active_device) + # Release SDR device — only if it's still ours (not reclaimed by a new start) + if my_device is not None and aprs_active_device == my_device: + app_module.release_sdr_device(my_device) aprs_active_device = None @@ -1596,31 +1599,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('/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('/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']) @@ -1908,25 +1911,25 @@ def stop_aprs() -> Response: return jsonify({'status': 'stopped'}) -@aprs_bp.route('/stream') -def stream_aprs() -> Response: - """SSE stream for APRS packets.""" - def _on_msg(msg: dict[str, Any]) -> None: - process_event('aprs', msg, msg.get('type')) - - response = Response( - sse_stream_fanout( - source_queue=app_module.aprs_queue, - channel_key='aprs', - timeout=SSE_QUEUE_TIMEOUT, - keepalive_interval=SSE_KEEPALIVE_INTERVAL, - on_message=_on_msg, - ), - mimetype='text/event-stream', - ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - return response +@aprs_bp.route('/stream') +def stream_aprs() -> Response: + """SSE stream for APRS packets.""" + def _on_msg(msg: dict[str, Any]) -> None: + process_event('aprs', msg, msg.get('type')) + + response = Response( + sse_stream_fanout( + source_queue=app_module.aprs_queue, + channel_key='aprs', + timeout=SSE_QUEUE_TIMEOUT, + keepalive_interval=SSE_KEEPALIVE_INTERVAL, + on_message=_on_msg, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + return response @aprs_bp.route('/frequencies') diff --git a/templates/index.html b/templates/index.html index e323d99..fe780f7 100644 --- a/templates/index.html +++ b/templates/index.html @@ -9687,7 +9687,7 @@ }); } - function stopAprs() { + async function stopAprs() { const isAgentMode = aprsCurrentAgent !== null; const endpoint = isAgentMode ? `/controller/agents/${aprsCurrentAgent}/aprs/stop` @@ -9697,9 +9697,8 @@ isAprsRunning = false; aprsCurrentAgent = null; resetAprsAgentStationTracking(); - document.getElementById('aprsStripStartBtn').style.display = 'inline-block'; document.getElementById('aprsStripStopBtn').style.display = 'none'; - document.getElementById('aprsMapStatus').textContent = 'STANDBY'; + document.getElementById('aprsMapStatus').textContent = 'STOPPING'; document.getElementById('aprsMapStatus').style.color = ''; updateAprsStatus('standby'); document.getElementById('aprsStripFreq').textContent = '--'; @@ -9722,7 +9721,9 @@ aprsPollTimer = null; } - return postStopRequest(endpoint, timeoutMs); + await postStopRequest(endpoint, timeoutMs); + document.getElementById('aprsStripStartBtn').style.display = 'inline-block'; + document.getElementById('aprsMapStatus').textContent = 'STANDBY'; } function startAprsStream(isAgentMode = false) { @@ -9730,7 +9731,7 @@ // Use different stream endpoint for agent mode const streamUrl = isAgentMode ? '/controller/stream/all' : '/aprs/stream'; - aprsEventSource = new EventSource(streamUrl); + aprsEventSource = new EventSource(streamUrl + (streamUrl.includes('?') ? '&' : '?') + 't=' + Date.now()); aprsEventSource.onmessage = function (e) { const data = JSON.parse(e.data);