Fix Meteor LRPT decoding in Docker and enhance weather satellite UI

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) <noreply@anthropic.com>
This commit is contained in:
mitchross
2026-03-25 00:05:31 -04:00
parent 1dde2a008e
commit 43fb735e4e
6 changed files with 722 additions and 167 deletions
+25 -6
View File
@@ -126,6 +126,7 @@ RUN cd /tmp \
&& rm -rf /tmp/slowrx && rm -rf /tmp/slowrx
# Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) — pinned to v1.2.2 # 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 \ RUN cd /tmp \
&& git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \ && git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \
&& cd SatDump \ && cd SatDump \
@@ -147,14 +148,29 @@ RUN cd /tmp \
fi; \ fi; \
done; \ done; \
fi \ 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 && 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 # Build hackrf CLI tools from source — avoids libhackrf0 version conflict
# between the 'hackrf' apt package and soapysdr-module-hackrf's newer libhackrf0 # between the 'hackrf' apt package and soapysdr-module-hackrf's newer libhackrf0
RUN cd /tmp \ RUN cd /tmp \
@@ -219,6 +235,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libpng16-16 \ libpng16-16 \
libtiff6 \ libtiff6 \
libjemalloc2 \ libjemalloc2 \
libfftw3-double3 \
libfftw3-single3 \
libvolk-bin \ libvolk-bin \
libnng1 \ libnng1 \
libzstd1 \ 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/bin/ /usr/bin/
COPY --from=builder /staging/usr/local/bin/ /usr/local/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/lib/ /usr/local/lib/
COPY --from=builder /staging/usr/local/share/ /usr/local/share/
COPY --from=builder /staging/opt/ /opt/ COPY --from=builder /staging/opt/ /opt/
# Copy radiosonde Python dependencies installed during builder stage # Copy radiosonde Python dependencies installed during builder stage
+136
View File
@@ -107,6 +107,23 @@
color: var(--accent-cyan, #00d4ff); 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 ===== */ /* ===== Auto-Schedule Toggle ===== */
.wxsat-schedule-toggle { .wxsat-schedule-toggle {
display: flex; display: flex;
@@ -317,6 +334,80 @@
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; 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 ===== */ /* ===== Pass Predictions Panel ===== */
.wxsat-passes-panel { .wxsat-passes-panel {
flex: 0 0 280px; flex: 0 0 280px;
@@ -1066,6 +1157,51 @@
color: var(--text-dim, #444); 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 { #wxsatConsoleToggle {
font-size: 10px; font-size: 10px;
width: 28px; width: 28px;
+346 -19
View File
@@ -36,11 +36,111 @@ const WeatherSat = (function() {
let imageRefreshInterval = null; let imageRefreshInterval = null;
let lastDecodeJobSignature = null; let lastDecodeJobSignature = null;
let lastDecodeSatellite = 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 * Initialize the Weather Satellite mode
*/ */
function init() { function init() {
// Restore timezone selector
const tzSel = document.getElementById('wxsatTimezone');
if (tzSel) tzSel.value = selectedTimezone;
if (initialized) { if (initialized) {
checkStatus(); checkStatus();
loadImages(); loadImages();
@@ -643,7 +743,10 @@ const WeatherSat = (function() {
renderPasses([]); renderPasses([]);
renderTimeline([]); renderTimeline([]);
updateCountdownFromPasses(); updateCountdownFromPasses();
updatePassAnalysis([]);
updateGroundTrack(null); updateGroundTrack(null);
const passCountEl = document.getElementById('wxsatStripPassCount');
if (passCountEl) passCountEl.textContent = '0';
return; return;
} }
@@ -675,7 +778,12 @@ const WeatherSat = (function() {
selectedPassIndex = -1; selectedPassIndex = -1;
renderPasses(passes); renderPasses(passes);
renderTimeline(passes); renderTimeline(passes);
updateTimelineLabels();
updateCountdownFromPasses(); updateCountdownFromPasses();
updatePassAnalysis(passes);
// Update strip pass count
const passCountEl = document.getElementById('wxsatStripPassCount');
if (passCountEl) passCountEl.textContent = passes.length;
if (passes.length > 0) { if (passes.length > 0) {
selectPass(0); selectPass(0);
} else { } else {
@@ -730,14 +838,17 @@ const WeatherSat = (function() {
return; return;
} }
const bestPass = findBestPass(passList);
container.innerHTML = passList.map((pass, idx) => { container.innerHTML = passList.map((pass, idx) => {
const modeClass = pass.mode === 'APT' ? 'apt' : 'lrpt'; const modeClass = pass.mode === 'APT' ? 'apt' : 'lrpt';
const timeStr = pass.startTime || '--'; const timeStr = formatDateTime(pass.startTimeISO) + getTZLabel();
const now = new Date(); const now = new Date();
const passStart = parsePassDate(pass.startTimeISO); const passStart = parsePassDate(pass.startTimeISO);
const diffMs = passStart ? passStart - now : NaN; const diffMs = passStart ? passStart - now : NaN;
const diffMins = Number.isFinite(diffMs) ? Math.floor(diffMs / 60000) : NaN; const diffMins = Number.isFinite(diffMs) ? Math.floor(diffMs / 60000) : NaN;
const isSelected = idx === selectedPassIndex; const isSelected = idx === selectedPassIndex;
const isBest = bestPass && pass.startTimeISO === bestPass.startTimeISO && pass.satellite === bestPass.satellite;
let countdown = '--'; let countdown = '--';
if (!Number.isFinite(diffMs)) { if (!Number.isFinite(diffMs)) {
@@ -752,10 +863,14 @@ const WeatherSat = (function() {
countdown = `in ${hrs}h${mins}m`; 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>' : '';
return ` return `
<div class="wxsat-pass-card${isSelected ? ' selected' : ''}" onclick="WeatherSat.selectPass(${idx})"> <div class="wxsat-pass-card${isSelected ? ' selected' : ''}" onclick="WeatherSat.selectPass(${idx})">
<div class="wxsat-pass-sat"> <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> <span class="wxsat-pass-mode ${modeClass}">${escapeHtml(pass.mode)}</span>
</div> </div>
<div class="wxsat-pass-details"> <div class="wxsat-pass-details">
@@ -765,8 +880,8 @@ const WeatherSat = (function() {
<span class="wxsat-pass-detail-value">${pass.maxEl}&deg;</span> <span class="wxsat-pass-detail-value">${pass.maxEl}&deg;</span>
<span class="wxsat-pass-detail-label">Duration</span> <span class="wxsat-pass-detail-label">Duration</span>
<span class="wxsat-pass-detail-value">${pass.duration} min</span> <span class="wxsat-pass-detail-value">${pass.duration} min</span>
<span class="wxsat-pass-detail-label">Freq</span> <span class="wxsat-pass-detail-label">Direction</span>
<span class="wxsat-pass-detail-value">${pass.frequency} MHz</span> <span class="wxsat-pass-detail-value">${riseDir} <span class="wxsat-dir-arrow">&rarr;</span> ${setDir}</span>
</div> </div>
<div style="display: flex; align-items: center; justify-content: space-between; margin-top: 4px;"> <div style="display: flex; align-items: center; justify-content: space-between; margin-top: 4px;">
<span class="wxsat-pass-quality ${pass.quality}">${pass.quality}</span> <span class="wxsat-pass-quality ${pass.quality}">${pass.quality}</span>
@@ -1334,12 +1449,17 @@ const WeatherSat = (function() {
if (hoursEl) hoursEl.textContent = h.toString().padStart(2, '0'); if (hoursEl) hoursEl.textContent = h.toString().padStart(2, '0');
if (minsEl) minsEl.textContent = m.toString().padStart(2, '0'); if (minsEl) minsEl.textContent = m.toString().padStart(2, '0');
if (secsEl) secsEl.textContent = s.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 (satEl) satEl.textContent = `${nextPass.name} ${nextPass.frequency} MHz`;
if (detailEl) { if (detailEl) {
if (isActive) { if (isActive) {
detailEl.textContent = `ACTIVE - ${nextPass.maxEl}\u00b0 max el`; detailEl.textContent = `ACTIVE - ${nextPass.maxEl}\u00b0 max el`;
} else { } 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.className = `wxsat-timeline-pass ${pass.mode === 'LRPT' ? 'lrpt' : 'apt'}`;
marker.style.left = startPct + '%'; marker.style.left = startPct + '%';
marker.style.width = widthPct + '%'; 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); marker.onclick = () => selectPass(idx);
track.appendChild(marker); track.appendChild(marker);
}); });
@@ -1414,6 +1534,71 @@ const WeatherSat = (function() {
cursor.style.left = pct + '%'; 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 // Auto-Scheduler
// ======================== // ========================
@@ -1627,12 +1812,16 @@ const WeatherSat = (function() {
return new Date(b.timestamp || 0) - new Date(a.timestamp || 0); return new Date(b.timestamp || 0) - new Date(a.timestamp || 0);
}); });
// Group by date // Group by date (timezone-aware)
const groups = {}; const groups = {};
sorted.forEach(img => { sorted.forEach(img => {
const dateKey = img.timestamp let dateKey = 'Unknown Date';
? new Date(img.timestamp).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) if (img.timestamp) {
: 'Unknown Date'; 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] = []; if (!groups[dateKey]) groups[dateKey] = [];
groups[dateKey].push(img); groups[dateKey].push(img);
}); });
@@ -1788,11 +1977,7 @@ const WeatherSat = (function() {
*/ */
function formatTimestamp(isoString) { function formatTimestamp(isoString) {
if (!isoString) return '--'; if (!isoString) return '--';
try { return formatDateTime(isoString) + getTZLabel();
return new Date(isoString).toLocaleString();
} catch {
return isoString;
}
} }
function ensureImageRefresh() { function ensureImageRefresh() {
@@ -1969,11 +2154,24 @@ const WeatherSat = (function() {
const log = document.getElementById('wxsatConsoleLog'); const log = document.getElementById('wxsatConsoleLog');
if (!log) return; if (!log) return;
const entry = document.createElement('div'); const type = logType || 'info';
entry.className = `wxsat-console-entry wxsat-log-${logType || 'info'}`; const now = new Date();
entry.textContent = message; const tz = TZ_MAP[selectedTimezone];
log.appendChild(entry); 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-${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); consoleEntries.push(entry);
// Cap at 200 entries // Cap at 200 entries
@@ -1986,6 +2184,40 @@ const WeatherSat = (function() {
log.scrollTop = log.scrollHeight; 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 * Update the phase indicator steps
*/ */
@@ -2048,8 +2280,14 @@ const WeatherSat = (function() {
const log = document.getElementById('wxsatConsoleLog'); const log = document.getElementById('wxsatConsoleLog');
if (log) log.innerHTML = ''; if (log) log.innerHTML = '';
consoleEntries = []; consoleEntries = [];
consoleFilter = 'all';
currentPhase = 'idle'; 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 => { document.querySelectorAll('#wxsatPhaseIndicator .wxsat-phase-step').forEach(step => {
step.classList.remove('active', 'completed', 'error'); step.classList.remove('active', 'completed', 'error');
}); });
@@ -2092,6 +2330,90 @@ const WeatherSat = (function() {
stopStream(); 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 // Public API
return { return {
init, init,
@@ -2113,6 +2435,11 @@ const WeatherSat = (function() {
toggleScheduler, toggleScheduler,
invalidateMap, invalidateMap,
toggleConsole, toggleConsole,
setTimezone,
filterConsole,
exportConsole,
clearConsole,
loadDemoData,
_getModalFilename: () => currentModalFilename, _getModalFilename: () => currentModalFilename,
}; };
})(); })();
+54 -2
View File
@@ -2484,6 +2484,25 @@
</div> </div>
</div> </div>
<div class="wxsat-strip-divider"></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"> <div class="wxsat-strip-group">
<label class="wxsat-schedule-toggle" title="Auto-capture passes"> <label class="wxsat-schedule-toggle" title="Auto-capture passes">
<input type="checkbox" id="wxsatAutoSchedule" onchange="WeatherSat.toggleScheduler(this)"> <input type="checkbox" id="wxsatAutoSchedule" onchange="WeatherSat.toggleScheduler(this)">
@@ -2515,6 +2534,29 @@
</div> </div>
</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 --> <!-- Capture progress -->
<div class="wxsat-capture-status" id="wxsatCaptureStatus"> <div class="wxsat-capture-status" id="wxsatCaptureStatus">
<div class="wxsat-capture-info"> <div class="wxsat-capture-info">
@@ -2543,8 +2585,18 @@
<span class="wxsat-phase-step" data-phase="complete">COMPLETE</span> <span class="wxsat-phase-step" data-phase="complete">COMPLETE</span>
</div> </div>
</div> </div>
<button class="wxsat-strip-btn" id="wxsatConsoleToggle" <div class="wxsat-console-filters" id="wxsatConsoleFilters">
onclick="WeatherSat.toggleConsole()" title="Toggle console">&#x25BC;</button> <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>
<div class="wxsat-console-body" id="wxsatConsoleBody"> <div class="wxsat-console-body" id="wxsatConsoleBody">
<div class="wxsat-console-log" id="wxsatConsoleLog"> <div class="wxsat-console-log" id="wxsatConsoleLog">
+59 -47
View File
@@ -2,13 +2,13 @@
<div id="weatherSatMode" class="mode-content"> <div id="weatherSatMode" class="mode-content">
<div class="section"> <div class="section">
<h3>Weather Satellite Decoder</h3> <h3>Weather Satellite Decoder</h3>
<div class="alpha-mode-notice"> <div class="alpha-mode-notice">
ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions. ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions.
</div> </div>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;"> <p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
Receive and decode Meteor LRPT weather imagery. 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. Uses SatDump for live SDR capture and image processing, and also shows Meteor imagery produced by the ground-station scheduler.
</p> </p>
</div> </div>
<div class="section"> <div class="section">
@@ -18,8 +18,9 @@
<select id="weatherSatSelect" class="mode-select"> <select id="weatherSatSelect" class="mode-select">
<option value="METEOR-M2-3" selected>Meteor-M2-3 (137.900 MHz LRPT)</option> <option value="METEOR-M2-3" selected>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">Meteor-M2-4 (137.900 MHz LRPT)</option>
</select> <option value="METEOR-M2-4-80K">Meteor-M2-4 80k baud (fallback)</option>
</div> </select>
</div>
<div class="form-group"> <div class="form-group">
<label>Gain (dB)</label> <label>Gain (dB)</label>
<input type="number" id="weatherSatGain" value="40" step="0.1" min="0" max="50"> <input type="number" id="weatherSatGain" value="40" step="0.1" min="0" max="50">
@@ -69,7 +70,7 @@
<li><strong style="color: var(--text-primary);">Connection:</strong> Solder elements to coax center + shield, connect to SDR via SMA</li> <li><strong style="color: var(--text-primary);">Connection:</strong> Solder elements to coax center + shield, connect to SDR via SMA</li>
</ul> </ul>
<p style="margin-top: 6px; color: var(--text-dim); font-style: italic;"> <p style="margin-top: 6px; color: var(--text-dim); font-style: italic;">
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.
</p> </p>
</div> </div>
@@ -132,8 +133,8 @@
<li><strong style="color: var(--text-primary);">Antenna up:</strong> Point the antenna straight UP (zenith) for best overhead coverage</li> <li><strong style="color: var(--text-primary);">Antenna up:</strong> Point the antenna straight UP (zenith) for best overhead coverage</li>
<li><strong style="color: var(--text-primary);">Avoid:</strong> Metal roofs, power lines, buildings blocking the sky</li> <li><strong style="color: var(--text-primary);">Avoid:</strong> Metal roofs, power lines, buildings blocking the sky</li>
<li><strong style="color: var(--text-primary);">Coax length:</strong> Keep short (&lt;10m). Signal loss at 137 MHz is ~3 dB per 10m of RG-58</li> <li><strong style="color: var(--text-primary);">Coax length:</strong> Keep short (&lt;10m). Signal loss at 137 MHz is ~3 dB per 10m of RG-58</li>
<li><strong style="color: var(--text-primary);">LNA:</strong> Mount at the antenna feed point, NOT at the SDR end. <li><strong style="color: var(--text-primary);">LNA:</strong> Mount at the antenna feed point, NOT at the SDR end.
Recommended: a low-noise 137 MHz filtered LNA near the antenna feed point</li> Recommended: a low-noise 137 MHz filtered LNA near the antenna feed point</li>
<li><strong style="color: var(--text-primary);">Bias-T:</strong> Enable the Bias-T checkbox above if your LNA is powered via the coax from the SDR</li> <li><strong style="color: var(--text-primary);">Bias-T:</strong> Enable the Bias-T checkbox above if your LNA is powered via the coax from the SDR</li>
</ul> </ul>
</div> </div>
@@ -162,9 +163,9 @@
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td> <td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">RHCP</td> <td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">RHCP</td>
</tr> </tr>
<tr> <tr>
<td style="padding: 3px 4px; color: var(--text-dim);">Meteor (LRPT) bandwidth</td> <td style="padding: 3px 4px; color: var(--text-dim);">Meteor (LRPT) bandwidth</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~140 kHz</td> <td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~140 kHz</td>
</tr> </tr>
</table> </table>
</div> </div>
@@ -177,29 +178,30 @@
<span class="wxsat-collapse-icon collapsed" style="font-size: 10px; transition: transform 0.2s; display: inline-block;">&#9660;</span> <span class="wxsat-collapse-icon collapsed" style="font-size: 10px; transition: transform 0.2s; display: inline-block;">&#9660;</span>
</h3> </h3>
<div class="wxsat-test-decode-body collapsed" style="overflow: hidden;"> <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;"> <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. Decode a pre-recorded Meteor IQ file without SDR hardware.
Shared ground-station recordings are also accepted by the backend. Shared ground-station recordings are also accepted by the backend.
</p> </p>
<div class="form-group"> <div class="form-group">
<label>Satellite</label> <label>Satellite</label>
<select id="wxsatTestSatSelect" class="mode-select"> <select id="wxsatTestSatSelect" class="mode-select">
<option value="METEOR-M2-3" selected>Meteor-M2-3 (LRPT)</option> <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">Meteor-M2-4 (LRPT)</option>
</select> <option value="METEOR-M2-4-80K">Meteor-M2-4 80k baud</option>
</div> </select>
<div class="form-group"> </div>
<label>File Path (server-side)</label> <div class="form-group">
<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)</label>
</div> <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;">
<div class="form-group"> </div>
<label>Sample Rate</label> <div class="form-group">
<select id="wxsatTestSampleRate" class="mode-select"> <label>Sample Rate</label>
<option value="500000">500 kHz (IQ LRPT)</option> <select id="wxsatTestSampleRate" class="mode-select">
<option value="1000000">1 MHz (IQ narrow)</option> <option value="500000">500 kHz (IQ LRPT)</option>
<option value="2400000" selected>2.4 MHz (INTERCEPT default)</option> <option value="1000000">1 MHz (IQ narrow)</option>
</select> <option value="2400000" selected>2.4 MHz (INTERCEPT default)</option>
</div> </select>
</div>
<button class="mode-btn" onclick="WeatherSat.testDecode()" style="width: 100%; margin-top: 4px;"> <button class="mode-btn" onclick="WeatherSat.testDecode()" style="width: 100%; margin-top: 4px;">
Test Decode Test Decode
</button> </button>
@@ -223,15 +225,25 @@
</div> </div>
</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"> <div class="section">
<h3>Resources</h3> <h3>Resources</h3>
<div style="display: flex; flex-direction: column; gap: 6px;"> <div style="display: flex; flex-direction: column; gap: 6px;">
<a href="https://github.com/SatDump/SatDump" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;"> <a href="https://github.com/SatDump/SatDump" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
SatDump Documentation SatDump Documentation
</a> </a>
<a href="https://www.rtl-sdr.com/rtl-sdr-tutorial-receiving-meteor-m2-weather-satellite-images/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;"> <a href="https://www.rtl-sdr.com/rtl-sdr-tutorial-receiving-meteor-m2-weather-satellite-images/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
Meteor Reception Guide Meteor Reception Guide
</a> </a>
</div> </div>
</div> </div>
</div> </div>
+102 -93
View File
@@ -1,14 +1,14 @@
"""Weather satellite decoder focused on Meteor LRPT workflows. """Weather satellite decoder focused on Meteor LRPT workflows.
Provides automated capture and decoding of weather imagery using SatDump. Provides automated capture and decoding of weather imagery using SatDump.
Active satellites: Active satellites:
- Meteor-M2-3: 137.900 MHz (LRPT) - Meteor-M2-3: 137.900 MHz (LRPT)
- Meteor-M2-4: 137.900 MHz (LRPT) - Meteor-M2-4: 137.900 MHz (LRPT)
Legacy NOAA APT entries remain in ``WEATHER_SATELLITES`` for compatibility Legacy NOAA APT entries remain in ``WEATHER_SATELLITES`` for compatibility
and historical metadata, but they are no longer active operational targets. and historical metadata, but they are no longer active operational targets.
""" """
from __future__ import annotations from __future__ import annotations
@@ -29,17 +29,17 @@ from typing import Callable
from utils.logging import get_logger from utils.logging import get_logger
from utils.process import register_process, safe_terminate from utils.process import register_process, safe_terminate
logger = get_logger('intercept.weather_sat') logger = get_logger('intercept.weather_sat')
PROJECT_ROOT = Path(__file__).resolve().parent.parent PROJECT_ROOT = Path(__file__).resolve().parent.parent
ALLOWED_OFFLINE_INPUT_DIRS = ( ALLOWED_OFFLINE_INPUT_DIRS = (
PROJECT_ROOT / 'data', PROJECT_ROOT / 'data',
PROJECT_ROOT / 'instance' / 'ground_station' / 'recordings', PROJECT_ROOT / 'instance' / 'ground_station' / 'recordings',
) )
# Weather satellite definitions. # Weather satellite definitions.
# NOAA APT entries are retained as inactive compatibility metadata. # NOAA APT entries are retained as inactive compatibility metadata.
WEATHER_SATELLITES = { WEATHER_SATELLITES = {
'NOAA-15': { 'NOAA-15': {
'name': 'NOAA 15', 'name': 'NOAA 15',
@@ -86,6 +86,15 @@ WEATHER_SATELLITES = {
'description': 'Meteor-M2-4 LRPT (digital color imagery)', 'description': 'Meteor-M2-4 LRPT (digital color imagery)',
'active': True, '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 # Default sample rate for weather satellite reception
@@ -153,12 +162,12 @@ class CaptureProgress:
return result return result
class WeatherSatDecoder: class WeatherSatDecoder:
"""Weather satellite decoder using SatDump CLI. """Weather satellite decoder using SatDump CLI.
Manages live SDR capture and offline decode for the active Meteor LRPT Manages live SDR capture and offline decode for the active Meteor LRPT
workflow, while preserving compatibility with older weather-sat metadata. workflow, while preserving compatibility with older weather-sat metadata.
""" """
def __init__(self, output_dir: str | Path | None = None): def __init__(self, output_dir: str | Path | None = None):
self._process: subprocess.Popen | None = None self._process: subprocess.Popen | None = None
@@ -175,14 +184,14 @@ class WeatherSatDecoder:
self._pty_master_fd: int | None = None self._pty_master_fd: int | None = None
self._current_satellite: str = '' self._current_satellite: str = ''
self._current_frequency: float = 0.0 self._current_frequency: float = 0.0
self._current_mode: str = '' self._current_mode: str = ''
self._capture_start_time: float = 0 self._capture_start_time: float = 0
self._device_index: int = -1 self._device_index: int = -1
self._capture_output_dir: Path | None = None self._capture_output_dir: Path | None = None
self._on_complete_callback: Callable[[], None] | None = None self._on_complete_callback: Callable[[], None] | None = None
self._capture_phase: str = 'idle' self._capture_phase: str = 'idle'
self._last_error_message: str = '' self._last_error_message: str = ''
self._last_process_returncode: int | None = None self._last_process_returncode: int | None = None
# Ensure output directory exists # Ensure output directory exists
self._output_dir.mkdir(parents=True, exist_ok=True) 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. No SDR hardware is required — SatDump runs in offline mode.
Args: 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 input_file: Path to IQ baseband or WAV audio file
sample_rate: Sample rate of the recording in Hz sample_rate: Sample rate of the recording in Hz
@@ -283,16 +292,16 @@ class WeatherSatDecoder:
input_path = Path(input_file) input_path = Path(input_file)
# Security: restrict offline decode inputs to application-owned # Security: restrict offline decode inputs to application-owned
# capture directories so external paths cannot be injected. # capture directories so external paths cannot be injected.
try: try:
resolved = input_path.resolve() resolved = input_path.resolve()
if not any(resolved.is_relative_to(base) for base in ALLOWED_OFFLINE_INPUT_DIRS): 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}") logger.warning(f"Path traversal blocked in start_from_file: {input_file}")
msg = 'Input file must be under INTERCEPT data or ground-station recordings' msg = 'Input file must be under INTERCEPT data or ground-station recordings'
self._emit_progress(CaptureProgress( self._emit_progress(CaptureProgress(
status='error', status='error',
message=msg, message=msg,
)) ))
return False, msg return False, msg
except (OSError, ValueError): except (OSError, ValueError):
@@ -314,13 +323,13 @@ class WeatherSatDecoder:
self._current_satellite = satellite self._current_satellite = satellite
self._current_frequency = sat_info['frequency'] self._current_frequency = sat_info['frequency']
self._current_mode = sat_info['mode'] self._current_mode = sat_info['mode']
self._device_index = -1 # Offline decode does not claim an SDR device self._device_index = -1 # Offline decode does not claim an SDR device
self._capture_start_time = time.time() self._capture_start_time = time.time()
self._capture_phase = 'decoding' self._capture_phase = 'decoding'
self._last_error_message = '' self._last_error_message = ''
self._last_process_returncode = None self._last_process_returncode = None
self._stop_event.clear() self._stop_event.clear()
try: try:
self._running = True self._running = True
@@ -368,7 +377,7 @@ class WeatherSatDecoder:
"""Start weather satellite capture and decode. """Start weather satellite capture and decode.
Args: Args:
satellite: Satellite key (for example ``'METEOR-M2-3'``) satellite: Satellite key (for example ``'METEOR-M2-3'``)
device_index: RTL-SDR device index device_index: RTL-SDR device index
gain: SDR gain in dB gain: SDR gain in dB
sample_rate: Sample rate in Hz sample_rate: Sample rate in Hz
@@ -410,13 +419,13 @@ class WeatherSatDecoder:
self._current_satellite = satellite self._current_satellite = satellite
self._current_frequency = sat_info['frequency'] self._current_frequency = sat_info['frequency']
self._current_mode = sat_info['mode'] self._current_mode = sat_info['mode']
self._device_index = device_index self._device_index = device_index
self._capture_start_time = time.time() self._capture_start_time = time.time()
self._capture_phase = 'tuning' self._capture_phase = 'tuning'
self._last_error_message = '' self._last_error_message = ''
self._last_process_returncode = None self._last_process_returncode = None
self._stop_event.clear() self._stop_event.clear()
try: try:
self._running = True self._running = True
@@ -890,17 +899,17 @@ class WeatherSatDecoder:
if was_running: if was_running:
# Collect exit status (returncode is only set after poll/wait) # Collect exit status (returncode is only set after poll/wait)
if process and process.returncode is None: if process and process.returncode is None:
try: try:
process.wait(timeout=5) process.wait(timeout=5)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
process.kill() process.kill()
process.wait() process.wait()
retcode = process.returncode if process else None retcode = process.returncode if process else None
self._last_process_returncode = retcode self._last_process_returncode = retcode
if retcode and retcode != 0: if retcode and retcode != 0:
self._capture_phase = 'error' self._capture_phase = 'error'
self._emit_progress(CaptureProgress( self._emit_progress(CaptureProgress(
status='error', status='error',
satellite=self._current_satellite, satellite=self._current_satellite,
frequency=self._current_frequency, frequency=self._current_frequency,
@@ -1143,15 +1152,15 @@ class WeatherSatDecoder:
self._images.clear() self._images.clear()
return count return count
def _emit_progress(self, progress: CaptureProgress) -> None: def _emit_progress(self, progress: CaptureProgress) -> None:
"""Emit progress update to callback.""" """Emit progress update to callback."""
if progress.status == 'error' and progress.message: if progress.status == 'error' and progress.message:
self._last_error_message = str(progress.message) self._last_error_message = str(progress.message)
if self._callback: if self._callback:
try: try:
self._callback(progress) self._callback(progress)
except Exception as e: except Exception as e:
logger.error(f"Error in progress callback: {e}") logger.error(f"Error in progress callback: {e}")
def get_status(self) -> dict: def get_status(self) -> dict:
"""Get current decoder status.""" """Get current decoder status."""
@@ -1159,19 +1168,19 @@ class WeatherSatDecoder:
if self._running and self._capture_start_time: if self._running and self._capture_start_time:
elapsed = int(time.time() - self._capture_start_time) elapsed = int(time.time() - self._capture_start_time)
return { return {
'available': self._decoder is not None, 'available': self._decoder is not None,
'decoder': self._decoder, 'decoder': self._decoder,
'running': self._running, 'running': self._running,
'satellite': self._current_satellite, 'satellite': self._current_satellite,
'frequency': self._current_frequency, 'frequency': self._current_frequency,
'mode': self._current_mode, 'mode': self._current_mode,
'capture_phase': self._capture_phase, 'capture_phase': self._capture_phase,
'elapsed_seconds': elapsed, 'elapsed_seconds': elapsed,
'image_count': len(self._images), 'image_count': len(self._images),
'last_error': self._last_error_message, 'last_error': self._last_error_message,
'last_returncode': self._last_process_returncode, 'last_returncode': self._last_process_returncode,
} }
# Global decoder instance # Global decoder instance