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">
@@ -18,6 +18,7 @@
<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>
<option value="METEOR-M2-4-80K">Meteor-M2-4 80k baud (fallback)</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -186,6 +187,7 @@
<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>
<option value="METEOR-M2-4-80K">Meteor-M2-4 80k baud</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -223,6 +225,16 @@
</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;">
+9
View File
@@ -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