diff --git a/Dockerfile b/Dockerfile index 68b8806..6dacb09 100644 --- a/Dockerfile +++ b/Dockerfile @@ -95,6 +95,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libfftw3-dev \ liblapack-dev \ libcodec2-dev \ + libglib2.0-dev \ + libxml2-dev \ # Build dump1090 && cd /tmp \ && git clone --depth 1 https://github.com/flightaware/dump1090.git \ @@ -141,6 +143,25 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && make \ && cp acarsdec /usr/bin/acarsdec \ && rm -rf /tmp/acarsdec \ + # Build libacars (required by dumpvdl2) + && cd /tmp \ + && git clone --depth 1 https://github.com/szpajder/libacars.git \ + && cd libacars \ + && mkdir build && cd build \ + && cmake .. \ + && make \ + && make install \ + && ldconfig \ + && rm -rf /tmp/libacars \ + # Build dumpvdl2 (VDL2 aircraft datalink decoder) + && cd /tmp \ + && git clone --depth 1 https://github.com/szpajder/dumpvdl2.git \ + && cd dumpvdl2 \ + && mkdir build && cd build \ + && cmake .. \ + && make \ + && cp src/dumpvdl2 /usr/bin/dumpvdl2 \ + && rm -rf /tmp/dumpvdl2 \ # Build slowrx (SSTV decoder) — pinned to known-good commit && cd /tmp \ && git clone https://github.com/windytan/slowrx.git \ diff --git a/app.py b/app.py index b412458..e190d57 100644 --- a/app.py +++ b/app.py @@ -150,6 +150,11 @@ acars_process = None acars_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) acars_lock = threading.Lock() +# VDL2 aircraft datalink +vdl2_process = None +vdl2_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) +vdl2_lock = threading.Lock() + # APRS amateur radio tracking aprs_process = None aprs_rtl_process = None @@ -680,6 +685,7 @@ def health_check() -> Response: 'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False), 'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False), 'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False), + 'vdl2': vdl2_process is not None and (vdl2_process.poll() is None if vdl2_process else False), 'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False), 'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False), 'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False), @@ -702,6 +708,7 @@ def health_check() -> Response: def kill_all() -> Response: """Kill all decoder, WiFi, and Bluetooth processes.""" global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process + global vdl2_process global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process global dmr_process, dmr_rtl_process @@ -714,7 +721,7 @@ def kill_all() -> Response: processes_to_kill = [ 'rtl_fm', 'multimon-ng', 'rtl_433', 'airodump-ng', 'aireplay-ng', 'airmon-ng', - 'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher', + 'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher', 'hcitool', 'bluetoothctl', 'satdump', 'dsd', 'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg', 'hackrf_transfer', 'hackrf_sweep' @@ -751,6 +758,10 @@ def kill_all() -> Response: with acars_lock: acars_process = None + # Reset VDL2 state + with vdl2_lock: + vdl2_process = None + # Reset APRS state with aprs_lock: aprs_process = None diff --git a/routes/__init__.py b/routes/__init__.py index 49d1783..3e55949 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -13,6 +13,7 @@ def register_blueprints(app): from .ais import ais_bp from .dsc import dsc_bp from .acars import acars_bp + from .vdl2 import vdl2_bp from .aprs import aprs_bp from .satellite import satellite_bp from .gps import gps_bp @@ -46,6 +47,7 @@ def register_blueprints(app): app.register_blueprint(ais_bp) app.register_blueprint(dsc_bp) # VHF DSC maritime distress app.register_blueprint(acars_bp) + app.register_blueprint(vdl2_bp) app.register_blueprint(aprs_bp) app.register_blueprint(satellite_bp) app.register_blueprint(gps_bp) diff --git a/routes/vdl2.py b/routes/vdl2.py new file mode 100644 index 0000000..ea64208 --- /dev/null +++ b/routes/vdl2.py @@ -0,0 +1,357 @@ +"""VDL2 aircraft datalink routes.""" + +from __future__ import annotations + +import json +import queue +import shutil +import subprocess +import threading +import time +from datetime import datetime +from typing import Generator + +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.sdr import SDRFactory, SDRType +from utils.sse import format_sse +from utils.event_pipeline import process_event +from utils.constants import ( + PROCESS_TERMINATE_TIMEOUT, + SSE_KEEPALIVE_INTERVAL, + SSE_QUEUE_TIMEOUT, + PROCESS_START_WAIT, +) +from utils.process import register_process, unregister_process + +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 + + +def find_dumpvdl2(): + """Find dumpvdl2 binary.""" + return shutil.which('dumpvdl2') + + +def stream_vdl2_output(process: subprocess.Popen) -> 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'}) + + for line in iter(process.stdout.readline, b''): + 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' + + # Update stats + vdl2_message_count += 1 + vdl2_last_message_time = time.time() + + app_module.vdl2_queue.put(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 + # 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_device = 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 + + 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 + + # Check if device is available + device_int = int(device) + error = app_module.claim_sdr_device(device_int, 'vdl2') + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error + }), 409 + + vdl2_active_device = device_int + + # 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 + + # 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 + + is_soapy = sdr_type not in (SDRType.RTL_SDR,) + + # Build dumpvdl2 command + # dumpvdl2 --output decoded:json --rtlsdr --gain --correction ... + cmd = [dumpvdl2_path] + cmd.extend(['--output', 'decoded:json']) + + 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: + 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_device = None + stderr = '' + if process.stderr: + stderr = process.stderr.read().decode('utf-8', errors='replace') + error_msg = 'dumpvdl2 failed to start' + if stderr: + error_msg += f': {stderr[:200]}' + 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,), + 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_device = 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 + + 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_device = None + + return jsonify({'status': 'stopped'}) + + +@vdl2_bp.route('/stream') +def stream_vdl2() -> Response: + """SSE stream for VDL2 messages.""" + def generate() -> Generator[str, None, None]: + last_keepalive = time.time() + + while True: + try: + msg = app_module.vdl2_queue.get(timeout=SSE_QUEUE_TIMEOUT) + last_keepalive = time.time() + try: + process_event('vdl2', msg, msg.get('type')) + except Exception: + pass + yield format_sse(msg) + except queue.Empty: + now = time.time() + if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL: + yield format_sse({'type': 'keepalive'}) + last_keepalive = now + + response = Response(generate(), mimetype='text/event-stream') + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + return response + + +@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'], + } + }) diff --git a/setup.sh b/setup.sh index b986fb5..b28a3d2 100755 --- a/setup.sh +++ b/setup.sh @@ -226,6 +226,7 @@ check_tools() { check_optional "hackrf_sweep" "HackRF spectrum analyzer" hackrf_sweep check_required "dump1090" "ADS-B decoder" dump1090 check_required "acarsdec" "ACARS decoder" acarsdec + check_optional "dumpvdl2" "VDL2 decoder" dumpvdl2 check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump echo @@ -636,6 +637,80 @@ install_acarsdec_from_source_macos() { ) } +install_dumpvdl2_from_source_macos() { + info "Building dumpvdl2 from source (with libacars dependency)..." + + brew_install cmake + brew_install librtlsdr + brew_install pkg-config + brew_install glib + + ( + tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' EXIT + + HOMEBREW_PREFIX="$(brew --prefix)" + export PKG_CONFIG_PATH="${HOMEBREW_PREFIX}/lib/pkgconfig:${PKG_CONFIG_PATH:-}" + export CMAKE_PREFIX_PATH="${HOMEBREW_PREFIX}" + + # Build libacars first + info "Cloning libacars..." + git clone --depth 1 https://github.com/szpajder/libacars.git "$tmp_dir/libacars" >/dev/null 2>&1 \ + || { warn "Failed to clone libacars"; exit 1; } + + cd "$tmp_dir/libacars" + mkdir -p build && cd build + + info "Compiling libacars..." + build_log="$tmp_dir/libacars-build.log" + if cmake .. \ + -DCMAKE_C_FLAGS="-I${HOMEBREW_PREFIX}/include" \ + -DCMAKE_EXE_LINKER_FLAGS="-L${HOMEBREW_PREFIX}/lib" \ + >"$build_log" 2>&1 \ + && make >>"$build_log" 2>&1; then + if [[ -w /usr/local/lib ]]; then + make install >>"$build_log" 2>&1 + else + refresh_sudo + $SUDO make install >>"$build_log" 2>&1 + fi + ok "libacars installed" + else + warn "Failed to build libacars." + tail -20 "$build_log" | while IFS= read -r line; do warn " $line"; done + exit 1 + fi + + # Build dumpvdl2 + info "Cloning dumpvdl2..." + git clone --depth 1 https://github.com/szpajder/dumpvdl2.git "$tmp_dir/dumpvdl2" >/dev/null 2>&1 \ + || { warn "Failed to clone dumpvdl2"; exit 1; } + + cd "$tmp_dir/dumpvdl2" + mkdir -p build && cd build + + info "Compiling dumpvdl2..." + build_log="$tmp_dir/dumpvdl2-build.log" + if cmake .. \ + -DCMAKE_C_FLAGS="-I${HOMEBREW_PREFIX}/include" \ + -DCMAKE_EXE_LINKER_FLAGS="-L${HOMEBREW_PREFIX}/lib" \ + >"$build_log" 2>&1 \ + && make >>"$build_log" 2>&1; then + if [[ -w /usr/local/bin ]]; then + install -m 0755 src/dumpvdl2 /usr/local/bin/dumpvdl2 + else + refresh_sudo + $SUDO install -m 0755 src/dumpvdl2 /usr/local/bin/dumpvdl2 + fi + ok "dumpvdl2 installed successfully from source" + else + warn "Failed to build dumpvdl2. VDL2 decoding will not be available." + warn "Build log (last 30 lines):" + tail -30 "$build_log" | while IFS= read -r line; do warn " $line"; done + fi + ) +} + install_aiscatcher_from_source_macos() { info "AIS-catcher not available via Homebrew. Building from source..." @@ -874,6 +949,13 @@ install_macos_packages() { ok "acarsdec already installed" fi + progress "Installing dumpvdl2 (optional)" + if ! cmd_exists dumpvdl2; then + install_dumpvdl2_from_source_macos || warn "dumpvdl2 not available. VDL2 decoding will not be available." + else + ok "dumpvdl2 already installed" + fi + progress "Installing AIS-catcher" if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then (brew_install aiscatcher) || install_aiscatcher_from_source_macos || warn "AIS-catcher not available" @@ -1028,6 +1110,52 @@ install_acarsdec_from_source_debian() { ) } +install_dumpvdl2_from_source_debian() { + info "Building dumpvdl2 from source (with libacars dependency)..." + + apt_install build-essential git cmake \ + librtlsdr-dev libusb-1.0-0-dev libglib2.0-dev libxml2-dev + + ( + tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' EXIT + + # Build libacars first + info "Cloning libacars..." + git clone --depth 1 https://github.com/szpajder/libacars.git "$tmp_dir/libacars" >/dev/null 2>&1 \ + || { warn "Failed to clone libacars"; exit 1; } + + cd "$tmp_dir/libacars" + mkdir -p build && cd build + + info "Compiling libacars..." + if cmake .. >/dev/null 2>&1 && make >/dev/null 2>&1; then + $SUDO make install >/dev/null 2>&1 + $SUDO ldconfig + ok "libacars installed" + else + warn "Failed to build libacars." + exit 1 + fi + + # Build dumpvdl2 + info "Cloning dumpvdl2..." + git clone --depth 1 https://github.com/szpajder/dumpvdl2.git "$tmp_dir/dumpvdl2" >/dev/null 2>&1 \ + || { warn "Failed to clone dumpvdl2"; exit 1; } + + cd "$tmp_dir/dumpvdl2" + mkdir -p build && cd build + + info "Compiling dumpvdl2..." + if cmake .. >/dev/null 2>&1 && make >/dev/null 2>&1; then + $SUDO install -m 0755 src/dumpvdl2 /usr/local/bin/dumpvdl2 + ok "dumpvdl2 installed successfully." + else + warn "Failed to build dumpvdl2 from source. VDL2 decoding will not be available." + fi + ) +} + install_aiscatcher_from_source_debian() { info "AIS-catcher not available via APT. Building from source..." @@ -1344,6 +1472,13 @@ install_debian_packages() { fi cmd_exists acarsdec || install_acarsdec_from_source_debian + progress "Installing dumpvdl2 (optional)" + if ! cmd_exists dumpvdl2; then + install_dumpvdl2_from_source_debian || warn "dumpvdl2 not available. VDL2 decoding will not be available." + else + ok "dumpvdl2 already installed" + fi + progress "Installing AIS-catcher" if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then install_aiscatcher_from_source_debian diff --git a/static/css/adsb_dashboard.css b/static/css/adsb_dashboard.css index 6ad087c..c53eb6d 100644 --- a/static/css/adsb_dashboard.css +++ b/static/css/adsb_dashboard.css @@ -419,6 +419,163 @@ body { to { opacity: 1; transform: translateY(0); } } +/* VDL2 sidebar (left of map, after ACARS) - Collapsible */ +.vdl2-sidebar { + display: none; + background: var(--bg-panel); + border-right: 1px solid var(--border-color); + flex-direction: row; + overflow: hidden; + height: 100%; + min-height: 0; +} + +@media (min-width: 1024px) { + .vdl2-sidebar { + display: flex; + max-height: calc(100dvh - 160px); + } +} + +.vdl2-collapse-btn { + width: 28px; + min-width: 28px; + background: var(--bg-card); + border: none; + border-left: 1px solid var(--border-color); + color: var(--accent-cyan); + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 0; + transition: background 0.2s; +} + +.vdl2-collapse-btn:hover { + background: rgba(74, 158, 255, 0.2); +} + +.vdl2-collapse-label { + writing-mode: vertical-rl; + text-orientation: mixed; + font-size: 9px; + font-weight: 600; + letter-spacing: 1px; + text-transform: uppercase; +} + +.vdl2-sidebar.collapsed .vdl2-collapse-label { + display: block; +} + +.vdl2-sidebar:not(.collapsed) .vdl2-collapse-label { + display: none; +} + +#vdl2CollapseIcon { + font-size: 10px; + transition: transform 0.3s; +} + +.vdl2-sidebar.collapsed #vdl2CollapseIcon { + transform: rotate(180deg); +} + +.vdl2-sidebar-content { + width: 300px; + display: flex; + flex-direction: column; + overflow: hidden; + transition: width 0.3s ease, opacity 0.2s ease; + height: 100%; + min-height: 0; +} + +.vdl2-sidebar.collapsed .vdl2-sidebar-content { + width: 0; + opacity: 0; + pointer-events: none; +} + +.vdl2-sidebar .panel { + flex: 1; + display: flex; + flex-direction: column; + border: none; + border-radius: 0; + min-height: 0; + overflow: hidden; +} + +.vdl2-sidebar .panel::before { + display: none; +} + +.vdl2-sidebar .panel-header { + flex-shrink: 0; +} + +.vdl2-sidebar #vdl2PanelContent { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.vdl2-sidebar .vdl2-info, +.vdl2-sidebar .vdl2-controls { + flex-shrink: 0; +} + +.vdl2-sidebar .vdl2-messages { + flex: 1; + overflow-y: auto; + min-height: 0; +} + +.vdl2-sidebar .vdl2-btn { + background: var(--accent-green); + border: none; + color: #fff; + padding: 6px 10px; + font-size: 10px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + text-transform: uppercase; + letter-spacing: 1px; + border-radius: 4px; +} + +.vdl2-sidebar .vdl2-btn:hover { + background: #1db954; + box-shadow: 0 0 10px rgba(34, 197, 94, 0.3); +} + +.vdl2-sidebar .vdl2-btn.active { + background: var(--accent-red); +} + +.vdl2-sidebar .vdl2-btn.active:hover { + background: #dc2626; + box-shadow: 0 0 10px rgba(239, 68, 68, 0.3); +} + +.vdl2-message-item { + padding: 8px 10px; + border-bottom: 1px solid var(--border-color); + font-size: 10px; + animation: fadeIn 0.3s ease; +} + +.vdl2-message-item:hover { + background: rgba(74, 158, 255, 0.05); +} + /* Panels */ .panel { background: var(--bg-panel); @@ -627,12 +784,18 @@ body { /* Selected aircraft panel */ .selected-aircraft { flex-shrink: 0; - max-height: 480px; + max-height: 280px; overflow-y: auto; } +@media (min-height: 900px) { + .selected-aircraft { + max-height: 340px; + } +} + .selected-info { - padding: 12px; + padding: 8px; } #aircraftPhotoContainer { @@ -640,7 +803,7 @@ body { } #aircraftPhotoContainer img { - max-height: 140px; + max-height: 100px; width: 100%; object-fit: cover; border-radius: 6px; @@ -649,24 +812,24 @@ body { .selected-callsign { font-family: 'Orbitron', monospace; - font-size: 20px; + font-size: 16px; font-weight: 700; color: var(--accent-cyan); text-shadow: 0 0 15px var(--accent-cyan); text-align: center; - margin-bottom: 12px; + margin-bottom: 6px; } .telemetry-grid { display: grid; grid-template-columns: repeat(2, 1fr); - gap: 6px; + gap: 4px; } .telemetry-item { background: rgba(0, 0, 0, 0.3); border-radius: 4px; - padding: 8px; + padding: 5px 8px; border-left: 2px solid var(--accent-cyan); } @@ -778,7 +941,8 @@ body { background: var(--bg-panel); border-top: 1px solid rgba(74, 158, 255, 0.3); font-size: 11px; - overflow: hidden; + overflow-x: auto; + overflow-y: hidden; } .controls-bar > .control-group { @@ -1489,6 +1653,10 @@ body { margin-top: 1px; } +.strip-stat.source-stat .strip-value { + font-size: 11px; +} + .strip-stat.session-stat { background: rgba(34, 197, 94, 0.05); border-color: rgba(34, 197, 94, 0.2); @@ -1779,6 +1947,9 @@ body { .strip-btn { position: relative; z-index: 10; + display: inline-flex; + align-items: center; + gap: 4px; background: rgba(74, 158, 255, 0.1); border: 1px solid rgba(74, 158, 255, 0.2); color: var(--text-primary); @@ -1789,6 +1960,12 @@ body { cursor: pointer; transition: all 0.2s; white-space: nowrap; + text-decoration: none; +} + +.strip-btn svg { + flex-shrink: 0; + opacity: 0.7; } .strip-btn:hover:not(:disabled) { diff --git a/static/css/modes/vdl2.css b/static/css/modes/vdl2.css new file mode 100644 index 0000000..2253866 --- /dev/null +++ b/static/css/modes/vdl2.css @@ -0,0 +1,31 @@ +/* VDL2 Mode Styles */ + +/* VDL2 Status Indicator */ +.vdl2-status-dot.listening { + background: var(--accent-cyan) !important; + animation: vdl2-pulse 1.5s ease-in-out infinite; +} +.vdl2-status-dot.receiving { + background: var(--accent-green) !important; +} +.vdl2-status-dot.error { + background: var(--accent-red) !important; +} +@keyframes vdl2-pulse { + 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); } + 50% { opacity: 0.6; box-shadow: 0 0 6px 3px rgba(74, 158, 255, 0.3); } +} + +/* VDL2 message animation */ +.vdl2-msg { + padding: 6px 8px; + border-bottom: 1px solid var(--border-color); + animation: vdl2FadeIn 0.3s ease; +} +.vdl2-msg:hover { + background: rgba(74, 158, 255, 0.05); +} +@keyframes vdl2FadeIn { + from { opacity: 0; transform: translateY(-3px); } + to { opacity: 1; transform: translateY(0); } +} diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html index 79fcb55..8e473b8 100644 --- a/templates/adsb_dashboard.html +++ b/templates/adsb_dashboard.html @@ -92,8 +92,12 @@ 0 ACARS +
+ 0 + VDL2 +
- Local + Local SOURCE
@@ -106,17 +110,25 @@
- 📚 History + + History +
@@ -176,6 +188,55 @@
+ +
+
+
+
+ VDL2 MESSAGES +
+ 0 +
+
+
+
+
+ Requires separate SDR (VHF ~137 MHz) +
+
+
+ + +
+
+ +
+ +
+
+
+
No VDL2 messages
+
Start VDL2 to receive digital datalink messages
+
+
+
+
+
+ +
+
@@ -223,88 +284,6 @@
- - -
-
- ANTENNA GUIDE - -
- -
@@ -4115,6 +4094,211 @@ sudo make install }); }); + // ============================================ + // VDL2 DATALINK PANEL + // ============================================ + let vdl2EventSource = null; + let isVdl2Running = false; + let vdl2MessageCount = 0; + let vdl2SidebarCollapsed = localStorage.getItem('vdl2SidebarCollapsed') !== 'false'; + let vdl2Frequencies = { + 'na': ['136975000', '136100000', '136650000', '136700000', '136800000'], + 'eu': ['136975000', '136675000', '136725000', '136775000', '136825000'], + 'ap': ['136975000', '136900000'] + }; + let vdl2FreqLabels = { + '136975000': '136.975', '136100000': '136.100', '136650000': '136.650', + '136700000': '136.700', '136800000': '136.800', '136675000': '136.675', + '136725000': '136.725', '136775000': '136.775', '136825000': '136.825', + '136900000': '136.900' + }; + + function toggleVdl2Sidebar() { + const sidebar = document.getElementById('vdl2Sidebar'); + vdl2SidebarCollapsed = !vdl2SidebarCollapsed; + sidebar.classList.toggle('collapsed', vdl2SidebarCollapsed); + localStorage.setItem('vdl2SidebarCollapsed', vdl2SidebarCollapsed); + } + + document.addEventListener('DOMContentLoaded', () => { + const sidebar = document.getElementById('vdl2Sidebar'); + if (sidebar && vdl2SidebarCollapsed) { + sidebar.classList.add('collapsed'); + } + updateVdl2FreqCheckboxes(); + }); + + function updateVdl2FreqCheckboxes() { + const region = document.getElementById('vdl2RegionDashSelect').value; + const freqs = vdl2Frequencies[region] || vdl2Frequencies['na']; + const container = document.getElementById('vdl2FreqSelector'); + + const previouslyChecked = new Set(); + container.querySelectorAll('input:checked').forEach(cb => previouslyChecked.add(cb.value)); + + container.innerHTML = freqs.map(freq => { + const checked = previouslyChecked.size === 0 || previouslyChecked.has(freq) ? 'checked' : ''; + const label = vdl2FreqLabels[freq] || freq; + return ` + + `; + }).join(''); + } + + function getVdl2RegionFreqs() { + const checkboxes = document.querySelectorAll('.vdl2-freq-cb:checked'); + const selectedFreqs = Array.from(checkboxes).map(cb => cb.value); + if (selectedFreqs.length === 0) { + const region = document.getElementById('vdl2RegionDashSelect').value; + return vdl2Frequencies[region] || vdl2Frequencies['na']; + } + return selectedFreqs; + } + + function toggleVdl2() { + if (isVdl2Running) { + stopVdl2(); + } else { + startVdl2(); + } + } + + function startVdl2() { + const vdl2Select = document.getElementById('vdl2DeviceSelect'); + const device = vdl2Select.value; + const sdr_type = vdl2Select.selectedOptions[0]?.dataset.sdrType || 'rtlsdr'; + const frequencies = getVdl2RegionFreqs(); + + if (isTracking && device === '0') { + const useAnyway = confirm( + 'Warning: ADS-B tracking may be using SDR device 0.\n\n' + + 'VDL2 uses VHF frequencies (~137 MHz) while ADS-B uses 1090 MHz.\n' + + 'You need TWO separate SDR devices to receive both simultaneously.\n\n' + + 'Click OK to start VDL2 on device ' + device + ' anyway.' + ); + if (!useAnyway) return; + } + + fetch('/vdl2/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ device, frequencies, gain: '40', sdr_type }) + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'started') { + isVdl2Running = true; + vdl2MessageCount = 0; + document.getElementById('vdl2ToggleBtn').innerHTML = '■ STOP VDL2'; + document.getElementById('vdl2ToggleBtn').classList.add('active'); + document.getElementById('vdl2PanelIndicator').classList.add('active'); + startVdl2Stream(); + } else { + alert('VDL2 Error: ' + (data.message || 'Failed to start')); + } + }) + .catch(err => alert('VDL2 Error: ' + err)); + } + + function stopVdl2() { + fetch('/vdl2/stop', { method: 'POST' }) + .then(r => r.json()) + .then(() => { + isVdl2Running = false; + document.getElementById('vdl2ToggleBtn').innerHTML = '▶ START VDL2'; + document.getElementById('vdl2ToggleBtn').classList.remove('active'); + document.getElementById('vdl2PanelIndicator').classList.remove('active'); + if (vdl2EventSource) { + vdl2EventSource.close(); + vdl2EventSource = null; + } + }); + } + + function startVdl2Stream() { + if (vdl2EventSource) vdl2EventSource.close(); + + vdl2EventSource = new EventSource('/vdl2/stream'); + vdl2EventSource.onmessage = function(e) { + const data = JSON.parse(e.data); + if (data.type === 'vdl2') { + vdl2MessageCount++; + if (typeof stats !== 'undefined') stats.vdl2Messages = (stats.vdl2Messages || 0) + 1; + document.getElementById('vdl2Count').textContent = vdl2MessageCount; + document.getElementById('stripVdl2').textContent = vdl2MessageCount; + addVdl2Message(data); + } + }; + + vdl2EventSource.onerror = function() { + console.error('VDL2 stream error'); + }; + } + + function addVdl2Message(data) { + const container = document.getElementById('vdl2Messages'); + + const placeholder = container.querySelector('.no-aircraft'); + if (placeholder) placeholder.remove(); + + const msg = document.createElement('div'); + msg.className = 'vdl2-message-item'; + msg.style.cssText = 'padding: 6px 8px; border-bottom: 1px solid var(--border-color); font-size: 10px;'; + + const station = data.station || ''; + const avlc = data.avlc || {}; + const src = avlc.src?.addr || ''; + const dst = avlc.dst?.addr || ''; + const acars = avlc.acars || {}; + const flight = acars.flight || ''; + const msgText = acars.msg_text || ''; + const time = new Date().toLocaleTimeString(); + const freq = data.freq ? (data.freq / 1000000).toFixed(3) : ''; + + msg.innerHTML = ` +
+ ${flight || src || 'VDL2'} + ${time} +
+ ${freq ? `
${freq} MHz
` : ''} + ${dst ? `
To: ${dst}
` : ''} + ${msgText ? `
${msgText}
` : ''} + `; + + container.insertBefore(msg, container.firstChild); + + while (container.children.length > 50) { + container.removeChild(container.lastChild); + } + } + + // Populate VDL2 device selector + document.addEventListener('DOMContentLoaded', () => { + fetch('/devices') + .then(r => r.json()) + .then(devices => { + const select = document.getElementById('vdl2DeviceSelect'); + select.innerHTML = ''; + if (devices.length === 0) { + select.innerHTML = ''; + } else { + devices.forEach((d, i) => { + const opt = document.createElement('option'); + opt.value = d.index || i; + opt.dataset.sdrType = d.sdr_type || 'rtlsdr'; + opt.textContent = `SDR ${d.index || i}: ${d.name || d.type || 'SDR'}`; + select.appendChild(opt); + }); + if (devices.length > 1) { + select.value = '1'; + } + } + }); + }); + // ============================================ // SQUAWK CODE REFERENCE // ============================================ @@ -4174,6 +4358,18 @@ sudo make install document.getElementById('squawkModal')?.addEventListener('click', (e) => { if (e.target.id === 'squawkModal') closeSquawkModal(); }); + + // ============================================ + // ANTENNA GUIDE + // ============================================ + function toggleAntennaGuide() { + const modal = document.getElementById('antennaGuideModal'); + modal.classList.toggle('active'); + } + + document.getElementById('antennaGuideModal')?.addEventListener('click', (e) => { + if (e.target.id === 'antennaGuideModal') toggleAntennaGuide(); + }); @@ -4227,6 +4423,72 @@ sudo make install + +
+
+
+ ANTENNA GUIDE — 1090 MHz ADS-B + +
+
+

+ 1090 MHz — stock SDR antenna can work but is not ideal +

+ +
+ Stock Telescopic Antenna +
    +
  • 1090 MHz: Collapse to ~6.9 cm (quarter-wave). It works for nearby aircraft
  • +
  • Range: Expect ~50 NM (90 km) indoors, ~100 NM outdoors
  • +
+
+ + + +
+ Commercial Options +
    +
  • FlightAware antenna: ~$35, 1090 MHz tuned, 66cm fiberglass whip
  • +
  • ADSBexchange whip: ~$40, similar performance
  • +
  • Jetvision A3: ~$50, high-gain 1090 MHz collinear
  • +
+
+ +
+ Placement & LNA +
    +
  • Location: OUTDOORS, as high as possible. Roof or mast mount
  • +
  • Height: Every 3m higher adds ~10 NM range (line-of-sight)
  • +
  • LNA: 1090 MHz filtered LNA at antenna feed (e.g. Uputronics, ~$30)
  • +
  • Filter: A 1090 MHz bandpass filter removes cell/FM interference
  • +
  • Coax: Keep short. At 1090 MHz, RG-58 loses ~10 dB per 10m
  • +
  • Bias-T: Enable Bias-T in controls above if LNA is powered via coax
  • +
+
+ +
+ Quick Reference + + + + + + + +
ADS-B frequency1090 MHz
Quarter-wave length6.9 cm
ModulationPPM (pulse)
PolarizationVertical
Bandwidth~2 MHz
Typical range (outdoor)100–250 NM
+
+
+
+
+