mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
feat: Add VDL2 mode and ACARS standalone frontend
Add VDL2 (VHF Digital Link Mode 2) decoding via dumpvdl2 as a new mode, and promote ACARS from ADS-B-dashboard-only to a first-class standalone mode in the main SPA. Both aviation datalink modes now have full nav entries, sidebar partials with region-based frequency selectors, and SSE streaming. VDL2 also integrated into the ADS-B dashboard as a collapsible sidebar alongside ACARS. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
21
Dockerfile
21
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 \
|
||||
|
||||
13
app.py
13
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
357
routes/vdl2.py
Normal file
357
routes/vdl2.py
Normal file
@@ -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 <device> --gain <gain> --correction <ppm> <freq1> <freq2> ...
|
||||
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'],
|
||||
}
|
||||
})
|
||||
135
setup.sh
135
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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
31
static/css/modes/vdl2.css
Normal file
31
static/css/modes/vdl2.css
Normal file
@@ -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); }
|
||||
}
|
||||
@@ -92,8 +92,12 @@
|
||||
<span class="strip-value" id="stripAcars">0</span>
|
||||
<span class="strip-label">ACARS</span>
|
||||
</div>
|
||||
<div class="strip-stat">
|
||||
<span class="strip-value" id="stripVdl2">0</span>
|
||||
<span class="strip-label">VDL2</span>
|
||||
</div>
|
||||
<div class="strip-stat source-stat" title="Data source (Local or Agent name)">
|
||||
<span class="strip-value" id="stripSource" style="font-size: 10px;">Local</span>
|
||||
<span class="strip-value" id="stripSource">Local</span>
|
||||
<span class="strip-label">SOURCE</span>
|
||||
</div>
|
||||
<div class="strip-stat signal-stat" title="Signal quality (messages/errors)">
|
||||
@@ -106,17 +110,25 @@
|
||||
</div>
|
||||
<div class="strip-divider"></div>
|
||||
<button type="button" class="strip-btn" onclick="showSquawkInfo(null)" title="Squawk Code Reference">
|
||||
📟 Squawk
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="6" width="20" height="12" rx="2"/><line x1="6" y1="10" x2="6" y2="14"/><line x1="10" y1="10" x2="10" y2="14"/><line x1="14" y1="10" x2="14" y2="14"/><line x1="18" y1="10" x2="18" y2="14"/></svg>
|
||||
Squawk
|
||||
</button>
|
||||
<button type="button" class="strip-btn" onclick="lookupSelectedFlight()" title="Lookup selected aircraft on FlightAware" id="flightLookupBtn" disabled>
|
||||
🔗 Lookup
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
Lookup
|
||||
</button>
|
||||
<button type="button" class="strip-btn primary" onclick="generateReport()" title="Generate Session Report">
|
||||
📊 Report
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
|
||||
Report
|
||||
</button>
|
||||
<a class="strip-btn" href="/adsb/history" title="Open History Reporting">
|
||||
📚 History
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
History
|
||||
</a>
|
||||
<button type="button" class="strip-btn" onclick="toggleAntennaGuide()" title="1090 MHz Antenna Guide">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12 L12 2 L22 12"/><line x1="12" y1="2" x2="12" y2="22"/><path d="M4.93 4.93 L12 12"/><path d="M19.07 4.93 L12 12"/></svg>
|
||||
Antenna
|
||||
</button>
|
||||
<div class="strip-divider"></div>
|
||||
<div class="strip-status">
|
||||
<div class="status-dot inactive" id="trackingDot"></div>
|
||||
@@ -176,6 +188,55 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- VDL2 Panel (left of map, after ACARS) - Collapsible -->
|
||||
<div class="vdl2-sidebar" id="vdl2Sidebar">
|
||||
<div class="vdl2-sidebar-content" id="vdl2SidebarContent">
|
||||
<div class="panel vdl2-panel">
|
||||
<div class="panel-header">
|
||||
<span>VDL2 MESSAGES</span>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span id="vdl2Count" style="font-size: 10px; color: var(--accent-cyan);">0</span>
|
||||
<div class="panel-indicator" id="vdl2PanelIndicator"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="vdl2PanelContent">
|
||||
<div class="vdl2-info" style="font-size: 9px; color: var(--text-muted); padding: 5px 8px; border-bottom: 1px solid var(--border-color);">
|
||||
<span style="color: var(--accent-yellow);">⚠</span> Requires separate SDR (VHF ~137 MHz)
|
||||
</div>
|
||||
<div class="vdl2-controls" style="padding: 8px; border-bottom: 1px solid var(--border-color);">
|
||||
<div style="display: flex; gap: 5px; margin-bottom: 5px;">
|
||||
<select id="vdl2DeviceSelect" style="flex: 1; font-size: 10px;">
|
||||
<option value="0">SDR 0</option>
|
||||
<option value="1">SDR 1</option>
|
||||
</select>
|
||||
<select id="vdl2RegionDashSelect" onchange="updateVdl2FreqCheckboxes()" style="flex: 1; font-size: 10px;">
|
||||
<option value="na">N. America</option>
|
||||
<option value="eu">Europe</option>
|
||||
<option value="ap">Asia-Pac</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="vdl2FreqSelector" style="display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px; font-size: 9px;">
|
||||
<!-- Frequency checkboxes populated by JS -->
|
||||
</div>
|
||||
<button class="vdl2-btn" id="vdl2ToggleBtn" onclick="toggleVdl2()" style="width: 100%;">
|
||||
▶ START VDL2
|
||||
</button>
|
||||
</div>
|
||||
<div class="vdl2-messages" id="vdl2Messages">
|
||||
<div class="no-aircraft" style="padding: 20px; text-align: center;">
|
||||
<div style="font-size: 10px; color: var(--text-muted);">No VDL2 messages</div>
|
||||
<div style="font-size: 9px; color: var(--text-dim); margin-top: 5px;">Start VDL2 to receive digital datalink messages</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="vdl2-collapse-btn" id="vdl2CollapseBtn" onclick="toggleVdl2Sidebar()" title="Toggle VDL2 Panel">
|
||||
<span id="vdl2CollapseIcon">◀</span>
|
||||
<span class="vdl2-collapse-label">VDL2</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Main Display (Map or Radar Scope) -->
|
||||
<div class="main-display">
|
||||
<div class="display-container">
|
||||
@@ -223,88 +284,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Antenna Guide Panel -->
|
||||
<div class="panel" id="antennaGuidePanel">
|
||||
<div class="panel-header" style="cursor: pointer;" onclick="document.getElementById('antennaGuideContent').style.display = document.getElementById('antennaGuideContent').style.display === 'none' ? 'block' : 'none'; this.querySelector('.panel-toggle').textContent = document.getElementById('antennaGuideContent').style.display === 'none' ? '▶' : '▼';">
|
||||
<span>ANTENNA GUIDE</span>
|
||||
<span class="panel-toggle" style="font-size: 10px; color: var(--text-muted);">▶</span>
|
||||
</div>
|
||||
<div id="antennaGuideContent" style="display: none; padding: 10px; font-size: 11px; color: var(--text-secondary); line-height: 1.5;">
|
||||
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
||||
1090 MHz — stock SDR antenna can work but is not ideal
|
||||
</p>
|
||||
|
||||
<div style="background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px; margin-bottom: 8px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 11px;">Stock Telescopic Antenna</strong>
|
||||
<ul style="margin: 4px 0 0 14px; padding: 0; font-size: 10px;">
|
||||
<li><strong style="color: var(--text-primary);">1090 MHz:</strong> Collapse to ~6.9 cm (quarter-wave). It works for nearby aircraft</li>
|
||||
<li><strong style="color: var(--text-primary);">Range:</strong> Expect ~50 NM (90 km) indoors, ~100 NM outdoors</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px; margin-bottom: 8px;">
|
||||
<strong style="color: #00ff88; font-size: 11px;">Recommended: 1090 MHz Collinear (~$10-20 DIY)</strong>
|
||||
<ul style="margin: 4px 0 0 14px; padding: 0; font-size: 10px;">
|
||||
<li><strong style="color: var(--text-primary);">Design:</strong> 8 coaxial collinear elements from RG-6 coax cable</li>
|
||||
<li><strong style="color: var(--text-primary);">Element length:</strong> ~6.9 cm segments soldered alternating center/shield</li>
|
||||
<li><strong style="color: var(--text-primary);">Gain:</strong> ~5–7 dBi omnidirectional, ideal for 360° coverage</li>
|
||||
<li><strong style="color: var(--text-primary);">Range:</strong> 150–250+ NM depending on height and LOS</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px; margin-bottom: 8px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 11px;">Commercial Options</strong>
|
||||
<ul style="margin: 4px 0 0 14px; padding: 0; font-size: 10px;">
|
||||
<li><strong style="color: var(--text-primary);">FlightAware antenna:</strong> ~$35, 1090 MHz tuned, 66cm fiberglass whip</li>
|
||||
<li><strong style="color: var(--text-primary);">ADSBexchange whip:</strong> ~$40, similar performance</li>
|
||||
<li><strong style="color: var(--text-primary);">Jetvision A3:</strong> ~$50, high-gain 1090 MHz collinear</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px; margin-bottom: 8px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 11px;">Placement & LNA</strong>
|
||||
<ul style="margin: 4px 0 0 14px; padding: 0; font-size: 10px;">
|
||||
<li><strong style="color: var(--text-primary);">Location:</strong> OUTDOORS, as high as possible. Roof or mast mount</li>
|
||||
<li><strong style="color: var(--text-primary);">Height:</strong> Every 3m higher adds ~10 NM range (line-of-sight)</li>
|
||||
<li><strong style="color: var(--text-primary);">LNA:</strong> 1090 MHz filtered LNA at antenna feed (e.g. Uputronics, ~$30)</li>
|
||||
<li><strong style="color: var(--text-primary);">Filter:</strong> A 1090 MHz bandpass filter removes cell/FM interference</li>
|
||||
<li><strong style="color: var(--text-primary);">Coax:</strong> Keep short. At 1090 MHz, RG-58 loses ~10 dB per 10m</li>
|
||||
<li><strong style="color: var(--text-primary);">Bias-T:</strong> Enable Bias-T in controls above if LNA is powered via coax</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 11px;">Quick Reference</strong>
|
||||
<table style="width: 100%; margin-top: 4px; font-size: 10px; border-collapse: collapse;">
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 2px 4px; color: var(--text-dim);">ADS-B frequency</td>
|
||||
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">1090 MHz</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 2px 4px; color: var(--text-dim);">Quarter-wave length</td>
|
||||
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">6.9 cm</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 2px 4px; color: var(--text-dim);">Modulation</td>
|
||||
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">PPM (pulse)</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 2px 4px; color: var(--text-dim);">Polarization</td>
|
||||
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 2px 4px; color: var(--text-dim);">Bandwidth</td>
|
||||
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">~2 MHz</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 2px 4px; color: var(--text-dim);">Typical range (outdoor)</td>
|
||||
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">100–250 NM</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls Bar - Reorganized -->
|
||||
@@ -4115,6 +4094,211 @@ sudo make install</code>
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 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 `
|
||||
<label style="display: flex; align-items: center; gap: 3px; padding: 2px 6px; background: var(--bg-secondary); border-radius: 3px; cursor: pointer;">
|
||||
<input type="checkbox" class="vdl2-freq-cb" value="${freq}" ${checked} style="margin: 0; cursor: pointer;">
|
||||
<span>${label}</span>
|
||||
</label>
|
||||
`;
|
||||
}).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 = `
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 2px;">
|
||||
<span style="color: var(--accent-cyan); font-weight: bold;">${flight || src || 'VDL2'}</span>
|
||||
<span style="color: var(--text-muted);">${time}</span>
|
||||
</div>
|
||||
${freq ? `<div style="color: var(--text-dim); font-size: 9px;">${freq} MHz</div>` : ''}
|
||||
${dst ? `<div style="color: var(--text-muted); font-size: 9px;">To: ${dst}</div>` : ''}
|
||||
${msgText ? `<div style="color: var(--text-primary); margin-top: 3px; word-break: break-word;">${msgText}</div>` : ''}
|
||||
`;
|
||||
|
||||
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 = '<option value="0">No SDR detected</option>';
|
||||
} 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</code>
|
||||
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();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Squawk Code Reference Modal -->
|
||||
@@ -4227,6 +4423,72 @@ sudo make install</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Antenna Guide Modal -->
|
||||
<div id="antennaGuideModal" class="antenna-guide-modal">
|
||||
<div class="antenna-guide-modal-content">
|
||||
<div class="antenna-guide-modal-header">
|
||||
<span>ANTENNA GUIDE — 1090 MHz ADS-B</span>
|
||||
<button class="antenna-guide-modal-close" onclick="toggleAntennaGuide()">×</button>
|
||||
</div>
|
||||
<div class="antenna-guide-modal-body">
|
||||
<p style="margin-bottom: 10px; color: var(--accent-cyan, #00d4ff); font-weight: 600; font-size: 12px;">
|
||||
1090 MHz — stock SDR antenna can work but is not ideal
|
||||
</p>
|
||||
|
||||
<div class="antenna-section">
|
||||
<strong style="color: var(--accent-cyan, #00d4ff);">Stock Telescopic Antenna</strong>
|
||||
<ul>
|
||||
<li><strong>1090 MHz:</strong> Collapse to ~6.9 cm (quarter-wave). It works for nearby aircraft</li>
|
||||
<li><strong>Range:</strong> Expect ~50 NM (90 km) indoors, ~100 NM outdoors</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="antenna-section recommended">
|
||||
<strong style="color: #00ff88;">Recommended: 1090 MHz Collinear (~$10-20 DIY)</strong>
|
||||
<ul>
|
||||
<li><strong>Design:</strong> 8 coaxial collinear elements from RG-6 coax cable</li>
|
||||
<li><strong>Element length:</strong> ~6.9 cm segments soldered alternating center/shield</li>
|
||||
<li><strong>Gain:</strong> ~5–7 dBi omnidirectional, ideal for 360° coverage</li>
|
||||
<li><strong>Range:</strong> 150–250+ NM depending on height and LOS</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="antenna-section">
|
||||
<strong style="color: var(--accent-cyan, #00d4ff);">Commercial Options</strong>
|
||||
<ul>
|
||||
<li><strong>FlightAware antenna:</strong> ~$35, 1090 MHz tuned, 66cm fiberglass whip</li>
|
||||
<li><strong>ADSBexchange whip:</strong> ~$40, similar performance</li>
|
||||
<li><strong>Jetvision A3:</strong> ~$50, high-gain 1090 MHz collinear</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="antenna-section">
|
||||
<strong style="color: var(--accent-cyan, #00d4ff);">Placement & LNA</strong>
|
||||
<ul>
|
||||
<li><strong>Location:</strong> OUTDOORS, as high as possible. Roof or mast mount</li>
|
||||
<li><strong>Height:</strong> Every 3m higher adds ~10 NM range (line-of-sight)</li>
|
||||
<li><strong>LNA:</strong> 1090 MHz filtered LNA at antenna feed (e.g. Uputronics, ~$30)</li>
|
||||
<li><strong>Filter:</strong> A 1090 MHz bandpass filter removes cell/FM interference</li>
|
||||
<li><strong>Coax:</strong> Keep short. At 1090 MHz, RG-58 loses ~10 dB per 10m</li>
|
||||
<li><strong>Bias-T:</strong> Enable Bias-T in controls above if LNA is powered via coax</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="antenna-section">
|
||||
<strong style="color: var(--accent-cyan, #00d4ff);">Quick Reference</strong>
|
||||
<table class="antenna-ref-table">
|
||||
<tr><td>ADS-B frequency</td><td>1090 MHz</td></tr>
|
||||
<tr><td>Quarter-wave length</td><td>6.9 cm</td></tr>
|
||||
<tr><td>Modulation</td><td>PPM (pulse)</td></tr>
|
||||
<tr><td>Polarization</td><td>Vertical</td></tr>
|
||||
<tr><td>Bandwidth</td><td>~2 MHz</td></tr>
|
||||
<tr><td>Typical range (outdoor)</td><td>100–250 NM</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.squawk-clickable {
|
||||
cursor: pointer;
|
||||
@@ -4374,6 +4636,108 @@ sudo make install</code>
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* Antenna Guide Modal */
|
||||
.antenna-guide-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.antenna-guide-modal.active {
|
||||
display: flex;
|
||||
}
|
||||
.antenna-guide-modal-content {
|
||||
background: var(--bg-secondary, #1a1a1a);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 480px;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.antenna-guide-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-tertiary, #252525);
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
.antenna-guide-modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary, #888);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.antenna-guide-modal-close:hover {
|
||||
color: #fff;
|
||||
}
|
||||
.antenna-guide-modal-body {
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, #999);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.antenna-section {
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.antenna-section strong {
|
||||
font-size: 11px;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.antenna-section ul {
|
||||
margin: 4px 0 0 14px;
|
||||
padding: 0;
|
||||
font-size: 10px;
|
||||
}
|
||||
.antenna-section ul li {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.antenna-section ul li strong {
|
||||
display: inline;
|
||||
color: var(--text-primary, #fff);
|
||||
font-size: 10px;
|
||||
}
|
||||
.antenna-ref-table {
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
font-size: 10px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.antenna-ref-table tr {
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
}
|
||||
.antenna-ref-table td {
|
||||
padding: 3px 4px;
|
||||
}
|
||||
.antenna-ref-table td:first-child {
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
.antenna-ref-table td:last-child {
|
||||
color: var(--text-primary, #fff);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Watchlist Modal */
|
||||
.watchlist-modal {
|
||||
display: none;
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/acars.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/vdl2.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/aprs.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/tscm.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-cards.css') }}">
|
||||
@@ -569,6 +570,10 @@
|
||||
|
||||
{% include 'partials/modes/bt_locate.html' %}
|
||||
|
||||
{% include 'partials/modes/acars.html' %}
|
||||
|
||||
{% include 'partials/modes/vdl2.html' %}
|
||||
|
||||
<button class="preset-btn" onclick="killAll()"
|
||||
style="width: 100%; margin-top: 10px; border-color: #ff3366; color: #ff3366;">
|
||||
Kill All Processes
|
||||
@@ -3158,7 +3163,8 @@
|
||||
const validModes = new Set([
|
||||
'pager', 'sensor', 'rtlamr', 'aprs', 'listening',
|
||||
'spystations', 'meshtastic', 'wifi', 'bluetooth', 'bt_locate',
|
||||
'tscm', 'satellite', 'sstv', 'weathersat', 'sstv_general', 'gps', 'websdr', 'subghz'
|
||||
'tscm', 'satellite', 'sstv', 'weathersat', 'sstv_general', 'gps', 'websdr', 'subghz',
|
||||
'acars', 'vdl2'
|
||||
]);
|
||||
|
||||
function getModeFromQuery() {
|
||||
@@ -3617,7 +3623,7 @@
|
||||
'wifi': 'wireless', 'bluetooth': 'wireless', 'bt_locate': 'wireless',
|
||||
'tscm': 'security',
|
||||
'rtlamr': 'sdr', 'ais': 'sdr', 'spystations': 'sdr',
|
||||
'meshtastic': 'sdr',
|
||||
'meshtastic': 'sdr', 'acars': 'sdr', 'vdl2': 'sdr',
|
||||
'satellite': 'space', 'sstv': 'space', 'weathersat': 'space', 'sstv_general': 'space', 'gps': 'space',
|
||||
'subghz': 'sdr'
|
||||
};
|
||||
@@ -3722,6 +3728,8 @@
|
||||
document.getElementById('dmrMode')?.classList.toggle('active', mode === 'dmr');
|
||||
document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr');
|
||||
document.getElementById('subghzMode')?.classList.toggle('active', mode === 'subghz');
|
||||
document.getElementById('acarsMode')?.classList.toggle('active', mode === 'acars');
|
||||
document.getElementById('vdl2Mode')?.classList.toggle('active', mode === 'vdl2');
|
||||
const pagerStats = document.getElementById('pagerStats');
|
||||
const sensorStats = document.getElementById('sensorStats');
|
||||
const satelliteStats = document.getElementById('satelliteStats');
|
||||
@@ -3762,7 +3770,9 @@
|
||||
'meshtastic': 'MESHTASTIC',
|
||||
'dmr': 'DIGITAL VOICE',
|
||||
'websdr': 'WEBSDR',
|
||||
'subghz': 'SUBGHZ'
|
||||
'subghz': 'SUBGHZ',
|
||||
'acars': 'ACARS',
|
||||
'vdl2': 'VDL2'
|
||||
};
|
||||
const activeModeIndicator = document.getElementById('activeModeIndicator');
|
||||
if (activeModeIndicator) activeModeIndicator.innerHTML = '<span class="pulse-dot"></span>' + (modeNames[mode] || mode.toUpperCase());
|
||||
@@ -3836,7 +3846,9 @@
|
||||
'meshtastic': 'Meshtastic Mesh Monitor',
|
||||
'dmr': 'Digital Voice Decoder',
|
||||
'websdr': 'HF/Shortwave WebSDR',
|
||||
'subghz': 'SubGHz Transceiver'
|
||||
'subghz': 'SubGHz Transceiver',
|
||||
'acars': 'ACARS Aircraft Messaging',
|
||||
'vdl2': 'VDL2 Aircraft Datalink'
|
||||
};
|
||||
const outputTitle = document.getElementById('outputTitle');
|
||||
if (outputTitle) outputTitle.textContent = titles[mode] || 'Signal Monitor';
|
||||
@@ -3854,7 +3866,7 @@
|
||||
const reconBtn = document.getElementById('reconBtn');
|
||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||
const reconPanel = document.getElementById('reconPanel');
|
||||
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'gps' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz') {
|
||||
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'gps' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz' || mode === 'acars' || mode === 'vdl2') {
|
||||
if (reconPanel) reconPanel.style.display = 'none';
|
||||
if (reconBtn) reconBtn.style.display = 'none';
|
||||
if (intelBtn) intelBtn.style.display = 'none';
|
||||
@@ -3869,12 +3881,12 @@
|
||||
|
||||
// Show agent selector for modes that support remote agents
|
||||
const agentSection = document.getElementById('agentSection');
|
||||
const agentModes = ['pager', 'sensor', 'rtlamr', 'listening', 'aprs', 'wifi', 'bluetooth', 'aircraft', 'tscm', 'ais', 'acars', 'dsc'];
|
||||
const agentModes = ['pager', 'sensor', 'rtlamr', 'listening', 'aprs', 'wifi', 'bluetooth', 'aircraft', 'tscm', 'ais', 'acars', 'vdl2', 'dsc'];
|
||||
if (agentSection) agentSection.style.display = agentModes.includes(mode) ? 'block' : 'none';
|
||||
|
||||
// Show RTL-SDR device section for modes that use it
|
||||
const rtlDeviceSection = document.getElementById('rtlDeviceSection');
|
||||
if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'listening' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'dmr') ? 'block' : 'none';
|
||||
if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'listening' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'dmr' || mode === 'acars' || mode === 'vdl2') ? 'block' : 'none';
|
||||
|
||||
// Show waterfall panel if running in listening mode
|
||||
const waterfallPanel = document.getElementById('waterfallPanel');
|
||||
|
||||
208
templates/partials/modes/acars.html
Normal file
208
templates/partials/modes/acars.html
Normal file
@@ -0,0 +1,208 @@
|
||||
<!-- ACARS AIRCRAFT MESSAGING MODE -->
|
||||
<div id="acarsMode" class="mode-content" style="display: none;">
|
||||
<div class="section">
|
||||
<h3>ACARS Messaging</h3>
|
||||
<div class="info-text" style="margin-bottom: 15px;">
|
||||
Decode ACARS (Aircraft Communications Addressing and Reporting System) messages on VHF frequencies (~129-131 MHz). Captures flight data, weather reports, position updates, and operational messages from aircraft.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Region & Frequencies</h3>
|
||||
<div class="form-group">
|
||||
<label>Region</label>
|
||||
<select id="acarsRegionSelect" onchange="updateAcarsMainFreqs()" style="width: 100%;">
|
||||
<option value="na">North America</option>
|
||||
<option value="eu">Europe</option>
|
||||
<option value="ap">Asia-Pacific</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="acarsMainFreqSelector" style="display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; font-size: 11px;">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Gain (dB, 0 = auto)</label>
|
||||
<input type="number" id="acarsGainInput" value="40" min="0" max="50" placeholder="0-50">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Status</h3>
|
||||
<div id="acarsStatusDisplay" class="info-text">
|
||||
<p>Status: <span id="acarsStatusText" style="color: var(--accent-yellow);">Standby</span></p>
|
||||
<p>Messages: <span id="acarsMessageCount">0</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Antenna Guide -->
|
||||
<div class="section">
|
||||
<h3>Antenna Guide</h3>
|
||||
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
||||
VHF Airband (~130 MHz) — stock SDR antenna may work at close range
|
||||
</p>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Simple Dipole</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">Element length:</strong> ~57 cm each (quarter-wave at 130 MHz)</li>
|
||||
<li><strong style="color: var(--text-primary);">Material:</strong> Wire, coat hanger, or copper rod</li>
|
||||
<li><strong style="color: var(--text-primary);">Orientation:</strong> Vertical (airband is vertically polarized)</li>
|
||||
<li><strong style="color: var(--text-primary);">Placement:</strong> Outdoors, as high as possible with clear sky view</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
|
||||
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Primary (worldwide)</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">131.550 MHz</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter-wave length</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">57 cm</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Modulation</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">AM MSK 2400 baud</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="run-btn" id="startAcarsBtn" onclick="startAcarsMode()">
|
||||
Start ACARS
|
||||
</button>
|
||||
<button class="stop-btn" id="stopAcarsBtn" onclick="stopAcarsMode()" style="display: none;">
|
||||
Stop ACARS
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let acarsMainEventSource = null;
|
||||
let acarsMainMsgCount = 0;
|
||||
|
||||
const acarsMainFrequencies = {
|
||||
'na': ['129.125', '130.025', '130.450', '131.550'],
|
||||
'eu': ['131.525', '131.725', '131.550'],
|
||||
'ap': ['131.550', '131.450']
|
||||
};
|
||||
|
||||
function updateAcarsMainFreqs() {
|
||||
const region = document.getElementById('acarsRegionSelect').value;
|
||||
const freqs = acarsMainFrequencies[region] || acarsMainFrequencies['na'];
|
||||
const container = document.getElementById('acarsMainFreqSelector');
|
||||
|
||||
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' : '';
|
||||
return `
|
||||
<label style="display: flex; align-items: center; gap: 3px; padding: 2px 6px; background: var(--bg-secondary); border-radius: 3px; cursor: pointer;">
|
||||
<input type="checkbox" class="acars-main-freq-cb" value="${freq}" ${checked} style="margin: 0; cursor: pointer;">
|
||||
<span>${freq}</span>
|
||||
</label>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function getAcarsMainSelectedFreqs() {
|
||||
const checkboxes = document.querySelectorAll('.acars-main-freq-cb:checked');
|
||||
const selected = Array.from(checkboxes).map(cb => cb.value);
|
||||
if (selected.length === 0) {
|
||||
const region = document.getElementById('acarsRegionSelect').value;
|
||||
return acarsMainFrequencies[region] || acarsMainFrequencies['na'];
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function startAcarsMode() {
|
||||
const gain = document.getElementById('acarsGainInput').value || '40';
|
||||
const device = document.getElementById('deviceSelect')?.value || '0';
|
||||
const frequencies = getAcarsMainSelectedFreqs();
|
||||
|
||||
fetch('/acars/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ device, gain, frequencies })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'started') {
|
||||
document.getElementById('startAcarsBtn').style.display = 'none';
|
||||
document.getElementById('stopAcarsBtn').style.display = 'block';
|
||||
document.getElementById('acarsStatusText').textContent = 'Listening';
|
||||
document.getElementById('acarsStatusText').style.color = 'var(--accent-green)';
|
||||
acarsMainMsgCount = 0;
|
||||
startAcarsMainSSE();
|
||||
} else {
|
||||
alert(data.message || 'Failed to start ACARS');
|
||||
}
|
||||
})
|
||||
.catch(err => alert('Error: ' + err.message));
|
||||
}
|
||||
|
||||
function stopAcarsMode() {
|
||||
fetch('/acars/stop', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
document.getElementById('startAcarsBtn').style.display = 'block';
|
||||
document.getElementById('stopAcarsBtn').style.display = 'none';
|
||||
document.getElementById('acarsStatusText').textContent = 'Standby';
|
||||
document.getElementById('acarsStatusText').style.color = 'var(--accent-yellow)';
|
||||
if (acarsMainEventSource) {
|
||||
acarsMainEventSource.close();
|
||||
acarsMainEventSource = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function startAcarsMainSSE() {
|
||||
if (acarsMainEventSource) acarsMainEventSource.close();
|
||||
|
||||
acarsMainEventSource = new EventSource('/acars/stream');
|
||||
acarsMainEventSource.onmessage = function(e) {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'acars') {
|
||||
acarsMainMsgCount++;
|
||||
document.getElementById('acarsMessageCount').textContent = acarsMainMsgCount;
|
||||
}
|
||||
} catch (err) {}
|
||||
};
|
||||
|
||||
acarsMainEventSource.onerror = function() {
|
||||
setTimeout(() => {
|
||||
if (document.getElementById('stopAcarsBtn').style.display === 'block') {
|
||||
startAcarsMainSSE();
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
}
|
||||
|
||||
// Check initial status
|
||||
fetch('/acars/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.running) {
|
||||
document.getElementById('startAcarsBtn').style.display = 'none';
|
||||
document.getElementById('stopAcarsBtn').style.display = 'block';
|
||||
document.getElementById('acarsStatusText').textContent = 'Listening';
|
||||
document.getElementById('acarsStatusText').style.color = 'var(--accent-green)';
|
||||
document.getElementById('acarsMessageCount').textContent = data.message_count || 0;
|
||||
acarsMainMsgCount = data.message_count || 0;
|
||||
startAcarsMainSSE();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Initialize frequency checkboxes
|
||||
document.addEventListener('DOMContentLoaded', () => updateAcarsMainFreqs());
|
||||
</script>
|
||||
228
templates/partials/modes/vdl2.html
Normal file
228
templates/partials/modes/vdl2.html
Normal file
@@ -0,0 +1,228 @@
|
||||
<!-- VDL2 AIRCRAFT DATALINK MODE -->
|
||||
<div id="vdl2Mode" class="mode-content" style="display: none;">
|
||||
<div class="section">
|
||||
<h3>VDL2 Datalink</h3>
|
||||
<div class="info-text" style="margin-bottom: 15px;">
|
||||
Decode VDL Mode 2 (VHF Digital Link) messages on ~136 MHz. VDL2 is the digital successor to ACARS, using D8PSK modulation for higher throughput aircraft datalink communications.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Region & Frequencies</h3>
|
||||
<div class="form-group">
|
||||
<label>Region</label>
|
||||
<select id="vdl2RegionSelect" onchange="updateVdl2MainFreqs()" style="width: 100%;">
|
||||
<option value="na">North America</option>
|
||||
<option value="eu">Europe</option>
|
||||
<option value="ap">Asia-Pacific</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="vdl2MainFreqSelector" style="display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; font-size: 11px;">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Gain (dB, 0 = auto)</label>
|
||||
<input type="number" id="vdl2GainInput" value="40" min="0" max="50" placeholder="0-50">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Status</h3>
|
||||
<div id="vdl2StatusDisplay" class="info-text">
|
||||
<p>Status: <span id="vdl2StatusText" style="color: var(--accent-yellow);">Standby</span></p>
|
||||
<p>Messages: <span id="vdl2MessageCount">0</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Antenna Guide -->
|
||||
<div class="section">
|
||||
<h3>Antenna Guide</h3>
|
||||
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
||||
VHF Airband (~137 MHz) — stock SDR antenna may work at close range
|
||||
</p>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Simple Dipole</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">Element length:</strong> ~55 cm each (quarter-wave at 137 MHz)</li>
|
||||
<li><strong style="color: var(--text-primary);">Material:</strong> Wire, coat hanger, or copper rod</li>
|
||||
<li><strong style="color: var(--text-primary);">Orientation:</strong> Vertical (airband is vertically polarized)</li>
|
||||
<li><strong style="color: var(--text-primary);">Placement:</strong> Outdoors, as high as possible with clear sky view</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
|
||||
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Primary (worldwide)</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">136.975 MHz</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter-wave length</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">55 cm</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Modulation</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">D8PSK 31.5 kbps</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Bandwidth</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">25 kHz</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="run-btn" id="startVdl2Btn" onclick="startVdl2Mode()">
|
||||
Start VDL2
|
||||
</button>
|
||||
<button class="stop-btn" id="stopVdl2Btn" onclick="stopVdl2Mode()" style="display: none;">
|
||||
Stop VDL2
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let vdl2MainEventSource = null;
|
||||
let vdl2MainMsgCount = 0;
|
||||
|
||||
// VDL2 frequencies in Hz (as required by dumpvdl2)
|
||||
const vdl2MainFrequencies = {
|
||||
'na': ['136975000', '136100000', '136650000', '136700000', '136800000'],
|
||||
'eu': ['136975000', '136675000', '136725000', '136775000', '136825000'],
|
||||
'ap': ['136975000', '136900000']
|
||||
};
|
||||
|
||||
// Display-friendly MHz labels
|
||||
const 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 updateVdl2MainFreqs() {
|
||||
const region = document.getElementById('vdl2RegionSelect').value;
|
||||
const freqs = vdl2MainFrequencies[region] || vdl2MainFrequencies['na'];
|
||||
const container = document.getElementById('vdl2MainFreqSelector');
|
||||
|
||||
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 `
|
||||
<label style="display: flex; align-items: center; gap: 3px; padding: 2px 6px; background: var(--bg-secondary); border-radius: 3px; cursor: pointer;">
|
||||
<input type="checkbox" class="vdl2-main-freq-cb" value="${freq}" ${checked} style="margin: 0; cursor: pointer;">
|
||||
<span>${label}</span>
|
||||
</label>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function getVdl2MainSelectedFreqs() {
|
||||
const checkboxes = document.querySelectorAll('.vdl2-main-freq-cb:checked');
|
||||
const selected = Array.from(checkboxes).map(cb => cb.value);
|
||||
if (selected.length === 0) {
|
||||
const region = document.getElementById('vdl2RegionSelect').value;
|
||||
return vdl2MainFrequencies[region] || vdl2MainFrequencies['na'];
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function startVdl2Mode() {
|
||||
const gain = document.getElementById('vdl2GainInput').value || '40';
|
||||
const device = document.getElementById('deviceSelect')?.value || '0';
|
||||
const frequencies = getVdl2MainSelectedFreqs();
|
||||
|
||||
fetch('/vdl2/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ device, gain, frequencies })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'started') {
|
||||
document.getElementById('startVdl2Btn').style.display = 'none';
|
||||
document.getElementById('stopVdl2Btn').style.display = 'block';
|
||||
document.getElementById('vdl2StatusText').textContent = 'Listening';
|
||||
document.getElementById('vdl2StatusText').style.color = 'var(--accent-green)';
|
||||
vdl2MainMsgCount = 0;
|
||||
startVdl2MainSSE();
|
||||
} else {
|
||||
alert(data.message || 'Failed to start VDL2');
|
||||
}
|
||||
})
|
||||
.catch(err => alert('Error: ' + err.message));
|
||||
}
|
||||
|
||||
function stopVdl2Mode() {
|
||||
fetch('/vdl2/stop', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
document.getElementById('startVdl2Btn').style.display = 'block';
|
||||
document.getElementById('stopVdl2Btn').style.display = 'none';
|
||||
document.getElementById('vdl2StatusText').textContent = 'Standby';
|
||||
document.getElementById('vdl2StatusText').style.color = 'var(--accent-yellow)';
|
||||
if (vdl2MainEventSource) {
|
||||
vdl2MainEventSource.close();
|
||||
vdl2MainEventSource = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function startVdl2MainSSE() {
|
||||
if (vdl2MainEventSource) vdl2MainEventSource.close();
|
||||
|
||||
vdl2MainEventSource = new EventSource('/vdl2/stream');
|
||||
vdl2MainEventSource.onmessage = function(e) {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'vdl2') {
|
||||
vdl2MainMsgCount++;
|
||||
document.getElementById('vdl2MessageCount').textContent = vdl2MainMsgCount;
|
||||
}
|
||||
} catch (err) {}
|
||||
};
|
||||
|
||||
vdl2MainEventSource.onerror = function() {
|
||||
setTimeout(() => {
|
||||
if (document.getElementById('stopVdl2Btn').style.display === 'block') {
|
||||
startVdl2MainSSE();
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
}
|
||||
|
||||
// Check initial status
|
||||
fetch('/vdl2/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.running) {
|
||||
document.getElementById('startVdl2Btn').style.display = 'none';
|
||||
document.getElementById('stopVdl2Btn').style.display = 'block';
|
||||
document.getElementById('vdl2StatusText').textContent = 'Listening';
|
||||
document.getElementById('vdl2StatusText').style.color = 'var(--accent-green)';
|
||||
document.getElementById('vdl2MessageCount').textContent = data.message_count || 0;
|
||||
vdl2MainMsgCount = data.message_count || 0;
|
||||
startVdl2MainSSE();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Initialize frequency checkboxes
|
||||
document.addEventListener('DOMContentLoaded', () => updateVdl2MainFreqs());
|
||||
</script>
|
||||
@@ -67,6 +67,8 @@
|
||||
{{ mode_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
|
||||
{{ mode_item('adsb', 'Aircraft', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>', '/adsb/dashboard') }}
|
||||
{{ mode_item('ais', 'Vessels', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg>', '/ais/dashboard') }}
|
||||
{{ mode_item('acars', 'ACARS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/><line x1="4" y1="4" x2="8" y2="8"/><line x1="20" y1="4" x2="16" y2="8"/></svg>') }}
|
||||
{{ mode_item('vdl2', 'VDL2', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/><path d="M2 2l4 4M22 2l-4 4M12 12v0"/></svg>') }}
|
||||
{{ mode_item('aprs', 'APRS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>') }}
|
||||
{{ mode_item('listening', 'Listening Post', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
|
||||
{{ mode_item('spystations', 'Spy Stations', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
||||
@@ -178,6 +180,8 @@
|
||||
{{ mobile_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
|
||||
{{ mobile_item('adsb', 'Aircraft', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>', '/adsb/dashboard') }}
|
||||
{{ mobile_item('ais', 'Vessels', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg>', '/ais/dashboard') }}
|
||||
{{ mobile_item('acars', 'ACARS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>') }}
|
||||
{{ mobile_item('vdl2', 'VDL2', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>') }}
|
||||
{{ mobile_item('aprs', 'APRS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>') }}
|
||||
{{ mobile_item('wifi', 'WiFi', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/></svg>') }}
|
||||
{{ mobile_item('bluetooth', 'BT', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg>') }}
|
||||
|
||||
Reference in New Issue
Block a user