mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Radiosonde route now runs a quick import check before launching the full subprocess, catching missing Python dependencies immediately with a clear message instead of a truncated traceback. Error messages are context-aware: import errors suggest re-running setup.sh rather than checking SDR connections. Increased stderr truncation limit from 200 to 500 chars and added full stderr logging via logger.error() across all affected routes (radiosonde, ais, aprs, acars, vdl2) for easier debugging. Closes #173 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
749 lines
23 KiB
Python
749 lines
23 KiB
Python
"""Radiosonde weather balloon tracking routes.
|
|
|
|
Uses radiosonde_auto_rx to automatically scan for and decode radiosonde
|
|
telemetry (position, altitude, temperature, humidity, pressure) on the
|
|
400-406 MHz band. Telemetry arrives as JSON over UDP.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import queue
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from typing import Any
|
|
|
|
from flask import Blueprint, Response, jsonify, request
|
|
|
|
import app as app_module
|
|
from utils.constants import (
|
|
MAX_RADIOSONDE_AGE_SECONDS,
|
|
PROCESS_TERMINATE_TIMEOUT,
|
|
RADIOSONDE_TERMINATE_TIMEOUT,
|
|
RADIOSONDE_UDP_PORT,
|
|
SSE_KEEPALIVE_INTERVAL,
|
|
SSE_QUEUE_TIMEOUT,
|
|
)
|
|
from utils.gps import is_gpsd_running
|
|
from utils.logging import get_logger
|
|
from utils.sdr import SDRFactory, SDRType
|
|
from utils.sse import sse_stream_fanout
|
|
from utils.validation import (
|
|
validate_device_index,
|
|
validate_gain,
|
|
validate_latitude,
|
|
validate_longitude,
|
|
)
|
|
|
|
logger = get_logger('intercept.radiosonde')
|
|
|
|
radiosonde_bp = Blueprint('radiosonde', __name__, url_prefix='/radiosonde')
|
|
|
|
# Track radiosonde state
|
|
radiosonde_running = False
|
|
radiosonde_active_device: int | None = None
|
|
radiosonde_active_sdr_type: str | None = None
|
|
|
|
# Active balloon data: serial -> telemetry dict
|
|
radiosonde_balloons: dict[str, dict[str, Any]] = {}
|
|
_balloons_lock = threading.Lock()
|
|
|
|
# UDP listener socket reference (so /stop can close it)
|
|
_udp_socket: socket.socket | None = None
|
|
|
|
# Common installation paths for radiosonde_auto_rx
|
|
AUTO_RX_PATHS = [
|
|
'/opt/radiosonde_auto_rx/auto_rx/auto_rx.py',
|
|
'/usr/local/bin/radiosonde_auto_rx',
|
|
'/opt/auto_rx/auto_rx.py',
|
|
]
|
|
|
|
|
|
def find_auto_rx() -> str | None:
|
|
"""Find radiosonde_auto_rx script/binary."""
|
|
# Check PATH first
|
|
path = shutil.which('radiosonde_auto_rx')
|
|
if path:
|
|
return path
|
|
# Check common locations
|
|
for p in AUTO_RX_PATHS:
|
|
if os.path.isfile(p) and os.access(p, os.X_OK):
|
|
return p
|
|
# Check for Python script (not executable but runnable)
|
|
for p in AUTO_RX_PATHS:
|
|
if os.path.isfile(p):
|
|
return p
|
|
return None
|
|
|
|
|
|
def generate_station_cfg(
|
|
freq_min: float = 400.0,
|
|
freq_max: float = 406.0,
|
|
gain: float = 40.0,
|
|
device_index: int = 0,
|
|
ppm: int = 0,
|
|
bias_t: bool = False,
|
|
udp_port: int = RADIOSONDE_UDP_PORT,
|
|
latitude: float = 0.0,
|
|
longitude: float = 0.0,
|
|
station_alt: float = 0.0,
|
|
gpsd_enabled: bool = False,
|
|
) -> str:
|
|
"""Generate a station.cfg for radiosonde_auto_rx and return the file path."""
|
|
cfg_dir = os.path.abspath(os.path.join('data', 'radiosonde'))
|
|
log_dir = os.path.join(cfg_dir, 'logs')
|
|
os.makedirs(log_dir, exist_ok=True)
|
|
cfg_path = os.path.join(cfg_dir, 'station.cfg')
|
|
|
|
# Full station.cfg based on radiosonde_auto_rx v1.8+ example config.
|
|
# All sections and keys included to avoid missing-key crashes.
|
|
cfg = f"""# Auto-generated by INTERCEPT for radiosonde_auto_rx
|
|
|
|
[sdr]
|
|
sdr_type = RTLSDR
|
|
sdr_quantity = 1
|
|
sdr_hostname = localhost
|
|
sdr_port = 5555
|
|
|
|
[sdr_1]
|
|
device_idx = {device_index}
|
|
ppm = {ppm}
|
|
gain = {gain}
|
|
bias = {str(bias_t)}
|
|
|
|
[search_params]
|
|
min_freq = {freq_min}
|
|
max_freq = {freq_max}
|
|
rx_timeout = 180
|
|
only_scan = []
|
|
never_scan = []
|
|
always_scan = []
|
|
always_decode = []
|
|
|
|
[location]
|
|
station_lat = {latitude}
|
|
station_lon = {longitude}
|
|
station_alt = {station_alt}
|
|
gpsd_enabled = {str(gpsd_enabled)}
|
|
gpsd_host = localhost
|
|
gpsd_port = 2947
|
|
|
|
[habitat]
|
|
uploader_callsign = INTERCEPT
|
|
upload_listener_position = False
|
|
uploader_antenna = unknown
|
|
|
|
[sondehub]
|
|
sondehub_enabled = False
|
|
sondehub_upload_rate = 15
|
|
sondehub_contact_email = none@none.com
|
|
|
|
[aprs]
|
|
aprs_enabled = False
|
|
aprs_user = N0CALL
|
|
aprs_pass = 00000
|
|
upload_rate = 30
|
|
aprs_server = radiosondy.info
|
|
aprs_port = 14580
|
|
station_beacon_enabled = False
|
|
station_beacon_rate = 30
|
|
station_beacon_comment = radiosonde_auto_rx
|
|
station_beacon_icon = /`
|
|
aprs_object_id = <id>
|
|
aprs_use_custom_object_id = False
|
|
aprs_custom_comment = <type> <freq>
|
|
|
|
[oziplotter]
|
|
ozi_enabled = False
|
|
ozi_update_rate = 5
|
|
ozi_host = 127.0.0.1
|
|
ozi_port = 8942
|
|
payload_summary_enabled = True
|
|
payload_summary_host = 127.0.0.1
|
|
payload_summary_port = {udp_port}
|
|
|
|
[email]
|
|
email_enabled = False
|
|
launch_notifications = True
|
|
landing_notifications = True
|
|
encrypted_sonde_notifications = True
|
|
landing_range_threshold = 30
|
|
landing_altitude_threshold = 1000
|
|
error_notifications = False
|
|
smtp_server = localhost
|
|
smtp_port = 25
|
|
smtp_authentication = None
|
|
smtp_login = None
|
|
smtp_password = None
|
|
from = sonde@localhost
|
|
to = none@none.com
|
|
subject = Sonde launch detected
|
|
|
|
[rotator]
|
|
rotator_enabled = False
|
|
update_rate = 30
|
|
rotation_threshold = 5.0
|
|
rotator_hostname = 127.0.0.1
|
|
rotator_port = 4533
|
|
rotator_homing_enabled = False
|
|
rotator_homing_delay = 10
|
|
rotator_home_azimuth = 0.0
|
|
rotator_home_elevation = 0.0
|
|
azimuth_only = False
|
|
|
|
[logging]
|
|
per_sonde_log = True
|
|
save_system_log = False
|
|
enable_debug_logging = False
|
|
save_cal_data = False
|
|
|
|
[web]
|
|
web_host = 127.0.0.1
|
|
web_port = 0
|
|
archive_age = 120
|
|
web_control = False
|
|
web_password = none
|
|
kml_refresh_rate = 10
|
|
|
|
[debugging]
|
|
save_detection_audio = False
|
|
save_decode_audio = False
|
|
save_decode_iq = False
|
|
save_raw_hex = False
|
|
|
|
[advanced]
|
|
search_step = 800
|
|
snr_threshold = 10
|
|
max_peaks = 10
|
|
min_distance = 1000
|
|
scan_dwell_time = 20
|
|
detect_dwell_time = 5
|
|
scan_delay = 10
|
|
quantization = 10000
|
|
decoder_spacing_limit = 15000
|
|
temporary_block_time = 120
|
|
max_async_scan_workers = 4
|
|
synchronous_upload = True
|
|
payload_id_valid = 3
|
|
sdr_fm_path = rtl_fm
|
|
sdr_power_path = rtl_power
|
|
ss_iq_path = ./ss_iq
|
|
ss_power_path = ./ss_power
|
|
|
|
[filtering]
|
|
max_altitude = 50000
|
|
max_radius_km = 1000
|
|
min_radius_km = 0
|
|
radius_temporary_block = False
|
|
sonde_time_threshold = 3
|
|
"""
|
|
|
|
try:
|
|
with open(cfg_path, 'w') as f:
|
|
f.write(cfg)
|
|
except OSError as e:
|
|
logger.error(f"Cannot write station.cfg to {cfg_path}: {e}")
|
|
raise RuntimeError(
|
|
f"Cannot write radiosonde config to {cfg_path}: {e}. "
|
|
f"Fix permissions with: sudo chown -R $(whoami) {cfg_dir}"
|
|
) from e
|
|
|
|
# When running as root via sudo, fix ownership so next non-root run
|
|
# can still read/write these files.
|
|
_fix_data_ownership(cfg_dir)
|
|
|
|
logger.info(f"Generated station.cfg at {cfg_path}")
|
|
return cfg_path
|
|
|
|
|
|
def _fix_data_ownership(path: str) -> None:
|
|
"""Recursively chown a path to the real (non-root) user when running via sudo."""
|
|
uid = os.environ.get('INTERCEPT_SUDO_UID')
|
|
gid = os.environ.get('INTERCEPT_SUDO_GID')
|
|
if uid is None or gid is None:
|
|
return
|
|
try:
|
|
uid_int, gid_int = int(uid), int(gid)
|
|
for dirpath, dirnames, filenames in os.walk(path):
|
|
os.chown(dirpath, uid_int, gid_int)
|
|
for fname in filenames:
|
|
os.chown(os.path.join(dirpath, fname), uid_int, gid_int)
|
|
except OSError as e:
|
|
logger.warning(f"Could not fix ownership of {path}: {e}")
|
|
|
|
|
|
def parse_radiosonde_udp(udp_port: int) -> None:
|
|
"""Thread function: listen for radiosonde_auto_rx UDP JSON telemetry."""
|
|
global radiosonde_running, _udp_socket
|
|
|
|
logger.info(f"Radiosonde UDP listener started on port {udp_port}")
|
|
|
|
try:
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
sock.bind(('0.0.0.0', udp_port))
|
|
sock.settimeout(2.0)
|
|
_udp_socket = sock
|
|
except OSError as e:
|
|
logger.error(f"Failed to bind UDP port {udp_port}: {e}")
|
|
return
|
|
|
|
while radiosonde_running:
|
|
try:
|
|
data, _addr = sock.recvfrom(4096)
|
|
except socket.timeout:
|
|
# Clean up stale balloons
|
|
_cleanup_stale_balloons()
|
|
continue
|
|
except OSError:
|
|
break
|
|
|
|
try:
|
|
msg = json.loads(data.decode('utf-8', errors='ignore'))
|
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
continue
|
|
|
|
balloon = _process_telemetry(msg)
|
|
if balloon:
|
|
serial = balloon.get('id', '')
|
|
if serial:
|
|
with _balloons_lock:
|
|
radiosonde_balloons[serial] = balloon
|
|
try:
|
|
app_module.radiosonde_queue.put_nowait({
|
|
'type': 'balloon',
|
|
**balloon,
|
|
})
|
|
except queue.Full:
|
|
pass
|
|
|
|
try:
|
|
sock.close()
|
|
except OSError:
|
|
pass
|
|
_udp_socket = None
|
|
logger.info("Radiosonde UDP listener stopped")
|
|
|
|
|
|
def _process_telemetry(msg: dict) -> dict | None:
|
|
"""Extract relevant fields from a radiosonde_auto_rx UDP telemetry packet."""
|
|
# auto_rx broadcasts packets with a 'type' field
|
|
# Telemetry packets have type 'payload_summary' or individual sonde data
|
|
serial = msg.get('id') or msg.get('serial')
|
|
if not serial:
|
|
return None
|
|
|
|
balloon: dict[str, Any] = {'id': str(serial)}
|
|
|
|
# Sonde type (RS41, RS92, DFM, M10, etc.) — prefer subtype if available
|
|
if 'subtype' in msg:
|
|
balloon['sonde_type'] = msg['subtype']
|
|
elif 'type' in msg:
|
|
balloon['sonde_type'] = msg['type']
|
|
|
|
# Timestamp
|
|
if 'datetime' in msg:
|
|
balloon['datetime'] = msg['datetime']
|
|
|
|
# Position
|
|
for key in ('lat', 'latitude'):
|
|
if key in msg:
|
|
try:
|
|
balloon['lat'] = float(msg[key])
|
|
except (ValueError, TypeError):
|
|
pass
|
|
break
|
|
for key in ('lon', 'longitude'):
|
|
if key in msg:
|
|
try:
|
|
balloon['lon'] = float(msg[key])
|
|
except (ValueError, TypeError):
|
|
pass
|
|
break
|
|
|
|
# Altitude (metres)
|
|
if 'alt' in msg:
|
|
try:
|
|
balloon['alt'] = float(msg['alt'])
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
# Meteorological data
|
|
for field in ('temp', 'humidity', 'pressure'):
|
|
if field in msg:
|
|
try:
|
|
balloon[field] = float(msg[field])
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
# Velocity
|
|
if 'vel_h' in msg:
|
|
try:
|
|
balloon['vel_h'] = float(msg['vel_h'])
|
|
except (ValueError, TypeError):
|
|
pass
|
|
if 'vel_v' in msg:
|
|
try:
|
|
balloon['vel_v'] = float(msg['vel_v'])
|
|
except (ValueError, TypeError):
|
|
pass
|
|
if 'heading' in msg:
|
|
try:
|
|
balloon['heading'] = float(msg['heading'])
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
# GPS satellites
|
|
if 'sats' in msg:
|
|
try:
|
|
balloon['sats'] = int(msg['sats'])
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
# Battery voltage
|
|
if 'batt' in msg:
|
|
try:
|
|
balloon['batt'] = float(msg['batt'])
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
# Frequency
|
|
if 'freq' in msg:
|
|
try:
|
|
balloon['freq'] = float(msg['freq'])
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
balloon['last_seen'] = time.time()
|
|
return balloon
|
|
|
|
|
|
def _cleanup_stale_balloons() -> None:
|
|
"""Remove balloons not seen within the retention window."""
|
|
now = time.time()
|
|
with _balloons_lock:
|
|
stale = [
|
|
k for k, v in radiosonde_balloons.items()
|
|
if now - v.get('last_seen', 0) > MAX_RADIOSONDE_AGE_SECONDS
|
|
]
|
|
for k in stale:
|
|
del radiosonde_balloons[k]
|
|
|
|
|
|
@radiosonde_bp.route('/tools')
|
|
def check_tools():
|
|
"""Check for radiosonde decoding tools and hardware."""
|
|
auto_rx_path = find_auto_rx()
|
|
devices = SDRFactory.detect_devices()
|
|
has_rtlsdr = any(d.sdr_type == SDRType.RTL_SDR for d in devices)
|
|
|
|
return jsonify({
|
|
'auto_rx': auto_rx_path is not None,
|
|
'auto_rx_path': auto_rx_path,
|
|
'has_rtlsdr': has_rtlsdr,
|
|
'device_count': len(devices),
|
|
})
|
|
|
|
|
|
@radiosonde_bp.route('/status')
|
|
def radiosonde_status():
|
|
"""Get radiosonde tracking status."""
|
|
process_running = False
|
|
if app_module.radiosonde_process:
|
|
process_running = app_module.radiosonde_process.poll() is None
|
|
|
|
with _balloons_lock:
|
|
balloon_count = len(radiosonde_balloons)
|
|
balloons_snapshot = dict(radiosonde_balloons)
|
|
|
|
return jsonify({
|
|
'tracking_active': radiosonde_running,
|
|
'active_device': radiosonde_active_device,
|
|
'balloon_count': balloon_count,
|
|
'balloons': balloons_snapshot,
|
|
'queue_size': app_module.radiosonde_queue.qsize(),
|
|
'auto_rx_path': find_auto_rx(),
|
|
'process_running': process_running,
|
|
})
|
|
|
|
|
|
@radiosonde_bp.route('/start', methods=['POST'])
|
|
def start_radiosonde():
|
|
"""Start radiosonde tracking."""
|
|
global radiosonde_running, radiosonde_active_device, radiosonde_active_sdr_type
|
|
|
|
with app_module.radiosonde_lock:
|
|
if radiosonde_running:
|
|
return jsonify({
|
|
'status': 'already_running',
|
|
'message': 'Radiosonde tracking already active',
|
|
}), 409
|
|
|
|
data = request.json or {}
|
|
|
|
# Validate inputs
|
|
try:
|
|
gain = float(validate_gain(data.get('gain', '40')))
|
|
device = validate_device_index(data.get('device', '0'))
|
|
except ValueError as e:
|
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
|
|
|
freq_min = data.get('freq_min', 400.0)
|
|
freq_max = data.get('freq_max', 406.0)
|
|
try:
|
|
freq_min = float(freq_min)
|
|
freq_max = float(freq_max)
|
|
if not (380.0 <= freq_min <= 410.0) or not (380.0 <= freq_max <= 410.0):
|
|
raise ValueError("Frequency out of range")
|
|
if freq_min >= freq_max:
|
|
raise ValueError("Min frequency must be less than max")
|
|
except (ValueError, TypeError) as e:
|
|
return jsonify({'status': 'error', 'message': f'Invalid frequency range: {e}'}), 400
|
|
|
|
bias_t = data.get('bias_t', False)
|
|
ppm = int(data.get('ppm', 0))
|
|
|
|
# Validate optional location
|
|
latitude = 0.0
|
|
longitude = 0.0
|
|
if data.get('latitude') is not None and data.get('longitude') is not None:
|
|
try:
|
|
latitude = validate_latitude(data['latitude'])
|
|
longitude = validate_longitude(data['longitude'])
|
|
except ValueError:
|
|
latitude = 0.0
|
|
longitude = 0.0
|
|
|
|
# Check if gpsd is available for live position updates
|
|
gpsd_enabled = is_gpsd_running()
|
|
|
|
# Find auto_rx
|
|
auto_rx_path = find_auto_rx()
|
|
if not auto_rx_path:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'radiosonde_auto_rx not found. Install from https://github.com/projecthorus/radiosonde_auto_rx',
|
|
}), 400
|
|
|
|
# Get SDR type
|
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
|
|
|
# Kill any existing process
|
|
if app_module.radiosonde_process:
|
|
try:
|
|
pgid = os.getpgid(app_module.radiosonde_process.pid)
|
|
os.killpg(pgid, 15)
|
|
app_module.radiosonde_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
|
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
|
|
try:
|
|
pgid = os.getpgid(app_module.radiosonde_process.pid)
|
|
os.killpg(pgid, 9)
|
|
except (ProcessLookupError, OSError):
|
|
pass
|
|
app_module.radiosonde_process = None
|
|
logger.info("Killed existing radiosonde process")
|
|
|
|
# Claim SDR device
|
|
device_int = int(device)
|
|
error = app_module.claim_sdr_device(device_int, 'radiosonde', sdr_type_str)
|
|
if error:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'error_type': 'DEVICE_BUSY',
|
|
'message': error,
|
|
}), 409
|
|
|
|
# Generate config
|
|
try:
|
|
cfg_path = generate_station_cfg(
|
|
freq_min=freq_min,
|
|
freq_max=freq_max,
|
|
gain=gain,
|
|
device_index=device_int,
|
|
ppm=ppm,
|
|
bias_t=bias_t,
|
|
latitude=latitude,
|
|
longitude=longitude,
|
|
gpsd_enabled=gpsd_enabled,
|
|
)
|
|
except (OSError, RuntimeError) as e:
|
|
app_module.release_sdr_device(device_int, sdr_type_str)
|
|
logger.error(f"Failed to generate radiosonde config: {e}")
|
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
|
|
|
# Build command - auto_rx -c expects the path to station.cfg
|
|
cfg_abs = os.path.abspath(cfg_path)
|
|
if auto_rx_path.endswith('.py'):
|
|
cmd = [sys.executable, auto_rx_path, '-c', cfg_abs]
|
|
else:
|
|
cmd = [auto_rx_path, '-c', cfg_abs]
|
|
|
|
# Set cwd to the auto_rx directory so 'from autorx.scan import ...' works
|
|
auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path))
|
|
|
|
# Quick dependency check before launching the full process
|
|
if auto_rx_path.endswith('.py'):
|
|
dep_check = subprocess.run(
|
|
[sys.executable, '-c', 'import autorx.scan'],
|
|
cwd=auto_rx_dir,
|
|
capture_output=True,
|
|
timeout=10,
|
|
)
|
|
if dep_check.returncode != 0:
|
|
dep_error = dep_check.stderr.decode('utf-8', errors='ignore').strip()
|
|
logger.error(f"radiosonde_auto_rx dependency check failed:\n{dep_error}")
|
|
app_module.release_sdr_device(device_int, sdr_type_str)
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': (
|
|
'radiosonde_auto_rx dependencies not satisfied. '
|
|
f'Re-run setup.sh to install. Error: {dep_error[:500]}'
|
|
),
|
|
}), 500
|
|
|
|
try:
|
|
logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}")
|
|
app_module.radiosonde_process = subprocess.Popen(
|
|
cmd,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.PIPE,
|
|
start_new_session=True,
|
|
cwd=auto_rx_dir,
|
|
)
|
|
|
|
# Wait briefly for process to start
|
|
time.sleep(2.0)
|
|
|
|
if app_module.radiosonde_process.poll() is not None:
|
|
app_module.release_sdr_device(device_int, sdr_type_str)
|
|
stderr_output = ''
|
|
if app_module.radiosonde_process.stderr:
|
|
try:
|
|
stderr_output = app_module.radiosonde_process.stderr.read().decode(
|
|
'utf-8', errors='ignore'
|
|
).strip()
|
|
except Exception:
|
|
pass
|
|
if stderr_output:
|
|
logger.error(f"radiosonde_auto_rx stderr:\n{stderr_output}")
|
|
if stderr_output and (
|
|
'ImportError' in stderr_output
|
|
or 'ModuleNotFoundError' in stderr_output
|
|
):
|
|
error_msg = (
|
|
'radiosonde_auto_rx failed to start due to missing Python '
|
|
'dependencies. Re-run setup.sh or reinstall radiosonde_auto_rx.'
|
|
)
|
|
else:
|
|
error_msg = (
|
|
'radiosonde_auto_rx failed to start. '
|
|
'Check SDR device connection.'
|
|
)
|
|
if stderr_output:
|
|
error_msg += f' Error: {stderr_output[:500]}'
|
|
return jsonify({'status': 'error', 'message': error_msg}), 500
|
|
|
|
radiosonde_running = True
|
|
radiosonde_active_device = device_int
|
|
radiosonde_active_sdr_type = sdr_type_str
|
|
|
|
# Clear stale data
|
|
with _balloons_lock:
|
|
radiosonde_balloons.clear()
|
|
|
|
# Start UDP listener thread
|
|
udp_thread = threading.Thread(
|
|
target=parse_radiosonde_udp,
|
|
args=(RADIOSONDE_UDP_PORT,),
|
|
daemon=True,
|
|
)
|
|
udp_thread.start()
|
|
|
|
return jsonify({
|
|
'status': 'started',
|
|
'message': 'Radiosonde tracking started',
|
|
'device': device,
|
|
})
|
|
except Exception as e:
|
|
app_module.release_sdr_device(device_int, sdr_type_str)
|
|
logger.error(f"Failed to start radiosonde_auto_rx: {e}")
|
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
|
|
|
|
|
@radiosonde_bp.route('/stop', methods=['POST'])
|
|
def stop_radiosonde():
|
|
"""Stop radiosonde tracking."""
|
|
global radiosonde_running, radiosonde_active_device, radiosonde_active_sdr_type, _udp_socket
|
|
|
|
with app_module.radiosonde_lock:
|
|
if app_module.radiosonde_process:
|
|
try:
|
|
pgid = os.getpgid(app_module.radiosonde_process.pid)
|
|
os.killpg(pgid, 15)
|
|
app_module.radiosonde_process.wait(timeout=RADIOSONDE_TERMINATE_TIMEOUT)
|
|
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
|
|
try:
|
|
pgid = os.getpgid(app_module.radiosonde_process.pid)
|
|
os.killpg(pgid, 9)
|
|
except (ProcessLookupError, OSError):
|
|
pass
|
|
app_module.radiosonde_process = None
|
|
logger.info("Radiosonde process stopped")
|
|
|
|
# Close UDP socket to unblock listener thread
|
|
if _udp_socket:
|
|
try:
|
|
_udp_socket.close()
|
|
except OSError:
|
|
pass
|
|
_udp_socket = None
|
|
|
|
# Release SDR device
|
|
if radiosonde_active_device is not None:
|
|
app_module.release_sdr_device(
|
|
radiosonde_active_device,
|
|
radiosonde_active_sdr_type or 'rtlsdr',
|
|
)
|
|
|
|
radiosonde_running = False
|
|
radiosonde_active_device = None
|
|
radiosonde_active_sdr_type = None
|
|
|
|
with _balloons_lock:
|
|
radiosonde_balloons.clear()
|
|
|
|
return jsonify({'status': 'stopped'})
|
|
|
|
|
|
@radiosonde_bp.route('/stream')
|
|
def stream_radiosonde():
|
|
"""SSE stream for radiosonde telemetry."""
|
|
response = Response(
|
|
sse_stream_fanout(
|
|
source_queue=app_module.radiosonde_queue,
|
|
channel_key='radiosonde',
|
|
timeout=SSE_QUEUE_TIMEOUT,
|
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
|
),
|
|
mimetype='text/event-stream',
|
|
)
|
|
response.headers['Cache-Control'] = 'no-cache'
|
|
response.headers['X-Accel-Buffering'] = 'no'
|
|
return response
|
|
|
|
|
|
@radiosonde_bp.route('/balloons')
|
|
def get_balloons():
|
|
"""Get current balloon data."""
|
|
with _balloons_lock:
|
|
return jsonify({
|
|
'status': 'success',
|
|
'count': len(radiosonde_balloons),
|
|
'balloons': dict(radiosonde_balloons),
|
|
})
|