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 78f55e3..50602e9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/HARDWARE.md b/docs/HARDWARE.md index 0b91140..6f7e08b 100644 --- a/docs/HARDWARE.md +++ b/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 ` | 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 diff --git a/routes/acars.py b/routes/acars.py index 5ffabef..88b91c7 100644 --- a/routes/acars.py +++ b/routes/acars.py @@ -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'], } diff --git a/routes/adsb.py b/routes/adsb.py index 1c97020..26575c9 100644 --- a/routes/adsb.py +++ b/routes/adsb.py @@ -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}) diff --git a/routes/vdl2.py b/routes/vdl2.py index 5f22878..2d0eae2 100644 --- a/routes/vdl2.py +++ b/routes/vdl2.py @@ -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.""" diff --git a/static/css/modes/acars.css b/static/css/modes/acars.css index 043bb68..28bc85f 100644 --- a/static/css/modes/acars.css +++ b/static/css/modes/acars.css @@ -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); +} diff --git a/static/js/modes/weather-satellite.js b/static/js/modes/weather-satellite.js index ea39d3f..5627e88 100644 --- a/static/js/modes/weather-satellite.js +++ b/static/js/modes/weather-satellite.js @@ -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'; diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html index 6ce0a96..4a4d3c8 100644 --- a/templates/adsb_dashboard.html +++ b/templates/adsb_dashboard.html @@ -173,9 +173,14 @@
- +
+ + +
@@ -223,9 +228,14 @@
- +
+ + +
@@ -2841,6 +2851,9 @@ sudo make install // Agent badge if aircraft came from remote agent const agentBadge = ac._agent ? `${ac._agent}` : ''; + // ACARS indicator if this aircraft has datalink messages + const acarsIndicator = (typeof acarsAircraftIcaos !== 'undefined' && acarsAircraftIcaos.has(ac.icao)) ? + `DLK` : ''; // Vertical rate indicator: arrow up (climbing), arrow down (descending), or dash (level) let vsIndicator = '-'; let vsColor = ''; @@ -2851,8 +2864,8 @@ sudo make install return `
- ${callsign}${badge}${agentBadge} - ${typeCode ? typeCode + ' • ' : ''}${ac.icao} + ${escapeHtml(callsign)}${badge}${acarsIndicator}${agentBadge} + ${typeCode ? escapeHtml(typeCode) + ' • ' : ''}${escapeHtml(ac.icao)}
@@ -3029,6 +3042,7 @@ sudo make install 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 } } + 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
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 || []); + let newHtml; + if (msgs.length === 0) { + newHtml = 'No messages yet'; + } 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 '' + lbl + ''; + } + + // 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 = '
' + + p.lat.toFixed(4) + ', ' + p.lon.toFixed(4) + + (p.flight_level ? ' • ' + escapeHtml(String(p.flight_level)) : '') + + (p.destination ? ' → ' + escapeHtml(String(p.destination)) : '') + '
'; + } 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 = '
' + parts.slice(0, 4).join(' | ') + '
'; + } + } else if (type === 'oooi' && p.origin) { + parsedHtml = '
' + + 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'])) : '') + '
'; + } + } + + return '
' + + '
' + + '' + badge + ' ' + desc + '' + + '' + time + '
' + + (flight ? '
' + flight + '
' : '') + + parsedHtml + + (truncText && type !== 'link_test' && type !== 'handshake' ? + '
' + truncText + '
' : '') + + '
'; } // Cache for aircraft photos to avoid repeated API calls @@ -3188,12 +3347,15 @@ sudo make install 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 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 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 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 `