mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 22:59:59 -07:00
- Fix SSE fanout thread AttributeError when source queue is None during interpreter shutdown by snapshotting to local variable with null guard - Fix branded "i" logo rendering oversized on first page load (FOUC) by adding inline width/height to SVG elements across 10 templates - Bump version to 2.26.0 in config.py, pyproject.toml, and CHANGELOG.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
412 lines
14 KiB
Python
412 lines
14 KiB
Python
"""VDL2 aircraft datalink routes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
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
|
|
|
|
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.responses import api_error
|
|
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
|
|
with contextlib.suppress(Exception):
|
|
get_flight_correlator().add_vdl2_message(data)
|
|
|
|
# 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:
|
|
with contextlib.suppress(Exception):
|
|
process.kill()
|
|
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 api_error('VDL2 decoder already running', 409)
|
|
|
|
# Check for dumpvdl2
|
|
dumpvdl2_path = find_dumpvdl2()
|
|
if not dumpvdl2_path:
|
|
return api_error('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 api_error(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 api_error(error, 409, error_type='DEVICE_BUSY')
|
|
|
|
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 = open(master_fd, 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 api_error(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 api_error(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 api_error('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'],
|
|
}
|
|
})
|