Merge pull request #145 from mitchross/main

All review issues addressed. Merging with fixup commit for XSS escaping, import cleanup, VDL2 click behavior, frequency defaults, and misc fixes.
This commit is contained in:
Smittix
2026-03-01 20:42:50 +00:00
committed by GitHub
14 changed files with 1592 additions and 86 deletions

View File

@@ -1,2 +1,32 @@
# Uncomment and set to use external storage for ADS-B history
# PGDATA_PATH=/mnt/external/intercept/pgdata
# =============================================================================
# INTERCEPT CONTROLLER (.env)
# =============================================================================
# Copy to .env and edit for your setup
# Container timezone (e.g. America/New_York, Europe/London, Australia/Sydney)
TZ=UTC
# Postgres password (default: intercept)
INTERCEPT_ADSB_DB_PASSWORD=intercept
# Auto-start ADS-B when dashboard loads
INTERCEPT_ADSB_AUTO_START=false
# Share observer location across all modules
INTERCEPT_SHARED_OBSERVER_LOCATION=true
# Observer coordinates (uncomment and set to skip GPS prompt)
# INTERCEPT_DEFAULT_LAT=40.7128
# INTERCEPT_DEFAULT_LON=-74.0060
# =============================================================================
# AGENT SETTINGS (for docker-compose.agent.yml on remote Pis)
# =============================================================================
# Agent identity
AGENT_NAME=sdr-agent-1
AGENT_PORT=8020
# Controller connection (IP of the machine running docker-compose.yml)
CONTROLLER_URL=http://192.168.1.100:5050
AGENT_API_KEY=changeme

View File

@@ -33,6 +33,7 @@ services:
# Optional: mount logs directory
# - ./logs:/app/logs
environment:
- TZ=${TZ:-UTC}
- INTERCEPT_HOST=0.0.0.0
- INTERCEPT_PORT=5050
- INTERCEPT_LOG_LEVEL=INFO
@@ -87,6 +88,7 @@ services:
volumes:
- ./data:/app/data
environment:
- TZ=${TZ:-UTC}
- INTERCEPT_HOST=0.0.0.0
- INTERCEPT_PORT=5050
- INTERCEPT_LOG_LEVEL=INFO
@@ -120,6 +122,7 @@ services:
profiles:
- history
environment:
- TZ=${TZ:-UTC}
- POSTGRES_DB=intercept_adsb
- POSTGRES_USER=intercept
- POSTGRES_PASSWORD=intercept

View File

@@ -94,6 +94,126 @@ sudo modprobe -r dvb_usb_rtl28xxu
---
## Multiple RTL-SDR Dongles
If you're running two (or more) RTL-SDR dongles on the same machine, they ship with the same default serial number so Linux can't tell them apart reliably. Follow these steps to give each a unique identity.
### Step 1: Blacklist the DVB-T driver
Already covered above, but make sure this is done first — the kernel's DVB driver will grab the dongles before librtlsdr can:
```bash
echo "blacklist dvb_usb_rtl28xxu" | sudo tee /etc/modprobe.d/blacklist-rtl.conf
sudo modprobe -r dvb_usb_rtl28xxu
```
### Step 2: Burn unique serial numbers
Each dongle has an EEPROM that stores a serial number. By default they're all `00000001`. You need to give each one a unique serial.
**Plug in only the first dongle**, then:
```bash
rtl_eeprom -d 0 -s 00000001
```
**Unplug it, plug in the second dongle**, then:
```bash
rtl_eeprom -d 0 -s 00000002
```
> Pick any 8-digit hex serials you like. The `-d 0` means "device index 0" (the only one plugged in).
Unplug and replug both dongles after writing.
### Step 3: Verify
With both plugged in:
```bash
rtl_test -t
```
You should see:
```
0: Realtek, RTL2838UHIDIR, SN: 00000001
1: Realtek, RTL2838UHIDIR, SN: 00000002
```
**Tip:** If you don't know which physical dongle has which serial, unplug one and run `rtl_test -t` — the one still detected is the one still plugged in.
### Step 4: Udev rules with stable symlinks
Create rules that give each dongle a persistent name based on its serial:
```bash
sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF
# RTL-SDR dongles - permissions and stable symlinks by serial
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTR{idProduct}=="2838", MODE="0666"
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTR{idProduct}=="2832", MODE="0666"
# Symlinks by serial — change names/serials to match your hardware
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTRS{serial}=="00000001", SYMLINK+="sdr-dongle1"
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTRS{serial}=="00000002", SYMLINK+="sdr-dongle2"
EOF'
sudo udevadm control --reload-rules
sudo udevadm trigger
```
After replugging, you'll have `/dev/sdr-dongle1` and `/dev/sdr-dongle2`.
### Step 5: USB power (Raspberry Pi)
Two dongles can draw more current than the Pi allows by default:
```bash
# In /boot/firmware/config.txt, add:
usb_max_current_enable=1
```
Disable USB autosuspend so dongles don't get powered off:
```bash
# In /etc/default/grub or kernel cmdline, add:
usbcore.autosuspend=-1
```
Or via udev:
```bash
echo 'ACTION=="add", SUBSYSTEM=="usb", ATTR{power/autosuspend}="-1"' | \
sudo tee /etc/udev/rules.d/50-usb-autosuspend.rules
```
### Step 6: Docker access
Your `docker-compose.yml` needs privileged mode and USB passthrough:
```yaml
services:
intercept:
privileged: true
volumes:
- /dev/bus/usb:/dev/bus/usb
```
INTERCEPT auto-detects both dongles inside the container via `rtl_test -t` and addresses them by device index (`-d 0`, `-d 1`).
### Quick reference
| Step | What | Why |
|------|------|-----|
| Blacklist DVB | `/etc/modprobe.d/blacklist-rtl.conf` | Kernel won't steal the dongles |
| Burn serials | `rtl_eeprom -d 0 -s <serial>` | Unique identity per dongle |
| Udev rules | `/etc/udev/rules.d/20-rtlsdr.rules` | Permissions + stable `/dev/sdr-*` names |
| USB power | `config.txt` + autosuspend off | Enough current for two dongles on a Pi |
| Docker | `privileged: true` + USB volume | Container sees both dongles |
---
## Verify Installation
### Check dependencies

View File

@@ -13,30 +13,35 @@ import subprocess
import threading
import time
from datetime import datetime
from typing import Generator
from typing import Any, Generator
from flask import Blueprint, jsonify, request, Response
from flask import Blueprint, Response, jsonify, request
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 sse_stream_fanout
from utils.event_pipeline import process_event
from utils.acars_translator import translate_message
from utils.constants import (
PROCESS_START_WAIT,
PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
PROCESS_START_WAIT,
)
from utils.event_pipeline import process_event
from utils.flight_correlator import get_flight_correlator
from utils.logging import sensor_logger as logger
from utils.process import register_process, unregister_process
from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout
from utils.validation import validate_device_index, validate_gain, validate_ppm
acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
# Default VHF ACARS frequencies (MHz) - common worldwide
# Default VHF ACARS frequencies (MHz) - North America primary
DEFAULT_ACARS_FREQUENCIES = [
'131.725', # North America
'131.825', # North America
'131.550', # Primary worldwide / North America
'130.025', # North America secondary
'129.125', # North America tertiary
'131.725', # North America (major US carriers)
'131.825', # North America (major US carriers)
]
# Message counter for statistics
@@ -121,6 +126,15 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
data['type'] = 'acars'
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
# Enrich with translated label and parsed fields
try:
translation = translate_message(data)
data['label_description'] = translation['label_description']
data['message_type'] = translation['message_type']
data['parsed'] = translation['parsed']
except Exception:
pass
# Update stats
acars_message_count += 1
acars_last_message_time = time.time()
@@ -129,7 +143,6 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
# Feed flight correlator
try:
from utils.flight_correlator import get_flight_correlator
get_flight_correlator().add_acars_message(data)
except Exception:
pass
@@ -439,13 +452,32 @@ def stream_acars() -> Response:
return response
@acars_bp.route('/messages')
def get_acars_messages() -> Response:
"""Get recent ACARS messages from correlator (for history reload)."""
limit = request.args.get('limit', 50, type=int)
limit = max(1, min(limit, 200))
msgs = get_flight_correlator().get_recent_messages('acars', limit)
return jsonify(msgs)
@acars_bp.route('/clear', methods=['POST'])
def clear_acars_messages() -> Response:
"""Clear stored ACARS messages and reset counter."""
global acars_message_count, acars_last_message_time
get_flight_correlator().clear_acars()
acars_message_count = 0
acars_last_message_time = None
return jsonify({'status': 'cleared'})
@acars_bp.route('/frequencies')
def get_frequencies() -> Response:
"""Get default ACARS frequencies."""
return jsonify({
'default': DEFAULT_ACARS_FREQUENCIES,
'regions': {
'north_america': ['131.725', '131.825'],
'north_america': ['131.550', '130.025', '129.125', '131.725', '131.825'],
'europe': ['131.525', '131.725', '131.550'],
'asia_pacific': ['131.550', '131.450'],
}

View File

@@ -13,8 +13,7 @@ import time
from datetime import datetime, timezone
from typing import Any, Generator
from flask import Blueprint, jsonify, request, Response, render_template
from flask import make_response
from flask import Blueprint, Response, jsonify, make_response, render_template, request
# psycopg2 is optional - only needed for PostgreSQL history persistence
try:
@@ -28,39 +27,38 @@ except ImportError:
import app as app_module
from config import (
ADSB_AUTO_START,
ADSB_DB_HOST,
ADSB_DB_NAME,
ADSB_DB_PASSWORD,
ADSB_DB_PORT,
ADSB_DB_USER,
ADSB_AUTO_START,
ADSB_HISTORY_ENABLED,
SHARED_OBSERVER_LOCATION_ENABLED,
)
from utils.logging import adsb_logger as logger
from utils.process import write_dump1090_pid, clear_dump1090_pid, cleanup_stale_dump1090
from utils.validation import (
validate_device_index, validate_gain,
validate_rtl_tcp_host, validate_rtl_tcp_port
)
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.sdr import SDRFactory, SDRType
from utils import aircraft_db
from utils.acars_translator import translate_message
from utils.adsb_history import _ensure_adsb_schema, adsb_history_writer, adsb_snapshot_writer
from utils.constants import (
ADSB_SBS_PORT,
ADSB_TERMINATE_TIMEOUT,
PROCESS_TERMINATE_TIMEOUT,
SBS_SOCKET_TIMEOUT,
SBS_RECONNECT_DELAY,
SOCKET_BUFFER_SIZE,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
SOCKET_CONNECT_TIMEOUT,
ADSB_UPDATE_INTERVAL,
DUMP1090_START_WAIT,
PROCESS_TERMINATE_TIMEOUT,
SBS_RECONNECT_DELAY,
SBS_SOCKET_TIMEOUT,
SOCKET_BUFFER_SIZE,
SOCKET_CONNECT_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
)
from utils import aircraft_db
from utils.adsb_history import adsb_history_writer, adsb_snapshot_writer, _ensure_adsb_schema
from utils.event_pipeline import process_event
from utils.flight_correlator import get_flight_correlator
from utils.logging import adsb_logger as logger
from utils.process import cleanup_stale_dump1090, clear_dump1090_pid, write_dump1090_pid
from utils.sdr import SDRFactory, SDRType
from utils.sse import format_sse
from utils.validation import validate_device_index, validate_gain, validate_rtl_tcp_host, validate_rtl_tcp_port
adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb')
@@ -1245,7 +1243,21 @@ def get_aircraft_messages(icao: str):
aircraft = app_module.adsb_aircraft.get(icao.upper())
callsign = aircraft.get('callsign') if aircraft else None
registration = aircraft.get('registration') if aircraft else None
messages = get_flight_correlator().get_messages_for_aircraft(
icao=icao.upper(), callsign=callsign, registration=registration
)
# Backfill translation on messages missing label_description
try:
for msg in messages.get('acars', []):
if not msg.get('label_description'):
translation = translate_message(msg)
msg['label_description'] = translation['label_description']
msg['message_type'] = translation['message_type']
msg['parsed'] = translation['parsed']
except Exception:
pass
from utils.flight_correlator import get_flight_correlator
messages = get_flight_correlator().get_messages_for_aircraft(icao=icao.upper(), callsign=callsign)
return jsonify({'status': 'success', 'icao': icao.upper(), **messages})

View File

@@ -13,23 +13,25 @@ import subprocess
import threading
import time
from datetime import datetime
from typing import Generator
from typing import Any, Generator
from flask import Blueprint, jsonify, request, Response
from flask import Blueprint, Response, jsonify, request
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 sse_stream_fanout
from utils.event_pipeline import process_event
from utils.acars_translator import translate_message
from utils.constants import (
PROCESS_START_WAIT,
PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
PROCESS_START_WAIT,
)
from utils.event_pipeline import process_event
from utils.flight_correlator import get_flight_correlator
from utils.logging import sensor_logger as logger
from utils.process import register_process, unregister_process
from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout
from utils.validation import validate_device_index, validate_gain, validate_ppm
vdl2_bp = Blueprint('vdl2', __name__, url_prefix='/vdl2')
@@ -80,6 +82,21 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
data['type'] = 'vdl2'
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
# Enrich with translated ACARS label at top level (consistent with ACARS route)
try:
vdl2_inner = data.get('vdl2', data)
acars_payload = (vdl2_inner.get('avlc') or {}).get('acars')
if acars_payload and acars_payload.get('label'):
translation = translate_message({
'label': acars_payload.get('label'),
'text': acars_payload.get('msg_text', ''),
})
data['label_description'] = translation['label_description']
data['message_type'] = translation['message_type']
data['parsed'] = translation['parsed']
except Exception:
pass
# Update stats
vdl2_message_count += 1
vdl2_last_message_time = time.time()
@@ -88,7 +105,6 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
# Feed flight correlator
try:
from utils.flight_correlator import get_flight_correlator
get_flight_correlator().add_vdl2_message(data)
except Exception:
pass
@@ -376,6 +392,26 @@ def stream_vdl2() -> Response:
return response
@vdl2_bp.route('/messages')
def get_vdl2_messages() -> Response:
"""Get recent VDL2 messages from correlator (for history reload)."""
limit = request.args.get('limit', 50, type=int)
limit = max(1, min(limit, 200))
msgs = get_flight_correlator().get_recent_messages('vdl2', limit)
return jsonify(msgs)
@vdl2_bp.route('/clear', methods=['POST'])
def clear_vdl2_messages() -> Response:
"""Clear stored VDL2 messages and reset counter."""
global vdl2_message_count, vdl2_last_message_time
get_flight_correlator().clear_vdl2()
vdl2_message_count = 0
vdl2_last_message_time = None
return jsonify({'status': 'cleared'})
@vdl2_bp.route('/frequencies')
def get_frequencies() -> Response:
"""Get default VDL2 frequencies."""

View File

@@ -89,3 +89,27 @@
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); }
}
/* ACARS Standalone Message Feed */
.acars-message-feed {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
.acars-message-feed::-webkit-scrollbar {
width: 4px;
}
.acars-message-feed::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 2px;
}
.acars-feed-card {
transition: background 0.15s;
}
.acars-feed-card:hover {
background: rgba(74, 158, 255, 0.05);
}
/* Clickable ACARS sidebar messages (linked to tracked aircraft) */
.acars-message-item[style*="cursor: pointer"]:hover {
background: rgba(74, 158, 255, 0.1);
}

View File

@@ -9,6 +9,7 @@ const WeatherSat = (function() {
let isRunning = false;
let eventSource = null;
let images = [];
let allPasses = [];
let passes = [];
let selectedPassIndex = -1;
let currentSatellite = null;
@@ -130,6 +131,8 @@ const WeatherSat = (function() {
if (!locationListenersAttached) {
if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs);
const satSelect = document.getElementById('weatherSatSelect');
if (satSelect) satSelect.addEventListener('change', applyPassFilter);
locationListenersAttached = true;
}
}
@@ -584,6 +587,7 @@ const WeatherSat = (function() {
}
if (!storedLat || !storedLon) {
allPasses = [];
passes = [];
selectedPassIndex = -1;
renderPasses([]);
@@ -599,8 +603,7 @@ const WeatherSat = (function() {
const data = await response.json();
if (data.status === 'ok') {
passes = data.passes || [];
// Apply satellite filter and render
allPasses = data.passes || [];
applyPassFilter();
}
} catch (err) {
@@ -608,6 +611,29 @@ const WeatherSat = (function() {
}
}
/**
* Filter displayed passes by the currently selected satellite dropdown value.
* Updates the module-level `passes` from `allPasses` so selectPass/countdown work.
*/
function applyPassFilter() {
const satSelect = document.getElementById('weatherSatSelect');
const selected = satSelect?.value;
passes = selected
? allPasses.filter(p => p.satellite === selected)
: allPasses.slice();
selectedPassIndex = -1;
renderPasses(passes);
renderTimeline(passes);
updateCountdownFromPasses();
if (passes.length > 0) {
selectPass(0);
} else {
updateGroundTrack(null);
drawPolarPlot(null);
}
}
/**
* Select a pass to display in polar plot and map
*/
@@ -712,6 +738,7 @@ const WeatherSat = (function() {
* Draw polar plot for a pass trajectory
*/
function drawPolarPlot(pass) {
if (!pass) return;
const canvas = document.getElementById('wxsatPolarCanvas');
if (!canvas) return;
@@ -771,7 +798,7 @@ const WeatherSat = (function() {
ctx.stroke();
// Trajectory
const trajectory = pass.trajectory;
const trajectory = pass?.trajectory;
if (!trajectory || trajectory.length === 0) return;
const color = pass.mode === 'LRPT' ? '#00ff88' : '#00d4ff';

View File

@@ -173,9 +173,14 @@
<div id="acarsFreqSelector" style="display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px; font-size: 9px;">
<!-- Frequency checkboxes populated by JS -->
</div>
<button class="acars-btn" id="acarsToggleBtn" onclick="toggleAcars()" style="width: 100%;">
▶ START ACARS
</button>
<div style="display: flex; gap: 5px;">
<button class="acars-btn" id="acarsToggleBtn" onclick="toggleAcars()" style="flex: 1;">
▶ START ACARS
</button>
<button class="acars-btn" id="acarsClearBtn" onclick="clearAcarsMessages()" title="Clear messages" style="padding: 4px 8px; font-size: 9px; opacity: 0.7;">
CLEAR
</button>
</div>
</div>
<div class="acars-messages" id="acarsMessages">
<div class="no-aircraft" style="padding: 20px; text-align: center;">
@@ -223,9 +228,14 @@
<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%;">
&#9654; START VDL2
</button>
<div style="display: flex; gap: 5px;">
<button class="vdl2-btn" id="vdl2ToggleBtn" onclick="toggleVdl2()" style="flex: 1;">
&#9654; START VDL2
</button>
<button class="vdl2-btn" id="vdl2ClearBtn" onclick="clearVdl2Messages()" title="Clear messages" style="padding: 4px 8px; font-size: 9px; opacity: 0.7;">
CLEAR
</button>
</div>
</div>
<div class="vdl2-messages" id="vdl2Messages">
<div class="no-aircraft" style="padding: 20px; text-align: center;">
@@ -2841,6 +2851,9 @@ sudo make install</code>
// Agent badge if aircraft came from remote agent
const agentBadge = ac._agent ?
`<span class="agent-badge">${ac._agent}</span>` : '';
// ACARS indicator if this aircraft has datalink messages
const acarsIndicator = (typeof acarsAircraftIcaos !== 'undefined' && acarsAircraftIcaos.has(ac.icao)) ?
`<span style="background:var(--accent-cyan);color:#000;padding:1px 4px;border-radius:2px;font-size:7px;font-weight:700;margin-left:4px;" title="Has ACARS messages">DLK</span>` : '';
// Vertical rate indicator: arrow up (climbing), arrow down (descending), or dash (level)
let vsIndicator = '-';
let vsColor = '';
@@ -2851,8 +2864,8 @@ sudo make install</code>
return `
<div class="aircraft-header">
<span class="aircraft-callsign">${callsign}${badge}${agentBadge}</span>
<span class="aircraft-icao">${typeCode ? typeCode + ' • ' : ''}${ac.icao}</span>
<span class="aircraft-callsign">${escapeHtml(callsign)}${badge}${acarsIndicator}${agentBadge}</span>
<span class="aircraft-icao">${typeCode ? escapeHtml(typeCode) + ' • ' : ''}${escapeHtml(ac.icao)}</span>
</div>
<div class="aircraft-details">
<div class="aircraft-detail">
@@ -3029,6 +3042,7 @@ sudo make install</code>
renderAircraftList();
showAircraftDetails(icao);
updateFlightLookupBtn();
highlightSidebarMessages(icao);
const ac = aircraft[icao];
if (ac && ac.lat !== undefined && ac.lat !== null && ac.lon !== undefined && ac.lon !== null) {
@@ -3044,6 +3058,24 @@ sudo make install</code>
}
}
function highlightSidebarMessages(icao) {
// Highlight ACARS/VDL2 sidebar messages matching the selected aircraft
const containers = ['acarsMessages', 'vdl2Messages'];
containers.forEach(containerId => {
const container = document.getElementById(containerId);
if (!container) return;
for (const item of container.children) {
if (item.dataset.icao === icao) {
item.style.borderLeft = '3px solid var(--accent-cyan)';
item.style.background = 'rgba(0, 212, 255, 0.08)';
} else {
item.style.borderLeft = '';
item.style.background = '';
}
}
});
}
function showAircraftDetails(icao) {
const ac = aircraft[icao];
const container = document.getElementById('selectedInfo');
@@ -3122,12 +3154,139 @@ sudo make install</code>
<div class="telemetry-label">Range</div>
<div class="telemetry-value">${ac.lat ? calculateDistanceNm(observerLocation.lat, observerLocation.lon, ac.lat, ac.lon).toFixed(1) + ' nm' : 'N/A'}</div>
</div>
</div>
<div id="aircraftAcarsSection" style="margin-top:12px;">
<div style="display:flex;justify-content:space-between;align-items:center;font-size:10px;font-weight:600;color:var(--accent-cyan);text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;border-top:1px solid var(--border-color);padding-top:8px;">
<span>Datalink Messages</span>
<button onclick="clearAircraftMessages()" title="Clear datalink messages" style="background:none;border:1px solid var(--border-color);color:var(--text-muted);cursor:pointer;font-size:9px;padding:1px 5px;border-radius:3px;font-family:var(--font-mono);line-height:1;">&#10005;</button>
</div>
<div id="aircraftAcarsMessages" style="font-size:10px;color:var(--text-dim);transition:opacity 0.15s ease;">Loading...</div>
</div>`;
// Fetch aircraft photo if registration is available
if (registration) {
fetchAircraftPhoto(registration);
}
// Fetch ACARS messages for this aircraft
fetchAircraftMessages(icao);
}
// ACARS message refresh timer for selected aircraft
let acarsMessageTimer = null;
function fetchAircraftMessages(icao) {
// Clear previous timer
if (acarsMessageTimer) {
clearInterval(acarsMessageTimer);
acarsMessageTimer = null;
}
function doFetch() {
fetch('/adsb/aircraft/' + icao + '/messages')
.then(r => r.json())
.then(data => {
const container = document.getElementById('aircraftAcarsMessages');
if (!container) return;
const msgs = (data.acars || []).concat(data.vdl2 || []);
let newHtml;
if (msgs.length === 0) {
newHtml = '<span style="color:var(--text-muted);font-style:italic;">No messages yet</span>';
} else {
msgs.sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || ''));
newHtml = msgs.slice(0, 20).map(renderAcarsCard).join('');
}
// Only update DOM if content actually changed
if (container.innerHTML !== newHtml) {
container.style.opacity = '0.7';
setTimeout(() => {
container.innerHTML = newHtml;
container.style.opacity = '1';
}, 150);
}
})
.catch(() => { /* keep existing content on error */ });
}
doFetch();
acarsMessageTimer = setInterval(doFetch, 10000);
}
function getAcarsTypeBadge(type) {
const colors = {
position: '#00ff88',
engine_data: '#ff9500',
weather: '#00d4ff',
ats: '#ffdd00',
cpdlc: '#b388ff',
oooi: '#4fc3f7',
squawk: '#ff6b6b',
link_test: '#666',
handshake: '#555',
other: '#888',
};
const labels = {
position: 'POS',
engine_data: 'ENG',
weather: 'WX',
ats: 'ATS',
cpdlc: 'CPDLC',
oooi: 'OOOI',
squawk: 'SQK',
link_test: 'LINK',
handshake: 'HSHK',
other: 'MSG',
};
const color = colors[type] || '#888';
const lbl = labels[type] || 'MSG';
return '<span style="display:inline-block;padding:1px 5px;border-radius:3px;font-size:8px;font-weight:700;color:#000;background:' + color + ';">' + lbl + '</span>';
}
// TODO: Similar to renderAcarsMainCard in partials/modes/acars.html — consider unifying
function renderAcarsCard(msg) {
const type = msg.message_type || 'other';
const badge = getAcarsTypeBadge(type);
const desc = escapeHtml(msg.label_description || ('Label ' + (msg.label || '?')));
const text = msg.text || msg.msg || '';
const truncText = escapeHtml(text.length > 120 ? text.substring(0, 120) + '...' : text);
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : '';
const flight = escapeHtml(msg.flight || '');
let parsedHtml = '';
if (msg.parsed) {
const p = msg.parsed;
if (type === 'position' && p.lat !== undefined) {
parsedHtml = '<div style="color:var(--accent-green);margin-top:2px;">' +
p.lat.toFixed(4) + ', ' + p.lon.toFixed(4) +
(p.flight_level ? ' • ' + escapeHtml(String(p.flight_level)) : '') +
(p.destination ? ' → ' + escapeHtml(String(p.destination)) : '') + '</div>';
} else if (type === 'engine_data') {
const parts = [];
Object.keys(p).forEach(k => {
parts.push(escapeHtml(k) + ': ' + escapeHtml(String(p[k].value)));
});
if (parts.length) {
parsedHtml = '<div style="color:var(--accent-orange,#ff9500);margin-top:2px;">' + parts.slice(0, 4).join(' | ') + '</div>';
}
} else if (type === 'oooi' && p.origin) {
parsedHtml = '<div style="color:var(--accent-cyan);margin-top:2px;">' +
escapeHtml(String(p.origin)) + ' → ' + escapeHtml(String(p.destination)) +
(p.out ? ' | OUT ' + escapeHtml(String(p.out)) : '') +
(p.off ? ' OFF ' + escapeHtml(String(p.off)) : '') +
(p.on ? ' ON ' + escapeHtml(String(p.on)) : '') +
(p['in'] ? ' IN ' + escapeHtml(String(p['in'])) : '') + '</div>';
}
}
return '<div style="padding:5px 0;border-bottom:1px solid rgba(255,255,255,0.05);">' +
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:2px;">' +
'<span>' + badge + ' <span style="color:var(--text-primary);">' + desc + '</span></span>' +
'<span style="color:var(--text-muted);font-size:9px;">' + time + '</span></div>' +
(flight ? '<div style="color:var(--accent-cyan);font-size:9px;">' + flight + '</div>' : '') +
parsedHtml +
(truncText && type !== 'link_test' && type !== 'handshake' ?
'<div style="color:var(--text-dim);font-family:var(--font-mono);font-size:9px;margin-top:2px;word-break:break-all;">' + truncText + '</div>' : '') +
'</div>';
}
// Cache for aircraft photos to avoid repeated API calls
@@ -3188,12 +3347,15 @@ sudo make install</code>
cleanupTrail(icao);
delete aircraft[icao];
delete alertedAircraft[icao];
if (typeof acarsAircraftIcaos !== 'undefined') acarsAircraftIcaos.delete(icao);
needsUpdate = true;
if (selectedIcao === icao) {
selectedIcao = null;
showAircraftDetails(null);
updateFlightLookupBtn();
highlightSidebarMessages(null);
clearAircraftMessages();
}
}
});
@@ -3583,7 +3745,7 @@ sudo make install</code>
let acarsMessageCount = 0;
let acarsSidebarCollapsed = localStorage.getItem('acarsSidebarCollapsed') !== 'false';
let acarsFrequencies = {
'na': ['131.725', '131.825'],
'na': ['131.550', '130.025', '129.125'],
'eu': ['131.525', '131.725', '131.550'],
'ap': ['131.550', '131.450']
};
@@ -3623,6 +3785,17 @@ sudo make install</code>
document.getElementById('acarsToggleBtn').classList.add('active');
document.getElementById('acarsPanelIndicator').classList.add('active');
startAcarsStream(false);
// Reload message history from backend
fetch('/acars/messages?limit=50')
.then(r => r.json())
.then(msgs => {
if (!msgs || !msgs.length) return;
// Add oldest first so newest ends up on top
for (let i = msgs.length - 1; i >= 0; i--) {
addAcarsMessage(msgs[i]);
}
})
.catch(() => {});
}
})
.catch(() => {});
@@ -3639,8 +3812,8 @@ sudo make install</code>
previouslyChecked.add(cb.value);
});
container.innerHTML = freqs.map(freq => {
// Check by default if it was previously checked or if this is initial load
container.innerHTML = freqs.map((freq, i) => {
// On initial load, check all frequencies; otherwise preserve state
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;">
@@ -3684,9 +3857,9 @@ sudo make install</code>
acarsCurrentAgent = isAgentMode ? adsbCurrentAgent : null;
// Warn if using same device as ADS-B (only for local mode)
if (!isAgentMode && isTracking && device === '0') {
if (!isAgentMode && isTracking && adsbActiveDevice !== null && device === String(adsbActiveDevice)) {
const useAnyway = confirm(
'Warning: ADS-B tracking may be using SDR device 0.\n\n' +
`Warning: ADS-B tracking is using SDR device ${adsbActiveDevice}.\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.'
@@ -3865,6 +4038,63 @@ sudo make install</code>
}, pollInterval);
}
// Track which aircraft have ACARS messages (by ICAO)
const acarsAircraftIcaos = new Set();
// IATA (2-letter) → ICAO (3-letter) airline code mapping
// NOTE: Duplicated from utils/airline_codes.py — keep both in sync
const IATA_TO_ICAO = {
'AA':'AAL','DL':'DAL','UA':'UAL','WN':'SWA','B6':'JBU','AS':'ASA',
'NK':'NKS','F9':'FFT','G4':'AAY','HA':'HAL','SY':'SCX','WS':'WJA',
'AC':'ACA','WG':'WGN','TS':'TSC','PD':'POE','MX':'MXA','QX':'QXE',
'OH':'COM','OO':'SKW','YX':'RPA','9E':'FLG','PT':'SWQ','MQ':'ENY',
'YV':'ASH','AX':'LOF','ZW':'AWI','G7':'GJS','EV':'ASQ',
'AM':'AMX','VB':'VIV','4O':'AIJ','Y4':'VOI',
'5X':'UPS','FX':'FDX',
'BA':'BAW','LH':'DLH','AF':'AFR','KL':'KLM','IB':'IBE','AZ':'ITY',
'SK':'SAS','AY':'FIN','OS':'AUA','LX':'SWR','SN':'BEL','TP':'TAP',
'EI':'EIN','U2':'EZY','FR':'RYR','W6':'WZZ','VY':'VLG','PC':'PGT',
'TK':'THY','LO':'LOT','BT':'BTI','DY':'NAX','VS':'VIR','EW':'EWG',
'SQ':'SIA','CX':'CPA','QF':'QFA','JL':'JAL','NH':'ANA','KE':'KAL',
'OZ':'AAR','CI':'CAL','BR':'EVA','CZ':'CSN','MU':'CES','CA':'CCA',
'AI':'AIC','GA':'GIA','TG':'THA','MH':'MAS','PR':'PAL','VN':'HVN',
'NZ':'ANZ','3K':'JSA','JQ':'JST','AK':'AXM','TR':'TGW','5J':'CEB',
'EK':'UAE','QR':'QTR','EY':'ETD','GF':'GFA','SV':'SVA',
'ET':'ETH','MS':'MSR','SA':'SAA','RJ':'RJA','WY':'OMA',
'LA':'LAN','G3':'GLO','AD':'AZU','AV':'AVA','CM':'CMP','AR':'ARG'
};
const ICAO_TO_IATA = Object.fromEntries(Object.entries(IATA_TO_ICAO).map(([k,v]) => [v,k]));
function translateFlight(flight) {
if (!flight) return [];
const m = flight.match(/^([A-Z0-9]{2,3})(\d+[A-Z]?)$/);
if (!m) return [];
const [, prefix, num] = m;
const results = [];
if (IATA_TO_ICAO[prefix]) results.push(IATA_TO_ICAO[prefix] + num);
if (ICAO_TO_IATA[prefix]) results.push(ICAO_TO_IATA[prefix] + num);
return results;
}
function findAircraftIcaoByFlight(flight) {
if (!flight || flight === 'UNKNOWN') return null;
const upper = flight.trim().toUpperCase();
// Build candidate list: original + translated variants
const candidates = [upper, ...translateFlight(upper)];
for (const candidate of candidates) {
for (const [icao, ac] of Object.entries(aircraft)) {
const cs = (ac.callsign || '').trim().toUpperCase();
if (cs === candidate) return icao;
// Also match by registration (tail number)
const reg = (ac.registration || '').trim().toUpperCase();
if (reg && reg === candidate) return icao;
}
// Also check ICAO hex directly
if (aircraft[candidate]) return candidate;
}
return null;
}
function addAcarsMessage(data) {
const container = document.getElementById('acarsMessages');
@@ -3874,22 +4104,51 @@ sudo make install</code>
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 labelDesc = data.label_description || '';
const msgType = data.message_type || 'other';
const text = data.text || data.msg || '';
const time = new Date().toLocaleTimeString();
// Escape user-controlled strings for safe innerHTML insertion
const eFlight = escapeHtml(flight);
const eReg = escapeHtml(reg);
const eLabelDesc = escapeHtml(labelDesc || (label ? 'Label: ' + label : ''));
const eText = escapeHtml(text.length > 80 ? text.substring(0, 80) + '...' : text);
// Try to find matching tracked aircraft
const matchedIcao = findAircraftIcaoByFlight(flight) ||
findAircraftIcaoByFlight(data.tail) ||
findAircraftIcaoByFlight(data.reg) ||
(data.icao && aircraft[data.icao.toUpperCase()] ? data.icao.toUpperCase() : null);
if (matchedIcao) acarsAircraftIcaos.add(matchedIcao);
// Tag message with matched ICAO for cross-highlighting
if (matchedIcao) msg.dataset.icao = matchedIcao;
// Make clickable if we have a matching aircraft
msg.style.cssText = 'padding: 6px 8px; border-bottom: 1px solid var(--border-color); font-size: 10px;' +
(matchedIcao ? ' cursor: pointer;' : '');
if (matchedIcao) {
msg.onclick = () => selectAircraft(matchedIcao);
msg.title = 'Click to locate ' + flight + ' on map';
}
const typeBadge = typeof getAcarsTypeBadge === 'function' ? getAcarsTypeBadge(msgType) : '';
const linkIcon = matchedIcao ? '<span style="color:var(--accent-green);font-size:9px;margin-left:3px;" title="Tracked on map">&#9992;</span>' : '';
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(--accent-cyan); font-weight: bold;">${eFlight}${linkIcon}</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>` : ''}
${reg ? `<div style="color: var(--text-muted); font-size: 9px;">Reg: ${eReg}</div>` : ''}
<div style="margin-top: 2px;">${typeBadge} <span style="color: var(--text-primary);">${eLabelDesc}</span></div>
${text && msgType !== 'link_test' && msgType !== 'handshake' ? `<div style="color: var(--text-dim); margin-top: 3px; word-break: break-word; font-size: 9px;">${eText}</div>` : ''}
`;
container.insertBefore(msg, container.firstChild);
@@ -3900,6 +4159,33 @@ sudo make install</code>
}
}
function clearAcarsMessages() {
fetch('/acars/clear', { method: 'POST' }).catch(() => {});
const container = document.getElementById('acarsMessages');
container.innerHTML = '<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>';
acarsMessageCount = 0;
document.getElementById('acarsCount').textContent = '0';
}
function clearVdl2Messages() {
fetch('/vdl2/clear', { method: 'POST' }).catch(() => {});
const container = document.getElementById('vdl2Messages');
container.innerHTML = '<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>';
vdl2MessageCount = 0;
document.getElementById('vdl2Count').textContent = '0';
}
function clearAircraftMessages() {
const container = document.getElementById('aircraftAcarsMessages');
if (container) {
container.innerHTML = '<span style="color:var(--text-muted);font-style:italic;">No messages yet</span>';
}
if (acarsMessageTimer) {
clearInterval(acarsMessageTimer);
acarsMessageTimer = null;
}
}
// Populate ACARS device selector
document.addEventListener('DOMContentLoaded', () => {
fetch('/devices')
@@ -3920,10 +4206,6 @@ sudo make install</code>
opt.textContent = `SDR ${idx}: ${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 = select.options[1]?.value || select.options[0]?.value;
}
}
});
});
@@ -4023,9 +4305,9 @@ sudo make install</code>
vdl2CurrentAgent = isAgentMode ? adsbCurrentAgent : null;
// Warn if using same device as ADS-B (only for local mode)
if (!isAgentMode && isTracking && device === '0') {
if (!isAgentMode && isTracking && adsbActiveDevice !== null && device === String(adsbActiveDevice)) {
const useAnyway = confirm(
'Warning: ADS-B tracking may be using SDR device 0.\n\n' +
`Warning: ADS-B tracking is using SDR device ${adsbActiveDevice}.\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.'
@@ -4261,13 +4543,14 @@ sudo make install</code>
if (acars.reg) acarsFields.push(['Registration', acars.reg]);
if (acars.flight) acarsFields.push(['Flight', acars.flight]);
if (acars.mode) acarsFields.push(['Mode', acars.mode]);
if (acars.label) acarsFields.push(['Label', acars.label]);
if (acars.label) acarsFields.push(['Label', acars.label + (acars.label_description ? ' — ' + acars.label_description : '')]);
if (acars.sublabel) acarsFields.push(['Sublabel', acars.sublabel]);
if (acars.blk_id) acarsFields.push(['Block ID', acars.blk_id]);
if (acars.ack) acarsFields.push(['ACK', acars.ack === '!' ? 'NAK' : acars.ack]);
if (acars.msg_num) acarsFields.push(['Msg Number', acars.msg_num]);
if (acars.msg_num_seq) acarsFields.push(['Msg Seq', acars.msg_num_seq]);
if (acars.more != null) acarsFields.push(['More Follows', acars.more ? 'Yes' : 'No']);
if (acars.message_type && acars.message_type !== 'other') acarsFields.push(['Message Type', acars.message_type.replace(/_/g, ' ')]);
const acarsHtml = buildSection('ACARS', acarsFields);
// XID fields
@@ -4386,17 +4669,42 @@ sudo make install</code>
});
const label = flight || src || (avlc.frame_type || 'VDL2');
// Try to find matching tracked aircraft (same logic as ACARS)
const matchedIcao = findAircraftIcaoByFlight(flight) ||
findAircraftIcaoByFlight(acars.reg) ||
(src && aircraft[src.toUpperCase()] ? src.toUpperCase() : null);
if (matchedIcao) {
acarsAircraftIcaos.add(matchedIcao);
msg.dataset.icao = matchedIcao;
}
msg.style.cssText = 'padding: 6px 8px; border-bottom: 1px solid var(--border-color); font-size: 10px;' +
(matchedIcao ? ' cursor: pointer;' : '');
const linkIcon = matchedIcao ? '<span style="color:var(--accent-green);font-size:9px;margin-left:3px;" title="Tracked on map">&#9992;</span>' : '';
const acarsLabelDesc = acars.label_description || '';
const acarsMsgType = acars.message_type || 'other';
const vdl2TypeBadge = typeof getAcarsTypeBadge === 'function' && acars.label ? getAcarsTypeBadge(acarsMsgType) : '';
msg.innerHTML = `
<div style="display: flex; justify-content: space-between; margin-bottom: 2px;">
<span style="color: var(--accent-cyan); font-weight: bold;">${escapeHtml(label)}</span>
<span style="color: var(--accent-cyan); font-weight: bold;">${escapeHtml(label)}${linkIcon}</span>
<span style="color: var(--text-muted);">${time}</span>
</div>
${acarsLabelDesc ? `<div style="margin-top: 2px;">${vdl2TypeBadge} <span style="color: var(--text-primary);">${escapeHtml(acarsLabelDesc)}</span></div>` : ''}
<div style="color: var(--text-dim); font-size: 9px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
${freq ? freq + ' MHz' : ''}${freq && acars.label ? ' · ' : ''}${acars.label ? 'Label: ' + escapeHtml(acars.label) : ''}${msgText ? ' · ' + escapeHtml(msgText.substring(0, 50)) + (msgText.length > 50 ? '…' : '') : ''}
</div>
`;
msg.addEventListener('click', () => showVdl2Modal(data, time));
if (matchedIcao) {
msg.addEventListener('click', () => selectAircraft(matchedIcao));
msg.addEventListener('dblclick', () => showVdl2Modal(data, time));
msg.title = 'Click to locate on map, double-click for details';
} else {
msg.addEventListener('click', () => showVdl2Modal(data, time));
}
container.insertBefore(msg, container.firstChild);
@@ -4447,9 +4755,6 @@ sudo make install</code>
opt.textContent = `SDR ${idx}: ${d.name || d.type || 'SDR'}`;
select.appendChild(opt);
});
if (devices.length > 1) {
select.value = select.options[1]?.value || select.options[0]?.value;
}
}
});
@@ -4465,6 +4770,16 @@ sudo make install</code>
document.getElementById('vdl2ToggleBtn').classList.add('active');
document.getElementById('vdl2PanelIndicator').classList.add('active');
startVdl2Stream(false);
// Reload message history from backend
fetch('/vdl2/messages?limit=50')
.then(r => r.json())
.then(msgs => {
if (!msgs || !msgs.length) return;
for (let i = msgs.length - 1; i >= 0; i--) {
addVdl2Message(msgs[i]);
}
})
.catch(() => {});
}
})
.catch(() => {});
@@ -5416,8 +5731,9 @@ sudo make install</code>
const adsbSelect = document.getElementById('adsbDeviceSelect');
const airbandSelect = document.getElementById('airbandDeviceSelect');
const acarsSelect = document.getElementById('acarsDeviceSelect');
const vdl2Select = document.getElementById('vdl2DeviceSelect');
[adsbSelect, airbandSelect, acarsSelect].forEach(select => {
[adsbSelect, airbandSelect, acarsSelect, vdl2Select].forEach(select => {
if (!select) return;
select.innerHTML = '';
@@ -5436,6 +5752,7 @@ sudo make install</code>
});
}
});
}
// Hook into page init

View File

@@ -57,7 +57,7 @@
<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 (N. America)</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">131.725 / 131.825 MHz</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">131.550 / 130.025 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>
@@ -82,6 +82,14 @@
<button class="stop-btn" id="stopAcarsBtn" onclick="stopAcarsMode()" style="display: none;">
Stop ACARS
</button>
<!-- Live Message Feed -->
<div class="section" id="acarsMessageFeedSection" style="margin-top: 15px;">
<h3>Message Feed</h3>
<div id="acarsMessageFeed" class="acars-message-feed" style="max-height: 400px; overflow-y: auto; font-size: 11px;">
<div style="color: var(--text-muted); font-style: italic; padding: 10px 0;">Start ACARS to see live messages</div>
</div>
</div>
</div>
<script>
@@ -89,7 +97,7 @@
let acarsMainMsgCount = 0;
const acarsMainFrequencies = {
'na': ['131.725', '131.825'],
'na': ['131.550', '130.025', '129.125'],
'eu': ['131.525', '131.725', '131.550'],
'ap': ['131.550', '131.450']
};
@@ -102,7 +110,7 @@
const previouslyChecked = new Set();
container.querySelectorAll('input:checked').forEach(cb => previouslyChecked.add(cb.value));
container.innerHTML = freqs.map(freq => {
container.innerHTML = freqs.map((freq, i) => {
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;">
@@ -164,9 +172,65 @@
});
}
function acarsMainTypeBadge(type) {
const colors = {
position: '#00ff88', engine_data: '#ff9500', weather: '#00d4ff',
ats: '#ffdd00', cpdlc: '#b388ff', oooi: '#4fc3f7', squawk: '#ff6b6b',
link_test: '#666', handshake: '#555', other: '#888'
};
const labels = {
position: 'POS', engine_data: 'ENG', weather: 'WX', ats: 'ATS',
cpdlc: 'CPDLC', oooi: 'OOOI', squawk: 'SQK', link_test: 'LINK',
handshake: 'HSHK', other: 'MSG'
};
const color = colors[type] || '#888';
const lbl = labels[type] || 'MSG';
return `<span style="display:inline-block;padding:1px 5px;border-radius:3px;font-size:8px;font-weight:700;color:#000;background:${color};">${lbl}</span>`;
}
// TODO: Similar to renderAcarsCard in templates/adsb_dashboard.html — consider unifying
function renderAcarsMainCard(data) {
const flight = escapeHtml(data.flight || 'UNKNOWN');
const type = data.message_type || 'other';
const badge = acarsMainTypeBadge(type);
const desc = escapeHtml(data.label_description || (data.label ? 'Label: ' + data.label : ''));
const text = data.text || data.msg || '';
const truncText = escapeHtml(text.length > 150 ? text.substring(0, 150) + '...' : text);
const time = new Date().toLocaleTimeString();
let parsedHtml = '';
if (data.parsed) {
const p = data.parsed;
if (type === 'position' && p.lat !== undefined) {
parsedHtml = `<div style="color:var(--accent-green);margin-top:2px;font-size:10px;">${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}${p.flight_level ? ' &bull; ' + escapeHtml(String(p.flight_level)) : ''}${p.destination ? ' &rarr; ' + escapeHtml(String(p.destination)) : ''}</div>`;
} else if (type === 'engine_data') {
const parts = [];
Object.keys(p).forEach(k => parts.push(escapeHtml(k) + ': ' + escapeHtml(String(p[k].value))));
if (parts.length) parsedHtml = `<div style="color:#ff9500;margin-top:2px;font-size:10px;">${parts.slice(0, 4).join(' | ')}</div>`;
} else if (type === 'oooi' && p.origin) {
parsedHtml = `<div style="color:var(--accent-cyan);margin-top:2px;font-size:10px;">${escapeHtml(String(p.origin))} &rarr; ${escapeHtml(String(p.destination))}${p.out ? ' | OUT ' + escapeHtml(String(p.out)) : ''}${p.off ? ' OFF ' + escapeHtml(String(p.off)) : ''}${p.on ? ' ON ' + escapeHtml(String(p.on)) : ''}${p['in'] ? ' IN ' + escapeHtml(String(p['in'])) : ''}</div>`;
}
}
return `<div class="acars-feed-card" style="padding:6px 8px;border-bottom:1px solid var(--border-color);animation:fadeInMsg 0.3s ease;">
<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:9px;">${time}</span>
</div>
<div style="margin-top:2px;">${badge} <span style="color:var(--text-primary);">${desc}</span></div>
${parsedHtml}
${truncText && type !== 'link_test' && type !== 'handshake' ? `<div style="color:var(--text-dim);font-family:var(--font-mono);font-size:9px;margin-top:3px;word-break:break-all;">${truncText}</div>` : ''}
</div>`;
}
function startAcarsMainSSE() {
if (acarsMainEventSource) acarsMainEventSource.close();
const feed = document.getElementById('acarsMessageFeed');
if (feed && feed.querySelector('[style*="font-style: italic"]')) {
feed.innerHTML = '';
}
acarsMainEventSource = new EventSource('/acars/stream');
acarsMainEventSource.onmessage = function(e) {
try {
@@ -174,6 +238,16 @@
if (data.type === 'acars') {
acarsMainMsgCount++;
document.getElementById('acarsMessageCount').textContent = acarsMainMsgCount;
// Add to message feed
const feed = document.getElementById('acarsMessageFeed');
if (feed) {
feed.insertAdjacentHTML('afterbegin', renderAcarsMainCard(data));
// Keep max 30 messages for RPi performance
while (feed.children.length > 30) {
feed.removeChild(feed.lastChild);
}
}
}
} catch (err) {}
};

View File

@@ -0,0 +1,265 @@
"""Tests for ACARS message translator."""
import pytest
from utils.acars_translator import (
ACARS_LABELS,
translate_label,
classify_message_type,
parse_position_report,
parse_engine_data,
parse_weather_data,
parse_oooi,
translate_message,
)
# --- translate_label ---
class TestTranslateLabel:
def test_known_labels(self):
assert translate_label('H1') == 'Position report (HF data link)'
assert translate_label('DF') == 'Engine data / DFDR'
assert translate_label('_d') == 'Demand mode (link test)'
assert translate_label('5Z') == 'OOOI (gate times)'
assert translate_label('B9') == 'ATC message'
assert translate_label('SQ') == 'Squawk assignment'
def test_unknown_label(self):
assert translate_label('ZZ') == 'Label ZZ'
def test_empty_label(self):
assert translate_label('') == 'Unknown label'
def test_none_label(self):
assert translate_label(None) == 'Unknown label'
def test_q_prefix_unknown(self):
"""Q-prefix labels not in table should get generic link management desc."""
assert 'Link management' in translate_label('QZ')
def test_whitespace_stripped(self):
assert translate_label(' H1 ') == 'Position report (HF data link)'
# --- classify_message_type ---
class TestClassifyMessageType:
def test_h1_is_position(self):
assert classify_message_type('H1') == 'position'
def test_df_is_engine_data(self):
assert classify_message_type('DF') == 'engine_data'
def test_h2_is_weather(self):
assert classify_message_type('H2') == 'weather'
def test_b9_is_ats(self):
assert classify_message_type('B9') == 'ats'
def test_5z_is_oooi(self):
assert classify_message_type('5Z') == 'oooi'
def test_sq_is_squawk(self):
assert classify_message_type('SQ') == 'squawk'
def test_underscore_d_is_handshake(self):
assert classify_message_type('_d') == 'handshake'
def test_q0_is_link_test(self):
assert classify_message_type('Q0') == 'link_test'
def test_aa_is_cpdlc(self):
assert classify_message_type('AA') == 'cpdlc'
def test_unknown_is_other(self):
assert classify_message_type('ZZ') == 'other'
def test_none_is_other(self):
assert classify_message_type(None) == 'other'
def test_text_with_bpos_override(self):
"""H1 with #M1BPOS text should be position."""
assert classify_message_type('H1', '#M1BPOSN42411W086034') == 'position'
# --- parse_position_report ---
class TestParsePositionReport:
def test_real_h1_bpos(self):
text = '#M1BPOSN42411W086034,CSG,070852,340,N42441W087074,DTW,0757,224A8C'
result = parse_position_report(text)
assert result is not None
assert result['lat'] > 42
assert result['lon'] < -86
assert result['waypoint'] == 'CSG'
assert result['flight_level'] == 'FL340'
assert result['destination'] == 'DTW'
def test_none_text(self):
assert parse_position_report(None) is None
def test_empty_text(self):
assert parse_position_report('') is None
def test_no_bpos_data(self):
assert parse_position_report('SOME RANDOM TEXT') is None
def test_temperature_field(self):
text = '#M1BPOSN42411W086034,CSG,070852,340,N42441W087074,DTW,0757/TSM045'
result = parse_position_report(text)
assert result is not None
assert result.get('temperature') == '-045 C'
def test_southern_hemisphere(self):
text = '#M1BPOSS33500E018200,CPT,120000,350,S33500E018200,CPT,1230,ABC123'
result = parse_position_report(text)
assert result is not None
assert result['lat'] < 0 # South
# --- parse_engine_data ---
class TestParseEngineData:
def test_real_dfdr_message(self):
text = '#DFB SM/0 AC0/85.2 AC1/84.9 FL/350 FU/12450 ES/15'
result = parse_engine_data(text)
assert result is not None
assert 'AC0' in result
assert result['AC0']['value'] == '85.2'
assert 'FL' in result
assert result['FL']['value'] == '350'
def test_none_text(self):
assert parse_engine_data(None) is None
def test_empty_text(self):
assert parse_engine_data('') is None
def test_no_engine_keys(self):
assert parse_engine_data('HELLO WORLD') is None
def test_n1_n2_values(self):
text = 'N1/92.3 N2/88.1 EGT/425'
result = parse_engine_data(text)
assert result is not None
assert result['N1']['value'] == '92.3'
assert result['N2']['value'] == '88.1'
assert result['EGT']['value'] == '425'
# --- parse_weather_data ---
class TestParseWeatherData:
def test_wind_data(self):
text = 'WND270015 KJFK VIS10'
result = parse_weather_data(text)
assert result is not None
assert result['wind_dir'] == '270 deg'
assert result['wind_speed'] == '015 kts'
def test_airports(self):
text = '/WX KJFK KLAX TMP24'
result = parse_weather_data(text)
assert result is not None
assert 'KJFK' in result['airports']
assert 'KLAX' in result['airports']
def test_none_text(self):
assert parse_weather_data(None) is None
def test_empty_text(self):
assert parse_weather_data('') is None
# --- parse_oooi ---
class TestParseOooi:
def test_full_oooi(self):
text = 'KJFK KLAX 1423 1435 1812 1824'
result = parse_oooi(text)
assert result is not None
assert result['origin'] == 'KJFK'
assert result['destination'] == 'KLAX'
assert result['out'] == '1423'
assert result['off'] == '1435'
assert result['on'] == '1812'
assert result['in'] == '1824'
def test_partial_oooi(self):
text = 'KJFK KLAX 1423 1435'
result = parse_oooi(text)
assert result is not None
assert result['origin'] == 'KJFK'
assert result['destination'] == 'KLAX'
def test_none_text(self):
assert parse_oooi(None) is None
def test_empty_text(self):
assert parse_oooi('') is None
# --- translate_message (integration) ---
class TestTranslateMessage:
def test_h1_position(self):
msg = {
'label': 'H1',
'text': '#M1BPOSN42411W086034,CSG,070852,340,N42441W087074,DTW,0757,224A8C',
}
result = translate_message(msg)
assert result['label_description'] == 'Position report (HF data link)'
assert result['message_type'] == 'position'
assert result['parsed'] is not None
assert 'lat' in result['parsed']
def test_df_engine(self):
msg = {
'label': 'DF',
'text': '#DFB SM/0 AC0/85.2 AC1/84.9 FL/350',
}
result = translate_message(msg)
assert result['message_type'] == 'engine_data'
assert result['parsed'] is not None
assert 'AC0' in result['parsed']
def test_underscore_d_handshake(self):
msg = {'label': '_d', 'text': ''}
result = translate_message(msg)
assert result['label_description'] == 'Demand mode (link test)'
assert result['message_type'] == 'handshake'
def test_unknown_label(self):
msg = {'label': 'ZZ', 'text': 'SOME DATA'}
result = translate_message(msg)
assert result['label_description'] == 'Label ZZ'
assert result['message_type'] == 'other'
assert result['parsed'] is None
def test_missing_fields(self):
"""Handles messages with no label or text gracefully."""
result = translate_message({})
assert result['label_description'] == 'Unknown label'
assert result['message_type'] == 'other'
assert result['parsed'] is None
def test_msg_field_fallback(self):
"""Uses 'msg' field when 'text' is missing."""
msg = {
'label': 'DF',
'msg': '#DFB N1/92.3 N2/88.1',
}
result = translate_message(msg)
assert result['parsed'] is not None
assert 'N1' in result['parsed']
def test_5z_oooi(self):
msg = {
'label': '5Z',
'text': 'KJFK KLAX 1423 1435 1812 1824',
}
result = translate_message(msg)
assert result['message_type'] == 'oooi'
assert result['parsed'] is not None
assert result['parsed']['origin'] == 'KJFK'

374
utils/acars_translator.py Normal file
View File

@@ -0,0 +1,374 @@
"""ACARS message translator — label lookup, classification, and field parsers."""
from __future__ import annotations
import re
# Common ACARS label codes → human-readable descriptions
# Sources: ARINC 618, ARINC 620, airline implementations
ACARS_LABELS: dict[str, str] = {
# Position & navigation
'H1': 'Position report (HF data link)',
'H2': 'Weather report',
'5Z': 'OOOI (gate times)',
'15': 'Departure report',
'16': 'Arrival report',
'20': 'Position report',
'22': 'Fuel report',
'2Z': 'Off-gate report',
'30': 'Progress report',
'44': 'Weather request',
'80': 'Free text (3-char header)',
'83': 'Free text',
'8E': 'ATIS request',
# Engine & performance
'DF': 'Engine data / DFDR',
'D3': 'Engine exceedance',
'D6': 'Engine trend data',
# ATS / air traffic services
'B1': 'ATC request',
'B2': 'ATC clearance',
'B3': 'ATC comm test',
'B6': 'ATC departure clearance',
'B9': 'ATC message',
'BA': 'ATC advisory',
'BB': 'ATC response',
# CPDLC (Controller-Pilot Data Link Communications)
'AA': 'CPDLC message',
'AB': 'CPDLC response',
'A0': 'CPDLC uplink',
'A1': 'CPDLC downlink',
'A2': 'CPDLC connection request',
'A3': 'CPDLC logon/logoff',
'A6': 'CPDLC message',
'A7': 'CPDLC response',
'AT': 'CPDLC transfer',
# Handshake & link management
'_d': 'Demand mode (link test)',
'Q0': 'Link test',
'QA': 'Link test reply',
'QB': 'Acknowledgement',
'QC': 'Link request',
'QD': 'Link accept',
'QE': 'Link reject',
'QF': 'Squitter / heartbeat',
'QG': 'Abort',
'QH': 'Version request',
'QK': 'Mode change',
'QM': 'Link verification',
'QN': 'Media advisory',
'QP': 'Polling',
'QQ': 'Status',
'QR': 'General response',
'QS': 'System table request',
'QT': 'System table',
'QX': 'Frequency change',
# Squawk & surveillance
'SQ': 'Squawk assignment',
'SA': 'Surveillance data',
'S1': 'ADS-C report',
# Airline operations
'C1': 'Crew scheduling',
'C2': 'Crew response',
'C3': 'Crew message',
'C4': 'Crew query',
'10': 'Delay message',
'12': 'Clearance request',
'17': 'Cargo/load data',
'4T': 'TWIP (terminal weather)',
'4X': 'Connectivity test',
'50': 'Weather observation',
'51': 'METAR/TAF request',
'52': 'METAR/TAF response',
'54': 'SIGMET / AIRMET',
'70': 'Maintenance report',
'7A': 'Fault message',
'7B': 'Fault clear',
'F3': 'Flight plan',
'F5': 'Flight plan amendment',
'F6': 'Route request',
'F7': 'Route clearance',
'RA': 'ATIS report',
'RB': 'ATIS request',
}
# Message type classification for UI colour coding
MESSAGE_TYPES = {
'position', 'engine_data', 'weather', 'ats', 'handshake',
'oooi', 'squawk', 'link_test', 'cpdlc', 'other',
}
def translate_label(label: str | None) -> str:
"""Return human-readable description for an ACARS label code."""
if not label:
return 'Unknown label'
label = label.strip()
if label in ACARS_LABELS:
return ACARS_LABELS[label]
# Check for Q-prefix group
if len(label) == 2 and label.startswith('Q'):
return f'Link management ({label})'
return f'Label {label}'
def classify_message_type(label: str | None, text: str | None = None) -> str:
"""Classify an ACARS message into a canonical type for UI display."""
if not label:
return 'other'
label = label.strip()
# Position reports
if label in ('H1', '20', '15', '16', '30', 'S1'):
return 'position'
if text and '#M1BPOS' in text:
return 'position'
# Engine / DFDR data
if label in ('DF', 'D3', 'D6'):
return 'engine_data'
# Weather
if label in ('H2', '44', '50', '51', '52', '54', '4T'):
return 'weather'
# ATS / ATC
if label.startswith('B') and len(label) == 2:
return 'ats'
# CPDLC
if label in ('AA', 'AB', 'A0', 'A1', 'A2', 'A3', 'A6', 'A7', 'AT'):
return 'cpdlc'
# OOOI (Out/Off/On/In gate times)
if label in ('5Z', '2Z'):
return 'oooi'
# Squawk
if label in ('SQ', 'SA'):
return 'squawk'
# Link test / handshake
if label in ('Q0', 'QA', 'QB', 'QC', 'QD', 'QE', 'QF', 'QG',
'QH', 'QK', 'QM', 'QN', 'QP', 'QQ', 'QR', 'QS', 'QT', 'QX',
'4X'):
return 'link_test'
# Handshake (_d is demand mode)
if label == '_d':
return 'handshake'
return 'other'
def parse_position_report(text: str | None) -> dict | None:
"""Parse H1 / #M1BPOS position report fields.
Example format:
#M1BPOSN42411W086034,CSG,070852,340,N42441W087074,DTW,0757,224A8C
Lat/Lon: N42411W086034 (N42.411 W086.034)
Waypoint: CSG
Time: 070852Z
FL: 340
Next waypoint coords, destination, ETA
"""
if not text:
return None
result: dict = {}
# Look for BPOS block
bpos_match = re.search(
r'#M\d[A-Z]*POS'
r'([NS])(\d{2,5})([EW])(\d{3,6})'
r',([^,]*),(\d{4,6})'
r',(\d{2,3})'
r'(?:,([NS]\d{2,5}[EW]\d{3,6}))?'
r'(?:,([A-Z]{3,4}))?',
text
)
if bpos_match:
lat_dir, lat_val, lon_dir, lon_val = bpos_match.group(1, 2, 3, 4)
# Convert to decimal degrees
if len(lat_val) >= 4:
lat_deg = int(lat_val[:2])
lat_min = int(lat_val[2:]) / (10 ** (len(lat_val) - 2)) * 60
lat = lat_deg + lat_min / 60
else:
lat = float(lat_val)
if lat_dir == 'S':
lat = -lat
if len(lon_val) >= 5:
lon_deg = int(lon_val[:3])
lon_min = int(lon_val[3:]) / (10 ** (len(lon_val) - 3)) * 60
lon = lon_deg + lon_min / 60
else:
lon = float(lon_val)
if lon_dir == 'W':
lon = -lon
result['lat'] = round(lat, 4)
result['lon'] = round(lon, 4)
result['waypoint'] = bpos_match.group(5).strip() if bpos_match.group(5) else None
result['time'] = bpos_match.group(6)
result['flight_level'] = f"FL{bpos_match.group(7)}"
if bpos_match.group(9):
result['destination'] = bpos_match.group(9)
# Look for temperature (e.g., /TS-045 or M045)
temp_match = re.search(r'/TS([MP]?)(\d{2,3})', text)
if temp_match:
sign = '-' if temp_match.group(1) == 'M' else ''
result['temperature'] = f"{sign}{temp_match.group(2)} C"
return result if result else None
def parse_engine_data(text: str | None) -> dict | None:
"""Parse DF (engine/DFDR) messages.
Common format: #DFB followed by KEY/VALUE pairs.
Keys: SM (source mode), AC0/AC1 (engine 1/2 N2), FL (flight level),
FU (fuel used), ES (EGT spread), BA (bleed air), CO (config), AO (auto)
"""
if not text:
return None
result: dict = {}
engine_keys = {
'SM': 'Source mode',
'AC0': 'Eng 1 N2 (%)',
'AC1': 'Eng 2 N2 (%)',
'FL': 'Flight level',
'FU': 'Fuel used (lbs)',
'ES': 'EGT spread',
'BA': 'Bleed air',
'CO': 'Config',
'AO': 'Auto',
'EGT': 'Exhaust gas temp',
'OIT': 'Oil temp',
'OIP': 'Oil pressure',
'N1': 'N1 (%)',
'N2': 'N2 (%)',
'FF': 'Fuel flow',
'VIB': 'Vibration',
}
# Match KEY/VALUE or KEY VALUE patterns
for key, desc in engine_keys.items():
pattern = rf'\b{re.escape(key)}[/: ]?\s*([+-]?\d+\.?\d*)'
m = re.search(pattern, text)
if m:
result[key] = {'value': m.group(1), 'description': desc}
return result if result else None
def parse_weather_data(text: str | None) -> dict | None:
"""Parse weather report fields (/WX blocks, METAR-like data)."""
if not text:
return None
result: dict = {}
# Wind: direction/speed (e.g., 270/15 or WND270015)
wind_match = re.search(r'(?:WND|WIND)\s*(\d{3})[/ ]?(\d{2,3})', text)
if wind_match:
result['wind_dir'] = f"{wind_match.group(1)} deg"
result['wind_speed'] = f"{wind_match.group(2)} kts"
# Airport codes (3-4 letter ICAO)
airports = re.findall(r'\b([A-Z]{3,4})\b', text)
if airports:
result['airports'] = list(dict.fromkeys(airports))[:4]
# Temperature (e.g., T24/D18, TMP24, TEMP -5)
temp_match = re.search(r'(?:TMP|TEMP|T)\s*([MP+-]?\d{1,3})', text)
if temp_match:
val = temp_match.group(1).replace('M', '-').replace('P', '')
result['temperature'] = f"{val} C"
# Visibility
vis_match = re.search(r'VIS\s*(\d+(?:\.\d+)?)', text)
if vis_match:
result['visibility'] = f"{vis_match.group(1)} SM"
return result if result else None
def parse_oooi(text: str | None) -> dict | None:
"""Parse 5Z OOOI (Out/Off/On/In) gate time messages.
Typical format: origin destination OUT OFF ON IN
e.g., KJFK KLAX 1423 1435 1812 1824
"""
if not text:
return None
result: dict = {}
# Try to find airport pair + 4 time blocks
oooi_match = re.search(
r'([A-Z]{3,4})\s+([A-Z]{3,4})\s+(\d{4})\s+(\d{4})\s+(\d{4})\s+(\d{4})',
text
)
if oooi_match:
result['origin'] = oooi_match.group(1)
result['destination'] = oooi_match.group(2)
result['out'] = oooi_match.group(3)
result['off'] = oooi_match.group(4)
result['on'] = oooi_match.group(5)
result['in'] = oooi_match.group(6)
return result
# Try partial (just origin/destination and some times)
partial = re.search(r'([A-Z]{3,4})\s+([A-Z]{3,4})', text)
if partial:
result['origin'] = partial.group(1)
result['destination'] = partial.group(2)
times = re.findall(r'\b(\d{4})\b', text)
labels = ['out', 'off', 'on', 'in']
for i, t in enumerate(times[:4]):
result[labels[i]] = t
return result if result else None
def translate_message(msg: dict) -> dict:
"""Translate an ACARS message dict, returning enrichment fields.
Args:
msg: Raw ACARS message dict with 'label', 'text'/'msg' fields.
Returns:
Dict with 'label_description', 'message_type', 'parsed'.
"""
label = msg.get('label')
text = msg.get('text') or msg.get('msg') or ''
label_description = translate_label(label)
message_type = classify_message_type(label, text)
parsed: dict | None = None
if message_type == 'position' or (label == 'H1' and 'POS' in text.upper()):
parsed = parse_position_report(text)
elif message_type == 'engine_data':
parsed = parse_engine_data(text)
elif message_type == 'weather':
parsed = parse_weather_data(text)
elif message_type == 'oooi':
parsed = parse_oooi(text)
return {
'label_description': label_description,
'message_type': message_type,
'parsed': parsed,
}

160
utils/airline_codes.py Normal file
View File

@@ -0,0 +1,160 @@
"""IATA ↔ ICAO airline code mapping for flight number translation."""
from __future__ import annotations
import re
# IATA (2-letter) → ICAO (3-letter) mapping for common airlines
# NOTE: Duplicated in templates/adsb_dashboard.html (JS IATA_TO_ICAO) — keep both in sync
IATA_TO_ICAO: dict[str, str] = {
# North America — Major
"AA": "AAL", # American Airlines
"DL": "DAL", # Delta Air Lines
"UA": "UAL", # United Airlines
"WN": "SWA", # Southwest Airlines
"B6": "JBU", # JetBlue Airways
"AS": "ASA", # Alaska Airlines
"NK": "NKS", # Spirit Airlines
"F9": "FFT", # Frontier Airlines
"G4": "AAY", # Allegiant Air
"HA": "HAL", # Hawaiian Airlines
"SY": "SCX", # Sun Country Airlines
"WS": "WJA", # WestJet
"AC": "ACA", # Air Canada
"WG": "WGN", # Sunwing Airlines
"TS": "TSC", # Air Transat
"PD": "POE", # Porter Airlines
"MX": "MXA", # Breeze Airways
"QX": "QXE", # Horizon Air
"OH": "COM", # PSA Airlines (Compass)
"OO": "SKW", # SkyWest Airlines
"YX": "RPA", # Republic Airways
"9E": "FLG", # Endeavor Air (Pinnacle)
"CP": "CPZ", # Compass Airlines
"PT": "SWQ", # Piedmont Airlines
"MQ": "ENY", # Envoy Air
"YV": "ASH", # Mesa Airlines
"AX": "LOF", # Trans States / GoJet
"ZW": "AWI", # Air Wisconsin
"G7": "GJS", # GoJet Airlines
"EV": "ASQ", # ExpressJet / Atlantic Southeast
"AM": "AMX", # Aeromexico
"VB": "VIV", # VivaAerobus
"4O": "AIJ", # Interjet
"Y4": "VOI", # Volaris
# North America — Cargo
"5X": "UPS", # UPS Airlines
"FX": "FDX", # FedEx Express
# Europe — Major
"BA": "BAW", # British Airways
"LH": "DLH", # Lufthansa
"AF": "AFR", # Air France
"KL": "KLM", # KLM Royal Dutch
"IB": "IBE", # Iberia
"AZ": "ITY", # ITA Airways
"SK": "SAS", # SAS Scandinavian
"AY": "FIN", # Finnair
"OS": "AUA", # Austrian Airlines
"LX": "SWR", # Swiss International
"SN": "BEL", # Brussels Airlines
"TP": "TAP", # TAP Air Portugal
"EI": "EIN", # Aer Lingus
"U2": "EZY", # easyJet
"FR": "RYR", # Ryanair
"W6": "WZZ", # Wizz Air
"VY": "VLG", # Vueling
"PC": "PGT", # Pegasus Airlines
"TK": "THY", # Turkish Airlines
"LO": "LOT", # LOT Polish
"BT": "BTI", # airBaltic
"DY": "NAX", # Norwegian Air Shuttle
"VS": "VIR", # Virgin Atlantic
"EW": "EWG", # Eurowings
# Asia-Pacific — Major
"SQ": "SIA", # Singapore Airlines
"CX": "CPA", # Cathay Pacific
"QF": "QFA", # Qantas
"JL": "JAL", # Japan Airlines
"NH": "ANA", # All Nippon Airways
"KE": "KAL", # Korean Air
"OZ": "AAR", # Asiana Airlines
"CI": "CAL", # China Airlines
"BR": "EVA", # EVA Air
"CZ": "CSN", # China Southern
"MU": "CES", # China Eastern
"CA": "CCA", # Air China
"AI": "AIC", # Air India
"GA": "GIA", # Garuda Indonesia
"TG": "THA", # Thai Airways
"MH": "MAS", # Malaysia Airlines
"PR": "PAL", # Philippine Airlines
"VN": "HVN", # Vietnam Airlines
"NZ": "ANZ", # Air New Zealand
"3K": "JSA", # Jetstar Asia
"JQ": "JST", # Jetstar Airways
"AK": "AXM", # AirAsia
"TR": "TGW", # Scoot
"5J": "CEB", # Cebu Pacific
# Middle East / Africa
"EK": "UAE", # Emirates
"QR": "QTR", # Qatar Airways
"EY": "ETD", # Etihad Airways
"GF": "GFA", # Gulf Air
"SV": "SVA", # Saudia
"ET": "ETH", # Ethiopian Airlines
"MS": "MSR", # EgyptAir
"SA": "SAA", # South African Airways
"RJ": "RJA", # Royal Jordanian
"WY": "OMA", # Oman Air
# South America
"LA": "LAN", # LATAM Airlines
"G3": "GLO", # Gol Transportes Aéreos
"AD": "AZU", # Azul Brazilian Airlines
"AV": "AVA", # Avianca
"CM": "CMP", # Copa Airlines
"AR": "ARG", # Aerolíneas Argentinas
}
# Build reverse mapping (ICAO → IATA)
ICAO_TO_IATA: dict[str, str] = {v: k for k, v in IATA_TO_ICAO.items()}
# Regex to split flight number into airline prefix and numeric part
_FLIGHT_RE = re.compile(r'^([A-Z]{2,3})(\d+[A-Z]?)$')
def translate_flight(flight: str) -> list[str]:
"""Translate a flight number to all possible equivalent forms.
Given "UA2412" (IATA), returns ["UAL2412"] (ICAO).
Given "UAL2412" (ICAO), returns ["UA2412"] (IATA).
Returns empty list if no translation found.
"""
if not flight:
return []
upper = flight.strip().upper()
m = _FLIGHT_RE.match(upper)
if not m:
return []
prefix, number = m.group(1), m.group(2)
results = []
# Try IATA → ICAO
if prefix in IATA_TO_ICAO:
results.append(IATA_TO_ICAO[prefix] + number)
# Try ICAO → IATA
if prefix in ICAO_TO_IATA:
results.append(ICAO_TO_IATA[prefix] + number)
return results
def expand_search_terms(terms: set[str]) -> set[str]:
"""Expand a set of callsign/flight search terms with translated variants."""
expanded = set(terms)
for term in list(terms):
for translated in translate_flight(term):
expanded.add(translated)
return expanded

View File

@@ -5,6 +5,8 @@ from __future__ import annotations
import time
from collections import deque
from utils.airline_codes import expand_search_terms, translate_flight
class FlightCorrelator:
"""Correlate ACARS and VDL2 messages with ADS-B aircraft."""
@@ -26,7 +28,10 @@ class FlightCorrelator:
})
def get_messages_for_aircraft(
self, icao: str | None = None, callsign: str | None = None
self,
icao: str | None = None,
callsign: str | None = None,
registration: str | None = None,
) -> dict[str, list[dict]]:
"""Match ACARS/VDL2 messages by callsign, flight, or registration fields."""
if not icao and not callsign:
@@ -37,6 +42,11 @@ class FlightCorrelator:
search_terms.add(callsign.strip().upper())
if icao:
search_terms.add(icao.strip().upper())
if registration:
search_terms.add(registration.strip().upper())
# Expand with IATA↔ICAO airline code translations
search_terms = expand_search_terms(search_terms)
acars = []
for msg in self._acars_messages:
@@ -55,8 +65,15 @@ class FlightCorrelator:
"""Check if any identifying field in msg matches the search terms."""
for field in ('flight', 'tail', 'reg', 'callsign', 'icao', 'addr'):
val = msg.get(field)
if val and str(val).strip().upper() in terms:
if not val:
continue
upper_val = str(val).strip().upper()
if upper_val in terms:
return True
# Also try translating the message field value
for translated in translate_flight(upper_val):
if translated in terms:
return True
return False
@staticmethod
@@ -64,6 +81,21 @@ class FlightCorrelator:
"""Return message without internal correlation fields."""
return {k: v for k, v in msg.items() if not k.startswith('_corr_')}
def get_recent_messages(self, msg_type: str = 'acars', limit: int = 50) -> list[dict]:
"""Return the most recent messages (newest first)."""
source = self._acars_messages if msg_type == 'acars' else self._vdl2_messages
msgs = [self._clean_msg(m) for m in source]
msgs.reverse()
return msgs[:limit]
def clear_acars(self) -> None:
"""Clear all stored ACARS messages."""
self._acars_messages.clear()
def clear_vdl2(self) -> None:
"""Clear all stored VDL2 messages."""
self._vdl2_messages.clear()
@property
def acars_count(self) -> int:
return len(self._acars_messages)