mirror of
https://github.com/smittix/intercept.git
synced 2026-07-24 08:58:09 -07:00
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/<icao>/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 <noreply@anthropic.com>
This commit is contained in:
+32
-2
@@ -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
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ services:
|
|||||||
# Optional: mount logs directory
|
# Optional: mount logs directory
|
||||||
# - ./logs:/app/logs
|
# - ./logs:/app/logs
|
||||||
environment:
|
environment:
|
||||||
|
- TZ=${TZ:-UTC}
|
||||||
- INTERCEPT_HOST=0.0.0.0
|
- INTERCEPT_HOST=0.0.0.0
|
||||||
- INTERCEPT_PORT=5050
|
- INTERCEPT_PORT=5050
|
||||||
- INTERCEPT_LOG_LEVEL=INFO
|
- INTERCEPT_LOG_LEVEL=INFO
|
||||||
@@ -78,6 +79,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
environment:
|
environment:
|
||||||
|
- TZ=${TZ:-UTC}
|
||||||
- INTERCEPT_HOST=0.0.0.0
|
- INTERCEPT_HOST=0.0.0.0
|
||||||
- INTERCEPT_PORT=5050
|
- INTERCEPT_PORT=5050
|
||||||
- INTERCEPT_LOG_LEVEL=INFO
|
- INTERCEPT_LOG_LEVEL=INFO
|
||||||
@@ -108,6 +110,7 @@ services:
|
|||||||
profiles:
|
profiles:
|
||||||
- history
|
- history
|
||||||
environment:
|
environment:
|
||||||
|
- TZ=${TZ:-UTC}
|
||||||
- POSTGRES_DB=intercept_adsb
|
- POSTGRES_DB=intercept_adsb
|
||||||
- POSTGRES_USER=intercept
|
- POSTGRES_USER=intercept
|
||||||
- POSTGRES_PASSWORD=intercept
|
- POSTGRES_PASSWORD=intercept
|
||||||
|
|||||||
+15
-4
@@ -33,10 +33,11 @@ from utils.process import register_process, unregister_process
|
|||||||
|
|
||||||
acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
|
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 = [
|
DEFAULT_ACARS_FREQUENCIES = [
|
||||||
'131.725', # North America
|
'131.550', # North America primary
|
||||||
'131.825', # North America
|
'130.025', # North America secondary
|
||||||
|
'129.125', # North America tertiary
|
||||||
]
|
]
|
||||||
|
|
||||||
# Message counter for statistics
|
# Message counter for statistics
|
||||||
@@ -120,6 +121,16 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
|||||||
data['type'] = 'acars'
|
data['type'] = 'acars'
|
||||||
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
|
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
|
# Update stats
|
||||||
acars_message_count += 1
|
acars_message_count += 1
|
||||||
acars_last_message_time = time.time()
|
acars_last_message_time = time.time()
|
||||||
@@ -438,7 +449,7 @@ def get_frequencies() -> Response:
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
'default': DEFAULT_ACARS_FREQUENCIES,
|
'default': DEFAULT_ACARS_FREQUENCIES,
|
||||||
'regions': {
|
'regions': {
|
||||||
'north_america': ['131.725', '131.825'],
|
'north_america': ['131.550', '130.025', '129.125'],
|
||||||
'europe': ['131.525', '131.725', '131.550'],
|
'europe': ['131.525', '131.725', '131.550'],
|
||||||
'asia_pacific': ['131.550', '131.450'],
|
'asia_pacific': ['131.550', '131.450'],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1189,4 +1189,17 @@ def get_aircraft_messages(icao: str):
|
|||||||
|
|
||||||
from utils.flight_correlator import get_flight_correlator
|
from utils.flight_correlator import get_flight_correlator
|
||||||
messages = get_flight_correlator().get_messages_for_aircraft(icao=icao.upper(), callsign=callsign)
|
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})
|
return jsonify({'status': 'success', 'icao': icao.upper(), **messages})
|
||||||
|
|||||||
@@ -89,3 +89,22 @@
|
|||||||
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); }
|
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); }
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2891,12 +2891,130 @@ sudo make install</code>
|
|||||||
<div class="telemetry-label">Range</div>
|
<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 class="telemetry-value">${ac.lat ? calculateDistanceNm(observerLocation.lat, observerLocation.lon, ac.lat, ac.lon).toFixed(1) + ' nm' : 'N/A'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="aircraftAcarsSection" style="margin-top:12px;">
|
||||||
|
<div style="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;">Datalink Messages</div>
|
||||||
|
<div id="aircraftAcarsMessages" style="font-size:10px;color:var(--text-dim);">Loading...</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
// Fetch aircraft photo if registration is available
|
// Fetch aircraft photo if registration is available
|
||||||
if (registration) {
|
if (registration) {
|
||||||
fetchAircraftPhoto(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 = '<span style="color:var(--text-muted);font-style:italic;">No messages yet</span>';
|
||||||
|
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 = '<span style="color:var(--text-muted);">—</span>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '<div style="color:var(--accent-green);margin-top:2px;">' +
|
||||||
|
p.lat.toFixed(4) + ', ' + p.lon.toFixed(4) +
|
||||||
|
(p.flight_level ? ' • ' + p.flight_level : '') +
|
||||||
|
(p.destination ? ' → ' + p.destination : '') + '</div>';
|
||||||
|
} else if (type === 'engine_data') {
|
||||||
|
const parts = [];
|
||||||
|
Object.keys(p).forEach(k => {
|
||||||
|
parts.push(k + ': ' + 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;">' +
|
||||||
|
p.origin + ' → ' + p.destination +
|
||||||
|
(p.out ? ' | OUT ' + p.out : '') +
|
||||||
|
(p.off ? ' OFF ' + p.off : '') +
|
||||||
|
(p.on ? ' ON ' + p.on : '') +
|
||||||
|
(p['in'] ? ' IN ' + 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
|
// Cache for aircraft photos to avoid repeated API calls
|
||||||
@@ -3647,17 +3765,21 @@ sudo make install</code>
|
|||||||
const flight = data.flight || 'UNKNOWN';
|
const flight = data.flight || 'UNKNOWN';
|
||||||
const reg = data.reg || '';
|
const reg = data.reg || '';
|
||||||
const label = data.label || '';
|
const label = data.label || '';
|
||||||
|
const labelDesc = data.label_description || '';
|
||||||
|
const msgType = data.message_type || 'other';
|
||||||
const text = data.text || data.msg || '';
|
const text = data.text || data.msg || '';
|
||||||
const time = new Date().toLocaleTimeString();
|
const time = new Date().toLocaleTimeString();
|
||||||
|
|
||||||
|
const typeBadge = typeof getAcarsTypeBadge === 'function' ? getAcarsTypeBadge(msgType) : '';
|
||||||
|
|
||||||
msg.innerHTML = `
|
msg.innerHTML = `
|
||||||
<div style="display: flex; justify-content: space-between; margin-bottom: 2px;">
|
<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;">${flight}</span>
|
||||||
<span style="color: var(--text-muted);">${time}</span>
|
<span style="color: var(--text-muted);">${time}</span>
|
||||||
</div>
|
</div>
|
||||||
${reg ? `<div style="color: var(--text-muted); font-size: 9px;">Reg: ${reg}</div>` : ''}
|
${reg ? `<div style="color: var(--text-muted); font-size: 9px;">Reg: ${reg}</div>` : ''}
|
||||||
${label ? `<div style="color: var(--accent-green);">Label: ${label}</div>` : ''}
|
<div style="margin-top: 2px;">${typeBadge} <span style="color: var(--text-primary);">${labelDesc || (label ? 'Label: ' + label : '')}</span></div>
|
||||||
${text ? `<div style="color: var(--text-primary); margin-top: 3px; word-break: break-word;">${text}</div>` : ''}
|
${text && msgType !== 'link_test' && msgType !== 'handshake' ? `<div style="color: var(--text-dim); margin-top: 3px; word-break: break-word; font-size: 9px;">${text.length > 80 ? text.substring(0, 80) + '...' : text}</div>` : ''}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
container.insertBefore(msg, container.firstChild);
|
container.insertBefore(msg, container.firstChild);
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
||||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
<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-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>
|
||||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter-wave length</td>
|
<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;">
|
<button class="stop-btn" id="stopAcarsBtn" onclick="stopAcarsMode()" style="display: none;">
|
||||||
Stop ACARS
|
Stop ACARS
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -89,7 +97,7 @@
|
|||||||
let acarsMainMsgCount = 0;
|
let acarsMainMsgCount = 0;
|
||||||
|
|
||||||
const acarsMainFrequencies = {
|
const acarsMainFrequencies = {
|
||||||
'na': ['131.725', '131.825'],
|
'na': ['131.550', '130.025', '129.125'],
|
||||||
'eu': ['131.525', '131.725', '131.550'],
|
'eu': ['131.525', '131.725', '131.550'],
|
||||||
'ap': ['131.550', '131.450']
|
'ap': ['131.550', '131.450']
|
||||||
};
|
};
|
||||||
@@ -164,9 +172,64 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAcarsMainCard(data) {
|
||||||
|
const flight = data.flight || 'UNKNOWN';
|
||||||
|
const type = data.message_type || 'other';
|
||||||
|
const badge = acarsMainTypeBadge(type);
|
||||||
|
const desc = data.label_description || (data.label ? 'Label: ' + data.label : '');
|
||||||
|
const text = data.text || data.msg || '';
|
||||||
|
const truncText = 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 ? ' • ' + p.flight_level : ''}${p.destination ? ' → ' + p.destination : ''}</div>`;
|
||||||
|
} else if (type === 'engine_data') {
|
||||||
|
const parts = [];
|
||||||
|
Object.keys(p).forEach(k => parts.push(k + ': ' + 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;">${p.origin} → ${p.destination}${p.out ? ' | OUT ' + p.out : ''}${p.off ? ' OFF ' + p.off : ''}${p.on ? ' ON ' + p.on : ''}${p['in'] ? ' IN ' + 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() {
|
function startAcarsMainSSE() {
|
||||||
if (acarsMainEventSource) acarsMainEventSource.close();
|
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 = new EventSource('/acars/stream');
|
||||||
acarsMainEventSource.onmessage = function(e) {
|
acarsMainEventSource.onmessage = function(e) {
|
||||||
try {
|
try {
|
||||||
@@ -174,6 +237,16 @@
|
|||||||
if (data.type === 'acars') {
|
if (data.type === 'acars') {
|
||||||
acarsMainMsgCount++;
|
acarsMainMsgCount++;
|
||||||
document.getElementById('acarsMessageCount').textContent = 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) {}
|
} catch (err) {}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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_link_test(self):
|
||||||
|
assert classify_message_type('_d') == 'link_test'
|
||||||
|
|
||||||
|
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'] in ('link_test', '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'
|
||||||
@@ -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 label == 'H1' or (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 ('_d', '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,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user