From d28d8cb9ef55c852f6e25360e7b271c6ca789e39 Mon Sep 17 00:00:00 2001 From: mitchross Date: Fri, 20 Feb 2026 11:11:57 -0500 Subject: [PATCH] feat(acars): add message translator and ADS-B datalink integration Add ACARS label translation, message classification, and field parsers so decoded messages show human-readable descriptions instead of raw label codes (H1, DF, _d, 5Z, etc.). Integrate translated ACARS messages into the ADS-B aircraft detail panel and add a live message feed to the standalone ACARS mode. - New utils/acars_translator.py with ~50 label codes, type classifier, and parsers for position reports, engine data, weather, and OOOI - Enrich messages at ingest in routes/acars.py with translation fields - Backfill translation in /adsb/aircraft//messages endpoint - ADS-B dashboard: DATALINK MESSAGES section in aircraft detail panel with auto-refresh, color-coded type badges, and parsed field display - Standalone ACARS mode: scrollable live message feed (max 30 cards) - Fix default N. America ACARS frequencies to 131.550/130.025/129.125 - Unit tests covering all translator functions Co-Authored-By: Claude Opus 4.6 --- .env.example | 34 ++- docker-compose.yml | 3 + routes/acars.py | 59 +++-- routes/adsb.py | 13 + static/css/modes/acars.css | 19 ++ templates/adsb_dashboard.html | 126 +++++++++- templates/partials/modes/acars.html | 77 +++++- tests/test_acars_translator.py | 265 ++++++++++++++++++++ utils/acars_translator.py | 374 ++++++++++++++++++++++++++++ 9 files changed, 940 insertions(+), 30 deletions(-) create mode 100644 tests/test_acars_translator.py create mode 100644 utils/acars_translator.py diff --git a/.env.example b/.env.example index 8e2c1ff..e78804b 100644 --- a/.env.example +++ b/.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 diff --git a/docker-compose.yml b/docker-compose.yml index b6318ba..99b98cb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,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 @@ -78,6 +79,7 @@ services: volumes: - ./data:/app/data environment: + - TZ=${TZ:-UTC} - INTERCEPT_HOST=0.0.0.0 - INTERCEPT_PORT=5050 - INTERCEPT_LOG_LEVEL=INFO @@ -108,6 +110,7 @@ services: profiles: - history environment: + - TZ=${TZ:-UTC} - POSTGRES_DB=intercept_adsb - POSTGRES_USER=intercept - POSTGRES_PASSWORD=intercept diff --git a/routes/acars.py b/routes/acars.py index 7b9da78..db05df0 100644 --- a/routes/acars.py +++ b/routes/acars.py @@ -21,7 +21,7 @@ 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.sse import sse_stream_fanout from utils.event_pipeline import process_event from utils.constants import ( PROCESS_TERMINATE_TIMEOUT, @@ -33,10 +33,11 @@ from utils.process import register_process, unregister_process 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', # North America primary + '130.025', # North America secondary + '129.125', # North America tertiary ] # Message counter for statistics @@ -120,6 +121,16 @@ 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: + from utils.acars_translator import translate_message + 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() @@ -411,25 +422,25 @@ def stop_acars() -> Response: return jsonify({'status': 'stopped'}) -@acars_bp.route('/stream') -def stream_acars() -> Response: - """SSE stream for ACARS messages.""" - def _on_msg(msg: dict[str, Any]) -> None: - process_event('acars', msg, msg.get('type')) - - response = Response( - sse_stream_fanout( - source_queue=app_module.acars_queue, - channel_key='acars', - timeout=SSE_QUEUE_TIMEOUT, - keepalive_interval=SSE_KEEPALIVE_INTERVAL, - on_message=_on_msg, - ), - mimetype='text/event-stream', - ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - return response +@acars_bp.route('/stream') +def stream_acars() -> Response: + """SSE stream for ACARS messages.""" + def _on_msg(msg: dict[str, Any]) -> None: + process_event('acars', msg, msg.get('type')) + + response = Response( + sse_stream_fanout( + source_queue=app_module.acars_queue, + channel_key='acars', + timeout=SSE_QUEUE_TIMEOUT, + keepalive_interval=SSE_KEEPALIVE_INTERVAL, + on_message=_on_msg, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + return response @acars_bp.route('/frequencies') @@ -438,7 +449,7 @@ def get_frequencies() -> Response: return jsonify({ 'default': DEFAULT_ACARS_FREQUENCIES, 'regions': { - 'north_america': ['131.725', '131.825'], + 'north_america': ['131.550', '130.025', '129.125'], 'europe': ['131.525', '131.725', '131.550'], 'asia_pacific': ['131.550', '131.450'], } diff --git a/routes/adsb.py b/routes/adsb.py index 65e44a2..0cdfd6d 100644 --- a/routes/adsb.py +++ b/routes/adsb.py @@ -1189,4 +1189,17 @@ def get_aircraft_messages(icao: str): from utils.flight_correlator import get_flight_correlator messages = get_flight_correlator().get_messages_for_aircraft(icao=icao.upper(), callsign=callsign) + + # Backfill translation on messages missing label_description + try: + from utils.acars_translator import translate_message + 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 + return jsonify({'status': 'success', 'icao': icao.upper(), **messages}) diff --git a/static/css/modes/acars.css b/static/css/modes/acars.css index 043bb68..bdbaef5 100644 --- a/static/css/modes/acars.css +++ b/static/css/modes/acars.css @@ -89,3 +89,22 @@ 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); +} diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html index 11940ef..17b0329 100644 --- a/templates/adsb_dashboard.html +++ b/templates/adsb_dashboard.html @@ -2891,12 +2891,130 @@ sudo make install
Range
${ac.lat ? calculateDistanceNm(observerLocation.lat, observerLocation.lon, ac.lat, ac.lon).toFixed(1) + ' nm' : 'N/A'}
+ +
+
Datalink Messages
+
Loading...
`; // 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 || []); + if (msgs.length === 0) { + container.innerHTML = 'No messages yet'; + return; + } + // Sort newest first + msgs.sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || '')); + container.innerHTML = msgs.slice(0, 20).map(renderAcarsCard).join(''); + }) + .catch(() => { + const container = document.getElementById('aircraftAcarsMessages'); + if (container) container.innerHTML = ''; + }); + } + + 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 '' + lbl + ''; + } + + function renderAcarsCard(msg) { + const type = msg.message_type || 'other'; + const badge = getAcarsTypeBadge(type); + const desc = msg.label_description || ('Label ' + (msg.label || '?')); + const text = msg.text || msg.msg || ''; + const truncText = text.length > 120 ? text.substring(0, 120) + '...' : text; + const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : ''; + const flight = msg.flight || ''; + + let parsedHtml = ''; + if (msg.parsed) { + const p = msg.parsed; + if (type === 'position' && p.lat !== undefined) { + parsedHtml = '
' + + p.lat.toFixed(4) + ', ' + p.lon.toFixed(4) + + (p.flight_level ? ' • ' + p.flight_level : '') + + (p.destination ? ' → ' + p.destination : '') + '
'; + } else if (type === 'engine_data') { + const parts = []; + Object.keys(p).forEach(k => { + parts.push(k + ': ' + p[k].value); + }); + if (parts.length) { + parsedHtml = '
' + parts.slice(0, 4).join(' | ') + '
'; + } + } else if (type === 'oooi' && p.origin) { + parsedHtml = '
' + + p.origin + ' → ' + p.destination + + (p.out ? ' | OUT ' + p.out : '') + + (p.off ? ' OFF ' + p.off : '') + + (p.on ? ' ON ' + p.on : '') + + (p['in'] ? ' IN ' + p['in'] : '') + '
'; + } + } + + return '
' + + '
' + + '' + badge + ' ' + desc + '' + + '' + time + '
' + + (flight ? '
' + flight + '
' : '') + + parsedHtml + + (truncText && type !== 'link_test' && type !== 'handshake' ? + '
' + truncText + '
' : '') + + '
'; } // Cache for aircraft photos to avoid repeated API calls @@ -3647,17 +3765,21 @@ sudo make install 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(); + const typeBadge = typeof getAcarsTypeBadge === 'function' ? getAcarsTypeBadge(msgType) : ''; + msg.innerHTML = `
${flight} ${time}
${reg ? `
Reg: ${reg}
` : ''} - ${label ? `
Label: ${label}
` : ''} - ${text ? `
${text}
` : ''} +
${typeBadge} ${labelDesc || (label ? 'Label: ' + label : '')}
+ ${text && msgType !== 'link_test' && msgType !== 'handshake' ? `
${text.length > 80 ? text.substring(0, 80) + '...' : text}
` : ''} `; container.insertBefore(msg, container.firstChild); diff --git a/templates/partials/modes/acars.html b/templates/partials/modes/acars.html index 6627bac..b650f65 100644 --- a/templates/partials/modes/acars.html +++ b/templates/partials/modes/acars.html @@ -57,7 +57,7 @@ - + @@ -82,6 +82,14 @@ + + +
+

Message Feed

+
+
Start ACARS to see live messages
+
+
Primary (N. America)131.725 / 131.825 MHz131.550 / 130.025 MHz
Quarter-wave length