Merge pull request #202 from mitchross/misc-fixes

Fix Meteor LRPT, global timezone, VDL2 correlation, and weather sat UX
This commit is contained in:
Smittix
2026-04-26 15:05:56 +01:00
committed by GitHub
15 changed files with 1186 additions and 179 deletions
+25 -6
View File
@@ -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
+5
View File
@@ -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"]
+8 -11
View File
@@ -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:
+27 -3
View File
@@ -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', ''),
+217
View File
@@ -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;
+23 -3
View File
@@ -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();
+108
View File
@@ -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
+372 -28
View File
@@ -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 = `
<div class="wxsat-gallery-empty">
<p>${hasLocation ? 'No passes in next 24h' : 'Set location to see pass predictions'}</p>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width: 32px; height: 32px; margin-bottom: 8px; opacity: 0.3;">
${hasLocation
? '<circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>'
: '<circle cx="12" cy="12" r="10"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/>'}
</svg>
<p style="font-size: 12px; font-weight: 600; color: var(--text-secondary);">
${hasLocation ? 'No passes in next 24 hours' : 'Set your location'}
</p>
<p style="font-size: 11px; margin-top: 4px;">
${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'}
</p>
</div>
`;
// 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 ? '<span class="wxsat-pass-best-badge">BEST</span>' : '';
const durMin = Math.round((pass.duration || 0) / 60);
const aosStr = formatShortTime(pass.startTimeISO);
const losStr = formatShortTime(pass.endTimeISO);
const tzLabel = getTZLabel();
return `
<div class="wxsat-pass-card${isSelected ? ' selected' : ''}" onclick="WeatherSat.selectPass(${idx})">
<div class="wxsat-pass-sat">
<span class="wxsat-pass-sat-name">${escapeHtml(pass.name)}</span>
<span class="wxsat-pass-sat-name">${escapeHtml(pass.name)}${bestBadge}</span>
<span class="wxsat-pass-mode ${modeClass}">${escapeHtml(pass.mode)}</span>
</div>
<div class="wxsat-pass-details">
<span class="wxsat-pass-detail-label">Time</span>
<span class="wxsat-pass-detail-value">${escapeHtml(timeStr)}</span>
<span class="wxsat-pass-detail-label">Max El</span>
<span class="wxsat-pass-detail-value">${pass.maxEl}&deg;</span>
<span class="wxsat-pass-detail-label">Duration</span>
<span class="wxsat-pass-detail-value">${pass.duration} min</span>
<span class="wxsat-pass-detail-label">Freq</span>
<span class="wxsat-pass-detail-value">${pass.frequency} MHz</span>
<span class="wxsat-pass-detail-label">AOS</span>
<span class="wxsat-pass-detail-value">${escapeHtml(aosStr)}${escapeHtml(tzLabel)} &middot; ${Math.round(pass.riseAz || 0)}&deg; ${riseDir}</span>
<span class="wxsat-pass-detail-label">LOS</span>
<span class="wxsat-pass-detail-value">${escapeHtml(losStr)}${escapeHtml(tzLabel)} &middot; ${Math.round(pass.setAz || 0)}&deg; ${setDir}</span>
<span class="wxsat-pass-detail-label">Peak</span>
<span class="wxsat-pass-detail-value">${pass.maxEl}&deg; el &middot; ${durMin} min</span>
<span class="wxsat-pass-detail-label">Track</span>
<span class="wxsat-pass-detail-value">${riseDir} <span class="wxsat-dir-arrow">&rarr;</span> ${setDir}</span>
</div>
<div style="display: flex; align-items: center; justify-content: space-between; margin-top: 4px;">
<span class="wxsat-pass-quality ${pass.quality}">${pass.quality}</span>
@@ -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 entry = document.createElement('div');
entry.className = `wxsat-console-entry wxsat-log-${logType || 'info'}`;
entry.textContent = message;
log.appendChild(entry);
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-${type}`;
entry.dataset.logType = type;
entry.innerHTML = `<span class="wxsat-console-ts">${ts}</span>${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,
};
})();
+39 -10
View File
@@ -23,6 +23,7 @@
window.INTERCEPT_DEFAULT_LAT = {{ default_latitude | tojson }};
window.INTERCEPT_DEFAULT_LON = {{ default_longitude | tojson }};
</script>
<script src="{{ url_for('static', filename='js/core/utils.js') }}"></script>
<script defer src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
</head>
@@ -3489,15 +3490,17 @@ sudo make install</code>
return '<span style="display:inline-block;padding:1px 5px;border-radius:3px;font-size:8px;font-weight:700;color:#000;background:' + color + ';">' + lbl + '</span>';
}
// 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 @@ sudo make install</code>
if (type === 'position' && p.lat !== undefined) {
parsedHtml = '<div style="color:var(--accent-green);margin-top:2px;">' +
p.lat.toFixed(4) + ', ' + p.lon.toFixed(4) +
(p.flight_level ? ' ' + escapeHtml(String(p.flight_level)) : '') +
(p.destination ? ' ' + escapeHtml(String(p.destination)) : '') + '</div>';
(p.flight_level ? ' &bull; FL' + escapeHtml(String(p.flight_level)) : '') +
(p.destination ? ' &rarr; ' + escapeHtml(String(p.destination)) : '') + '</div>';
} 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 = '<div style="color:var(--accent-orange,#ff9500);margin-top:2px;">' + parts.slice(0, 4).join(' | ') + '</div>';
}
} else if (type === 'oooi' && p.origin) {
parsedHtml = '<div style="color:var(--accent-cyan);margin-top:2px;">' +
escapeHtml(String(p.origin)) + ' ' + escapeHtml(String(p.destination)) +
escapeHtml(String(p.origin)) + ' &rarr; ' + 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'])) : '') + '</div>';
} 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 = '<div style="color:#00d4ff;margin-top:2px;">' + wx.join(' | ') + '</div>';
} else if (type === 'cpdlc') {
const cpdlcText = p.message || p.text || '';
if (cpdlcText) parsedHtml = '<div style="color:#b388ff;margin-top:2px;font-weight:600;">' + escapeHtml(String(cpdlcText)) + '</div>';
} else if (type === 'squawk' && p.squawk) {
parsedHtml = '<div style="color:#ff6b6b;margin-top:2px;font-weight:600;">Squawk: ' + escapeHtml(String(p.squawk)) + '</div>';
}
}
@@ -3529,7 +3544,7 @@ sudo make install</code>
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:2px;">' +
'<span>' + badge + ' <span style="color:var(--text-primary);">' + desc + '</span></span>' +
'<span style="color:var(--text-muted);font-size:9px;">' + time + '</span></div>' +
(flight ? '<div style="color:var(--accent-cyan);font-size:9px;">' + flight + '</div>' : '') +
(flight || tail ? '<div style="color:var(--accent-cyan);font-size:9px;">' + flight + (tail ? ' (' + tail + ')' : '') + '</div>' : '') +
parsedHtml +
(truncText && type !== 'link_test' && type !== 'handshake' ?
'<div style="color:var(--text-dim);font-family:var(--font-mono);font-size:9px;margin-top:2px;word-break:break-all;">' + truncText + '</div>' : '') +
@@ -4362,7 +4377,9 @@ sudo make install</code>
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 @@ sudo make install</code>
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 @@ sudo make install</code>
<!-- Settings Modal -->
{% include 'partials/settings-modal.html' %}
<script>
// Initialize timezone/format dropdowns from saved preferences
(function() {
if (typeof InterceptTime === 'undefined') return;
var tzSel = document.getElementById('globalTimezoneSelect');
var fmtSel = document.getElementById('globalTimeFormatSelect');
if (tzSel) tzSel.value = InterceptTime.getTimezone();
if (fmtSel) fmtSel.value = InterceptTime.getHour12() ? '12' : '24';
})();
</script>
<!-- Help Modal -->
{% include 'partials/help-modal.html' %}
+95 -7
View File
@@ -2545,6 +2545,25 @@
</div>
</div>
<div class="wxsat-strip-divider"></div>
<div class="wxsat-strip-group">
<div class="wxsat-strip-stat">
<span class="wxsat-strip-value" id="wxsatStripPassCount">0</span>
<span class="wxsat-strip-label">PASSES</span>
</div>
</div>
<div class="wxsat-strip-divider"></div>
<div class="wxsat-strip-group">
<span class="wxsat-strip-label" style="margin-right: 4px;">TZ</span>
<select id="wxsatTimezone" class="wxsat-tz-select" onchange="WeatherSat.setTimezone(this.value)">
<option value="UTC">UTC</option>
<option value="local">Local</option>
<option value="US/Eastern">Eastern</option>
<option value="US/Central">Central</option>
<option value="US/Mountain">Mountain</option>
<option value="US/Pacific">Pacific</option>
</select>
</div>
<div class="wxsat-strip-divider"></div>
<div class="wxsat-strip-group">
<label class="wxsat-schedule-toggle" title="Auto-capture passes">
<input type="checkbox" id="wxsatAutoSchedule" onchange="WeatherSat.toggleScheduler(this)">
@@ -2576,6 +2595,29 @@
</div>
</div>
<!-- Pass Analysis Bar -->
<div class="wxsat-analysis-bar" id="wxsatAnalysisBar">
<div class="wxsat-analysis-stats">
<div class="wxsat-analysis-stat">
<span class="wxsat-analysis-value" id="wxsatAnalysisTotal">0</span>
<span class="wxsat-analysis-label">24h passes</span>
</div>
<div class="wxsat-analysis-stat">
<span class="wxsat-analysis-value excellent" id="wxsatAnalysisExcellent">0</span>
<span class="wxsat-analysis-label">excellent</span>
</div>
<div class="wxsat-analysis-stat">
<span class="wxsat-analysis-value good" id="wxsatAnalysisGood">0</span>
<span class="wxsat-analysis-label">good</span>
</div>
<div class="wxsat-analysis-stat">
<span class="wxsat-analysis-value fair" id="wxsatAnalysisFair">0</span>
<span class="wxsat-analysis-label">fair</span>
</div>
</div>
<div class="wxsat-analysis-best" id="wxsatAnalysisBest">--</div>
</div>
<!-- Capture progress -->
<div class="wxsat-capture-status" id="wxsatCaptureStatus">
<div class="wxsat-capture-info">
@@ -2604,8 +2646,18 @@
<span class="wxsat-phase-step" data-phase="complete">COMPLETE</span>
</div>
</div>
<button class="wxsat-strip-btn" id="wxsatConsoleToggle"
onclick="WeatherSat.toggleConsole()" title="Toggle console">&#x25BC;</button>
<div class="wxsat-console-filters" id="wxsatConsoleFilters">
<button class="wxsat-console-filter active" data-filter="all" onclick="WeatherSat.filterConsole('all')">ALL</button>
<button class="wxsat-console-filter" data-filter="signal" onclick="WeatherSat.filterConsole('signal')">SIGNAL</button>
<button class="wxsat-console-filter" data-filter="progress" onclick="WeatherSat.filterConsole('progress')">PROG</button>
<button class="wxsat-console-filter" data-filter="error" onclick="WeatherSat.filterConsole('error')">ERR</button>
</div>
<div class="wxsat-console-actions">
<button class="wxsat-strip-btn" onclick="WeatherSat.exportConsole()" title="Copy log to clipboard">COPY</button>
<button class="wxsat-strip-btn" onclick="WeatherSat.clearConsole()" title="Clear console">CLR</button>
<button class="wxsat-strip-btn" id="wxsatConsoleToggle"
onclick="WeatherSat.toggleConsole()" title="Toggle console">&#x25BC;</button>
</div>
</div>
<div class="wxsat-console-body" id="wxsatConsoleBody">
<div class="wxsat-console-log" id="wxsatConsoleLog">
@@ -2621,10 +2673,36 @@
<div class="wxsat-passes-header">
<span class="wxsat-passes-title">Upcoming Passes</span>
<span class="wxsat-passes-count" id="wxsatPassesCount">0</span>
<button class="wxsat-strip-btn" onclick="WeatherSat.loadPasses()" title="Refresh pass predictions" style="margin-left: auto; font-size: 9px; padding: 2px 6px;">REFRESH</button>
</div>
<!-- Pass geometry detail (shown when pass selected) -->
<div class="wxsat-pass-geometry" id="wxsatPassGeometry" style="display: none;">
<div class="wxsat-geom-event">
<span class="wxsat-geom-label">AOS</span>
<span class="wxsat-geom-time" id="wxsatGeomAosTime">--</span>
<span class="wxsat-geom-az" id="wxsatGeomAosAz">--</span>
</div>
<div class="wxsat-geom-arrow">&rarr;</div>
<div class="wxsat-geom-event wxsat-geom-tca">
<span class="wxsat-geom-label">TCA</span>
<span class="wxsat-geom-time" id="wxsatGeomTcaEl">--</span>
<span class="wxsat-geom-az" id="wxsatGeomTcaAz">--</span>
</div>
<div class="wxsat-geom-arrow">&rarr;</div>
<div class="wxsat-geom-event">
<span class="wxsat-geom-label">LOS</span>
<span class="wxsat-geom-time" id="wxsatGeomLosTime">--</span>
<span class="wxsat-geom-az" id="wxsatGeomLosAz">--</span>
</div>
<div class="wxsat-geom-meta" id="wxsatGeomMeta">--</div>
</div>
<div class="wxsat-passes-list" id="wxsatPassesList">
<div class="wxsat-gallery-empty">
<p>Set location to see pass predictions</p>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width: 32px; height: 32px; margin-bottom: 8px; opacity: 0.3;">
<circle cx="12" cy="12" r="10"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/>
</svg>
<p style="font-size: 12px; font-weight: 600; color: var(--text-secondary);">Set your location</p>
<p style="font-size: 11px; margin-top: 4px;">Enter lat/lon in the strip bar above or click the GPS button to load pass predictions</p>
</div>
</div>
</div>
@@ -3799,11 +3877,17 @@
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 @@
}
// 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
+22 -6
View File
@@ -188,33 +188,49 @@
return `<span style="display:inline-block;padding:1px 5px;border-radius:3px;font-size:8px;font-weight:700;color:#000;background:${color};">${lbl}</span>`;
}
// 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 = `<div style="color:var(--accent-green);margin-top:2px;font-size:10px;">${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}${p.flight_level ? ' &bull; ' + escapeHtml(String(p.flight_level)) : ''}${p.destination ? ' &rarr; ' + escapeHtml(String(p.destination)) : ''}</div>`;
parsedHtml = `<div style="color:var(--accent-green);margin-top:2px;font-size:10px;">${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}${p.flight_level ? ' &bull; FL' + escapeHtml(String(p.flight_level)) : ''}${p.destination ? ' &rarr; ' + escapeHtml(String(p.destination)) : ''}</div>`;
} 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 = `<div style="color:#ff9500;margin-top:2px;font-size:10px;">${parts.slice(0, 4).join(' | ')}</div>`;
} else if (type === 'oooi' && p.origin) {
parsedHtml = `<div style="color:var(--accent-cyan);margin-top:2px;font-size:10px;">${escapeHtml(String(p.origin))} &rarr; ${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'])) : ''}</div>`;
} 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 = `<div style="color:#00d4ff;margin-top:2px;font-size:10px;">${wx.join(' | ')}</div>`;
} else if (type === 'cpdlc') {
const cpdlcText = p.message || p.text || '';
if (cpdlcText) parsedHtml = `<div style="color:#b388ff;margin-top:2px;font-size:10px;font-weight:600;">${escapeHtml(String(cpdlcText))}</div>`;
} else if (type === 'squawk' && p.squawk) {
parsedHtml = `<div style="color:#ff6b6b;margin-top:2px;font-size:10px;font-weight:600;">Squawk: ${escapeHtml(String(p.squawk))}</div>`;
}
}
return `<div class="acars-feed-card" style="padding:6px 8px;border-bottom:1px solid var(--border-color);animation:fadeInMsg 0.3s ease;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:2px;">
<span style="color:var(--accent-cyan);font-weight:bold;">${flight}</span>
<span style="color:var(--accent-cyan);font-weight:bold;">${flight}${tail ? ' <span style="color:var(--text-muted);font-weight:normal;font-size:9px;">(' + tail + ')</span>' : ''}</span>
<span style="color:var(--text-muted);font-size:9px;">${time}</span>
</div>
<div style="margin-top:2px;">${badge} <span style="color:var(--text-primary);">${desc}</span></div>
+102 -10
View File
@@ -11,13 +11,83 @@
</p>
</div>
<!-- Getting Started Guide -->
<div class="section">
<h3>Getting Started</h3>
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.6;">
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">What are Meteor satellites?</strong>
<p style="margin-top: 6px;">
Russia's <strong style="color: var(--text-primary);">Meteor-M2-3</strong> and <strong style="color: var(--text-primary);">Meteor-M2-4</strong>
are polar-orbiting weather satellites that continuously transmit real-time color imagery (clouds, land, sea) at <strong style="color: var(--text-primary);">137.900 MHz</strong>
using the LRPT digital format. Unlike old analog NOAA APT, LRPT produces sharp, full-color images.
</p>
<p style="margin-top: 6px;">
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 <strong style="color: var(--text-primary);">4&ndash;8 usable passes per day</strong>,
each lasting 8&ndash;15 minutes as the satellite crosses your sky.
</p>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Step-by-step</strong>
<ol style="margin: 6px 0 0 16px; padding: 0;">
<li style="margin-bottom: 4px;"><strong style="color: var(--text-primary);">Set your location</strong> &mdash; Enter your lat/lon in the strip bar above (or click GPS). This is required for pass predictions.</li>
<li style="margin-bottom: 4px;"><strong style="color: var(--text-primary);">Check upcoming passes</strong> &mdash; The pass list shows when each satellite will be overhead. Higher max elevation = better signal. Passes above 30&deg; are "good", above 60&deg; are "excellent".</li>
<li style="margin-bottom: 4px;"><strong style="color: var(--text-primary);">Prepare your antenna</strong> &mdash; You need a 137 MHz antenna outdoors with clear sky (see Antenna Guide below). A $5 V-dipole works for high passes.</li>
<li style="margin-bottom: 4px;"><strong style="color: var(--text-primary);">Click Capture</strong> on a pass card when it's about to start, or enable <strong style="color: var(--text-primary);">AUTO</strong> to let the scheduler capture automatically.</li>
<li style="margin-bottom: 4px;"><strong style="color: var(--text-primary);">Wait for images</strong> &mdash; SatDump will tune, lock the signal, and decode. Decoded images appear in the gallery after the pass completes.</li>
</ol>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">When to look</strong>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">Best passes:</strong> When the satellite is high overhead (&gt;30&deg; elevation). The countdown timer shows the next one.</li>
<li><strong style="color: var(--text-primary);">Day vs night:</strong> Daytime passes produce visible-light imagery. Night passes still work but only produce infrared/thermal images.</li>
<li><strong style="color: var(--text-primary);">Both satellites share 137.9 MHz</strong> so they won't transmit at the same time. You'll see separate pass predictions for each.</li>
<li><strong style="color: var(--text-primary);">Pass direction:</strong> Meteor satellites travel roughly north&rarr;south or south&rarr;north. The pass cards show the exact rise/set direction.</li>
</ul>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">What you need</strong>
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">SDR receiver</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">RTL-SDR V3/V4 ($25-35)</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Antenna</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">137 MHz V-dipole ($5 DIY) or QFH ($20-30)</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">LNA (optional)</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">137 MHz filtered, at antenna ($15-25)</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Location</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Outdoors, clear sky view</td>
</tr>
<tr>
<td style="padding: 3px 4px; color: var(--text-dim);">No hardware?</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Use <em>Load Demo Data</em> below to explore the UI</td>
</tr>
</table>
</div>
</div>
</div>
<div class="section">
<h3>Satellite</h3>
<div class="form-group">
<label>Select Satellite</label>
<select id="weatherSatSelect" class="mode-select">
<option value="METEOR-M2-3" selected>Meteor-M2-3 (137.900 MHz LRPT)</option>
<option value="" selected>All Meteor Satellites</option>
<option value="METEOR-M2-3">Meteor-M2-3 (137.900 MHz LRPT)</option>
<option value="METEOR-M2-4">Meteor-M2-4 (137.900 MHz LRPT)</option>
<option value="METEOR-M2-4-80K">Meteor-M2-4 80k baud (fallback)</option>
</select>
</div>
<div class="form-group">
@@ -33,10 +103,13 @@
</div>
</div>
<!-- Antenna Guide - detailed -->
<!-- Antenna Guide - detailed (collapsed by default) -->
<div class="section">
<h3>Antenna Guide</h3>
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
<h3 onclick="this.parentElement.querySelector('.wxsat-antenna-body').classList.toggle('collapsed'); this.querySelector('.wxsat-collapse-icon').classList.toggle('collapsed')" style="cursor: pointer; display: flex; align-items: center; justify-content: space-between; user-select: none;">
Antenna Guide
<span class="wxsat-collapse-icon collapsed" style="font-size: 10px; transition: transform 0.2s; display: inline-block;">&#9660;</span>
</h3>
<div class="wxsat-antenna-body wxsat-test-decode-body collapsed" style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
<p style="margin-bottom: 10px; color: var(--accent-cyan); font-weight: 600;">
137 MHz band &mdash; your stock SDR antenna will NOT work.
@@ -174,24 +247,33 @@
<div class="section">
<h3 onclick="this.parentElement.querySelector('.wxsat-test-decode-body').classList.toggle('collapsed'); this.querySelector('.wxsat-collapse-icon').classList.toggle('collapsed')" style="cursor: pointer; display: flex; align-items: center; justify-content: space-between; user-select: none;">
Test Decode (File)
Offline Decode (IQ File)
<span class="wxsat-collapse-icon collapsed" style="font-size: 10px; transition: transform 0.2s; display: inline-block;">&#9660;</span>
</h3>
<div class="wxsat-test-decode-body collapsed" style="overflow: hidden;">
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">
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 <code>.raw</code>, <code>.sigmf-data</code>, or <code>.wav</code> recording of a Meteor pass.
</p>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px; margin-bottom: 10px; font-size: 10px; color: var(--text-dim); line-height: 1.5;">
<strong style="color: var(--text-secondary);">Where to get a test file:</strong>
<ul style="margin: 4px 0 0 14px; padding: 0;">
<li>Record one yourself with <code>rtl_sdr -f 137900000 -s 2400000 meteor.raw</code> during a pass</li>
<li>Download samples from <a href="https://www.sigidwiki.com/wiki/Meteor-M_LRPT" target="_blank" rel="noopener" style="color: var(--accent-cyan);">SigID Wiki</a> or <a href="https://www.sondehub.org/" target="_blank" rel="noopener" style="color: var(--accent-cyan);">community forums</a></li>
<li>Place the file in <code>data/weather_sat/</code> on the server</li>
</ul>
</div>
<div class="form-group">
<label>Satellite</label>
<select id="wxsatTestSatSelect" class="mode-select">
<option value="METEOR-M2-3" selected>Meteor-M2-3 (LRPT)</option>
<option value="METEOR-M2-4">Meteor-M2-4 (LRPT)</option>
<option value="METEOR-M2-4-80K">Meteor-M2-4 80k baud</option>
</select>
</div>
<div class="form-group">
<label>File Path (server-side)</label>
<input type="text" id="wxsatTestFilePath" value="data/weather_sat/samples/meteor_lrpt.sigmf-data" style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; font-size: 11px;">
<label>File Path (server-side, relative to app root)</label>
<input type="text" id="wxsatTestFilePath" placeholder="data/weather_sat/my_recording.raw" style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; font-size: 11px;">
</div>
<div class="form-group">
<label>Sample Rate</label>
@@ -202,7 +284,7 @@
</select>
</div>
<button class="mode-btn" onclick="WeatherSat.testDecode()" style="width: 100%; margin-top: 4px;">
Test Decode
Decode File
</button>
</div>
</div>
@@ -224,6 +306,16 @@
</div>
</div>
<div class="section">
<h3>Debug / Test</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">
Load sample pass data and console output to test the UI without an SDR or live satellite pass.
</p>
<button class="mode-btn" onclick="WeatherSat.loadDemoData()" style="width: 100%;">
Load Demo Data
</button>
</div>
<div class="section">
<h3>Resources</h3>
<div style="display: flex; flex-direction: column; gap: 6px;">
+11 -2
View File
@@ -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);
}
}
})();
</script>
+30
View File
@@ -227,6 +227,36 @@
</label>
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Time & Timezone</div>
<div class="settings-row">
<div class="settings-label">
<span class="settings-label-text">Timezone</span>
<span class="settings-label-desc">Applied across all modes</span>
</div>
<select id="globalTimezoneSelect" class="settings-select" onchange="InterceptTime.setTimezone(this.value)">
<option value="UTC">UTC</option>
<option value="local">Local (browser)</option>
<option value="US/Eastern">Eastern (ET)</option>
<option value="US/Central">Central (CT)</option>
<option value="US/Mountain">Mountain (MT)</option>
<option value="US/Pacific">Pacific (PT)</option>
</select>
</div>
<div class="settings-row">
<div class="settings-label">
<span class="settings-label-text">Time Format</span>
<span class="settings-label-desc">12-hour (2:30 PM) or 24-hour (14:30)</span>
</div>
<select id="globalTimeFormatSelect" class="settings-select" onchange="InterceptTime.setHour12(this.value === '12')">
<option value="12">12-hour (AM/PM)</option>
<option value="24">24-hour</option>
</select>
</div>
</div>
</div>
<!-- Updates Section -->
+102 -93
View File
@@ -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 typing import Callable
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 @@ WEATHER_SATELLITES = {
'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 @@ class CaptureProgress:
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 @@ class WeatherSatDecoder:
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 @@ class WeatherSatDecoder:
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 @@ class WeatherSatDecoder:
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 @@ class WeatherSatDecoder:
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 @@ class WeatherSatDecoder:
"""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 @@ class WeatherSatDecoder:
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 @@ class WeatherSatDecoder:
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 @@ class WeatherSatDecoder:
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 @@ class WeatherSatDecoder:
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