Add ACARS aircraft messaging feature with collapsible sidebar

- Add routes/acars.py with start/stop/stream endpoints for ACARS decoding
- Build acarsdec from source in Dockerfile (not available in Debian slim)
- Add acarsdec installation script to setup.sh for native installs
- Add ACARS to dependency checker in utils/dependencies.py
- Add collapsible ACARS sidebar next to map in aircraft tracking tab
- Add collapsible ACARS panel in ADS-B dashboard with same layout
- Include guidance about needing two SDRs for simultaneous ADS-B + ACARS
- Support regional frequency presets (N.America, Europe, Asia-Pacific)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-01-13 22:42:45 +00:00
parent 98e4e38809
commit 135390788d
9 changed files with 1176 additions and 22 deletions

View File

@@ -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/*

16
app.py
View File

@@ -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()

View File

@@ -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)

290
routes/acars.py Normal file
View File

@@ -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 <device> -g <gain> -p <ppm> <freq1> <freq2> ...
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'],
}
})

View File

@@ -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

View File

@@ -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;

View File

@@ -53,6 +53,52 @@
</div>
</div>
<!-- ACARS Panel (right of map) - Collapsible -->
<div class="acars-sidebar" id="acarsSidebar">
<button class="acars-collapse-btn" id="acarsCollapseBtn" onclick="toggleAcarsSidebar()" title="Toggle ACARS Panel">
<span id="acarsCollapseIcon"></span>
<span class="acars-collapse-label">ACARS</span>
</button>
<div class="acars-sidebar-content" id="acarsSidebarContent">
<div class="panel acars-panel">
<div class="panel-header">
<span>ACARS MESSAGES</span>
<div style="display: flex; align-items: center; gap: 8px;">
<span id="acarsCount" style="font-size: 10px; color: var(--accent-cyan);">0</span>
<div class="panel-indicator" id="acarsPanelIndicator"></div>
</div>
</div>
<div id="acarsPanelContent">
<div class="acars-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 ~131 MHz)
</div>
<div class="acars-controls" style="padding: 8px; border-bottom: 1px solid var(--border-color);">
<div style="display: flex; gap: 5px; margin-bottom: 5px;">
<select id="acarsDeviceSelect" style="flex: 1; font-size: 10px;">
<option value="0">SDR 0</option>
<option value="1">SDR 1</option>
</select>
<select id="acarsRegionSelect" onchange="setAcarsFreqs()" 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>
<button class="acars-btn" id="acarsToggleBtn" onclick="toggleAcars()" style="width: 100%;">
▶ START ACARS
</button>
</div>
<div class="acars-messages" id="acarsMessages">
<div class="no-aircraft" style="padding: 20px; text-align: center;">
<div style="font-size: 10px; color: var(--text-muted);">No ACARS messages</div>
<div style="font-size: 9px; color: var(--text-dim); margin-top: 5px;">Start ACARS to receive aircraft datalink messages</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<!-- View Toggle -->
@@ -2215,6 +2261,179 @@ sudo make install</code>
// Initialize airband on page load
document.addEventListener('DOMContentLoaded', initAirband);
// ============================================
// ACARS Functions
// ============================================
let acarsEventSource = null;
let isAcarsRunning = false;
let acarsMessageCount = 0;
let acarsSidebarCollapsed = localStorage.getItem('acarsSidebarCollapsed') === 'true';
let acarsFrequencies = {
'na': ['129.125', '130.025', '130.450', '131.550'],
'eu': ['131.525', '131.725', '131.550'],
'ap': ['131.550', '131.450']
};
function toggleAcarsSidebar() {
const sidebar = document.getElementById('acarsSidebar');
acarsSidebarCollapsed = !acarsSidebarCollapsed;
sidebar.classList.toggle('collapsed', acarsSidebarCollapsed);
localStorage.setItem('acarsSidebarCollapsed', acarsSidebarCollapsed);
}
// Initialize ACARS sidebar state
document.addEventListener('DOMContentLoaded', () => {
const sidebar = document.getElementById('acarsSidebar');
if (sidebar && acarsSidebarCollapsed) {
sidebar.classList.add('collapsed');
}
});
function setAcarsFreqs() {
// Just updates the region selection - frequencies are sent on start
}
function getAcarsRegionFreqs() {
const region = document.getElementById('acarsRegionSelect').value;
return acarsFrequencies[region] || acarsFrequencies['na'];
}
function toggleAcars() {
if (isAcarsRunning) {
stopAcars();
} else {
startAcars();
}
}
function startAcars() {
const device = document.getElementById('acarsDeviceSelect').value;
const frequencies = getAcarsRegionFreqs();
// Warn if using same device as ADS-B
if (isTracking && device === '0') {
const useAnyway = confirm(
'Warning: ADS-B tracking may be using SDR device 0.\n\n' +
'ACARS uses VHF frequencies (129-131 MHz) while ADS-B uses 1090 MHz.\n' +
'You need TWO separate SDR devices to receive both simultaneously.\n\n' +
'Click OK to start ACARS on device ' + device + ' anyway.'
);
if (!useAnyway) return;
}
fetch('/acars/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device, frequencies, gain: '40' })
})
.then(r => r.json())
.then(data => {
if (data.status === 'started') {
isAcarsRunning = true;
acarsMessageCount = 0;
document.getElementById('acarsToggleBtn').textContent = '■ STOP ACARS';
document.getElementById('acarsToggleBtn').style.background = 'var(--accent-red)';
document.getElementById('acarsPanelIndicator').classList.add('active');
startAcarsStream();
} else {
alert('ACARS Error: ' + data.message);
}
})
.catch(err => alert('ACARS Error: ' + err));
}
function stopAcars() {
fetch('/acars/stop', { method: 'POST' })
.then(r => r.json())
.then(() => {
isAcarsRunning = false;
document.getElementById('acarsToggleBtn').textContent = '▶ START ACARS';
document.getElementById('acarsToggleBtn').style.background = '';
document.getElementById('acarsPanelIndicator').classList.remove('active');
if (acarsEventSource) {
acarsEventSource.close();
acarsEventSource = null;
}
});
}
function startAcarsStream() {
if (acarsEventSource) acarsEventSource.close();
acarsEventSource = new EventSource('/acars/stream');
acarsEventSource.onmessage = function(e) {
const data = JSON.parse(e.data);
if (data.type === 'acars') {
acarsMessageCount++;
document.getElementById('acarsCount').textContent = acarsMessageCount;
addAcarsMessage(data);
}
};
acarsEventSource.onerror = function() {
console.error('ACARS stream error');
};
}
function addAcarsMessage(data) {
const container = document.getElementById('acarsMessages');
// Remove "no messages" placeholder if present
const placeholder = container.querySelector('.no-aircraft');
if (placeholder) placeholder.remove();
const msg = document.createElement('div');
msg.className = 'acars-message-item';
msg.style.cssText = 'padding: 6px 8px; border-bottom: 1px solid var(--border-color); font-size: 10px;';
const flight = data.flight || 'UNKNOWN';
const reg = data.reg || '';
const label = data.label || '';
const text = data.text || data.msg || '';
const time = new Date().toLocaleTimeString();
msg.innerHTML = `
<div style="display: flex; justify-content: space-between; margin-bottom: 2px;">
<span style="color: var(--accent-cyan); font-weight: bold;">${flight}</span>
<span style="color: var(--text-muted);">${time}</span>
</div>
${reg ? `<div style="color: var(--text-muted); font-size: 9px;">Reg: ${reg}</div>` : ''}
${label ? `<div style="color: var(--accent-green);">Label: ${label}</div>` : ''}
${text ? `<div style="color: var(--text-primary); margin-top: 3px; word-break: break-word;">${text}</div>` : ''}
`;
container.insertBefore(msg, container.firstChild);
// Keep max 50 messages
while (container.children.length > 50) {
container.removeChild(container.lastChild);
}
}
// Populate ACARS device selector
document.addEventListener('DOMContentLoaded', () => {
fetch('/devices')
.then(r => r.json())
.then(devices => {
const select = document.getElementById('acarsDeviceSelect');
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.textContent = `Device ${d.index || i}: ${d.name || d.type || 'SDR'}`;
select.appendChild(opt);
});
// Default to device 1 if available (device 0 likely used for ADS-B)
if (devices.length > 1) {
select.value = '1';
}
}
});
});
// ============================================
// SQUAWK CODE REFERENCE
// ============================================

View File

@@ -857,6 +857,50 @@
<button class="stop-btn" id="stopAdsbBtn" onclick="stopAdsbScan()" style="display: none;">
Stop Tracking
</button>
<!-- ACARS Sub-section -->
<div class="section" style="margin-top: 15px; border-top: 1px solid var(--border-color); padding-top: 15px;">
<h3 style="display: flex; align-items: center; justify-content: space-between; cursor: pointer;" onclick="toggleAcarsPanel()">
<span>ACARS Messaging</span>
<span id="acarsToggleIcon" style="font-size: 10px; color: var(--text-secondary);">&#9660;</span>
</h3>
<div id="acarsPanel">
<div class="info-text" style="margin-bottom: 10px;">
Decode aircraft digital messages (ACARS) on VHF frequencies.
</div>
<div style="background: rgba(255,193,7,0.1); border: 1px solid var(--accent-yellow); border-radius: 4px; padding: 8px; margin-bottom: 10px; font-size: 10px;">
<strong style="color: var(--accent-yellow);">⚠ Two SDRs Required</strong><br>
<span style="color: var(--text-secondary);">ADS-B uses 1090 MHz, ACARS uses VHF (~131 MHz). To receive both simultaneously, you need two separate RTL-SDR devices.</span>
</div>
<div class="form-group">
<label>Frequencies (MHz)</label>
<input type="text" id="acarsFrequencies" value="131.550,130.025,129.125,131.525" placeholder="131.550,130.025,...">
<div class="info-text" style="margin-top: 4px;">Comma-separated VHF ACARS frequencies</div>
</div>
<div class="form-group">
<label>Gain (dB, 0 = auto)</label>
<input type="text" id="acarsGain" value="40" placeholder="40">
</div>
<div class="form-group">
<label>PPM Correction</label>
<input type="text" id="acarsPpm" value="0" placeholder="0">
</div>
<div class="preset-buttons" style="margin-bottom: 10px;">
<button class="preset-btn" onclick="setAcarsRegion('na')">N. America</button>
<button class="preset-btn" onclick="setAcarsRegion('eu')">Europe</button>
<button class="preset-btn" onclick="setAcarsRegion('ap')">Asia-Pacific</button>
</div>
<div class="info-text" style="margin-top: 8px; display: grid; grid-template-columns: auto auto; gap: 4px 8px; align-items: center;" id="acarsToolStatus">
<span>acarsdec:</span><span class="tool-status" id="acarsdecStatus">Checking...</span>
</div>
<button class="run-btn" id="startAcarsBtn" onclick="startAcars()" style="margin-top: 10px;">
Start ACARS
</button>
<button class="stop-btn" id="stopAcarsBtn" onclick="stopAcars()" style="display: none; margin-top: 10px;">
Stop ACARS
</button>
</div>
</div>
</div>
<!-- SATELLITE MODE -->
@@ -1189,18 +1233,55 @@
<!-- Aircraft Visualizations - Leaflet Map -->
<div class="wifi-visuals" id="aircraftVisuals" style="display: none;">
<!-- Map Panel -->
<div class="wifi-visual-panel" style="grid-column: span 2;">
<h5 style="color: var(--accent-cyan); text-shadow: 0 0 10px var(--accent-cyan);">ADS-B AIRCRAFT TRACKING</h5>
<div class="aircraft-map-container">
<div class="map-header">
<span id="radarTime">--:--:--</span>
<span id="radarStatus">TRACKING</span>
<!-- Map Panel with ACARS sidebar -->
<div class="wifi-visual-panel" style="grid-column: span 2; display: flex; gap: 0;">
<!-- Map -->
<div style="flex: 1; display: flex; flex-direction: column;">
<h5 style="color: var(--accent-cyan); text-shadow: 0 0 10px var(--accent-cyan); padding: 0 10px;">ADS-B AIRCRAFT TRACKING</h5>
<div class="aircraft-map-container" style="flex: 1;">
<div class="map-header">
<span id="radarTime">--:--:--</span>
<span id="radarStatus">TRACKING</span>
</div>
<div id="aircraftMap"></div>
<div class="map-footer">
<span>AIRCRAFT: <span id="aircraftCount">0</span></span>
<span>CENTER: <span id="mapCenter">--</span></span>
</div>
</div>
<div id="aircraftMap"></div>
<div class="map-footer">
<span>AIRCRAFT: <span id="aircraftCount">0</span></span>
<span>CENTER: <span id="mapCenter">--</span></span>
</div>
<!-- ACARS Sidebar (Collapsible) -->
<div class="main-acars-sidebar" id="mainAcarsSidebar">
<button class="main-acars-collapse-btn" onclick="toggleMainAcarsSidebar()" title="Toggle ACARS">
<span id="mainAcarsCollapseIcon"></span>
<span class="main-acars-collapse-label">ACARS</span>
</button>
<div class="main-acars-content" id="mainAcarsContent">
<div style="padding: 8px; border-bottom: 1px solid var(--border-color);">
<h5 style="color: var(--accent-green); margin-bottom: 8px; font-size: 11px;">ACARS MESSAGES</h5>
<div style="font-size: 9px; color: var(--text-muted); margin-bottom: 8px;">
<span style="color: var(--accent-yellow);"></span> Requires separate SDR
</div>
<div style="display: flex; gap: 4px; margin-bottom: 5px;">
<select id="mainAcarsDevice" style="flex: 1; font-size: 9px; padding: 3px;">
<option value="0">SDR 0</option>
<option value="1">SDR 1</option>
</select>
<select id="mainAcarsRegion" style="flex: 1; font-size: 9px; padding: 3px;">
<option value="na">N.America</option>
<option value="eu">Europe</option>
<option value="ap">Asia-Pac</option>
</select>
</div>
<button id="mainAcarsToggleBtn" onclick="toggleMainAcars()" style="width: 100%; padding: 5px; font-size: 9px; background: var(--bg-dark); border: 1px solid var(--accent-cyan); color: var(--accent-cyan); cursor: pointer;">
▶ START
</button>
</div>
<div class="main-acars-messages" id="mainAcarsMessages" style="flex: 1; overflow-y: auto; font-size: 10px;">
<div style="padding: 15px; text-align: center; color: var(--text-muted);">
No ACARS messages
</div>
</div>
</div>
</div>
</div>
@@ -1516,6 +1597,78 @@
0%, 100% { opacity: 0.3; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1); }
}
/* Main ACARS Sidebar (Collapsible) */
.main-acars-sidebar {
width: 220px;
background: var(--bg-panel);
border-left: 1px solid var(--border-color);
display: flex;
flex-direction: row;
transition: width 0.3s ease;
}
.main-acars-sidebar.collapsed {
width: 28px;
}
.main-acars-collapse-btn {
width: 28px;
min-width: 28px;
background: rgba(0,0,0,0.3);
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: 6px;
padding: 8px 0;
}
.main-acars-collapse-btn:hover {
background: rgba(74, 158, 255, 0.1);
}
.main-acars-collapse-label {
writing-mode: vertical-rl;
text-orientation: mixed;
font-size: 9px;
font-weight: 600;
letter-spacing: 1px;
}
.main-acars-sidebar.collapsed .main-acars-collapse-label { display: block; }
.main-acars-sidebar:not(.collapsed) .main-acars-collapse-label { display: none; }
#mainAcarsCollapseIcon {
font-size: 9px;
transition: transform 0.3s;
}
.main-acars-sidebar.collapsed #mainAcarsCollapseIcon {
transform: rotate(180deg);
}
.main-acars-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.main-acars-sidebar.collapsed .main-acars-content {
display: none;
}
.main-acars-messages {
max-height: 350px;
}
.main-acars-msg {
padding: 6px 8px;
border-bottom: 1px solid var(--border-color);
animation: fadeInMsg 0.3s ease;
}
.main-acars-msg:hover {
background: rgba(74, 158, 255, 0.05);
}
@keyframes fadeInMsg {
from { opacity: 0; transform: translateY(-3px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
<!-- Satellite Dashboard (Embedded) -->
@@ -2264,6 +2417,7 @@
initBtRadar();
} else if (mode === 'aircraft') {
checkAdsbTools();
checkAcarsTools();
initAircraftRadar();
} else if (mode === 'satellite') {
initPolarPlot();
@@ -7207,6 +7361,274 @@
});
}
// ============================================
// ACARS Functions
// ============================================
let acarsEventSource = null;
let isAcarsRunning = false;
let acarsMessageCount = 0;
function toggleAcarsPanel() {
const panel = document.getElementById('acarsPanel');
const icon = document.getElementById('acarsToggleIcon');
if (panel.style.display === 'none') {
panel.style.display = 'block';
icon.innerHTML = '&#9660;';
} else {
panel.style.display = 'none';
icon.innerHTML = '&#9654;';
}
}
function setAcarsRegion(region) {
const freqInput = document.getElementById('acarsFrequencies');
const regions = {
'na': '129.125,130.025,130.450,131.550',
'eu': '131.525,131.725,131.550',
'ap': '131.550,131.450'
};
freqInput.value = regions[region] || regions['na'];
}
function checkAcarsTools() {
fetch('/acars/tools')
.then(r => r.json())
.then(data => {
const status = document.getElementById('acarsdecStatus');
if (data.acarsdec) {
status.textContent = 'OK';
status.className = 'tool-status ok';
} else {
status.textContent = 'Missing';
status.className = 'tool-status missing';
}
})
.catch(() => {
const status = document.getElementById('acarsdecStatus');
status.textContent = 'Error';
status.className = 'tool-status missing';
});
}
function startAcars() {
const frequencies = document.getElementById('acarsFrequencies').value.split(',').map(f => f.trim());
const gain = document.getElementById('acarsGain').value;
const ppm = document.getElementById('acarsPpm').value;
const device = getSelectedDevice();
fetch('/acars/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ frequencies, gain, ppm, device })
})
.then(r => r.json())
.then(data => {
if (data.status === 'started') {
isAcarsRunning = true;
acarsMessageCount = 0;
document.getElementById('startAcarsBtn').style.display = 'none';
document.getElementById('stopAcarsBtn').style.display = 'block';
startAcarsStream();
} else {
alert('ACARS Error: ' + data.message);
}
})
.catch(err => alert('ACARS Error: ' + err));
}
function stopAcars() {
fetch('/acars/stop', { method: 'POST' })
.then(r => r.json())
.then(data => {
isAcarsRunning = false;
document.getElementById('startAcarsBtn').style.display = 'block';
document.getElementById('stopAcarsBtn').style.display = 'none';
if (acarsEventSource) {
acarsEventSource.close();
acarsEventSource = null;
}
});
}
function startAcarsStream() {
if (acarsEventSource) acarsEventSource.close();
acarsEventSource = new EventSource('/acars/stream');
acarsEventSource.onmessage = function(e) {
const data = JSON.parse(e.data);
if (data.type === 'acars') {
acarsMessageCount++;
addAcarsToOutput(data);
} else if (data.type === 'error') {
console.error('ACARS error:', data.message);
}
};
acarsEventSource.onerror = function() {
console.error('ACARS stream error');
};
}
function addAcarsToOutput(data) {
const output = document.getElementById('outputList');
const item = document.createElement('div');
item.className = 'output-item acars-message';
const flight = data.flight || 'Unknown';
const reg = data.reg || '';
const label = data.label || '';
const text = data.text || data.msg || '';
item.innerHTML = `
<span class="timestamp">${new Date().toLocaleTimeString()}</span>
<span class="acars-flight" style="color: var(--accent-cyan); font-weight: bold;">${flight}</span>
${reg ? `<span class="acars-reg" style="color: var(--text-secondary);">[${reg}]</span>` : ''}
<span class="acars-label" style="color: var(--accent-green);">${label}</span>
<span class="acars-text">${text}</span>
`;
output.insertBefore(item, output.firstChild);
// Keep output manageable
while (output.children.length > 200) {
output.removeChild(output.lastChild);
}
}
// ============================================
// Main ACARS Sidebar (Collapsible - Aircraft Tab)
// ============================================
let mainAcarsSidebarCollapsed = localStorage.getItem('mainAcarsSidebarCollapsed') === 'true';
let mainAcarsEventSource = null;
let isMainAcarsRunning = false;
let mainAcarsMessageCount = 0;
// Initialize sidebar state on load
document.addEventListener('DOMContentLoaded', function() {
const sidebar = document.getElementById('mainAcarsSidebar');
if (sidebar && mainAcarsSidebarCollapsed) {
sidebar.classList.add('collapsed');
}
});
function toggleMainAcarsSidebar() {
const sidebar = document.getElementById('mainAcarsSidebar');
mainAcarsSidebarCollapsed = !mainAcarsSidebarCollapsed;
sidebar.classList.toggle('collapsed', mainAcarsSidebarCollapsed);
localStorage.setItem('mainAcarsSidebarCollapsed', mainAcarsSidebarCollapsed);
}
function toggleMainAcars() {
if (isMainAcarsRunning) {
stopMainAcars();
} else {
startMainAcars();
}
}
function startMainAcars() {
const device = document.getElementById('mainAcarsDevice').value;
const region = document.getElementById('mainAcarsRegion').value;
const regions = {
'na': ['129.125', '130.025', '130.450', '131.550'],
'eu': ['131.525', '131.725', '131.550'],
'ap': ['131.550', '131.450']
};
const frequencies = regions[region] || regions['na'];
fetch('/acars/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ frequencies, device: parseInt(device), gain: 40 })
})
.then(r => r.json())
.then(data => {
if (data.status === 'started') {
isMainAcarsRunning = true;
mainAcarsMessageCount = 0;
document.getElementById('mainAcarsToggleBtn').innerHTML = '■ STOP';
document.getElementById('mainAcarsToggleBtn').style.background = 'var(--accent-red)';
document.getElementById('mainAcarsToggleBtn').style.borderColor = 'var(--accent-red)';
document.getElementById('mainAcarsToggleBtn').style.color = '#fff';
startMainAcarsStream();
} else {
alert('ACARS Error: ' + data.message);
}
})
.catch(err => alert('ACARS Error: ' + err));
}
function stopMainAcars() {
fetch('/acars/stop', { method: 'POST' })
.then(r => r.json())
.then(data => {
isMainAcarsRunning = false;
document.getElementById('mainAcarsToggleBtn').innerHTML = '▶ START';
document.getElementById('mainAcarsToggleBtn').style.background = 'var(--bg-dark)';
document.getElementById('mainAcarsToggleBtn').style.borderColor = 'var(--accent-cyan)';
document.getElementById('mainAcarsToggleBtn').style.color = 'var(--accent-cyan)';
if (mainAcarsEventSource) {
mainAcarsEventSource.close();
mainAcarsEventSource = null;
}
});
}
function startMainAcarsStream() {
if (mainAcarsEventSource) mainAcarsEventSource.close();
mainAcarsEventSource = new EventSource('/acars/stream');
mainAcarsEventSource.onmessage = function(e) {
const data = JSON.parse(e.data);
if (data.type === 'acars') {
mainAcarsMessageCount++;
addMainAcarsMessage(data);
// Also add to main output if ADS-B mode is active
addAcarsToOutput(data);
}
};
mainAcarsEventSource.onerror = function() {
console.error('Main ACARS stream error');
};
}
function addMainAcarsMessage(data) {
const container = document.getElementById('mainAcarsMessages');
// Remove placeholder if present
const placeholder = container.querySelector('div[style*="text-align: center"]');
if (placeholder && placeholder.textContent.includes('No ACARS')) {
placeholder.remove();
}
const flight = data.flight || 'Unknown';
const reg = data.reg || '';
const text = data.text || data.msg || '';
const label = data.label || '';
const time = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
const msgEl = document.createElement('div');
msgEl.className = 'main-acars-msg';
msgEl.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2px;">
<span style="color: var(--accent-cyan); font-weight: bold;">${flight}</span>
<span style="color: var(--text-muted); font-size: 8px;">${time}</span>
</div>
${reg ? `<div style="color: var(--text-secondary); font-size: 9px;">${reg}</div>` : ''}
${label ? `<div style="color: var(--accent-green); font-size: 9px;">[${label}]</div>` : ''}
${text ? `<div style="color: var(--text-primary); font-size: 9px; margin-top: 2px; word-break: break-word;">${text.substring(0, 100)}${text.length > 100 ? '...' : ''}</div>` : ''}
`;
container.insertBefore(msgEl, container.firstChild);
// Keep list manageable
while (container.children.length > 50) {
container.removeChild(container.lastChild);
}
}
// Batching state for aircraft updates to prevent browser freeze
let pendingAircraftUpdate = false;
let pendingAircraftData = [];

View File

@@ -195,6 +195,20 @@ TOOL_DEPENDENCIES = {
}
}
},
'acars': {
'name': 'Aircraft Messaging (ACARS)',
'tools': {
'acarsdec': {
'required': True,
'description': 'ACARS VHF decoder',
'install': {
'apt': 'sudo apt install acarsdec',
'brew': 'brew install acarsdec',
'manual': 'https://github.com/TLeconte/acarsdec'
}
}
}
},
'satellite': {
'name': 'Satellite Tracking',
'tools': {