Fix APRS stop/start not repopulating stations

- Make stopAprs() async and await backend stop completion before
  re-enabling the Start button, preventing race where a late stop
  request kills newly started processes
- Add cache-buster param to EventSource URL to prevent browser
  SSE connection reuse between stop/start cycles
- Capture aprs_active_device locally in stream_aprs_output so the
  old thread's finally block doesn't release a device claimed by
  a new session

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-25 18:31:10 +00:00
parent 6daddafad7
commit aebcbc77be
2 changed files with 247 additions and 243 deletions
+241 -238
View File
@@ -14,14 +14,14 @@ import threading
import time import time
from datetime import datetime from datetime import datetime
from subprocess import PIPE, STDOUT from subprocess import PIPE, STDOUT
from typing import Any, Generator, Optional from typing import Any, Generator, Optional
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, jsonify, request, Response
import app as app_module import app as app_module
from utils.logging import sensor_logger as logger from utils.logging import sensor_logger as logger
from utils.validation import validate_device_index, validate_gain, validate_ppm 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.event_pipeline import process_event
from utils.sdr import SDRFactory, SDRType from utils.sdr import SDRFactory, SDRType
from utils.constants import ( 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') 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.""" """Create a minimal direwolf config for receive-only operation."""
config = """# Minimal direwolf config for INTERCEPT (receive-only) config = """# Minimal direwolf config for INTERCEPT (receive-only)
# Audio input is handled via stdin # Audio input is handled via stdin
@@ -104,32 +104,32 @@ CHANNEL 0
MYCALL N0CALL MYCALL N0CALL
MODEM 1200 MODEM 1200
""" """
with open(DIREWOLF_CONFIG_PATH, 'w') as f: with open(DIREWOLF_CONFIG_PATH, 'w') as f:
f.write(config) f.write(config)
return DIREWOLF_CONFIG_PATH return DIREWOLF_CONFIG_PATH
def normalize_aprs_output_line(line: str) -> str: def normalize_aprs_output_line(line: str) -> str:
"""Normalize a decoder output line to raw APRS packet format. """Normalize a decoder output line to raw APRS packet format.
Handles common decoder prefixes: Handles common decoder prefixes:
- multimon-ng: ``AFSK1200: ...`` - multimon-ng: ``AFSK1200: ...``
- direwolf tags: ``[0.4] ...``, ``[0L] ...``, etc. - direwolf tags: ``[0.4] ...``, ``[0L] ...``, etc.
""" """
if not line: if not line:
return '' return ''
normalized = line.strip() normalized = line.strip()
if normalized.startswith('AFSK1200:'): if normalized.startswith('AFSK1200:'):
normalized = normalized[9:].strip() normalized = normalized[9:].strip()
# Strip one or more leading bracket tags emitted by decoders. # Strip one or more leading bracket tags emitted by decoders.
# Examples: [0.4], [0L], [NONE] # Examples: [0.4], [0L], [NONE]
normalized = re.sub(r'^(?:\[[^\]]+\]\s*)+', '', normalized) normalized = re.sub(r'^(?:\[[^\]]+\]\s*)+', '', normalized)
return normalized return normalized
def parse_aprs_packet(raw_packet: str) -> Optional[dict]: def parse_aprs_packet(raw_packet: str) -> Optional[dict]:
"""Parse APRS packet into structured data. """Parse APRS packet into structured data.
Supports all major APRS packet types: Supports all major APRS packet types:
@@ -143,19 +143,19 @@ def parse_aprs_packet(raw_packet: str) -> Optional[dict]:
- Third-party traffic - Third-party traffic
- Raw GPS/NMEA data - Raw GPS/NMEA data
- User-defined formats - User-defined formats
""" """
try: try:
raw_packet = normalize_aprs_output_line(raw_packet) raw_packet = normalize_aprs_output_line(raw_packet)
if not raw_packet: if not raw_packet:
return None return None
# Basic APRS packet format: CALLSIGN>PATH:DATA # Basic APRS packet format: CALLSIGN>PATH:DATA
# Example: N0CALL-9>APRS,TCPIP*:@092345z4903.50N/07201.75W_090/000g005t077 # Example: N0CALL-9>APRS,TCPIP*:@092345z4903.50N/07201.75W_090/000g005t077
# Source callsigns can include tactical suffixes like "/1" on some stations. # Source callsigns can include tactical suffixes like "/1" on some stations.
match = re.match(r'^([A-Z0-9/\-]+)>([^:]+):(.+)$', raw_packet, re.IGNORECASE) match = re.match(r'^([A-Z0-9/\-]+)>([^:]+):(.+)$', raw_packet, re.IGNORECASE)
if not match: if not match:
return None return None
callsign = match.group(1).upper() callsign = match.group(1).upper()
path = match.group(2) path = match.group(2)
@@ -418,18 +418,18 @@ def parse_aprs_packet(raw_packet: str) -> Optional[dict]:
return None return None
def parse_position(data: str) -> Optional[dict]: def parse_position(data: str) -> Optional[dict]:
"""Parse APRS position data.""" """Parse APRS position data."""
try: try:
# Format: DDMM.mmN/DDDMM.mmW (or similar with symbols) # Format: DDMM.mmN/DDDMM.mmW (or similar with symbols)
# Example: 4903.50N/07201.75W # 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: 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_deg = int(pos_match.group(1))
lat_min = float(pos_match.group(2)) lat_min = float(pos_match.group(2))
lat_dir = pos_match.group(3) lat_dir = pos_match.group(3)
@@ -467,113 +467,113 @@ def parse_position(data: str) -> Optional[dict]:
if alt_match: if alt_match:
result['altitude'] = int(alt_match.group(1)) # feet result['altitude'] = int(alt_match.group(1)) # feet
return result return result
# Legacy/no-decimal variant occasionally seen in degraded decodes: # Legacy/no-decimal variant occasionally seen in degraded decodes:
# DDMMN/DDDMMW (symbol chars still present between/after coords). # DDMMN/DDDMMW (symbol chars still present between/after coords).
nodot_match = re.match( nodot_match = re.match(
r'^(\d{2})(\d{2})([NS])(.)(\d{3})(\d{2})([EW])(.)?', r'^(\d{2})(\d{2})([NS])(.)(\d{3})(\d{2})([EW])(.)?',
data data
) )
if nodot_match: if nodot_match:
lat_deg = int(nodot_match.group(1)) lat_deg = int(nodot_match.group(1))
lat_min = float(nodot_match.group(2)) lat_min = float(nodot_match.group(2))
lat_dir = nodot_match.group(3) lat_dir = nodot_match.group(3)
symbol_table = nodot_match.group(4) symbol_table = nodot_match.group(4)
lon_deg = int(nodot_match.group(5)) lon_deg = int(nodot_match.group(5))
lon_min = float(nodot_match.group(6)) lon_min = float(nodot_match.group(6))
lon_dir = nodot_match.group(7) lon_dir = nodot_match.group(7)
symbol_code = nodot_match.group(8) or '' symbol_code = nodot_match.group(8) or ''
lat = lat_deg + lat_min / 60.0 lat = lat_deg + lat_min / 60.0
if lat_dir == 'S': if lat_dir == 'S':
lat = -lat lat = -lat
lon = lon_deg + lon_min / 60.0 lon = lon_deg + lon_min / 60.0
if lon_dir == 'W': if lon_dir == 'W':
lon = -lon lon = -lon
result = { result = {
'lat': round(lat, 6), 'lat': round(lat, 6),
'lon': round(lon, 6), 'lon': round(lon, 6),
'symbol': symbol_table + symbol_code, 'symbol': symbol_table + symbol_code,
} }
remaining = data[13:] if len(data) > 13 else '' remaining = data[13:] if len(data) > 13 else ''
cs_match = re.search(r'(\d{3})/(\d{3})', remaining) cs_match = re.search(r'(\d{3})/(\d{3})', remaining)
if cs_match: if cs_match:
result['course'] = int(cs_match.group(1)) result['course'] = int(cs_match.group(1))
result['speed'] = int(cs_match.group(2)) result['speed'] = int(cs_match.group(2))
alt_match = re.search(r'/A=(-?\d+)', remaining) alt_match = re.search(r'/A=(-?\d+)', remaining)
if alt_match: if alt_match:
result['altitude'] = int(alt_match.group(1)) result['altitude'] = int(alt_match.group(1))
return result return result
# Fallback: tolerate APRS ambiguity spaces in minute fields. # Fallback: tolerate APRS ambiguity spaces in minute fields.
# Example: 4903. N/07201. W # Example: 4903. N/07201. W
if len(data) >= 18: if len(data) >= 18:
lat_field = data[0:7] lat_field = data[0:7]
lat_dir = data[7] lat_dir = data[7]
symbol_table = data[8] if len(data) > 8 else '' symbol_table = data[8] if len(data) > 8 else ''
lon_field = data[9:17] if len(data) >= 17 else '' lon_field = data[9:17] if len(data) >= 17 else ''
lon_dir = data[17] if len(data) > 17 else '' lon_dir = data[17] if len(data) > 17 else ''
symbol_code = data[18] if len(data) > 18 else '' symbol_code = data[18] if len(data) > 18 else ''
if ( if (
len(lat_field) == 7 len(lat_field) == 7
and len(lon_field) == 8 and len(lon_field) == 8
and lat_dir in ('N', 'S') and lat_dir in ('N', 'S')
and lon_dir in ('E', 'W') and lon_dir in ('E', 'W')
): ):
lat_deg_txt = lat_field[:2] lat_deg_txt = lat_field[:2]
lat_min_txt = lat_field[2:].replace(' ', '0') lat_min_txt = lat_field[2:].replace(' ', '0')
lon_deg_txt = lon_field[:3] lon_deg_txt = lon_field[:3]
lon_min_txt = lon_field[3:].replace(' ', '0') lon_min_txt = lon_field[3:].replace(' ', '0')
if ( if (
lat_deg_txt.isdigit() lat_deg_txt.isdigit()
and lon_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+$', lat_min_txt)
and re.match(r'^\d{2}\.\d+$', lon_min_txt) and re.match(r'^\d{2}\.\d+$', lon_min_txt)
): ):
lat_deg = int(lat_deg_txt) lat_deg = int(lat_deg_txt)
lon_deg = int(lon_deg_txt) lon_deg = int(lon_deg_txt)
lat_min = float(lat_min_txt) lat_min = float(lat_min_txt)
lon_min = float(lon_min_txt) lon_min = float(lon_min_txt)
lat = lat_deg + lat_min / 60.0 lat = lat_deg + lat_min / 60.0
if lat_dir == 'S': if lat_dir == 'S':
lat = -lat lat = -lat
lon = lon_deg + lon_min / 60.0 lon = lon_deg + lon_min / 60.0
if lon_dir == 'W': if lon_dir == 'W':
lon = -lon lon = -lon
result = { result = {
'lat': round(lat, 6), 'lat': round(lat, 6),
'lon': round(lon, 6), 'lon': round(lon, 6),
'symbol': symbol_table + symbol_code, 'symbol': symbol_table + symbol_code,
} }
# Keep same extension parsing behavior as primary branch. # Keep same extension parsing behavior as primary branch.
remaining = data[19:] if len(data) > 19 else '' remaining = data[19:] if len(data) > 19 else ''
cs_match = re.search(r'(\d{3})/(\d{3})', remaining) cs_match = re.search(r'(\d{3})/(\d{3})', remaining)
if cs_match: if cs_match:
result['course'] = int(cs_match.group(1)) result['course'] = int(cs_match.group(1))
result['speed'] = int(cs_match.group(2)) result['speed'] = int(cs_match.group(2))
alt_match = re.search(r'/A=(-?\d+)', remaining) alt_match = re.search(r'/A=(-?\d+)', remaining)
if alt_match: if alt_match:
result['altitude'] = int(alt_match.group(1)) result['altitude'] = int(alt_match.group(1))
return result return result
except Exception as e: except Exception as e:
logger.debug(f"Failed to parse position: {e}") logger.debug(f"Failed to parse position: {e}")
return None 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) - type='meter': Audio level meter readings (rate-limited)
""" """
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations 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 # Reset meter state
_last_meter_time = 0.0 _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) app_module.aprs_queue.put(meter_msg)
continue # Audio level lines are not packets continue # Audio level lines are not packets
# Normalize decoder prefixes (multimon/direwolf) before parsing. # Normalize decoder prefixes (multimon/direwolf) before parsing.
line = normalize_aprs_output_line(line) line = normalize_aprs_output_line(line)
# Skip non-packet lines (APRS format: CALL>PATH:DATA) # Skip non-packet lines (APRS format: CALL>PATH:DATA)
if '>' not in line or ':' not in line: if '>' not in line or ':' not in line:
continue continue
packet = parse_aprs_packet(line) packet = parse_aprs_packet(line)
if packet: 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: if callsign and callsign not in aprs_stations:
aprs_station_count += 1 aprs_station_count += 1
# Update station data, preserving last known coordinates when # Update station data, preserving last known coordinates when
# packets do not contain position fields. # packets do not contain position fields.
if callsign: if callsign:
existing = aprs_stations.get(callsign, {}) existing = aprs_stations.get(callsign, {})
packet_lat = packet.get('lat') packet_lat = packet.get('lat')
packet_lon = packet.get('lon') packet_lon = packet.get('lon')
aprs_stations[callsign] = { aprs_stations[callsign] = {
'callsign': callsign, 'callsign': callsign,
'lat': packet_lat if packet_lat is not None else existing.get('lat'), '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'), 'lon': packet_lon if packet_lon is not None else existing.get('lon'),
'symbol': packet.get('symbol') or existing.get('symbol'), 'symbol': packet.get('symbol') or existing.get('symbol'),
'last_seen': packet.get('timestamp'), 'last_seen': packet.get('timestamp'),
'packet_type': packet.get('packet_type'), 'packet_type': packet.get('packet_type'),
} }
# Geofence check # Geofence check
_aprs_lat = packet_lat _aprs_lat = packet_lat
_aprs_lon = packet_lon _aprs_lon = packet_lon
if _aprs_lat is not None and _aprs_lon is not None: if _aprs_lat is not None and _aprs_lon is not None:
try: try:
from utils.geofence import get_geofence_manager from utils.geofence import get_geofence_manager
for _gf_evt in get_geofence_manager().check_position( for _gf_evt in get_geofence_manager().check_position(
callsign, 'aprs_station', _aprs_lat, _aprs_lon, callsign, 'aprs_station', _aprs_lat, _aprs_lon,
{'callsign': callsign} {'callsign': callsign}
): ):
process_event('aprs', _gf_evt, 'geofence') process_event('aprs', _gf_evt, 'geofence')
except Exception: except Exception:
@@ -1543,7 +1547,6 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
logger.error(f"APRS stream error: {e}") logger.error(f"APRS stream error: {e}")
app_module.aprs_queue.put({'type': 'error', 'message': str(e)}) app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
finally: finally:
global aprs_active_device
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'}) app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
# Cleanup processes # Cleanup processes
for proc in [rtl_process, decoder_process]: for proc in [rtl_process, decoder_process]:
@@ -1555,9 +1558,9 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
proc.kill() proc.kill()
except Exception: except Exception:
pass pass
# Release SDR device # Release SDR device — only if it's still ours (not reclaimed by a new start)
if aprs_active_device is not None: if my_device is not None and aprs_active_device == my_device:
app_module.release_sdr_device(aprs_active_device) app_module.release_sdr_device(my_device)
aprs_active_device = None aprs_active_device = None
@@ -1596,31 +1599,31 @@ def aprs_status() -> Response:
}) })
@aprs_bp.route('/stations') @aprs_bp.route('/stations')
def get_stations() -> Response: def get_stations() -> Response:
"""Get all tracked APRS stations.""" """Get all tracked APRS stations."""
return jsonify({ return jsonify({
'stations': list(aprs_stations.values()), 'stations': list(aprs_stations.values()),
'count': len(aprs_stations) 'count': len(aprs_stations)
}) })
@aprs_bp.route('/data') @aprs_bp.route('/data')
def aprs_data() -> Response: def aprs_data() -> Response:
"""Get APRS data snapshot for remote controller polling compatibility.""" """Get APRS data snapshot for remote controller polling compatibility."""
running = False running = False
if app_module.aprs_process: if app_module.aprs_process:
running = app_module.aprs_process.poll() is None running = app_module.aprs_process.poll() is None
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
'running': running, 'running': running,
'stations': list(aprs_stations.values()), 'stations': list(aprs_stations.values()),
'count': len(aprs_stations), 'count': len(aprs_stations),
'packet_count': aprs_packet_count, 'packet_count': aprs_packet_count,
'station_count': aprs_station_count, 'station_count': aprs_station_count,
'last_packet_time': aprs_last_packet_time, 'last_packet_time': aprs_last_packet_time,
}) })
@aprs_bp.route('/start', methods=['POST']) @aprs_bp.route('/start', methods=['POST'])
@@ -1908,25 +1911,25 @@ def stop_aprs() -> Response:
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@aprs_bp.route('/stream') @aprs_bp.route('/stream')
def stream_aprs() -> Response: def stream_aprs() -> Response:
"""SSE stream for APRS packets.""" """SSE stream for APRS packets."""
def _on_msg(msg: dict[str, Any]) -> None: def _on_msg(msg: dict[str, Any]) -> None:
process_event('aprs', msg, msg.get('type')) process_event('aprs', msg, msg.get('type'))
response = Response( response = Response(
sse_stream_fanout( sse_stream_fanout(
source_queue=app_module.aprs_queue, source_queue=app_module.aprs_queue,
channel_key='aprs', channel_key='aprs',
timeout=SSE_QUEUE_TIMEOUT, timeout=SSE_QUEUE_TIMEOUT,
keepalive_interval=SSE_KEEPALIVE_INTERVAL, keepalive_interval=SSE_KEEPALIVE_INTERVAL,
on_message=_on_msg, on_message=_on_msg,
), ),
mimetype='text/event-stream', mimetype='text/event-stream',
) )
response.headers['Cache-Control'] = 'no-cache' response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no' response.headers['X-Accel-Buffering'] = 'no'
return response return response
@aprs_bp.route('/frequencies') @aprs_bp.route('/frequencies')
+6 -5
View File
@@ -9687,7 +9687,7 @@
}); });
} }
function stopAprs() { async function stopAprs() {
const isAgentMode = aprsCurrentAgent !== null; const isAgentMode = aprsCurrentAgent !== null;
const endpoint = isAgentMode const endpoint = isAgentMode
? `/controller/agents/${aprsCurrentAgent}/aprs/stop` ? `/controller/agents/${aprsCurrentAgent}/aprs/stop`
@@ -9697,9 +9697,8 @@
isAprsRunning = false; isAprsRunning = false;
aprsCurrentAgent = null; aprsCurrentAgent = null;
resetAprsAgentStationTracking(); resetAprsAgentStationTracking();
document.getElementById('aprsStripStartBtn').style.display = 'inline-block';
document.getElementById('aprsStripStopBtn').style.display = 'none'; document.getElementById('aprsStripStopBtn').style.display = 'none';
document.getElementById('aprsMapStatus').textContent = 'STANDBY'; document.getElementById('aprsMapStatus').textContent = 'STOPPING';
document.getElementById('aprsMapStatus').style.color = ''; document.getElementById('aprsMapStatus').style.color = '';
updateAprsStatus('standby'); updateAprsStatus('standby');
document.getElementById('aprsStripFreq').textContent = '--'; document.getElementById('aprsStripFreq').textContent = '--';
@@ -9722,7 +9721,9 @@
aprsPollTimer = null; 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) { function startAprsStream(isAgentMode = false) {
@@ -9730,7 +9731,7 @@
// Use different stream endpoint for agent mode // Use different stream endpoint for agent mode
const streamUrl = isAgentMode ? '/controller/stream/all' : '/aprs/stream'; 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) { aprsEventSource.onmessage = function (e) {
const data = JSON.parse(e.data); const data = JSON.parse(e.data);