diff --git a/Dockerfile b/Dockerfile index b064c4a..828f277 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,25 +35,37 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ procps \ && rm -rf /var/lib/apt/lists/* -# Build dump1090-fa from source (packages not available in slim repos) +# Build dump1090-fa and acarsdec from source (packages not available in slim repos) RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ git \ pkg-config \ + cmake \ libncurses-dev \ + libsndfile1-dev \ + # Build dump1090 && cd /tmp \ && git clone --depth 1 https://github.com/flightaware/dump1090.git \ && cd dump1090 \ && make \ && cp dump1090 /usr/bin/dump1090-fa \ && ln -s /usr/bin/dump1090-fa /usr/bin/dump1090 \ - && cd /app \ && rm -rf /tmp/dump1090 \ + # Build acarsdec + && cd /tmp \ + && git clone --depth 1 https://github.com/TLeconte/acarsdec.git \ + && cd acarsdec \ + && mkdir build && cd build \ + && cmake .. -Drtl=ON \ + && make \ + && cp acarsdec /usr/bin/acarsdec \ + && rm -rf /tmp/acarsdec \ # Cleanup build tools to reduce image size && apt-get remove -y \ build-essential \ git \ pkg-config \ + cmake \ libncurses-dev \ && apt-get autoremove -y \ && rm -rf /var/lib/apt/lists/* diff --git a/app.py b/app.py index a5c06d7..efb3487 100644 --- a/app.py +++ b/app.py @@ -103,6 +103,11 @@ satellite_process = None satellite_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) satellite_lock = threading.Lock() +# ACARS aircraft messaging +acars_process = None +acars_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) +acars_lock = threading.Lock() + # ============================================ # GLOBAL STATE DICTIONARIES # ============================================ @@ -416,6 +421,7 @@ def health_check() -> Response: 'pager': current_process is not None and (current_process.poll() is None if current_process else False), 'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False), 'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False), + 'acars': acars_process is not None and (acars_process.poll() is None if acars_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), }, @@ -431,7 +437,7 @@ def health_check() -> Response: @app.route('/killall', methods=['POST']) def kill_all() -> Response: """Kill all decoder and WiFi processes.""" - global current_process, sensor_process, wifi_process, adsb_process + global current_process, sensor_process, wifi_process, adsb_process, acars_process # Import adsb module to reset its state from routes import adsb as adsb_module @@ -440,7 +446,7 @@ def kill_all() -> Response: processes_to_kill = [ 'rtl_fm', 'multimon-ng', 'rtl_433', 'airodump-ng', 'aireplay-ng', 'airmon-ng', - 'dump1090' + 'dump1090', 'acarsdec' ] for proc in processes_to_kill: @@ -465,6 +471,10 @@ def kill_all() -> Response: adsb_process = None adsb_module.adsb_using_service = False + # Reset ACARS state + with acars_lock: + acars_process = None + return jsonify({'status': 'killed', 'processes': killed}) @@ -517,7 +527,7 @@ def main() -> None: print("=" * 50) print(" INTERCEPT // Signal Intelligence") - print(" Pager / 433MHz / Aircraft / Satellite / WiFi / BT") + print(" Pager / 433MHz / Aircraft / ACARS / Satellite / WiFi / BT") print("=" * 50) print() diff --git a/routes/__init__.py b/routes/__init__.py index 5b6a326..62cdc79 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -7,6 +7,7 @@ def register_blueprints(app): from .wifi import wifi_bp from .bluetooth import bluetooth_bp from .adsb import adsb_bp + from .acars import acars_bp from .satellite import satellite_bp from .gps import gps_bp from .settings import settings_bp @@ -18,6 +19,7 @@ def register_blueprints(app): app.register_blueprint(wifi_bp) app.register_blueprint(bluetooth_bp) app.register_blueprint(adsb_bp) + app.register_blueprint(acars_bp) app.register_blueprint(satellite_bp) app.register_blueprint(gps_bp) app.register_blueprint(settings_bp) diff --git a/routes/acars.py b/routes/acars.py new file mode 100644 index 0000000..c79ceff --- /dev/null +++ b/routes/acars.py @@ -0,0 +1,290 @@ +"""ACARS aircraft messaging 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.sse import format_sse +from utils.constants import ( + PROCESS_TERMINATE_TIMEOUT, + SSE_KEEPALIVE_INTERVAL, + SSE_QUEUE_TIMEOUT, + PROCESS_START_WAIT, +) + +acars_bp = Blueprint('acars', __name__, url_prefix='/acars') + +# Default VHF ACARS frequencies (MHz) - common worldwide +DEFAULT_ACARS_FREQUENCIES = [ + '131.550', # Primary worldwide + '130.025', # Secondary USA/Canada + '129.125', # USA + '131.525', # Europe + '131.725', # Europe secondary +] + +# Message counter for statistics +acars_message_count = 0 +acars_last_message_time = None + + +def find_acarsdec(): + """Find acarsdec binary.""" + return shutil.which('acarsdec') + + +def stream_acars_output(process: subprocess.Popen) -> None: + """Stream acarsdec JSON output to queue.""" + global acars_message_count, acars_last_message_time + + try: + app_module.acars_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: + # acarsdec -j outputs JSON, one message per line + data = json.loads(line) + + # Add our metadata + data['type'] = 'acars' + data['timestamp'] = datetime.utcnow().isoformat() + 'Z' + + # Update stats + acars_message_count += 1 + acars_last_message_time = time.time() + + app_module.acars_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} | ACARS | {json.dumps(data)}\n") + except Exception: + pass + + except json.JSONDecodeError: + # Not JSON - could be status message + if line: + logger.debug(f"acarsdec non-JSON: {line[:100]}") + + except Exception as e: + logger.error(f"ACARS stream error: {e}") + app_module.acars_queue.put({'type': 'error', 'message': str(e)}) + finally: + app_module.acars_queue.put({'type': 'status', 'status': 'stopped'}) + with app_module.acars_lock: + app_module.acars_process = None + + +@acars_bp.route('/tools') +def check_acars_tools() -> Response: + """Check for ACARS decoding tools.""" + has_acarsdec = find_acarsdec() is not None + + return jsonify({ + 'acarsdec': has_acarsdec, + 'ready': has_acarsdec + }) + + +@acars_bp.route('/status') +def acars_status() -> Response: + """Get ACARS decoder status.""" + running = False + if app_module.acars_process: + running = app_module.acars_process.poll() is None + + return jsonify({ + 'running': running, + 'message_count': acars_message_count, + 'last_message_time': acars_last_message_time, + 'queue_size': app_module.acars_queue.qsize() + }) + + +@acars_bp.route('/start', methods=['POST']) +def start_acars() -> Response: + """Start ACARS decoder.""" + global acars_message_count, acars_last_message_time + + with app_module.acars_lock: + if app_module.acars_process and app_module.acars_process.poll() is None: + return jsonify({ + 'status': 'error', + 'message': 'ACARS decoder already running' + }), 409 + + # Check for acarsdec + acarsdec_path = find_acarsdec() + if not acarsdec_path: + return jsonify({ + 'status': 'error', + 'message': 'acarsdec not found. Install with: sudo apt install acarsdec' + }), 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 + + # Get frequencies - use provided or defaults + frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES) + if isinstance(frequencies, str): + frequencies = [f.strip() for f in frequencies.split(',')] + + # Clear queue + while not app_module.acars_queue.empty(): + try: + app_module.acars_queue.get_nowait() + except queue.Empty: + break + + # Reset stats + acars_message_count = 0 + acars_last_message_time = None + + # Build acarsdec command + # acarsdec -j -r -g -p ... + cmd = [ + acarsdec_path, + '-j', # JSON output + '-r', str(device), # RTL-SDR device index + ] + + # Add gain if not auto + if gain and str(gain) != '0': + cmd.extend(['-g', str(gain)]) + + # Add PPM correction if specified + if ppm and str(ppm) != '0': + cmd.extend(['-p', str(ppm)]) + + # Add frequencies + cmd.extend(frequencies) + + logger.info(f"Starting ACARS 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 + stderr = '' + if process.stderr: + stderr = process.stderr.read().decode('utf-8', errors='replace') + error_msg = f'acarsdec failed to start' + if stderr: + error_msg += f': {stderr[:200]}' + logger.error(error_msg) + return jsonify({'status': 'error', 'message': error_msg}), 500 + + app_module.acars_process = process + + # Start output streaming thread + thread = threading.Thread( + target=stream_acars_output, + args=(process,), + daemon=True + ) + thread.start() + + return jsonify({ + 'status': 'started', + 'frequencies': frequencies, + 'device': device, + 'gain': gain + }) + + except Exception as e: + logger.error(f"Failed to start ACARS decoder: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@acars_bp.route('/stop', methods=['POST']) +def stop_acars() -> Response: + """Stop ACARS decoder.""" + with app_module.acars_lock: + if not app_module.acars_process: + return jsonify({ + 'status': 'error', + 'message': 'ACARS decoder not running' + }), 400 + + try: + app_module.acars_process.terminate() + app_module.acars_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT) + except subprocess.TimeoutExpired: + app_module.acars_process.kill() + except Exception as e: + logger.error(f"Error stopping ACARS: {e}") + + app_module.acars_process = None + + return jsonify({'status': 'stopped'}) + + +@acars_bp.route('/stream') +def stream_acars() -> Response: + """SSE stream for ACARS messages.""" + def generate() -> Generator[str, None, None]: + last_keepalive = time.time() + + while True: + try: + msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT) + last_keepalive = time.time() + 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 + + +@acars_bp.route('/frequencies') +def get_frequencies() -> Response: + """Get default ACARS frequencies.""" + return jsonify({ + 'default': DEFAULT_ACARS_FREQUENCIES, + 'regions': { + 'north_america': ['129.125', '130.025', '130.450', '131.550'], + 'europe': ['131.525', '131.725', '131.550'], + 'asia_pacific': ['131.550', '131.450'], + } + }) diff --git a/setup.sh b/setup.sh index f7d405f..66a0f65 100755 --- a/setup.sh +++ b/setup.sh @@ -139,6 +139,7 @@ check_tools() { check_required "multimon-ng" "Pager decoder" multimon-ng check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433 check_required "dump1090" "ADS-B decoder" dump1090 + check_required "acarsdec" "ACARS decoder" acarsdec echo info "GPS:" @@ -305,7 +306,7 @@ install_multimon_ng_from_source_macos() { } install_macos_packages() { - TOTAL_STEPS=12 + TOTAL_STEPS=13 CURRENT_STEP=0 progress "Checking Homebrew" @@ -331,6 +332,9 @@ install_macos_packages() { progress "Installing dump1090" (brew_install dump1090-mutability) || warn "dump1090 not available via Homebrew" + progress "Installing acarsdec" + (brew_install acarsdec) || warn "acarsdec not available via Homebrew" + progress "Installing aircrack-ng" brew_install aircrack-ng @@ -412,6 +416,34 @@ install_dump1090_from_source_debian() { ) } +install_acarsdec_from_source_debian() { + info "acarsdec not available via APT. Building from source..." + + apt_install build-essential git cmake \ + librtlsdr-dev libusb-1.0-0-dev libsndfile1-dev + + # Run in subshell to isolate EXIT trap + ( + tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' EXIT + + info "Cloning acarsdec..." + git clone --depth 1 https://github.com/TLeconte/acarsdec.git "$tmp_dir/acarsdec" >/dev/null 2>&1 \ + || { warn "Failed to clone acarsdec"; exit 1; } + + cd "$tmp_dir/acarsdec" + mkdir -p build && cd build + + info "Compiling acarsdec..." + if cmake .. -Drtl=ON >/dev/null 2>&1 && make >/dev/null 2>&1; then + $SUDO install -m 0755 acarsdec /usr/local/bin/acarsdec + ok "acarsdec installed successfully." + else + warn "Failed to build acarsdec from source. ACARS decoding will not be available." + fi + ) +} + setup_udev_rules_debian() { [[ -d /etc/udev/rules.d ]] || { warn "udev not found; skipping RTL-SDR udev rules."; return 0; } @@ -464,7 +496,7 @@ install_debian_packages() { export DEBIAN_FRONTEND=noninteractive export NEEDRESTART_MODE=a - TOTAL_STEPS=16 + TOTAL_STEPS=17 CURRENT_STEP=0 progress "Updating APT package lists" @@ -520,6 +552,12 @@ install_debian_packages() { fi cmd_exists dump1090 || install_dump1090_from_source_debian + progress "Installing acarsdec" + if ! cmd_exists acarsdec; then + apt_install acarsdec || true + fi + cmd_exists acarsdec || install_acarsdec_from_source_debian + progress "Configuring udev rules" setup_udev_rules_debian diff --git a/static/css/adsb_dashboard.css b/static/css/adsb_dashboard.css index 1af7360..72074de 100644 --- a/static/css/adsb_dashboard.css +++ b/static/css/adsb_dashboard.css @@ -185,13 +185,138 @@ body { position: relative; z-index: 10; display: grid; - grid-template-columns: 1fr 340px; + grid-template-columns: 1fr auto 300px; grid-template-rows: 1fr auto; gap: 0; height: calc(100vh - 60px); min-height: 500px; } +/* ACARS sidebar (between map and main sidebar) - Collapsible */ +.acars-sidebar { + background: var(--bg-panel); + border-left: 1px solid var(--border-color); + display: flex; + flex-direction: row; + overflow: hidden; + transition: width 0.3s ease; + width: 280px; +} + +.acars-sidebar.collapsed { + width: 32px; +} + +.acars-collapse-btn { + width: 32px; + min-width: 32px; + background: var(--bg-card); + border: none; + border-right: 1px solid var(--border-color); + color: var(--accent-cyan); + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 0; + transition: background 0.2s; +} + +.acars-collapse-btn:hover { + background: rgba(74, 158, 255, 0.1); +} + +.acars-collapse-label { + writing-mode: vertical-rl; + text-orientation: mixed; + font-size: 10px; + font-weight: 600; + letter-spacing: 2px; + text-transform: uppercase; +} + +.acars-sidebar.collapsed .acars-collapse-label { + display: block; +} + +.acars-sidebar:not(.collapsed) .acars-collapse-label { + display: none; +} + +#acarsCollapseIcon { + font-size: 10px; + transition: transform 0.3s; +} + +.acars-sidebar.collapsed #acarsCollapseIcon { + transform: rotate(180deg); +} + +.acars-sidebar-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; +} + +.acars-sidebar.collapsed .acars-sidebar-content { + display: none; +} + +.acars-sidebar .panel { + flex: 1; + display: flex; + flex-direction: column; + border: none; + border-radius: 0; +} + +.acars-sidebar .panel::before { + display: none; +} + +.acars-sidebar .acars-messages { + flex: 1; + overflow-y: auto; + min-height: 0; +} + +.acars-sidebar .acars-btn { + background: var(--bg-card); + border: 1px solid var(--accent-cyan); + color: var(--accent-cyan); + padding: 6px 10px; + font-size: 10px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + text-transform: uppercase; + letter-spacing: 1px; +} + +.acars-sidebar .acars-btn:hover { + background: rgba(74, 158, 255, 0.2); +} + +.acars-message-item { + padding: 8px 10px; + border-bottom: 1px solid var(--border-color); + font-size: 10px; + animation: fadeIn 0.3s ease; +} + +.acars-message-item:hover { + background: rgba(74, 158, 255, 0.05); +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-5px); } + to { opacity: 1; transform: translateY(0); } +} + /* Panels */ .panel { background: var(--bg-panel); @@ -228,8 +353,14 @@ body { .panel-indicator { width: 6px; height: 6px; - background: var(--accent-cyan); + background: var(--text-dim); border-radius: 50%; + opacity: 0.5; +} + +.panel-indicator.active { + background: var(--accent-green); + opacity: 1; animation: blink 1s ease-in-out infinite; } @@ -656,8 +787,20 @@ body { opacity: 0.5; } -/* Responsive */ -@media (max-width: 1000px) { +/* Responsive - medium screens (hide ACARS sidebar, keep main sidebar) */ +@media (max-width: 1200px) { + .dashboard { + grid-template-columns: 1fr 300px; + grid-template-rows: 1fr auto; + } + + .acars-sidebar { + display: none; + } +} + +/* Responsive - small screens (single column) */ +@media (max-width: 900px) { .dashboard { grid-template-columns: 1fr; grid-template-rows: 1fr auto auto; @@ -667,6 +810,10 @@ body { min-height: 400px; } + .acars-sidebar { + display: none; + } + .sidebar { grid-column: 1; grid-row: 2; diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html index c7de9fd..07fcb70 100644 --- a/templates/adsb_dashboard.html +++ b/templates/adsb_dashboard.html @@ -53,6 +53,52 @@ + +
+ +
+
+
+ ACARS MESSAGES +
+ 0 +
+
+
+
+
+ Requires separate SDR (VHF ~131 MHz) +
+
+
+ + +
+ +
+
+
+
No ACARS messages
+
Start ACARS to receive aircraft datalink messages
+
+
+
+
+
+
+ @@ -1189,18 +1233,55 @@