mirror of
https://github.com/smittix/intercept.git
synced 2026-05-17 05:14:50 -07:00
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:
479
routes/aprs.py
479
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')
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user