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>
428 lines
14 KiB
Python
428 lines
14 KiB
Python
"""VDL2 aircraft datalink routes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import os
|
|
import platform
|
|
import pty
|
|
import queue
|
|
import shutil
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from datetime import datetime
|
|
from typing import Any, Generator
|
|
|
|
from flask import Blueprint, Response, jsonify, request
|
|
|
|
import app as app_module
|
|
from utils.acars_translator import translate_message
|
|
from utils.constants import (
|
|
PROCESS_START_WAIT,
|
|
PROCESS_TERMINATE_TIMEOUT,
|
|
SSE_KEEPALIVE_INTERVAL,
|
|
SSE_QUEUE_TIMEOUT,
|
|
)
|
|
from utils.event_pipeline import process_event
|
|
from utils.flight_correlator import get_flight_correlator
|
|
from utils.logging import sensor_logger as logger
|
|
from utils.process import register_process, unregister_process
|
|
from utils.sdr import SDRFactory, SDRType
|
|
from utils.sse import sse_stream_fanout
|
|
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
|
|
|
vdl2_bp = Blueprint('vdl2', __name__, url_prefix='/vdl2')
|
|
|
|
# Default VDL2 frequencies (MHz) - common worldwide
|
|
DEFAULT_VDL2_FREQUENCIES = [
|
|
'136975000', # Primary worldwide
|
|
'136725000', # Europe
|
|
'136775000', # Europe
|
|
'136800000', # Multi-region
|
|
'136875000', # Multi-region
|
|
]
|
|
|
|
# Message counter for statistics
|
|
vdl2_message_count = 0
|
|
vdl2_last_message_time = None
|
|
|
|
# Track which device is being used
|
|
vdl2_active_device: int | None = None
|
|
vdl2_active_sdr_type: str | None = None
|
|
|
|
|
|
def find_dumpvdl2():
|
|
"""Find dumpvdl2 binary."""
|
|
return shutil.which('dumpvdl2')
|
|
|
|
|
|
def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) -> None:
|
|
"""Stream dumpvdl2 JSON output to queue."""
|
|
global vdl2_message_count, vdl2_last_message_time
|
|
|
|
try:
|
|
app_module.vdl2_queue.put({'type': 'status', 'status': 'started'})
|
|
|
|
# Use appropriate sentinel based on mode (text mode for pty on macOS)
|
|
sentinel = '' if is_text_mode else b''
|
|
for line in iter(process.stdout.readline, sentinel):
|
|
if is_text_mode:
|
|
line = line.strip()
|
|
else:
|
|
line = line.decode('utf-8', errors='replace').strip()
|
|
if not line:
|
|
continue
|
|
|
|
try:
|
|
data = json.loads(line)
|
|
|
|
# Add our metadata
|
|
data['type'] = 'vdl2'
|
|
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
|
|
|
|
# Enrich with translated ACARS label at top level (consistent with ACARS route)
|
|
try:
|
|
vdl2_inner = data.get('vdl2', data)
|
|
acars_payload = (vdl2_inner.get('avlc') or {}).get('acars')
|
|
if acars_payload and acars_payload.get('label'):
|
|
translation = translate_message({
|
|
'label': acars_payload.get('label'),
|
|
'text': acars_payload.get('msg_text', ''),
|
|
})
|
|
data['label_description'] = translation['label_description']
|
|
data['message_type'] = translation['message_type']
|
|
data['parsed'] = translation['parsed']
|
|
except Exception:
|
|
pass
|
|
|
|
# Update stats
|
|
vdl2_message_count += 1
|
|
vdl2_last_message_time = time.time()
|
|
|
|
app_module.vdl2_queue.put(data)
|
|
|
|
# Feed flight correlator
|
|
try:
|
|
get_flight_correlator().add_vdl2_message(data)
|
|
except Exception:
|
|
pass
|
|
|
|
# Log if enabled
|
|
if app_module.logging_enabled:
|
|
try:
|
|
with open(app_module.log_file_path, 'a') as f:
|
|
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
f.write(f"{ts} | VDL2 | {json.dumps(data)}\n")
|
|
except Exception:
|
|
pass
|
|
|
|
except json.JSONDecodeError:
|
|
# Not JSON - could be status message
|
|
if line:
|
|
logger.debug(f"dumpvdl2 non-JSON: {line[:100]}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"VDL2 stream error: {e}")
|
|
app_module.vdl2_queue.put({'type': 'error', 'message': str(e)})
|
|
finally:
|
|
global vdl2_active_device, vdl2_active_sdr_type
|
|
# Ensure process is terminated
|
|
try:
|
|
process.terminate()
|
|
process.wait(timeout=2)
|
|
except Exception:
|
|
try:
|
|
process.kill()
|
|
except Exception:
|
|
pass
|
|
unregister_process(process)
|
|
app_module.vdl2_queue.put({'type': 'status', 'status': 'stopped'})
|
|
with app_module.vdl2_lock:
|
|
app_module.vdl2_process = None
|
|
# Release SDR device
|
|
if vdl2_active_device is not None:
|
|
app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
|
|
vdl2_active_device = None
|
|
vdl2_active_sdr_type = None
|
|
|
|
|
|
@vdl2_bp.route('/tools')
|
|
def check_vdl2_tools() -> Response:
|
|
"""Check for VDL2 decoding tools."""
|
|
has_dumpvdl2 = find_dumpvdl2() is not None
|
|
|
|
return jsonify({
|
|
'dumpvdl2': has_dumpvdl2,
|
|
'ready': has_dumpvdl2
|
|
})
|
|
|
|
|
|
@vdl2_bp.route('/status')
|
|
def vdl2_status() -> Response:
|
|
"""Get VDL2 decoder status."""
|
|
running = False
|
|
if app_module.vdl2_process:
|
|
running = app_module.vdl2_process.poll() is None
|
|
|
|
return jsonify({
|
|
'running': running,
|
|
'message_count': vdl2_message_count,
|
|
'last_message_time': vdl2_last_message_time,
|
|
'queue_size': app_module.vdl2_queue.qsize()
|
|
})
|
|
|
|
|
|
@vdl2_bp.route('/start', methods=['POST'])
|
|
def start_vdl2() -> Response:
|
|
"""Start VDL2 decoder."""
|
|
global vdl2_message_count, vdl2_last_message_time, vdl2_active_device, vdl2_active_sdr_type
|
|
|
|
with app_module.vdl2_lock:
|
|
if app_module.vdl2_process and app_module.vdl2_process.poll() is None:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'VDL2 decoder already running'
|
|
}), 409
|
|
|
|
# Check for dumpvdl2
|
|
dumpvdl2_path = find_dumpvdl2()
|
|
if not dumpvdl2_path:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'dumpvdl2 not found. Install from: https://github.com/szpajder/dumpvdl2'
|
|
}), 400
|
|
|
|
data = request.json or {}
|
|
|
|
# Validate inputs
|
|
try:
|
|
device = validate_device_index(data.get('device', '0'))
|
|
gain = validate_gain(data.get('gain', '40'))
|
|
ppm = validate_ppm(data.get('ppm', '0'))
|
|
except ValueError as e:
|
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
|
|
|
# Resolve SDR type for device selection
|
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
|
try:
|
|
sdr_type = SDRType(sdr_type_str)
|
|
except ValueError:
|
|
sdr_type = SDRType.RTL_SDR
|
|
|
|
# Check if device is available
|
|
device_int = int(device)
|
|
error = app_module.claim_sdr_device(device_int, 'vdl2', sdr_type_str)
|
|
if error:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'error_type': 'DEVICE_BUSY',
|
|
'message': error
|
|
}), 409
|
|
|
|
vdl2_active_device = device_int
|
|
vdl2_active_sdr_type = sdr_type_str
|
|
|
|
# Get frequencies - use provided or defaults
|
|
# dumpvdl2 expects frequencies in Hz (integers)
|
|
frequencies = data.get('frequencies', DEFAULT_VDL2_FREQUENCIES)
|
|
if isinstance(frequencies, str):
|
|
frequencies = [f.strip() for f in frequencies.split(',')]
|
|
|
|
# Clear queue
|
|
while not app_module.vdl2_queue.empty():
|
|
try:
|
|
app_module.vdl2_queue.get_nowait()
|
|
except queue.Empty:
|
|
break
|
|
|
|
# Reset stats
|
|
vdl2_message_count = 0
|
|
vdl2_last_message_time = None
|
|
|
|
is_soapy = sdr_type not in (SDRType.RTL_SDR,)
|
|
|
|
# Build dumpvdl2 command
|
|
# dumpvdl2 --output decoded:json --rtlsdr <device> --gain <gain> --correction <ppm> <freq1> <freq2> ...
|
|
cmd = [dumpvdl2_path]
|
|
cmd.extend(['--output', 'decoded:json:file:path=-'])
|
|
|
|
if is_soapy:
|
|
# SoapySDR device
|
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=device_int)
|
|
builder = SDRFactory.get_builder(sdr_type)
|
|
device_str = builder._build_device_string(sdr_device)
|
|
cmd.extend(['--soapysdr', device_str])
|
|
else:
|
|
cmd.extend(['--rtlsdr', str(device)])
|
|
|
|
# Add gain
|
|
if gain and str(gain) != '0':
|
|
cmd.extend(['--gain', str(gain)])
|
|
|
|
# Add PPM correction if specified
|
|
if ppm and str(ppm) != '0':
|
|
cmd.extend(['--correction', str(ppm)])
|
|
|
|
# Add frequencies (dumpvdl2 takes them as positional args in Hz)
|
|
cmd.extend(frequencies)
|
|
|
|
logger.info(f"Starting VDL2 decoder: {' '.join(cmd)}")
|
|
|
|
try:
|
|
is_text_mode = False
|
|
|
|
# On macOS, use pty to avoid stdout buffering issues
|
|
if platform.system() == 'Darwin':
|
|
master_fd, slave_fd = pty.openpty()
|
|
process = subprocess.Popen(
|
|
cmd,
|
|
stdout=slave_fd,
|
|
stderr=subprocess.PIPE,
|
|
start_new_session=True
|
|
)
|
|
os.close(slave_fd)
|
|
# Wrap master_fd as a text file for line-buffered reading
|
|
process.stdout = io.open(master_fd, 'r', buffering=1)
|
|
is_text_mode = True
|
|
else:
|
|
process = subprocess.Popen(
|
|
cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
start_new_session=True
|
|
)
|
|
|
|
# Wait briefly to check if process started
|
|
time.sleep(PROCESS_START_WAIT)
|
|
|
|
if process.poll() is not None:
|
|
# Process died - release device
|
|
if vdl2_active_device is not None:
|
|
app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
|
|
vdl2_active_device = None
|
|
vdl2_active_sdr_type = None
|
|
stderr = ''
|
|
if process.stderr:
|
|
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
|
if stderr:
|
|
logger.error(f"dumpvdl2 stderr:\n{stderr}")
|
|
error_msg = 'dumpvdl2 failed to start'
|
|
if stderr:
|
|
error_msg += f': {stderr[:500]}'
|
|
logger.error(error_msg)
|
|
return jsonify({'status': 'error', 'message': error_msg}), 500
|
|
|
|
app_module.vdl2_process = process
|
|
register_process(process)
|
|
|
|
# Start output streaming thread
|
|
thread = threading.Thread(
|
|
target=stream_vdl2_output,
|
|
args=(process, is_text_mode),
|
|
daemon=True
|
|
)
|
|
thread.start()
|
|
|
|
return jsonify({
|
|
'status': 'started',
|
|
'frequencies': frequencies,
|
|
'device': device,
|
|
'gain': gain
|
|
})
|
|
|
|
except Exception as e:
|
|
# Release device on failure
|
|
if vdl2_active_device is not None:
|
|
app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
|
|
vdl2_active_device = None
|
|
vdl2_active_sdr_type = None
|
|
logger.error(f"Failed to start VDL2 decoder: {e}")
|
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
|
|
|
|
|
@vdl2_bp.route('/stop', methods=['POST'])
|
|
def stop_vdl2() -> Response:
|
|
"""Stop VDL2 decoder."""
|
|
global vdl2_active_device, vdl2_active_sdr_type
|
|
|
|
with app_module.vdl2_lock:
|
|
if not app_module.vdl2_process:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'VDL2 decoder not running'
|
|
}), 400
|
|
|
|
try:
|
|
app_module.vdl2_process.terminate()
|
|
app_module.vdl2_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
|
except subprocess.TimeoutExpired:
|
|
app_module.vdl2_process.kill()
|
|
except Exception as e:
|
|
logger.error(f"Error stopping VDL2: {e}")
|
|
|
|
app_module.vdl2_process = None
|
|
|
|
# Release device from registry
|
|
if vdl2_active_device is not None:
|
|
app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
|
|
vdl2_active_device = None
|
|
vdl2_active_sdr_type = None
|
|
|
|
return jsonify({'status': 'stopped'})
|
|
|
|
|
|
@vdl2_bp.route('/stream')
|
|
def stream_vdl2() -> Response:
|
|
"""SSE stream for VDL2 messages."""
|
|
def _on_msg(msg: dict[str, Any]) -> None:
|
|
process_event('vdl2', msg, msg.get('type'))
|
|
|
|
response = Response(
|
|
sse_stream_fanout(
|
|
source_queue=app_module.vdl2_queue,
|
|
channel_key='vdl2',
|
|
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
|
|
|
|
|
|
|
|
@vdl2_bp.route('/messages')
|
|
def get_vdl2_messages() -> Response:
|
|
"""Get recent VDL2 messages from correlator (for history reload)."""
|
|
limit = request.args.get('limit', 50, type=int)
|
|
limit = max(1, min(limit, 200))
|
|
msgs = get_flight_correlator().get_recent_messages('vdl2', limit)
|
|
return jsonify(msgs)
|
|
|
|
|
|
@vdl2_bp.route('/clear', methods=['POST'])
|
|
def clear_vdl2_messages() -> Response:
|
|
"""Clear stored VDL2 messages and reset counter."""
|
|
global vdl2_message_count, vdl2_last_message_time
|
|
get_flight_correlator().clear_vdl2()
|
|
vdl2_message_count = 0
|
|
vdl2_last_message_time = None
|
|
return jsonify({'status': 'cleared'})
|
|
|
|
|
|
@vdl2_bp.route('/frequencies')
|
|
def get_frequencies() -> Response:
|
|
"""Get default VDL2 frequencies."""
|
|
return jsonify({
|
|
'default': DEFAULT_VDL2_FREQUENCIES,
|
|
'regions': {
|
|
'north_america': ['136975000', '136100000', '136650000', '136700000', '136800000'],
|
|
'europe': ['136975000', '136675000', '136725000', '136775000', '136825000'],
|
|
'asia_pacific': ['136975000', '136900000'],
|
|
}
|
|
})
|