feat(adsb): improve ACARS/VDL2 panels with history, clear, smooth updates, and translation

- Persist ACARS/VDL2 messages across page refresh via new /acars/messages
  and /vdl2/messages endpoints backed by FlightCorrelator
- Add clear buttons to ACARS/VDL2 sidebars and right-panel datalink section
  with /acars/clear and /vdl2/clear endpoints
- Fix right-panel DATALINK MESSAGES flickering by diffing innerHTML before
  updating, with opacity transition for smooth refreshes
- Add aircraft deselect toggle (click selected aircraft again to deselect)
- Enrich VDL2 messages with ACARS label translation (label_description,
  message_type, parsed fields) matching existing ACARS translator

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mitch Ross
2026-02-20 23:48:50 -05:00
parent 01409cfdea
commit c2405bfe14
4 changed files with 186 additions and 18 deletions
+20
View File
@@ -443,6 +443,26 @@ def stream_acars() -> Response:
return response
@acars_bp.route('/messages')
def get_acars_messages() -> Response:
"""Get recent ACARS messages from correlator (for history reload)."""
from utils.flight_correlator import get_flight_correlator
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
from utils.flight_correlator import get_flight_correlator
get_flight_correlator().clear_acars()
acars_message_count = 0
return jsonify({'status': 'cleared'})
@acars_bp.route('/frequencies')
def get_frequencies() -> Response:
"""Get default ACARS frequencies."""
+36
View File
@@ -79,6 +79,22 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
data['type'] = 'vdl2'
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
# Enrich embedded ACARS payload with translated label
try:
vdl2_inner = data.get('vdl2', data)
acars_payload = (vdl2_inner.get('avlc') or {}).get('acars')
if acars_payload and acars_payload.get('label'):
from utils.acars_translator import translate_message
translation = translate_message({
'label': acars_payload.get('label'),
'text': acars_payload.get('msg_text', ''),
})
acars_payload['label_description'] = translation['label_description']
acars_payload['message_type'] = translation['message_type']
acars_payload['parsed'] = translation['parsed']
except Exception:
pass
# Update stats
vdl2_message_count += 1
vdl2_last_message_time = time.time()
@@ -370,6 +386,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)."""
from utils.flight_correlator import get_flight_correlator
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
from utils.flight_correlator import get_flight_correlator
get_flight_correlator().clear_vdl2()
vdl2_message_count = 0
return jsonify({'status': 'cleared'})
@vdl2_bp.route('/frequencies')
def get_frequencies() -> Response:
"""Get default VDL2 frequencies."""
+115 -18
View File
@@ -171,9 +171,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;">
@@ -221,9 +226,14 @@
<div id="vdl2FreqSelector" style="display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px; font-size: 9px;">
<!-- Frequency checkboxes populated by JS -->
</div>
<button class="vdl2-btn" id="vdl2ToggleBtn" onclick="toggleVdl2()" style="width: 100%;">
&#9654; START VDL2
</button>
<div style="display: flex; gap: 5px;">
<button class="vdl2-btn" id="vdl2ToggleBtn" onclick="toggleVdl2()" style="flex: 1;">
&#9654; START VDL2
</button>
<button class="vdl2-btn" id="vdl2ClearBtn" onclick="clearVdl2Messages()" title="Clear messages" style="padding: 4px 8px; font-size: 9px; opacity: 0.7;">
CLEAR
</button>
</div>
</div>
<div class="vdl2-messages" id="vdl2Messages">
<div class="no-aircraft" style="padding: 20px; text-align: center;">
@@ -2786,6 +2796,31 @@ sudo make install</code>
}
function selectAircraft(icao) {
// Toggle: clicking the same aircraft deselects it
if (selectedIcao === icao) {
const prev = selectedIcao;
selectedIcao = null;
// Reset previous marker icon
if (prev && markers[prev] && aircraft[prev]) {
const ac = aircraft[prev];
const militaryInfo = isMilitaryAircraft(prev, ac.callsign);
const rotation = Math.round((ac.heading || 0) / 5) * 5;
const color = militaryInfo.military ? '#556b2f' : getAltitudeColor(ac.altitude);
const iconType = getAircraftIconType(ac.type_code, militaryInfo.military);
markers[prev].setIcon(createMarkerIcon(rotation, color, iconType, false));
if (markerState[prev]) markerState[prev].isSelected = false;
}
renderAircraftList();
showAircraftDetails(null);
updateFlightLookupBtn();
highlightSidebarMessages(null);
if (acarsMessageTimer) {
clearInterval(acarsMessageTimer);
acarsMessageTimer = null;
}
return;
}
const prevSelected = selectedIcao;
selectedIcao = icao;
@@ -2915,8 +2950,11 @@ sudo make install</code>
</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 style="display:flex;justify-content:space-between;align-items:center;font-size:10px;font-weight:600;color:var(--accent-cyan);text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;border-top:1px solid var(--border-color);padding-top:8px;">
<span>Datalink Messages</span>
<button onclick="clearAircraftMessages()" title="Clear datalink messages" style="background:none;border:1px solid var(--border-color);color:var(--text-muted);cursor:pointer;font-size:9px;padding:1px 5px;border-radius:3px;font-family:var(--font-mono);line-height:1;">&#10005;</button>
</div>
<div id="aircraftAcarsMessages" style="font-size:10px;color:var(--text-dim);transition:opacity 0.15s ease;">Loading...</div>
</div>`;
// Fetch aircraft photo if registration is available
@@ -2945,18 +2983,23 @@ sudo make install</code>
const container = document.getElementById('aircraftAcarsMessages');
if (!container) return;
const msgs = (data.acars || []).concat(data.vdl2 || []);
let newHtml;
if (msgs.length === 0) {
container.innerHTML = '<span style="color:var(--text-muted);font-style:italic;">No messages yet</span>';
return;
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);
}
// 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>';
});
.catch(() => { /* keep existing content on error */ });
}
doFetch();
@@ -3532,6 +3575,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(() => {});
@@ -3887,6 +3941,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')
@@ -4213,13 +4294,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
@@ -4352,11 +4434,16 @@ sudo make install</code>
(matchedIcao ? ' cursor: pointer;' : '');
const linkIcon = matchedIcao ? '<span style="color:var(--accent-green);font-size:9px;margin-left:3px;" title="Tracked on map">&#9992;</span>' : '';
const acarsLabelDesc = acars.label_description || '';
const acarsMsgType = acars.message_type || 'other';
const vdl2TypeBadge = typeof getAcarsTypeBadge === 'function' && acars.label ? getAcarsTypeBadge(acarsMsgType) : '';
msg.innerHTML = `
<div style="display: flex; justify-content: space-between; margin-bottom: 2px;">
<span style="color: var(--accent-cyan); font-weight: bold;">${escapeHtml(label)}${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>
@@ -4430,6 +4517,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(() => {});
+15
View File
@@ -81,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)