diff --git a/Dockerfile b/Dockerfile
index 49dd7b2b..bf24a2d5 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -126,6 +126,7 @@ RUN cd /tmp \
&& rm -rf /tmp/slowrx
# Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) — pinned to v1.2.2
+# Split into compile (heavy, cached) and staging (light, safe to change) layers
RUN cd /tmp \
&& git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \
&& cd SatDump \
@@ -147,14 +148,29 @@ RUN cd /tmp \
fi; \
done; \
fi \
- # Copy SatDump install artifacts to staging
- && cp -a /usr/local/bin/satdump /staging/usr/local/bin/ 2>/dev/null || true \
- && cp -a /usr/local/lib/libsatdump* /staging/usr/local/lib/ 2>/dev/null || true \
- && cp -a /usr/local/lib/satdump /staging/usr/local/lib/ 2>/dev/null || true \
- && cp -a /usr/local/share/satdump /staging/usr/local/share/ 2>/dev/null; mkdir -p /staging/usr/local/share \
- && cp -a /usr/local/share/satdump /staging/usr/local/share/ 2>/dev/null || true \
&& rm -rf /tmp/SatDump
+# Stage SatDump artifacts (separate layer so compile cache survives staging changes)
+# On arm64 cmake installs to /usr/{bin,lib,share}; on x86 to /usr/local/{bin,lib,share}
+RUN mkdir -p /staging/usr/local/share /staging/usr/local/lib/satdump/plugins \
+ # Binary
+ && (cp -a /usr/local/bin/satdump /staging/usr/local/bin/ 2>/dev/null \
+ || cp -a /usr/bin/satdump /staging/usr/local/bin/) \
+ # Core shared library
+ && (cp -a /usr/local/lib/libsatdump* /staging/usr/local/lib/ 2>/dev/null \
+ || cp -a /usr/lib/libsatdump* /staging/usr/local/lib/) \
+ # Plugins
+ && (cp -a /usr/local/lib/satdump/plugins/*.so /staging/usr/local/lib/satdump/plugins/ 2>/dev/null \
+ || cp -a /usr/lib/satdump/plugins/*.so /staging/usr/local/lib/satdump/plugins/ 2>/dev/null \
+ || true) \
+ # Pipeline definitions and resources
+ && (cp -a /usr/local/share/satdump /staging/usr/local/share/ 2>/dev/null \
+ || cp -a /usr/share/satdump /staging/usr/local/share/) \
+ # Verify
+ && test -x /staging/usr/local/bin/satdump \
+ && ls /staging/usr/local/share/satdump/pipelines/*.json >/dev/null 2>&1 \
+ && echo "SatDump staging OK: $(ls /staging/usr/local/share/satdump/pipelines/*.json | wc -l) pipeline files"
+
# Build hackrf CLI tools from source — avoids libhackrf0 version conflict
# between the 'hackrf' apt package and soapysdr-module-hackrf's newer libhackrf0
RUN cd /tmp \
@@ -219,6 +235,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libpng16-16 \
libtiff6 \
libjemalloc2 \
+ libfftw3-double3 \
+ libfftw3-single3 \
libvolk-bin \
libnng1 \
libzstd1 \
@@ -254,6 +272,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY --from=builder /staging/usr/bin/ /usr/bin/
COPY --from=builder /staging/usr/local/bin/ /usr/local/bin/
COPY --from=builder /staging/usr/local/lib/ /usr/local/lib/
+COPY --from=builder /staging/usr/local/share/ /usr/local/share/
COPY --from=builder /staging/opt/ /opt/
# Copy radiosonde Python dependencies installed during builder stage
diff --git a/app.py b/app.py
index acf5a8c7..d24a4ceb 100644
--- a/app.py
+++ b/app.py
@@ -436,6 +436,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"]
diff --git a/docker-compose.yml b/docker-compose.yml
index 12ce13d1..01d3133d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -6,17 +6,15 @@
# 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
ports:
- "5050:5050"
@@ -72,9 +70,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 +111,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 +143,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 84997329..9c7fc7bc 100644
--- a/routes/vdl2.py
+++ b/routes/vdl2.py
@@ -83,11 +83,35 @@ 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 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']
+ 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']
+
+ # 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/static/css/modes/weather-satellite.css b/static/css/modes/weather-satellite.css
index 28438e19..00b078df 100644
--- a/static/css/modes/weather-satellite.css
+++ b/static/css/modes/weather-satellite.css
@@ -107,6 +107,23 @@
color: var(--accent-cyan, #00d4ff);
}
+/* ===== Timezone Select ===== */
+.wxsat-tz-select {
+ padding: 3px 6px;
+ background: var(--bg-primary, #0d1117);
+ border: 1px solid var(--border-color, #2a3040);
+ border-radius: 3px;
+ color: var(--text-primary, #e0e0e0);
+ font-size: 11px;
+ font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
+ cursor: pointer;
+}
+
+.wxsat-tz-select:focus {
+ border-color: var(--accent-cyan, #00d4ff);
+ outline: none;
+}
+
/* ===== Auto-Schedule Toggle ===== */
.wxsat-schedule-toggle {
display: flex;
@@ -317,6 +334,161 @@
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
+/* ===== Pass Analysis Bar ===== */
+.wxsat-analysis-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 6px 16px;
+ background: var(--bg-tertiary, #1a1f2e);
+ border-bottom: 1px solid var(--border-color, #2a3040);
+ font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
+}
+
+.wxsat-analysis-stats {
+ display: flex;
+ gap: 16px;
+}
+
+.wxsat-analysis-stat {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 11px;
+}
+
+.wxsat-analysis-value {
+ font-weight: 600;
+ color: var(--text-primary, #e0e0e0);
+}
+
+.wxsat-analysis-value.excellent { color: var(--neon-green); }
+.wxsat-analysis-value.good { color: var(--accent-cyan); }
+.wxsat-analysis-value.fair { color: var(--accent-yellow); }
+
+.wxsat-analysis-label {
+ font-size: 10px;
+ color: var(--text-dim, #666);
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+
+.wxsat-analysis-best {
+ font-size: 11px;
+ color: var(--accent-cyan, #00d4ff);
+ white-space: nowrap;
+}
+
+/* ===== Best Pass Badge ===== */
+.wxsat-pass-best-badge {
+ display: inline-block;
+ font-size: 8px;
+ padding: 1px 5px;
+ border-radius: 2px;
+ background: rgba(0, 255, 136, 0.15);
+ color: var(--neon-green);
+ font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ font-weight: 600;
+ margin-left: 6px;
+}
+
+/* ===== Pass Direction ===== */
+.wxsat-pass-direction {
+ font-size: 10px;
+ color: var(--text-dim, #666);
+ font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
+ margin-top: 4px;
+}
+
+.wxsat-pass-direction .wxsat-dir-arrow {
+ color: var(--accent-cyan, #00d4ff);
+ 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;
@@ -1066,6 +1238,51 @@
color: var(--text-dim, #444);
}
+/* Console filter buttons */
+.wxsat-console-filters {
+ display: flex;
+ gap: 3px;
+ margin-left: auto;
+ margin-right: 8px;
+}
+
+.wxsat-console-filter {
+ font-size: 9px;
+ padding: 2px 6px;
+ border: 1px solid var(--border-color, #2a3040);
+ border-radius: 3px;
+ background: transparent;
+ color: var(--text-dim, #555);
+ font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
+ cursor: pointer;
+ transition: all 0.2s;
+ letter-spacing: 0.3px;
+}
+
+.wxsat-console-filter:hover {
+ border-color: var(--accent-cyan, #00d4ff);
+ color: var(--text-secondary, #999);
+}
+
+.wxsat-console-filter.active {
+ border-color: var(--accent-cyan, #00d4ff);
+ color: var(--accent-cyan, #00d4ff);
+ background: rgba(0, 212, 255, 0.08);
+}
+
+.wxsat-console-actions {
+ display: flex;
+ gap: 4px;
+ flex-shrink: 0;
+}
+
+/* Console entry timestamps */
+.wxsat-console-ts {
+ color: var(--text-dim, #444);
+ margin-right: 6px;
+ font-size: 9px;
+}
+
#wxsatConsoleToggle {
font-size: 10px;
width: 28px;
diff --git a/static/js/core/app.js b/static/js/core/app.js
index 8834146a..ffc3c04e 100644
--- a/static/js/core/app.js
+++ b/static/js/core/app.js
@@ -77,8 +77,23 @@ function declineDisclaimer() {
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 initTimeSettings() {
+ const tzSelect = document.getElementById('globalTimezoneSelect');
+ const fmtSelect = document.getElementById('globalTimeFormatSelect');
+ if (typeof InterceptTime !== 'undefined') {
+ if (tzSelect) tzSelect.value = InterceptTime.getTimezone();
+ if (fmtSelect) fmtSelect.value = InterceptTime.getHour12() ? '12' : '24';
+ }
}
// ============== MODE SWITCHING ==============
@@ -447,9 +462,14 @@ function initApp() {
// Load theme
loadTheme();
- // Start clock
+ // 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();
diff --git a/static/js/core/utils.js b/static/js/core/utils.js
index d09f7a10..9f4fcb50 100644
--- a/static/js/core/utils.js
+++ b/static/js/core/utils.js
@@ -55,6 +55,114 @@ function isValidChannel(ch) {
// ============== TIME FORMATTING ==============
+/**
+ * Global time preferences — timezone and 12h/24h format.
+ * Stored in localStorage, used by all modes.
+ */
+const InterceptTime = (function() {
+ const TZ_MAP = {
+ 'UTC': 'UTC',
+ 'local': undefined,
+ 'US/Eastern': 'America/New_York',
+ 'US/Central': 'America/Chicago',
+ 'US/Mountain': 'America/Denver',
+ 'US/Pacific': 'America/Los_Angeles',
+ };
+
+ const TZ_LABELS = {
+ 'UTC': 'UTC',
+ 'local': '',
+ 'US/Eastern': 'ET',
+ 'US/Central': 'CT',
+ 'US/Mountain': 'MT',
+ 'US/Pacific': 'PT',
+ };
+
+ let _timezone = localStorage.getItem('interceptTimezone') || 'US/Eastern';
+ let _hour12 = (localStorage.getItem('interceptHour12') || 'true') === 'true';
+ const _listeners = [];
+
+ function getTimezone() { return _timezone; }
+ function getHour12() { return _hour12; }
+ function getIANA() { return TZ_MAP[_timezone]; }
+ function getLabel() { return TZ_LABELS[_timezone] || ''; }
+
+ function setTimezone(tz) {
+ if (!TZ_MAP.hasOwnProperty(tz)) return;
+ _timezone = tz;
+ localStorage.setItem('interceptTimezone', tz);
+ // Migrate weather-sat specific key
+ localStorage.setItem('wxsatTimezone', tz);
+ _notify();
+ }
+
+ function setHour12(val) {
+ _hour12 = !!val;
+ localStorage.setItem('interceptHour12', _hour12 ? 'true' : 'false');
+ _notify();
+ }
+
+ function onChange(fn) { _listeners.push(fn); }
+ function _notify() { _listeners.forEach(fn => { try { fn(); } catch(e) { console.error(e); } }); }
+
+ /**
+ * Format a Date or ISO string for the global timezone.
+ * @param {Date|string} input - Date object or ISO string
+ * @param {object} [extraOpts] - Additional Intl.DateTimeFormat options
+ * @returns {string}
+ */
+ function format(input, extraOpts) {
+ if (!input) return '--';
+ try {
+ const date = typeof input === 'string' ? new Date(input) : input;
+ if (isNaN(date.getTime())) return typeof input === 'string' ? input : '--';
+ const opts = { hour12: _hour12, ...extraOpts };
+ const iana = getIANA();
+ if (iana) opts.timeZone = iana;
+ return date.toLocaleString(undefined, opts);
+ } catch { return typeof input === 'string' ? input : '--'; }
+ }
+
+ /** HH:MM (or h:MM AM/PM) */
+ function shortTime(input) {
+ return format(input, { hour: '2-digit', minute: '2-digit' });
+ }
+
+ /** HH:MM:SS */
+ function fullTime(input) {
+ return format(input, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
+ }
+
+ /** Mon 25, 14:30 (or 2:30 PM) */
+ function dateTime(input) {
+ return format(input, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
+ }
+
+ /** Mon 25, 2026 */
+ function dateOnly(input) {
+ const iana = getIANA();
+ const opts = { year: 'numeric', month: 'short', day: 'numeric' };
+ if (iana) opts.timeZone = iana;
+ try {
+ const date = typeof input === 'string' ? new Date(input) : input;
+ return date.toLocaleDateString(undefined, opts);
+ } catch { return '--'; }
+ }
+
+ /** Short label like " ET" or " UTC" for appending to times */
+ function tzSuffix() {
+ const l = getLabel();
+ return l ? ' ' + l : '';
+ }
+
+ return {
+ getTimezone, getHour12, getIANA, getLabel,
+ setTimezone, setHour12, onChange,
+ format, shortTime, fullTime, dateTime, dateOnly, tzSuffix,
+ TZ_MAP, TZ_LABELS,
+ };
+})();
+
/**
* Get relative time string from timestamp
* @param {string} timestamp - Time string in HH:MM:SS format
diff --git a/static/js/modes/weather-satellite.js b/static/js/modes/weather-satellite.js
index e5fc9165..c450bcec 100644
--- a/static/js/modes/weather-satellite.js
+++ b/static/js/modes/weather-satellite.js
@@ -36,11 +36,60 @@ const WeatherSat = (function() {
let imageRefreshInterval = null;
let lastDecodeJobSignature = null;
let lastDecodeSatellite = null;
+ let consoleFilter = 'all';
+
+ // Timezone — delegates to global InterceptTime utility
+ function formatShortTime(isoString) {
+ return typeof InterceptTime !== 'undefined' ? InterceptTime.shortTime(isoString) : (isoString || '--');
+ }
+
+ function formatDateTime(isoString) {
+ return typeof InterceptTime !== 'undefined' ? InterceptTime.dateTime(isoString) : (isoString || '--');
+ }
+
+ function getTZLabel() {
+ return typeof InterceptTime !== 'undefined' ? InterceptTime.tzSuffix() : '';
+ }
+
+ function setTimezone(tz) {
+ if (typeof InterceptTime !== 'undefined') InterceptTime.setTimezone(tz);
+ const sel = document.getElementById('wxsatTimezone');
+ if (sel && sel.value !== tz) sel.value = tz;
+ applyPassFilter();
+ renderGallery();
+ updateTimelineLabels();
+ }
+
+ /**
+ * Convert an azimuth angle (0-360) to a cardinal direction label.
+ */
+ function azToDir(az) {
+ if (typeof az !== 'number' || isNaN(az)) return '?';
+ const dirs = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
+ return dirs[Math.round(az / 22.5) % 16];
+ }
+
+ /**
+ * Find the best upcoming pass (highest max elevation).
+ */
+ function findBestPass(passList) {
+ const now = new Date();
+ const upcoming = passList.filter(p => {
+ const end = parsePassDate(p.endTimeISO);
+ return end && end > now;
+ });
+ if (upcoming.length === 0) return null;
+ return upcoming.reduce((best, p) => (p.maxEl > best.maxEl) ? p : best, upcoming[0]);
+ }
/**
* Initialize the Weather Satellite mode
*/
function init() {
+ // Sync timezone selector with global setting
+ const tzSel = document.getElementById('wxsatTimezone');
+ if (tzSel && typeof InterceptTime !== 'undefined') tzSel.value = InterceptTime.getTimezone();
+
if (initialized) {
checkStatus();
loadImages();
@@ -54,6 +103,17 @@ const WeatherSat = (function() {
}
initialized = true;
+ // Listen for global timezone/format changes
+ if (typeof InterceptTime !== 'undefined') {
+ InterceptTime.onChange(() => {
+ const sel = document.getElementById('wxsatTimezone');
+ if (sel) sel.value = InterceptTime.getTimezone();
+ applyPassFilter();
+ renderGallery();
+ updateTimelineLabels();
+ });
+ }
+
checkStatus();
loadImages();
loadLocationInputs();
@@ -643,12 +703,15 @@ const WeatherSat = (function() {
renderPasses([]);
renderTimeline([]);
updateCountdownFromPasses();
+ updatePassAnalysis([]);
updateGroundTrack(null);
+ const passCountEl = document.getElementById('wxsatStripPassCount');
+ if (passCountEl) passCountEl.textContent = '0';
return;
}
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();
@@ -675,7 +738,12 @@ const WeatherSat = (function() {
selectedPassIndex = -1;
renderPasses(passes);
renderTimeline(passes);
+ updateTimelineLabels();
updateCountdownFromPasses();
+ updatePassAnalysis(passes);
+ // Update strip pass count
+ const passCountEl = document.getElementById('wxsatStripPassCount');
+ if (passCountEl) passCountEl.textContent = passes.length;
if (passes.length > 0) {
selectPass(0);
} else {
@@ -707,6 +775,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}`;
}
/**
@@ -721,23 +825,42 @@ 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
+ ? ' '
+ : ' '}
+
+
+ ${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'}
+
`;
+ // Hide geometry panel when no passes
+ const geom = document.getElementById('wxsatPassGeometry');
+ if (geom) geom.style.display = 'none';
return;
}
+ const bestPass = findBestPass(passList);
+
container.innerHTML = passList.map((pass, idx) => {
const modeClass = pass.mode === 'APT' ? 'apt' : 'lrpt';
- const timeStr = pass.startTime || '--';
+ const timeStr = formatDateTime(pass.startTimeISO) + getTZLabel();
const now = new Date();
const passStart = parsePassDate(pass.startTimeISO);
const diffMs = passStart ? passStart - now : NaN;
const diffMins = Number.isFinite(diffMs) ? Math.floor(diffMs / 60000) : NaN;
const isSelected = idx === selectedPassIndex;
+ const isBest = bestPass && pass.startTimeISO === bestPass.startTimeISO && pass.satellite === bestPass.satellite;
let countdown = '--';
if (!Number.isFinite(diffMs)) {
@@ -752,21 +875,29 @@ const WeatherSat = (function() {
countdown = `in ${hrs}h${mins}m`;
}
+ const riseDir = azToDir(pass.riseAz);
+ const setDir = azToDir(pass.setAz);
+ const bestBadge = isBest ? 'BEST ' : '';
+ const durMin = Math.round((pass.duration || 0) / 60);
+ const aosStr = formatShortTime(pass.startTimeISO);
+ const losStr = formatShortTime(pass.endTimeISO);
+ const tzLabel = getTZLabel();
+
return `
- ${escapeHtml(pass.name)}
+ ${escapeHtml(pass.name)}${bestBadge}
${escapeHtml(pass.mode)}
- Time
- ${escapeHtml(timeStr)}
- Max El
- ${pass.maxEl}°
- Duration
- ${pass.duration} min
- Freq
- ${pass.frequency} MHz
+ AOS
+ ${escapeHtml(aosStr)}${escapeHtml(tzLabel)} · ${Math.round(pass.riseAz || 0)}° ${riseDir}
+ LOS
+ ${escapeHtml(losStr)}${escapeHtml(tzLabel)} · ${Math.round(pass.setAz || 0)}° ${setDir}
+ Peak
+ ${pass.maxEl}° el · ${durMin} min
+ Track
+ ${riseDir} → ${setDir}
${pass.quality}
@@ -1334,12 +1465,18 @@ const WeatherSat = (function() {
if (hoursEl) hoursEl.textContent = h.toString().padStart(2, '0');
if (minsEl) minsEl.textContent = m.toString().padStart(2, '0');
if (secsEl) secsEl.textContent = s.toString().padStart(2, '0');
+ const passTimeStr = formatShortTime(nextPass.startTimeISO) + getTZLabel();
if (satEl) satEl.textContent = `${nextPass.name} ${nextPass.frequency} MHz`;
if (detailEl) {
if (isActive) {
detailEl.textContent = `ACTIVE - ${nextPass.maxEl}\u00b0 max el`;
} else {
- detailEl.textContent = `${nextPass.maxEl}\u00b0 max el / ${nextPass.duration} min`;
+ const bestPass = findBestPass(filtered);
+ const durMin = Math.round((nextPass.duration || 0) / 60);
+ const bestNote = bestPass && bestPass.startTimeISO !== nextPass.startTimeISO
+ ? ` | Best: ${bestPass.name} ${formatShortTime(bestPass.startTimeISO)}${getTZLabel()} (${bestPass.maxEl}\u00b0)`
+ : '';
+ detailEl.textContent = `${passTimeStr} / ${nextPass.maxEl}\u00b0 max el / ${durMin} min${bestNote}`;
}
}
@@ -1391,7 +1528,7 @@ const WeatherSat = (function() {
marker.className = `wxsat-timeline-pass ${pass.mode === 'LRPT' ? 'lrpt' : 'apt'}`;
marker.style.left = startPct + '%';
marker.style.width = widthPct + '%';
- marker.title = `${pass.name} ${pass.startTime} (${pass.maxEl}\u00b0)`;
+ marker.title = `${pass.name} ${formatShortTime(pass.startTimeISO)}${getTZLabel()} (${pass.maxEl}\u00b0)`;
marker.onclick = () => selectPass(idx);
track.appendChild(marker);
});
@@ -1414,6 +1551,73 @@ const WeatherSat = (function() {
cursor.style.left = pct + '%';
}
+ /**
+ * Update timeline hour labels to match the selected timezone.
+ */
+ function updateTimelineLabels() {
+ const labels = document.querySelector('.wxsat-timeline-labels');
+ if (!labels) return;
+ const hours = [0, 6, 12, 18, 24];
+ const spans = labels.querySelectorAll('span');
+ if (spans.length !== hours.length) return;
+
+ const tz = typeof InterceptTime !== 'undefined' ? InterceptTime.getTimezone() : 'UTC';
+ const ianaName = typeof InterceptTime !== 'undefined' ? InterceptTime.getIANA() : undefined;
+
+ hours.forEach((h, i) => {
+ if (h === 24) {
+ spans[i].textContent = '24:00';
+ return;
+ }
+ if (tz === 'UTC' || tz === 'local') {
+ spans[i].textContent = `${String(h).padStart(2, '0')}:00`;
+ } else {
+ const d = new Date();
+ d.setHours(h, 0, 0, 0);
+ const opts = { hour: '2-digit', minute: '2-digit', hour12: false };
+ if (ianaName) opts.timeZone = ianaName;
+ spans[i].textContent = d.toLocaleTimeString(undefined, opts).slice(0, 5);
+ }
+ });
+ }
+
+ /**
+ * Update the pass analysis bar with stats about current passes.
+ */
+ function updatePassAnalysis(passList) {
+ const totalEl = document.getElementById('wxsatAnalysisTotal');
+ const excellentEl = document.getElementById('wxsatAnalysisExcellent');
+ const goodEl = document.getElementById('wxsatAnalysisGood');
+ const fairEl = document.getElementById('wxsatAnalysisFair');
+ const bestEl = document.getElementById('wxsatAnalysisBest');
+
+ const now = new Date();
+ const upcoming = passList.filter(p => {
+ const end = parsePassDate(p.endTimeISO);
+ return end && end > now;
+ });
+
+ const excellent = upcoming.filter(p => p.quality === 'excellent').length;
+ const good = upcoming.filter(p => p.quality === 'good').length;
+ const fair = upcoming.filter(p => p.quality === 'fair').length;
+
+ if (totalEl) totalEl.textContent = upcoming.length;
+ if (excellentEl) excellentEl.textContent = excellent;
+ if (goodEl) goodEl.textContent = good;
+ if (fairEl) fairEl.textContent = fair;
+
+ const best = findBestPass(passList);
+ if (bestEl) {
+ if (best) {
+ const t = formatShortTime(best.startTimeISO) + getTZLabel();
+ const bestDurMin = Math.round((best.duration || 0) / 60);
+ bestEl.textContent = `Best: ${best.name} at ${t} (${best.maxEl}\u00b0 el, ${bestDurMin} min)`;
+ } else {
+ bestEl.textContent = 'No upcoming passes';
+ }
+ }
+ }
+
// ========================
// Auto-Scheduler
// ========================
@@ -1627,12 +1831,15 @@ const WeatherSat = (function() {
return new Date(b.timestamp || 0) - new Date(a.timestamp || 0);
});
- // Group by date
+ // Group by date (timezone-aware via global InterceptTime)
const groups = {};
sorted.forEach(img => {
- const dateKey = img.timestamp
- ? new Date(img.timestamp).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
- : 'Unknown Date';
+ let dateKey = 'Unknown Date';
+ if (img.timestamp) {
+ dateKey = typeof InterceptTime !== 'undefined'
+ ? InterceptTime.dateOnly(img.timestamp)
+ : new Date(img.timestamp).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
+ }
if (!groups[dateKey]) groups[dateKey] = [];
groups[dateKey].push(img);
});
@@ -1788,11 +1995,7 @@ const WeatherSat = (function() {
*/
function formatTimestamp(isoString) {
if (!isoString) return '--';
- try {
- return new Date(isoString).toLocaleString();
- } catch {
- return isoString;
- }
+ return formatDateTime(isoString) + getTZLabel();
}
function ensureImageRefresh() {
@@ -1969,11 +2172,23 @@ const WeatherSat = (function() {
const log = document.getElementById('wxsatConsoleLog');
if (!log) return;
+ const type = logType || 'info';
+ const now = new Date();
+ const ts = typeof InterceptTime !== 'undefined'
+ ? InterceptTime.fullTime(now)
+ : now.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
+
const entry = document.createElement('div');
- entry.className = `wxsat-console-entry wxsat-log-${logType || 'info'}`;
- entry.textContent = message;
- log.appendChild(entry);
+ entry.className = `wxsat-console-entry wxsat-log-${type}`;
+ entry.dataset.logType = type;
+ entry.innerHTML = `
${ts} ${escapeHtml(message)}`;
+ // Apply current filter visibility
+ if (consoleFilter !== 'all' && type !== consoleFilter) {
+ entry.style.display = 'none';
+ }
+
+ log.appendChild(entry);
consoleEntries.push(entry);
// Cap at 200 entries
@@ -1986,6 +2201,40 @@ const WeatherSat = (function() {
log.scrollTop = log.scrollHeight;
}
+ /**
+ * Filter console entries by log type.
+ */
+ function filterConsole(filter) {
+ consoleFilter = filter;
+ // Update filter button states
+ document.querySelectorAll('.wxsat-console-filter').forEach(btn => {
+ btn.classList.toggle('active', btn.dataset.filter === filter);
+ });
+ // Show/hide entries
+ consoleEntries.forEach(entry => {
+ if (filter === 'all') {
+ entry.style.display = '';
+ } else {
+ entry.style.display = entry.dataset.logType === filter ? '' : 'none';
+ }
+ });
+ // Scroll to bottom
+ const log = document.getElementById('wxsatConsoleLog');
+ if (log) log.scrollTop = log.scrollHeight;
+ }
+
+ /**
+ * Export console contents to clipboard.
+ */
+ function exportConsole() {
+ const text = consoleEntries.map(e => e.textContent).join('\n');
+ navigator.clipboard.writeText(text).then(() => {
+ showNotification('Weather Sat', 'Console log copied to clipboard');
+ }).catch(() => {
+ showNotification('Weather Sat', 'Failed to copy console log');
+ });
+ }
+
/**
* Update the phase indicator steps
*/
@@ -2048,8 +2297,14 @@ const WeatherSat = (function() {
const log = document.getElementById('wxsatConsoleLog');
if (log) log.innerHTML = '';
consoleEntries = [];
+ consoleFilter = 'all';
currentPhase = 'idle';
+ // Reset filter buttons
+ document.querySelectorAll('.wxsat-console-filter').forEach(btn => {
+ btn.classList.toggle('active', btn.dataset.filter === 'all');
+ });
+
document.querySelectorAll('#wxsatPhaseIndicator .wxsat-phase-step').forEach(step => {
step.classList.remove('active', 'completed', 'error');
});
@@ -2092,6 +2347,90 @@ const WeatherSat = (function() {
stopStream();
}
+ /**
+ * Load demo/sample data for UI testing without a live satellite pass.
+ * Populates passes, console, and analysis bar with realistic fake data.
+ */
+ function loadDemoData() {
+ const now = new Date();
+
+ // Generate sample passes over next 24h
+ const demoSats = ['METEOR-M2-3', 'METEOR-M2-4'];
+ const demoPasses = [];
+
+ const offsets = [25, 95, 200, 340, 510, 720, 880, 1020];
+ const elevations = [72, 45, 28, 63, 18, 55, 82, 35];
+ const durations = [840, 720, 480, 780, 360, 660, 900, 600]; // seconds
+ const riseAzs = [350, 15, 200, 310, 170, 40, 280, 90];
+ const setAzs = [170, 195, 20, 130, 350, 220, 100, 270];
+
+ offsets.forEach((offset, i) => {
+ const start = new Date(now.getTime() + offset * 60000);
+ const end = new Date(start.getTime() + durations[i] * 1000);
+ const sat = demoSats[i % 2];
+ const el = elevations[i];
+ const quality = el >= 60 ? 'excellent' : el >= 30 ? 'good' : 'fair';
+
+ demoPasses.push({
+ id: `${sat}_demo_${i}`,
+ satellite: sat,
+ name: sat === 'METEOR-M2-3' ? 'Meteor-M2-3' : 'Meteor-M2-4',
+ frequency: 137.9,
+ mode: 'LRPT',
+ startTime: start.toISOString().replace('T', ' ').slice(0, 16) + ' UTC',
+ startTimeISO: start.toISOString(),
+ endTimeISO: end.toISOString(),
+ maxEl: el,
+ maxElAz: (riseAzs[i] + setAzs[i]) / 2,
+ riseAz: riseAzs[i],
+ setAz: setAzs[i],
+ duration: durations[i],
+ quality: quality,
+ trajectory: [],
+ groundTrack: [],
+ });
+ });
+
+ allPasses = demoPasses;
+ applyPassFilter();
+
+ // Simulate console output
+ clearConsole();
+ showConsole(true);
+ const demoLogs = [
+ ['SatDump v1.2.2 initialized', 'info'],
+ ['Pipeline: meteor_m2-x_lrpt', 'info'],
+ ['Frequency: 137.900 MHz | Sample rate: 2.4 MHz', 'info'],
+ ['RTL-SDR device 0 (SN: 00000101) opened', 'info'],
+ ['Tuning to 137900000 Hz...', 'info'],
+ ['Gain set to 40.0 dB', 'debug'],
+ ['Waiting for signal...', 'info'],
+ ['LRPT signal detected! SNR: 8.2 dB', 'signal'],
+ ['Viterbi lock acquired', 'signal'],
+ ['Frame sync OK - decoding frames', 'signal'],
+ ['Decoding LRPT... 15%', 'progress'],
+ ['Decoding LRPT... 30%', 'progress'],
+ ['Decoding LRPT... 45%', 'progress'],
+ ['Channel 1 (visible) - 1540 lines', 'info'],
+ ['Channel 2 (infrared) - 1540 lines', 'info'],
+ ['Decoding LRPT... 60%', 'progress'],
+ ['Decoding LRPT... 75%', 'progress'],
+ ['Decoding LRPT... 90%', 'progress'],
+ ['Image saved: meteor_m2-3_rgb_composite.png (2.4 MB)', 'save'],
+ ['Image saved: meteor_m2-3_channel_1.png (1.1 MB)', 'save'],
+ ['Image saved: meteor_m2-3_thermal.png (1.3 MB)', 'save'],
+ ['Decoding complete - 3 images produced', 'info'],
+ ['Signal lost - satellite below horizon', 'warning'],
+ ['Pass duration: 13m 42s', 'info'],
+ ];
+
+ demoLogs.forEach((entry, i) => {
+ setTimeout(() => addConsoleEntry(entry[0], entry[1]), i * 120);
+ });
+
+ showNotification('Weather Sat', 'Demo data loaded - showing sample passes and console output');
+ }
+
// Public API
return {
init,
@@ -2113,6 +2452,11 @@ const WeatherSat = (function() {
toggleScheduler,
invalidateMap,
toggleConsole,
+ setTimezone,
+ filterConsole,
+ exportConsole,
+ clearConsole,
+ loadDemoData,
_getModalFilename: () => currentModalFilename,
};
})();
diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html
index 78bab319..e70f3c2f 100644
--- a/templates/adsb_dashboard.html
+++ b/templates/adsb_dashboard.html
@@ -23,6 +23,7 @@
window.INTERCEPT_DEFAULT_LAT = {{ default_latitude | tojson }};
window.INTERCEPT_DEFAULT_LON = {{ default_longitude | tojson }};
+
@@ -3489,15 +3490,17 @@
Aircraft Log (${report.aircraftLog.length})
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 truncText = escapeHtml(text.length > 200 ? text.substring(0, 200) + '...' : text);
+ const time = msg.timestamp && typeof InterceptTime !== 'undefined'
+ ? InterceptTime.shortTime(msg.timestamp) + InterceptTime.tzSuffix()
+ : (msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : '');
const flight = escapeHtml(msg.flight || '');
+ const tail = escapeHtml(msg.tail || msg.reg || '');
let parsedHtml = '';
if (msg.parsed) {
@@ -3505,23 +3508,35 @@
Aircraft Log (${report.aircraftLog.length})
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)) : '') + '
';
+ (p.flight_level ? ' • FL' + 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)));
+ const val = typeof p[k] === 'object' ? p[k].value : p[k];
+ parts.push(escapeHtml(k) + ': ' + escapeHtml(String(val)));
});
if (parts.length) {
parsedHtml = '
' + parts.slice(0, 4).join(' | ') + '
';
}
} else if (type === 'oooi' && p.origin) {
parsedHtml = '
' +
- escapeHtml(String(p.origin)) + ' → ' + escapeHtml(String(p.destination)) +
+ 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'])) : '') + '
';
+ } else if (type === 'weather' && (p.wind_speed || p.temperature)) {
+ const wx = [];
+ if (p.wind_speed) wx.push('Wind ' + escapeHtml(String(p.wind_speed)) + (p.wind_dir ? '/' + escapeHtml(String(p.wind_dir)) : ''));
+ if (p.temperature) wx.push(escapeHtml(String(p.temperature)) + '°C');
+ if (p.turbulence) wx.push('Turb: ' + escapeHtml(String(p.turbulence)));
+ if (wx.length) parsedHtml = '
' + wx.join(' | ') + '
';
+ } else if (type === 'cpdlc') {
+ const cpdlcText = p.message || p.text || '';
+ if (cpdlcText) parsedHtml = '
' + escapeHtml(String(cpdlcText)) + '
';
+ } else if (type === 'squawk' && p.squawk) {
+ parsedHtml = '
Squawk: ' + escapeHtml(String(p.squawk)) + '
';
}
}
@@ -3529,7 +3544,7 @@
Aircraft Log (${report.aircraftLog.length})
'
' +
'' + badge + ' ' + desc + ' ' +
'' + time + '
' +
- (flight ? '
' + flight + '
' : '') +
+ (flight || tail ? '
' + flight + (tail ? ' (' + tail + ')' : '') + '
' : '') +
parsedHtml +
(truncText && type !== 'link_test' && type !== 'handshake' ?
'
' + truncText + '
' : '') +
@@ -4362,7 +4377,9 @@
Aircraft Log (${report.aircraftLog.length})
const labelDesc = data.label_description || '';
const msgType = data.message_type || 'other';
const text = data.text || data.msg || '';
- const time = new Date().toLocaleTimeString();
+ const time = typeof InterceptTime !== 'undefined'
+ ? InterceptTime.shortTime(new Date()) + InterceptTime.tzSuffix()
+ : new Date().toLocaleTimeString();
// Escape user-controlled strings for safe innerHTML insertion
const eFlight = escapeHtml(flight);
@@ -4878,7 +4895,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
@@ -5676,6 +5695,16 @@
Aircraft Log (${report.aircraftLog.length})
{% include 'partials/settings-modal.html' %}
+
{% include 'partials/help-modal.html' %}
diff --git a/templates/index.html b/templates/index.html
index a7640bbe..787ec8f7 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -2545,6 +2545,25 @@
ISS SSTV Decoder
+
+
+
+ TZ
+
+ UTC
+ Local
+ Eastern
+ Central
+ Mountain
+ Pacific
+
+
+
@@ -2576,6 +2595,29 @@ ISS SSTV Decoder
+
+
+
+
+ 0
+ 24h passes
+
+
+ 0
+ excellent
+
+
+ 0
+ good
+
+
+ 0
+ fair
+
+
+
--
+
+
@@ -2604,8 +2646,18 @@
ISS SSTV Decoder
COMPLETE
- ▼
+
+ ALL
+ SIGNAL
+ PROG
+ ERR
+
+
+ COPY
+ CLR
+ ▼
+
@@ -2621,10 +2673,36 @@
ISS SSTV Decoder
+
+
+
+ AOS
+ --
+ --
+
+
→
+
+ TCA
+ --
+ --
+
+
→
+
+ LOS
+ --
+ --
+
+
--
-
Set location to see pass predictions
+
+
+
+
Set your location
+
Enter lat/lon in the strip bar above or click the GPS button to load pass predictions
@@ -3799,11 +3877,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) {
@@ -3838,8 +3922,12 @@
▶ Device Intelligence
}
// Update clock every second
+ window._navClockStarted = true;
setInterval(updateHeaderClock, 1000);
- updateHeaderClock(); // Initial call
+ updateHeaderClock();
+ if (typeof InterceptTime !== 'undefined' && InterceptTime.onChange) {
+ InterceptTime.onChange(updateHeaderClock);
+ }
applyKeyboardAccessibility();
// Pager message filter functions
diff --git a/templates/partials/modes/acars.html b/templates/partials/modes/acars.html
index ef722e96..a6dc3ad9 100644
--- a/templates/partials/modes/acars.html
+++ b/templates/partials/modes/acars.html
@@ -188,33 +188,49 @@
Message Feed
return `
${lbl} `;
}
- // TODO: Similar to renderAcarsCard in templates/adsb_dashboard.html — consider unifying
function renderAcarsMainCard(data) {
const flight = escapeHtml(data.flight || 'UNKNOWN');
+ const tail = escapeHtml(data.tail || data.reg || '');
const type = data.message_type || 'other';
const badge = acarsMainTypeBadge(type);
const desc = escapeHtml(data.label_description || (data.label ? 'Label: ' + data.label : ''));
const text = data.text || data.msg || '';
- const truncText = escapeHtml(text.length > 150 ? text.substring(0, 150) + '...' : text);
- const time = new Date().toLocaleTimeString();
+ const truncText = escapeHtml(text.length > 200 ? text.substring(0, 200) + '...' : text);
+ const time = typeof InterceptTime !== 'undefined'
+ ? InterceptTime.shortTime(new Date()) + InterceptTime.tzSuffix()
+ : new Date().toLocaleTimeString();
let parsedHtml = '';
if (data.parsed) {
const p = data.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)) : ''}
`;
+ parsedHtml = `
${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}${p.flight_level ? ' • FL' + 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))));
+ Object.keys(p).forEach(k => {
+ const val = typeof p[k] === 'object' ? p[k].value : p[k];
+ parts.push(escapeHtml(k) + ': ' + escapeHtml(String(val)));
+ });
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'])) : ''}
`;
+ } else if (type === 'weather' && (p.wind_speed || p.temperature)) {
+ const wx = [];
+ if (p.wind_speed) wx.push('Wind ' + escapeHtml(String(p.wind_speed)) + (p.wind_dir ? '/' + escapeHtml(String(p.wind_dir)) : ''));
+ if (p.temperature) wx.push(escapeHtml(String(p.temperature)) + '°C');
+ if (p.turbulence) wx.push('Turb: ' + escapeHtml(String(p.turbulence)));
+ if (wx.length) parsedHtml = `
${wx.join(' | ')}
`;
+ } else if (type === 'cpdlc') {
+ const cpdlcText = p.message || p.text || '';
+ if (cpdlcText) parsedHtml = `
${escapeHtml(String(cpdlcText))}
`;
+ } else if (type === 'squawk' && p.squawk) {
+ parsedHtml = `
Squawk: ${escapeHtml(String(p.squawk))}
`;
}
}
return `
- ${flight}
+ ${flight}${tail ? ' (' + tail + ') ' : ''}
${time}
${badge} ${desc}
diff --git a/templates/partials/modes/weather-satellite.html b/templates/partials/modes/weather-satellite.html
index 5fdb2c05..dfe592b4 100644
--- a/templates/partials/modes/weather-satellite.html
+++ b/templates/partials/modes/weather-satellite.html
@@ -11,13 +11,83 @@
Weather Satellite Decoder
+
+
+
Getting Started
+
+
+
+
What are Meteor satellites?
+
+ 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
Select Satellite
- Meteor-M2-3 (137.900 MHz LRPT)
+ All Meteor Satellites
+ Meteor-M2-3 (137.900 MHz LRPT)
Meteor-M2-4 (137.900 MHz LRPT)
+ Meteor-M2-4 80k baud (fallback)
@@ -33,10 +103,13 @@
Satellite
-
+
-
Antenna Guide
-
+
+ Antenna Guide
+ ▼
+
+
137 MHz band — your stock SDR antenna will NOT work.
@@ -174,24 +247,33 @@
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
+ Download samples from SigID Wiki or community forums
+ Place the file in data/weather_sat/ on the server
+
+
Satellite
Meteor-M2-3 (LRPT)
Meteor-M2-4 (LRPT)
+ Meteor-M2-4 80k baud
- File Path (server-side)
-
+ File Path (server-side, relative to app root)
+
Sample Rate
@@ -202,7 +284,7 @@
- Test Decode
+ Decode File
@@ -224,6 +306,16 @@
Auto-Scheduler
+
+
Debug / Test
+
+ Load sample pass data and console output to test the UI without an SDR or live satellite pass.
+
+
+ Load Demo Data
+
+
+
Resources
diff --git a/templates/partials/nav.html b/templates/partials/nav.html
index b9136ba5..bf8ef191 100644
--- a/templates/partials/nav.html
+++ b/templates/partials/nav.html
@@ -540,12 +540,21 @@
window._navClockStarted = true;
function updateNavUtcClock() {
const now = new Date();
- const utc = now.toISOString().slice(11, 19);
const el = document.getElementById('headerUtcTime');
- if (el) el.textContent = utc;
+ 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().slice(11, 19);
+ }
}
setInterval(updateNavUtcClock, 1000);
updateNavUtcClock();
+ // React immediately when timezone/format changes in Settings
+ if (typeof InterceptTime !== 'undefined' && InterceptTime.onChange) {
+ InterceptTime.onChange(updateNavUtcClock);
+ }
}
})();
diff --git a/templates/partials/settings-modal.html b/templates/partials/settings-modal.html
index 2fb814f9..759daedb 100644
--- a/templates/partials/settings-modal.html
+++ b/templates/partials/settings-modal.html
@@ -227,6 +227,36 @@
+
+
+
Time & Timezone
+
+
+
+ Timezone
+ Applied across all modes
+
+
+ UTC
+ Local (browser)
+ Eastern (ET)
+ Central (CT)
+ Mountain (MT)
+ Pacific (PT)
+
+
+
+
+
+ Time Format
+ 12-hour (2:30 PM) or 24-hour (14:30)
+
+
+ 12-hour (AM/PM)
+ 24-hour
+
+
+
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