From 43fb735e4e8e2bb4a7cbd1da2d03d26ff6cb1c94 Mon Sep 17 00:00:00 2001 From: mitchross Date: Wed, 25 Mar 2026 00:05:31 -0400 Subject: [PATCH 1/8] Fix Meteor LRPT decoding in Docker and enhance weather satellite UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker fixes: - Add missing COPY for /usr/local/share/ (pipeline definitions were never reaching the runtime image — root cause of silent SatDump failures) - Add libfftw3-double3 and libfftw3-single3 runtime dependencies - Handle arm64 vs x86 install path differences (/usr vs /usr/local) - Split SatDump compile and staging into separate layers for better caching - Add build-time assertions to catch missing pipelines early UI enhancements: - Timezone selector (UTC, Local, Eastern, Central, Mountain, Pacific) with localStorage persistence — all time displays update instantly - Pass analysis bar showing 24h quality breakdown and best upcoming pass - Enhanced pass cards with cardinal direction (NW→SE), BEST badge - Console timestamps, log level filters (ALL/SIGNAL/PROG/ERR), COPY/CLR - Pass count in stats strip - Demo data mode for UI testing without SDR or live satellite pass - Meteor M2-4 80k baud fallback pipeline option Co-Authored-By: Claude Opus 4.6 (1M context) --- Dockerfile | 31 +- static/css/modes/weather-satellite.css | 136 +++++++ static/js/modes/weather-satellite.js | 363 +++++++++++++++++- templates/index.html | 56 ++- .../partials/modes/weather-satellite.html | 106 ++--- utils/weather_sat.py | 195 +++++----- 6 files changed, 721 insertions(+), 166 deletions(-) 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/static/css/modes/weather-satellite.css b/static/css/modes/weather-satellite.css index 28438e19..bbdc3b80 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,80 @@ 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 Predictions Panel ===== */ .wxsat-passes-panel { flex: 0 0 280px; @@ -1066,6 +1157,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/modes/weather-satellite.js b/static/js/modes/weather-satellite.js index e5fc9165..4fc3db5a 100644 --- a/static/js/modes/weather-satellite.js +++ b/static/js/modes/weather-satellite.js @@ -36,11 +36,111 @@ const WeatherSat = (function() { let imageRefreshInterval = null; let lastDecodeJobSignature = null; let lastDecodeSatellite = null; + let consoleFilter = 'all'; + + // Timezone support + const TZ_MAP = { + 'UTC': 'UTC', + 'local': null, // browser default + 'US/Eastern': 'America/New_York', + 'US/Central': 'America/Chicago', + 'US/Mountain': 'America/Denver', + 'US/Pacific': 'America/Los_Angeles', + }; + let selectedTimezone = localStorage.getItem('wxsatTimezone') || 'UTC'; + + /** + * Format an ISO date string for the selected timezone. + * @param {string} isoString - ISO 8601 date string + * @param {object} [opts] - Additional Intl.DateTimeFormat options + * @returns {string} Formatted date/time string + */ + function formatTimeForTZ(isoString, opts = {}) { + if (!isoString) return '--'; + try { + const date = new Date(isoString); + if (isNaN(date.getTime())) return isoString; + const tz = TZ_MAP[selectedTimezone]; + const defaults = { hour: '2-digit', minute: '2-digit', hour12: false }; + const options = { ...defaults, ...opts }; + if (tz) options.timeZone = tz; + return date.toLocaleString(undefined, options); + } catch { + return isoString; + } + } + + /** + * Format a short time (HH:MM) for the selected timezone. + */ + function formatShortTime(isoString) { + return formatTimeForTZ(isoString, { hour: '2-digit', minute: '2-digit', hour12: false }); + } + + /** + * Format date + time for the selected timezone. + */ + function formatDateTime(isoString) { + return formatTimeForTZ(isoString, { + month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', hour12: false + }); + } + + /** + * Get a short timezone label for display. + */ + function getTZLabel() { + if (selectedTimezone === 'local') return ''; + if (selectedTimezone === 'UTC') return ' UTC'; + const labels = { 'US/Eastern': ' ET', 'US/Central': ' CT', 'US/Mountain': ' MT', 'US/Pacific': ' PT' }; + return labels[selectedTimezone] || ''; + } + + /** + * Set timezone and refresh all displays. + */ + function setTimezone(tz) { + selectedTimezone = tz; + localStorage.setItem('wxsatTimezone', tz); + const sel = document.getElementById('wxsatTimezone'); + if (sel && sel.value !== tz) sel.value = tz; + // Refresh all time-dependent displays + 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() { + // Restore timezone selector + const tzSel = document.getElementById('wxsatTimezone'); + if (tzSel) tzSel.value = selectedTimezone; + if (initialized) { checkStatus(); loadImages(); @@ -643,7 +743,10 @@ const WeatherSat = (function() { renderPasses([]); renderTimeline([]); updateCountdownFromPasses(); + updatePassAnalysis([]); updateGroundTrack(null); + const passCountEl = document.getElementById('wxsatStripPassCount'); + if (passCountEl) passCountEl.textContent = '0'; return; } @@ -675,7 +778,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 { @@ -730,14 +838,17 @@ const WeatherSat = (function() { 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,10 +863,14 @@ const WeatherSat = (function() { countdown = `in ${hrs}h${mins}m`; } + const riseDir = azToDir(pass.riseAz); + const setDir = azToDir(pass.setAz); + const bestBadge = isBest ? 'BEST' : ''; + return `
- ${escapeHtml(pass.name)} + ${escapeHtml(pass.name)}${bestBadge} ${escapeHtml(pass.mode)}
@@ -765,8 +880,8 @@ const WeatherSat = (function() { ${pass.maxEl}° Duration ${pass.duration} min - Freq - ${pass.frequency} MHz + Direction + ${riseDir} ${setDir}
${pass.quality} @@ -1334,12 +1449,17 @@ 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 bestNote = bestPass && bestPass.startTimeISO !== nextPass.startTimeISO + ? ` | Best: ${bestPass.name} ${formatShortTime(bestPass.startTimeISO)}${getTZLabel()} (${bestPass.maxEl}\u00b0)` + : ''; + detailEl.textContent = `${passTimeStr} / ${nextPass.maxEl}\u00b0 max el / ${nextPass.duration} min${bestNote}`; } } @@ -1391,7 +1511,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 +1534,71 @@ 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; + + hours.forEach((h, i) => { + if (selectedTimezone === 'UTC' || selectedTimezone === 'local') { + spans[i].textContent = h === 24 ? '24:00' : `${String(h).padStart(2, '0')}:00`; + } else { + // Show timezone-adjusted labels + const d = new Date(); + d.setHours(h, 0, 0, 0); + const tz = TZ_MAP[selectedTimezone]; + const opts = { hour: '2-digit', minute: '2-digit', hour12: false }; + if (tz) opts.timeZone = tz; + if (h === 24) { + spans[i].textContent = '24:00'; + } else { + 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(); + bestEl.textContent = `Best: ${best.name} at ${t} (${best.maxEl}\u00b0 el, ${best.duration} min)`; + } else { + bestEl.textContent = 'No upcoming passes'; + } + } + } + // ======================== // Auto-Scheduler // ======================== @@ -1627,12 +1812,16 @@ const WeatherSat = (function() { return new Date(b.timestamp || 0) - new Date(a.timestamp || 0); }); - // Group by date + // Group by date (timezone-aware) 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) { + const tz = TZ_MAP[selectedTimezone]; + const opts = { year: 'numeric', month: 'short', day: 'numeric' }; + if (tz) opts.timeZone = tz; + dateKey = new Date(img.timestamp).toLocaleDateString(undefined, opts); + } if (!groups[dateKey]) groups[dateKey] = []; groups[dateKey].push(img); }); @@ -1788,11 +1977,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 +2154,24 @@ const WeatherSat = (function() { const log = document.getElementById('wxsatConsoleLog'); if (!log) return; + const type = logType || 'info'; + const now = new Date(); + const tz = TZ_MAP[selectedTimezone]; + const tsOpts = { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }; + if (tz) tsOpts.timeZone = tz; + const ts = now.toLocaleTimeString(undefined, tsOpts); + 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 +2184,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 +2280,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 +2330,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 = [14, 12, 8, 13, 6, 11, 15, 10]; + 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] * 60000); + 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 +2435,11 @@ const WeatherSat = (function() { toggleScheduler, invalidateMap, toggleConsole, + setTimezone, + filterConsole, + exportConsole, + clearConsole, + loadDemoData, _getModalFilename: () => currentModalFilename, }; })(); diff --git a/templates/index.html b/templates/index.html index 6485d777..68b38ffe 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2484,6 +2484,25 @@

ISS SSTV Decoder

+
+
+ 0 + PASSES +
+
+
+
+ TZ + +
+
+ +
+
+
+ 0 + 24h passes +
+
+ 0 + excellent +
+
+ 0 + good +
+
+ 0 + fair +
+
+
--
+
+
@@ -2543,8 +2585,18 @@

ISS SSTV Decoder

COMPLETE
- +
+ + + + +
+
+ + + +
diff --git a/templates/partials/modes/weather-satellite.html b/templates/partials/modes/weather-satellite.html index d6471fdb..48a4697e 100644 --- a/templates/partials/modes/weather-satellite.html +++ b/templates/partials/modes/weather-satellite.html @@ -2,13 +2,13 @@

Weather Satellite Decoder

-
- 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 @@

    +
    +

    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 = ` `; + // Hide geometry panel when no passes + const geom = document.getElementById('wxsatPassGeometry'); + if (geom) geom.style.display = 'none'; return; } @@ -866,6 +918,10 @@ const WeatherSat = (function() { 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 `
    @@ -874,13 +930,13 @@ const WeatherSat = (function() { ${escapeHtml(pass.mode)}
    - Time - ${escapeHtml(timeStr)} - Max El - ${pass.maxEl}° - Duration - ${pass.duration} min - Direction + 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}
    @@ -1456,10 +1512,11 @@ const WeatherSat = (function() { detailEl.textContent = `ACTIVE - ${nextPass.maxEl}\u00b0 max el`; } else { 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 / ${nextPass.duration} min${bestNote}`; + detailEl.textContent = `${passTimeStr} / ${nextPass.maxEl}\u00b0 max el / ${durMin} min${bestNote}`; } } @@ -1592,7 +1649,8 @@ const WeatherSat = (function() { if (bestEl) { if (best) { const t = formatShortTime(best.startTimeISO) + getTZLabel(); - bestEl.textContent = `Best: ${best.name} at ${t} (${best.maxEl}\u00b0 el, ${best.duration} min)`; + 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'; } @@ -2343,13 +2401,13 @@ const WeatherSat = (function() { const offsets = [25, 95, 200, 340, 510, 720, 880, 1020]; const elevations = [72, 45, 28, 63, 18, 55, 82, 35]; - const durations = [14, 12, 8, 13, 6, 11, 15, 10]; + 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] * 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'; diff --git a/templates/index.html b/templates/index.html index 68b38ffe..585d48bf 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2612,10 +2612,36 @@

    ISS SSTV Decoder

    Upcoming Passes 0 + +
    + +
    diff --git a/templates/partials/modes/weather-satellite.html b/templates/partials/modes/weather-satellite.html index 48a4697e..702aa843 100644 --- a/templates/partials/modes/weather-satellite.html +++ b/templates/partials/modes/weather-satellite.html @@ -11,6 +11,74 @@

    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 +
      +
    1. Set your location — Enter your lat/lon in the strip bar above (or click GPS). This is required for pass predictions.
    2. +
    3. 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".
    4. +
    5. 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.
    6. +
    7. Click Capture on a pass card when it's about to start, or enable AUTO to let the scheduler capture automatically.
    8. +
    9. Wait for images — SatDump will tune, lock the signal, and decode. Decoded images appear in the gallery after the pass completes.
    10. +
    +
    + +
    + 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 receiverRTL-SDR V3/V4 ($25-35)
    Antenna137 MHz V-dipole ($5 DIY) or QFH ($20-30)
    LNA (optional)137 MHz filtered, at antenna ($15-25)
    LocationOutdoors, clear sky view
    No hardware?Use Load Demo Data below to explore the UI
    +
    +
    +
    +

    Satellite

    @@ -33,10 +101,13 @@

    Satellite

    - +
    -

    Antenna Guide

    -
    +

    + Antenna Guide + +

    + From 7d704c9d42c717f341ae434d9f5b83b438c6f2df Mon Sep 17 00:00:00 2001 From: mitchross Date: Wed, 25 Mar 2026 01:36:32 -0400 Subject: [PATCH 4/8] Fix ACARS message display and add missing decoded data types - Use global InterceptTime for all ACARS timestamps (respects Eastern/12h) - Add weather message rendering (wind, temperature, turbulence) - Add CPDLC controller-pilot message rendering (purple highlight) - Add squawk code change rendering (red highlight) - Fix engine_data crash when parsed value isn't an object - Show tail/registration alongside flight number on all cards - Increase message text truncation to 200 chars - Add FL prefix to flight level in position reports - Applied consistently across ADS-B dashboard, sidebar feed, and standalone ACARS mode Co-Authored-By: Claude Opus 4.6 (1M context) --- templates/adsb_dashboard.html | 34 +++++++++++++++++++++-------- templates/partials/modes/acars.html | 28 +++++++++++++++++++----- 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html index 244d6653..860c08a9 100644 --- a/templates/adsb_dashboard.html +++ b/templates/adsb_dashboard.html @@ -3525,15 +3525,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) { @@ -3541,23 +3543,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)) + '
    '; } } @@ -3565,7 +3579,7 @@

    Aircraft Log (${report.aircraftLog.length})

    '
    ' + '' + badge + ' ' + desc + '' + '' + time + '
    ' + - (flight ? '
    ' + flight + '
    ' : '') + + (flight || tail ? '
    ' + flight + (tail ? ' (' + tail + ')' : '') + '
    ' : '') + parsedHtml + (truncText && type !== 'link_test' && type !== 'handshake' ? '
    ' + truncText + '
    ' : '') + @@ -4398,7 +4412,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); 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}
    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 @@

    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