mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
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:
34
.env.example
34
.env.example
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
120
docs/HARDWARE.md
120
docs/HARDWARE.md
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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%;">
|
||||
▶ START VDL2
|
||||
</button>
|
||||
<div style="display: flex; gap: 5px;">
|
||||
<button class="vdl2-btn" id="vdl2ToggleBtn" onclick="toggleVdl2()" style="flex: 1;">
|
||||
▶ 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;">✕</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">✈</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">✈</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
|
||||
|
||||
@@ -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 ? ' • ' + 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:#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))} → ${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) {}
|
||||
};
|
||||
|
||||
265
tests/test_acars_translator.py
Normal file
265
tests/test_acars_translator.py
Normal 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
374
utils/acars_translator.py
Normal 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
160
utils/airline_codes.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user