- ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions.
-
-
- Receive and decode Meteor LRPT weather imagery.
- Uses SatDump for live SDR capture and image processing, and also shows Meteor imagery produced by the ground-station scheduler.
-
+
+ ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions.
+
+
+ Receive and decode Meteor LRPT weather imagery.
+ Uses SatDump for live SDR capture and image processing, and also shows Meteor imagery produced by the ground-station scheduler.
+
@@ -18,8 +18,9 @@
Satellite
-
+
+
+
@@ -69,7 +70,7 @@
Antenna Guide
Connection: Solder elements to coax center + shield, connect to SDR via SMA
- Best starter antenna. Good enough for a clean Meteor LRPT pass when the satellite gets high overhead.
+ Best starter antenna. Good enough for a clean Meteor LRPT pass when the satellite gets high overhead.
@@ -132,8 +133,8 @@
Antenna Guide
Antenna up: Point the antenna straight UP (zenith) for best overhead coverage
Avoid: Metal roofs, power lines, buildings blocking the sky
Coax length: Keep short (<10m). Signal loss at 137 MHz is ~3 dB per 10m of RG-58
-
LNA: Mount at the antenna feed point, NOT at the SDR end.
- Recommended: a low-noise 137 MHz filtered LNA near the antenna feed point
+
LNA: Mount at the antenna feed point, NOT at the SDR end.
+ Recommended: a low-noise 137 MHz filtered LNA near the antenna feed point
Bias-T: Enable the Bias-T checkbox above if your LNA is powered via the coax from the SDR
@@ -162,9 +163,9 @@
Antenna Guide
Polarization
RHCP
-
-
Meteor (LRPT) bandwidth
-
~140 kHz
+
+
Meteor (LRPT) bandwidth
+
~140 kHz
@@ -177,29 +178,30 @@
▼
-
- Decode a pre-recorded Meteor IQ file without SDR hardware.
- Shared ground-station recordings are also accepted by the backend.
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ Decode a pre-recorded Meteor IQ file without SDR hardware.
+ Shared ground-station recordings are also accepted by the backend.
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -223,15 +225,25 @@
Auto-Scheduler
+
+
Debug / Test
+
+ Load sample pass data and console output to test the UI without an SDR or live satellite pass.
+
-
+
+ SatDump Documentation
+
+
+ Meteor Reception Guide
+
+
+
+
diff --git a/utils/weather_sat.py b/utils/weather_sat.py
index cdb33440..ff3fbb24 100644
--- a/utils/weather_sat.py
+++ b/utils/weather_sat.py
@@ -1,14 +1,14 @@
-"""Weather satellite decoder focused on Meteor LRPT workflows.
-
-Provides automated capture and decoding of weather imagery using SatDump.
-
-Active satellites:
- - Meteor-M2-3: 137.900 MHz (LRPT)
- - Meteor-M2-4: 137.900 MHz (LRPT)
-
-Legacy NOAA APT entries remain in ``WEATHER_SATELLITES`` for compatibility
-and historical metadata, but they are no longer active operational targets.
-"""
+"""Weather satellite decoder focused on Meteor LRPT workflows.
+
+Provides automated capture and decoding of weather imagery using SatDump.
+
+Active satellites:
+ - Meteor-M2-3: 137.900 MHz (LRPT)
+ - Meteor-M2-4: 137.900 MHz (LRPT)
+
+Legacy NOAA APT entries remain in ``WEATHER_SATELLITES`` for compatibility
+and historical metadata, but they are no longer active operational targets.
+"""
from __future__ import annotations
@@ -29,17 +29,17 @@
from utils.logging import get_logger
from utils.process import register_process, safe_terminate
-logger = get_logger('intercept.weather_sat')
-
-PROJECT_ROOT = Path(__file__).resolve().parent.parent
-ALLOWED_OFFLINE_INPUT_DIRS = (
- PROJECT_ROOT / 'data',
- PROJECT_ROOT / 'instance' / 'ground_station' / 'recordings',
-)
+logger = get_logger('intercept.weather_sat')
+PROJECT_ROOT = Path(__file__).resolve().parent.parent
+ALLOWED_OFFLINE_INPUT_DIRS = (
+ PROJECT_ROOT / 'data',
+ PROJECT_ROOT / 'instance' / 'ground_station' / 'recordings',
+)
-# Weather satellite definitions.
-# NOAA APT entries are retained as inactive compatibility metadata.
+
+# Weather satellite definitions.
+# NOAA APT entries are retained as inactive compatibility metadata.
WEATHER_SATELLITES = {
'NOAA-15': {
'name': 'NOAA 15',
@@ -86,6 +86,15 @@
'description': 'Meteor-M2-4 LRPT (digital color imagery)',
'active': True,
},
+ 'METEOR-M2-4-80K': {
+ 'name': 'Meteor-M2-4 (80k)',
+ 'frequency': 137.900,
+ 'mode': 'LRPT',
+ 'pipeline': 'meteor_m2-x_lrpt_80k',
+ 'tle_key': 'METEOR-M2-4',
+ 'description': 'Meteor-M2-4 LRPT 80k baud (fallback symbol rate)',
+ 'active': True,
+ },
}
# Default sample rate for weather satellite reception
@@ -153,12 +162,12 @@ def to_dict(self) -> dict:
return result
-class WeatherSatDecoder:
- """Weather satellite decoder using SatDump CLI.
-
- Manages live SDR capture and offline decode for the active Meteor LRPT
- workflow, while preserving compatibility with older weather-sat metadata.
- """
+class WeatherSatDecoder:
+ """Weather satellite decoder using SatDump CLI.
+
+ Manages live SDR capture and offline decode for the active Meteor LRPT
+ workflow, while preserving compatibility with older weather-sat metadata.
+ """
def __init__(self, output_dir: str | Path | None = None):
self._process: subprocess.Popen | None = None
@@ -175,14 +184,14 @@ def __init__(self, output_dir: str | Path | None = None):
self._pty_master_fd: int | None = None
self._current_satellite: str = ''
self._current_frequency: float = 0.0
- self._current_mode: str = ''
- self._capture_start_time: float = 0
- self._device_index: int = -1
- self._capture_output_dir: Path | None = None
- self._on_complete_callback: Callable[[], None] | None = None
- self._capture_phase: str = 'idle'
- self._last_error_message: str = ''
- self._last_process_returncode: int | None = None
+ self._current_mode: str = ''
+ self._capture_start_time: float = 0
+ self._device_index: int = -1
+ self._capture_output_dir: Path | None = None
+ self._on_complete_callback: Callable[[], None] | None = None
+ self._capture_phase: str = 'idle'
+ self._last_error_message: str = ''
+ self._last_process_returncode: int | None = None
# Ensure output directory exists
self._output_dir.mkdir(parents=True, exist_ok=True)
@@ -251,7 +260,7 @@ def start_from_file(
No SDR hardware is required — SatDump runs in offline mode.
Args:
- satellite: Satellite key (for example ``'METEOR-M2-3'``)
+ satellite: Satellite key (for example ``'METEOR-M2-3'``)
input_file: Path to IQ baseband or WAV audio file
sample_rate: Sample rate of the recording in Hz
@@ -283,16 +292,16 @@ def start_from_file(
input_path = Path(input_file)
- # Security: restrict offline decode inputs to application-owned
- # capture directories so external paths cannot be injected.
- try:
- resolved = input_path.resolve()
- if not any(resolved.is_relative_to(base) for base in ALLOWED_OFFLINE_INPUT_DIRS):
- logger.warning(f"Path traversal blocked in start_from_file: {input_file}")
- msg = 'Input file must be under INTERCEPT data or ground-station recordings'
- self._emit_progress(CaptureProgress(
- status='error',
- message=msg,
+ # Security: restrict offline decode inputs to application-owned
+ # capture directories so external paths cannot be injected.
+ try:
+ resolved = input_path.resolve()
+ if not any(resolved.is_relative_to(base) for base in ALLOWED_OFFLINE_INPUT_DIRS):
+ logger.warning(f"Path traversal blocked in start_from_file: {input_file}")
+ msg = 'Input file must be under INTERCEPT data or ground-station recordings'
+ self._emit_progress(CaptureProgress(
+ status='error',
+ message=msg,
))
return False, msg
except (OSError, ValueError):
@@ -314,13 +323,13 @@ def start_from_file(
self._current_satellite = satellite
self._current_frequency = sat_info['frequency']
- self._current_mode = sat_info['mode']
- self._device_index = -1 # Offline decode does not claim an SDR device
- self._capture_start_time = time.time()
- self._capture_phase = 'decoding'
- self._last_error_message = ''
- self._last_process_returncode = None
- self._stop_event.clear()
+ self._current_mode = sat_info['mode']
+ self._device_index = -1 # Offline decode does not claim an SDR device
+ self._capture_start_time = time.time()
+ self._capture_phase = 'decoding'
+ self._last_error_message = ''
+ self._last_process_returncode = None
+ self._stop_event.clear()
try:
self._running = True
@@ -368,7 +377,7 @@ def start(
"""Start weather satellite capture and decode.
Args:
- satellite: Satellite key (for example ``'METEOR-M2-3'``)
+ satellite: Satellite key (for example ``'METEOR-M2-3'``)
device_index: RTL-SDR device index
gain: SDR gain in dB
sample_rate: Sample rate in Hz
@@ -410,13 +419,13 @@ def start(
self._current_satellite = satellite
self._current_frequency = sat_info['frequency']
- self._current_mode = sat_info['mode']
- self._device_index = device_index
- self._capture_start_time = time.time()
- self._capture_phase = 'tuning'
- self._last_error_message = ''
- self._last_process_returncode = None
- self._stop_event.clear()
+ self._current_mode = sat_info['mode']
+ self._device_index = device_index
+ self._capture_start_time = time.time()
+ self._capture_phase = 'tuning'
+ self._last_error_message = ''
+ self._last_process_returncode = None
+ self._stop_event.clear()
try:
self._running = True
@@ -890,17 +899,17 @@ def _read_satdump_output(self) -> None:
if was_running:
# Collect exit status (returncode is only set after poll/wait)
- if process and process.returncode is None:
- try:
- process.wait(timeout=5)
- except subprocess.TimeoutExpired:
- process.kill()
- process.wait()
- retcode = process.returncode if process else None
- self._last_process_returncode = retcode
- if retcode and retcode != 0:
- self._capture_phase = 'error'
- self._emit_progress(CaptureProgress(
+ if process and process.returncode is None:
+ try:
+ process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.wait()
+ retcode = process.returncode if process else None
+ self._last_process_returncode = retcode
+ if retcode and retcode != 0:
+ self._capture_phase = 'error'
+ self._emit_progress(CaptureProgress(
status='error',
satellite=self._current_satellite,
frequency=self._current_frequency,
@@ -1143,15 +1152,15 @@ def delete_all_images(self) -> int:
self._images.clear()
return count
- def _emit_progress(self, progress: CaptureProgress) -> None:
- """Emit progress update to callback."""
- if progress.status == 'error' and progress.message:
- self._last_error_message = str(progress.message)
- if self._callback:
- try:
- self._callback(progress)
- except Exception as e:
- logger.error(f"Error in progress callback: {e}")
+ def _emit_progress(self, progress: CaptureProgress) -> None:
+ """Emit progress update to callback."""
+ if progress.status == 'error' and progress.message:
+ self._last_error_message = str(progress.message)
+ if self._callback:
+ try:
+ self._callback(progress)
+ except Exception as e:
+ logger.error(f"Error in progress callback: {e}")
def get_status(self) -> dict:
"""Get current decoder status."""
@@ -1159,19 +1168,19 @@ def get_status(self) -> dict:
if self._running and self._capture_start_time:
elapsed = int(time.time() - self._capture_start_time)
- return {
- 'available': self._decoder is not None,
- 'decoder': self._decoder,
- 'running': self._running,
- 'satellite': self._current_satellite,
- 'frequency': self._current_frequency,
- 'mode': self._current_mode,
- 'capture_phase': self._capture_phase,
- 'elapsed_seconds': elapsed,
- 'image_count': len(self._images),
- 'last_error': self._last_error_message,
- 'last_returncode': self._last_process_returncode,
- }
+ return {
+ 'available': self._decoder is not None,
+ 'decoder': self._decoder,
+ 'running': self._running,
+ 'satellite': self._current_satellite,
+ 'frequency': self._current_frequency,
+ 'mode': self._current_mode,
+ 'capture_phase': self._capture_phase,
+ 'elapsed_seconds': elapsed,
+ 'image_count': len(self._images),
+ 'last_error': self._last_error_message,
+ 'last_returncode': self._last_process_returncode,
+ }
# Global decoder instance
From 1e5bc0054d33ba8e53f5c9d0611fe38e8f8e0206 Mon Sep 17 00:00:00 2001
From: mitchross
Date: Wed, 25 Mar 2026 01:13:37 -0400
Subject: [PATCH 2/8] Enhance weather satellite UX with pass geometry, guides,
and wider predictions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Pass prediction improvements:
- Widen prediction window to 48h at 5° min elevation (was 24h/15°)
- Add AOS/TCA/LOS pass geometry detail panel with times and bearings
- Fix duration display (was showing seconds labeled as minutes)
- Enhanced pass cards with AOS/LOS times, bearings, and directions
- Add REFRESH button in passes panel header
- Better empty state with clear "set your location" prompt and icon
Countdown and visual:
- Pulse animation on countdown when pass is imminent or active
- Countdown numbers scale up and change color for urgency
Sidebar getting started guide:
- New "Getting Started" section explaining what Meteor satellites are,
polar orbits, 4-8 passes/day, step-by-step workflow
- "When to look" tips (elevation, day vs night, pass direction)
- "What you need" equipment table with costs
- Collapsed antenna guide by default to reduce initial overwhelm
- Improved offline decode section with clear instructions on where
to get IQ recordings
Co-Authored-By: Claude Opus 4.6 (1M context)
---
static/css/modes/weather-satellite.css | 81 ++++++++++++++++
static/js/modes/weather-satellite.js | 86 +++++++++++++---
templates/index.html | 28 +++++-
.../partials/modes/weather-satellite.html | 97 +++++++++++++++++--
4 files changed, 268 insertions(+), 24 deletions(-)
diff --git a/static/css/modes/weather-satellite.css b/static/css/modes/weather-satellite.css
index bbdc3b80..00b078df 100644
--- a/static/css/modes/weather-satellite.css
+++ b/static/css/modes/weather-satellite.css
@@ -408,6 +408,87 @@
margin: 0 2px;
}
+/* ===== Pass Geometry Detail ===== */
+.wxsat-pass-geometry {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 10px 14px;
+ background: var(--bg-primary, #0d1117);
+ border-bottom: 1px solid var(--border-color, #2a3040);
+ font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
+}
+
+.wxsat-geom-event {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ min-width: 60px;
+}
+
+.wxsat-geom-event.wxsat-geom-tca {
+ color: var(--neon-green);
+}
+
+.wxsat-geom-label {
+ font-size: 9px;
+ font-weight: 600;
+ color: var(--text-dim, #666);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.wxsat-geom-tca .wxsat-geom-label {
+ color: var(--neon-green);
+}
+
+.wxsat-geom-time {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-primary, #e0e0e0);
+}
+
+.wxsat-geom-tca .wxsat-geom-time {
+ color: var(--neon-green);
+}
+
+.wxsat-geom-az {
+ font-size: 10px;
+ color: var(--text-dim, #666);
+}
+
+.wxsat-geom-arrow {
+ font-size: 14px;
+ color: var(--text-dim, #444);
+}
+
+.wxsat-geom-meta {
+ font-size: 10px;
+ color: var(--text-dim, #666);
+ margin-left: 8px;
+ padding-left: 8px;
+ border-left: 1px solid var(--border-color, #2a3040);
+ white-space: nowrap;
+}
+
+/* ===== Countdown Pulse Animation ===== */
+.wxsat-countdown-box.imminent .wxsat-cd-value {
+ animation: wxsat-count-pulse 1s ease-in-out infinite;
+ color: var(--accent-yellow);
+}
+
+.wxsat-countdown-box.active .wxsat-cd-value {
+ animation: wxsat-count-pulse 1s ease-in-out infinite;
+ color: var(--neon-green);
+}
+
+@keyframes wxsat-count-pulse {
+ 0%, 100% { transform: scale(1); opacity: 1; }
+ 50% { transform: scale(1.15); opacity: 0.8; }
+}
+
/* ===== Pass Predictions Panel ===== */
.wxsat-passes-panel {
flex: 0 0 280px;
diff --git a/static/js/modes/weather-satellite.js b/static/js/modes/weather-satellite.js
index 4fc3db5a..44f47cf3 100644
--- a/static/js/modes/weather-satellite.js
+++ b/static/js/modes/weather-satellite.js
@@ -751,7 +751,7 @@ const WeatherSat = (function() {
}
try {
- const url = `/weather-sat/passes?latitude=${storedLat}&longitude=${storedLon}&hours=24&min_elevation=15&trajectory=true&ground_track=true`;
+ const url = `/weather-sat/passes?latitude=${storedLat}&longitude=${storedLon}&hours=48&min_elevation=5&trajectory=true&ground_track=true`;
const response = await fetch(url);
const data = await response.json();
@@ -815,6 +815,42 @@ const WeatherSat = (function() {
// Update polar panel subtitle
const polarSat = document.getElementById('wxsatPolarSat');
if (polarSat) polarSat.textContent = `${pass.name} ${pass.maxEl}\u00b0`;
+
+ // Update pass geometry detail panel
+ updatePassGeometry(pass);
+ }
+
+ /**
+ * Update the AOS/TCA/LOS pass geometry detail panel.
+ */
+ function updatePassGeometry(pass) {
+ const panel = document.getElementById('wxsatPassGeometry');
+ if (!panel) return;
+
+ if (!pass) {
+ panel.style.display = 'none';
+ return;
+ }
+ panel.style.display = 'flex';
+
+ const aosTime = document.getElementById('wxsatGeomAosTime');
+ const aosAz = document.getElementById('wxsatGeomAosAz');
+ const tcaEl = document.getElementById('wxsatGeomTcaEl');
+ const tcaAz = document.getElementById('wxsatGeomTcaAz');
+ const losTime = document.getElementById('wxsatGeomLosTime');
+ const losAz = document.getElementById('wxsatGeomLosAz');
+ const meta = document.getElementById('wxsatGeomMeta');
+
+ const tzLabel = getTZLabel();
+ if (aosTime) aosTime.textContent = formatShortTime(pass.startTimeISO) + tzLabel;
+ if (aosAz) aosAz.textContent = `${Math.round(pass.riseAz || 0)}\u00b0 ${azToDir(pass.riseAz)}`;
+ if (tcaEl) tcaEl.textContent = `${pass.maxEl}\u00b0 el`;
+ if (tcaAz) tcaAz.textContent = `${Math.round(pass.maxElAz || pass.tcaAz || 0)}\u00b0 ${azToDir(pass.maxElAz || pass.tcaAz)}`;
+ if (losTime) losTime.textContent = formatShortTime(pass.endTimeISO) + tzLabel;
+ if (losAz) losAz.textContent = `${Math.round(pass.setAz || 0)}\u00b0 ${azToDir(pass.setAz)}`;
+
+ const durMin = Math.round((pass.duration || 0) / 60);
+ if (meta) meta.textContent = `${durMin} min / ${pass.quality}`;
}
/**
@@ -829,12 +865,28 @@ const WeatherSat = (function() {
if (!container) return;
if (passList.length === 0) {
- const hasLocation = localStorage.getItem('observerLat') !== null;
+ const hasLocation = localStorage.getItem('observerLat') !== null ||
+ (window.ObserverLocation && ObserverLocation.isSharedEnabled() && ObserverLocation.getShared()?.lat);
container.innerHTML = `
-
${hasLocation ? 'No passes in next 24h' : 'Set location to see pass predictions'}
+
+
+ ${hasLocation ? 'No passes in next 24 hours' : 'Set your location'}
+
+
+ ${hasLocation
+ ? 'All Meteor passes may be below the minimum elevation. Try again later.'
+ : 'Enter lat/lon in the strip bar above or click GPS to load pass predictions'}
+
+ Russia's Meteor-M2-3 and Meteor-M2-4
+ are polar-orbiting weather satellites that continuously transmit real-time color imagery (clouds, land, sea) at 137.900 MHz
+ using the LRPT digital format. Unlike old analog NOAA APT, LRPT produces sharp, full-color images.
+
+
+ They orbit ~830 km high, circling the Earth every ~100 minutes in a near-polar sun-synchronous orbit.
+ From any location, you'll typically get 4–8 usable passes per day,
+ each lasting 8–15 minutes as the satellite crosses your sky.
+
+
+
+
+ Step-by-step
+
+
Set your location — Enter your lat/lon in the strip bar above (or click GPS). This is required for pass predictions.
+
Check upcoming passes — The pass list shows when each satellite will be overhead. Higher max elevation = better signal. Passes above 30° are "good", above 60° are "excellent".
+
Prepare your antenna — You need a 137 MHz antenna outdoors with clear sky (see Antenna Guide below). A $5 V-dipole works for high passes.
+
Click Capture on a pass card when it's about to start, or enable AUTO to let the scheduler capture automatically.
+
Wait for images — SatDump will tune, lock the signal, and decode. Decoded images appear in the gallery after the pass completes.
+
+
+
+
+ When to look
+
+
Best passes: When the satellite is high overhead (>30° elevation). The countdown timer shows the next one.
+
Day vs night: Daytime passes produce visible-light imagery. Night passes still work but only produce infrared/thermal images.
+
Both satellites share 137.9 MHz so they won't transmit at the same time. You'll see separate pass predictions for each.
+
Pass direction: Meteor satellites travel roughly north→south or south→north. The pass cards show the exact rise/set direction.
+
+
+
+
+ What you need
+
+
+
SDR receiver
+
RTL-SDR V3/V4 ($25-35)
+
+
+
Antenna
+
137 MHz V-dipole ($5 DIY) or QFH ($20-30)
+
+
+
LNA (optional)
+
137 MHz filtered, at antenna ($15-25)
+
+
+
Location
+
Outdoors, clear sky view
+
+
+
No hardware?
+
Use Load Demo Data below to explore the UI
+
+
+
+
+
+
Satellite
@@ -33,10 +101,13 @@
Satellite
-
+
-
Antenna Guide
-
+
+ Antenna Guide
+ ▼
+
+
137 MHz band — your stock SDR antenna will NOT work.
@@ -174,14 +245,22 @@
Antenna Guide
- Test Decode (File)
+ Offline Decode (IQ File)
▼
- Decode a pre-recorded Meteor IQ file without SDR hardware.
- Shared ground-station recordings are also accepted by the backend.
+ Decode a pre-recorded Meteor IQ baseband file without SDR hardware.
+ You need an actual .raw, .sigmf-data, or .wav recording of a Meteor pass.
+
+ Where to get a test file:
+
+
Record one yourself with rtl_sdr -f 137900000 -s 2400000 meteor.raw during a pass
From b66ac935b756f934cbbc21cb750fb0d024565eed Mon Sep 17 00:00:00 2001
From: mitchross
Date: Wed, 25 Mar 2026 01:43:15 -0400
Subject: [PATCH 5/8] Fix VDL2 messages not appearing in aircraft datalink
panel
Root cause: dumpvdl2 outputs nested JSON (vdl2.avlc.acars.flight) but
FlightCorrelator only checks top-level fields. VDL2 messages were stored
in the correlator but never matched to any aircraft.
Fix: Promote identifying fields (flight, reg, tail, icao, addr, label,
text) from the nested VDL2 structure to top-level before storing in the
correlator. Also promote AVLC source address as ICAO when src.type is
"Aircraft".
Also fix VDL2 sidebar timestamps to use global InterceptTime setting.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
routes/vdl2.py | 28 +++++++++++++++++++++++++---
templates/adsb_dashboard.html | 4 +++-
2 files changed, 28 insertions(+), 4 deletions(-)
diff --git a/routes/vdl2.py b/routes/vdl2.py
index 84997329..e61de7e6 100644
--- a/routes/vdl2.py
+++ b/routes/vdl2.py
@@ -83,11 +83,33 @@ 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)
+ # Flatten nested VDL2 identifying fields to top level for correlator matching
+ # dumpvdl2 nests flight/reg inside vdl2.avlc.acars and ICAO in avlc.src.addr
try:
vdl2_inner = data.get('vdl2', data)
- acars_payload = (vdl2_inner.get('avlc') or {}).get('acars')
- if acars_payload and acars_payload.get('label'):
+ avlc = vdl2_inner.get('avlc') or {}
+ acars_payload = avlc.get('acars') or {}
+
+ # Promote ACARS fields to top level so FlightCorrelator can match them
+ if acars_payload.get('flight'):
+ data['flight'] = acars_payload['flight']
+ if acars_payload.get('reg'):
+ data['reg'] = acars_payload['reg']
+ data['tail'] = acars_payload['reg']
+ if acars_payload.get('label'):
+ data['label'] = acars_payload['label']
+ if acars_payload.get('msg_text'):
+ data['text'] = acars_payload['msg_text']
+
+ # Promote AVLC source address (often ICAO hex for aircraft)
+ src_addr = (avlc.get('src') or {}).get('addr', '')
+ src_type = (avlc.get('src') or {}).get('type', '')
+ if src_addr and src_type == 'Aircraft':
+ data['icao'] = src_addr
+ data['addr'] = src_addr
+
+ # Enrich with translated ACARS label (consistent with ACARS route)
+ if acars_payload.get('label'):
translation = translate_message({
'label': acars_payload.get('label'),
'text': acars_payload.get('msg_text', ''),
diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html
index 860c08a9..5dbafeef 100644
--- a/templates/adsb_dashboard.html
+++ b/templates/adsb_dashboard.html
@@ -4930,7 +4930,9 @@
Aircraft Log (${report.aircraftLog.length})
const acars = avlc.acars || {};
const flight = acars.flight || '';
const msgText = acars.msg_text || '';
- const time = new Date().toLocaleTimeString();
+ const time = typeof InterceptTime !== 'undefined'
+ ? InterceptTime.shortTime(new Date()) + InterceptTime.tzSuffix()
+ : new Date().toLocaleTimeString();
const freq = inner.freq ? (inner.freq / 1000000).toFixed(3) : '';
// Store for CSV export
From f4672cf0c7ad7a86d317de9886a9a175af95f003 Mon Sep 17 00:00:00 2001
From: mitchross
Date: Thu, 26 Mar 2026 00:05:49 -0400
Subject: [PATCH 6/8] Fix global timezone on ADS-B dashboard and harden VDL2
correlation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Timezone fixes:
- Add utils.js (InterceptTime) to adsb_dashboard.html — was completely
missing, causing all times to fall back to UTC regardless of setting
- Register onChange listener in nav.html so clock updates instantly
when timezone/format is changed in Settings
- Initialize timezone/format dropdowns on ADS-B dashboard page load
- Browser-verified: ET/12h ↔ UTC/24h switches instantly on ADS-B page
VDL2 correlation fix:
- Force ICAO hex to uppercase when promoting from VDL2 src.addr (dumpvdl2
may output lowercase, ADS-B stores uppercase — case mismatch prevented
correlator from matching)
- Move ICAO/addr promotion before ACARS field extraction so even
non-ACARS VDL2 frames (XID, connection mgmt) get correlated
Auth:
- Add INTERCEPT_DISABLE_AUTH env var to skip login for local/dev use
- Configurable via docker-compose.yml environment
Co-Authored-By: Claude Opus 4.6 (1M context)
---
app.py | 23 ++++++++++++++---------
docker-compose.yml | 21 ++++++++++-----------
routes/vdl2.py | 16 +++++++++-------
templates/adsb_dashboard.html | 11 +++++++++++
templates/partials/nav.html | 4 ++++
5 files changed, 48 insertions(+), 27 deletions(-)
diff --git a/app.py b/app.py
index 8e069665..abffcee2 100644
--- a/app.py
+++ b/app.py
@@ -423,6 +423,11 @@ def get_sdr_device_status() -> dict[str, str]:
@app.before_request
def require_login():
+ # Skip auth entirely when INTERCEPT_DISABLE_AUTH is set
+ if os.environ.get('INTERCEPT_DISABLE_AUTH', '').lower() in ('1', 'true', 'yes'):
+ session['logged_in'] = True
+ return None
+
# Routes that don't require login (to avoid infinite redirect loop)
allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check']
@@ -478,15 +483,15 @@ def login():
return render_template('login.html', version=VERSION)
-@app.route('/')
-def index() -> str:
- if request.args.get('mode') == 'satellite':
- return redirect(url_for('satellite.satellite_dashboard'))
-
- tools = {
- 'rtl_fm': check_tool('rtl_fm'),
- 'multimon': check_tool('multimon-ng'),
- 'rtl_433': check_tool('rtl_433'),
+@app.route('/')
+def index() -> str:
+ if request.args.get('mode') == 'satellite':
+ return redirect(url_for('satellite.satellite_dashboard'))
+
+ tools = {
+ 'rtl_fm': check_tool('rtl_fm'),
+ 'multimon': check_tool('multimon-ng'),
+ 'rtl_433': check_tool('rtl_433'),
'rtlamr': check_tool('rtlamr')
}
devices = [d.to_dict() for d in SDRFactory.detect_devices()]
diff --git a/docker-compose.yml b/docker-compose.yml
index 12ce13d1..f34ad7ca 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -6,18 +6,18 @@
# Basic usage (build locally):
# docker compose --profile basic up -d --build
#
-# Basic usage (pre-built image from registry):
-# INTERCEPT_IMAGE=ghcr.io/user/intercept:latest docker compose --profile basic up -d
-#
# With ADS-B history (Postgres):
# docker compose --profile history up -d
services:
intercept:
- # When INTERCEPT_IMAGE is set, use that pre-built image; otherwise build locally
- image: ${INTERCEPT_IMAGE:-intercept:latest}
+ # Always build and use the local image
+ image: intercept:latest
build: .
+ pull_policy: never
container_name: intercept
+ profiles:
+ - basic
ports:
- "5050:5050"
# Uncomment for HTTPS support (set INTERCEPT_HTTPS=true below)
@@ -72,9 +72,10 @@ services:
# ADS-B history with Postgres persistence
# Enable with: docker compose --profile history up -d
intercept-history:
- # Same image/build fallback pattern as above
- image: ${INTERCEPT_IMAGE:-intercept:latest}
+ # Always build and use the local image
+ image: intercept:latest
build: .
+ pull_policy: never
container_name: intercept-history
profiles:
- history
@@ -112,6 +113,8 @@ services:
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
# Shared observer location across modules
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
+ # Disable login auth (set to true for local/dev use)
+ - INTERCEPT_DISABLE_AUTH=${INTERCEPT_DISABLE_AUTH:-false}
# Default observer coordinates (set to your location to skip the GPS prompt)
# - INTERCEPT_DEFAULT_LAT=${INTERCEPT_DEFAULT_LAT:-0}
# - INTERCEPT_DEFAULT_LON=${INTERCEPT_DEFAULT_LON:-0}
@@ -142,7 +145,3 @@ services:
interval: 10s
timeout: 5s
retries: 5
-
-# Optional: Add volume for persistent SQLite database
-# volumes:
-# intercept-data:
diff --git a/routes/vdl2.py b/routes/vdl2.py
index e61de7e6..9c7fc7bc 100644
--- a/routes/vdl2.py
+++ b/routes/vdl2.py
@@ -90,6 +90,15 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
avlc = vdl2_inner.get('avlc') or {}
acars_payload = avlc.get('acars') or {}
+ # Promote AVLC source address — this is the aircraft ICAO hex
+ # Do this FIRST so even non-ACARS VDL2 frames can be correlated
+ src = avlc.get('src') or {}
+ src_addr = src.get('addr', '')
+ src_type = src.get('type', '')
+ if src_addr and src_type == 'Aircraft':
+ data['icao'] = src_addr.upper()
+ data['addr'] = src_addr.upper()
+
# Promote ACARS fields to top level so FlightCorrelator can match them
if acars_payload.get('flight'):
data['flight'] = acars_payload['flight']
@@ -101,13 +110,6 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
if acars_payload.get('msg_text'):
data['text'] = acars_payload['msg_text']
- # Promote AVLC source address (often ICAO hex for aircraft)
- src_addr = (avlc.get('src') or {}).get('addr', '')
- src_type = (avlc.get('src') or {}).get('type', '')
- if src_addr and src_type == 'Aircraft':
- data['icao'] = src_addr
- data['addr'] = src_addr
-
# Enrich with translated ACARS label (consistent with ACARS route)
if acars_payload.get('label'):
translation = translate_message({
diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html
index 5dbafeef..df50ef72 100644
--- a/templates/adsb_dashboard.html
+++ b/templates/adsb_dashboard.html
@@ -22,6 +22,7 @@
window.INTERCEPT_DEFAULT_LAT = {{ default_latitude | tojson }};
window.INTERCEPT_DEFAULT_LON = {{ default_longitude | tojson }};
+
@@ -5730,6 +5731,16 @@
Aircraft Log (${report.aircraftLog.length})
{% include 'partials/settings-modal.html' %}
+
{% include 'partials/help-modal.html' %}
diff --git a/templates/partials/nav.html b/templates/partials/nav.html
index 8b78cbcd..bf8ef191 100644
--- a/templates/partials/nav.html
+++ b/templates/partials/nav.html
@@ -551,6 +551,10 @@
}
setInterval(updateNavUtcClock, 1000);
updateNavUtcClock();
+ // React immediately when timezone/format changes in Settings
+ if (typeof InterceptTime !== 'undefined' && InterceptTime.onChange) {
+ InterceptTime.onChange(updateNavUtcClock);
+ }
}
})();
From 6de443e833cdba634806c2d7e52310526a230da3 Mon Sep 17 00:00:00 2001
From: mitchross
Date: Thu, 26 Mar 2026 00:29:06 -0400
Subject: [PATCH 7/8] Fix clock flickering from duplicate setInterval timers
app.js and nav.html both started 1-second intervals updating the same
#headerUtcTime element. Even though both used InterceptTime, their
slightly different timing caused visible text flicker.
Fix: app.js now sets window._navClockStarted before starting its
interval, so nav.html's guard condition skips its duplicate. Also
register InterceptTime.onChange listener in app.js for instant updates.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
static/js/core/app.js | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/static/js/core/app.js b/static/js/core/app.js
index 7734f5fa..ffc3c04e 100644
--- a/static/js/core/app.js
+++ b/static/js/core/app.js
@@ -465,7 +465,11 @@ function initApp() {
// Start clock and init time settings
initTimeSettings();
updateHeaderClock();
+ window._navClockStarted = true; // Prevent nav.html from starting a duplicate interval
setInterval(updateHeaderClock, 1000);
+ if (typeof InterceptTime !== 'undefined' && InterceptTime.onChange) {
+ InterceptTime.onChange(updateHeaderClock);
+ }
// Load bias-T setting
loadBiasTSetting();
From 3aadaf1c86c00490f40c8f7b62d2d90e50e65bcf Mon Sep 17 00:00:00 2001
From: mitchross
Date: Thu, 26 Mar 2026 01:15:28 -0400
Subject: [PATCH 8/8] =?UTF-8?q?Fix=20clock=20flickering=20on=20main=20page?=
=?UTF-8?q?=20=E2=80=94=20inline=20updateHeaderClock=20was=20using=20UTC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The updateHeaderClock function in index.html was inlined and still using
raw UTC (toISOString), while nav.html's version used InterceptTime.
Both ran on 1-second intervals updating the same element, causing the
clock to rapidly alternate between ET and UTC.
Fix: Updated the inline version in index.html to use InterceptTime,
matching nav.html. Added _navClockStarted guard and onChange listener
so only one interval runs and timezone changes apply instantly.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
templates/index.html | 18 ++++++++++++++----
1 file changed, 14 insertions(+), 4 deletions(-)
diff --git a/templates/index.html b/templates/index.html
index 585d48bf..24d7c92f 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -3816,11 +3816,17 @@
▶ Device Intelligence
keywords: []
};
- // UTC Clock Update
+ // Clock Update (uses global InterceptTime for timezone/format)
function updateHeaderClock() {
const now = new Date();
- const utc = now.toISOString().substring(11, 19);
- document.getElementById('headerUtcTime').textContent = utc;
+ const el = document.getElementById('headerUtcTime');
+ const label = document.querySelector('.utc-label');
+ if (typeof InterceptTime !== 'undefined') {
+ if (el) el.textContent = InterceptTime.fullTime(now);
+ if (label) label.textContent = InterceptTime.getLabel() || 'LOCAL';
+ } else {
+ if (el) el.textContent = now.toISOString().substring(11, 19);
+ }
}
function setActiveModeIndicator(label) {
@@ -3855,8 +3861,12 @@