mirror of
https://github.com/smittix/intercept.git
synced 2026-06-08 06:01:56 -07:00
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:
+25
-6
@@ -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
|
||||
|
||||
@@ -107,6 +107,23 @@
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
/* ===== Timezone Select ===== */
|
||||
.wxsat-tz-select {
|
||||
padding: 3px 6px;
|
||||
background: var(--bg-primary, #0d1117);
|
||||
border: 1px solid var(--border-color, #2a3040);
|
||||
border-radius: 3px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 11px;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wxsat-tz-select:focus {
|
||||
border-color: var(--accent-cyan, #00d4ff);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ===== Auto-Schedule Toggle ===== */
|
||||
.wxsat-schedule-toggle {
|
||||
display: flex;
|
||||
@@ -317,6 +334,80 @@
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
/* ===== Pass Analysis Bar ===== */
|
||||
.wxsat-analysis-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 6px 16px;
|
||||
background: var(--bg-tertiary, #1a1f2e);
|
||||
border-bottom: 1px solid var(--border-color, #2a3040);
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
.wxsat-analysis-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.wxsat-analysis-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.wxsat-analysis-value {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.wxsat-analysis-value.excellent { color: var(--neon-green); }
|
||||
.wxsat-analysis-value.good { color: var(--accent-cyan); }
|
||||
.wxsat-analysis-value.fair { color: var(--accent-yellow); }
|
||||
|
||||
.wxsat-analysis-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.wxsat-analysis-best {
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ===== Best Pass Badge ===== */
|
||||
.wxsat-pass-best-badge {
|
||||
display: inline-block;
|
||||
font-size: 8px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 2px;
|
||||
background: rgba(0, 255, 136, 0.15);
|
||||
color: var(--neon-green);
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
/* ===== Pass Direction ===== */
|
||||
.wxsat-pass-direction {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.wxsat-pass-direction .wxsat-dir-arrow {
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
/* ===== Pass Predictions Panel ===== */
|
||||
.wxsat-passes-panel {
|
||||
flex: 0 0 280px;
|
||||
@@ -1066,6 +1157,51 @@
|
||||
color: var(--text-dim, #444);
|
||||
}
|
||||
|
||||
/* Console filter buttons */
|
||||
.wxsat-console-filters {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
margin-left: auto;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.wxsat-console-filter {
|
||||
font-size: 9px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--border-color, #2a3040);
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--text-dim, #555);
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.wxsat-console-filter:hover {
|
||||
border-color: var(--accent-cyan, #00d4ff);
|
||||
color: var(--text-secondary, #999);
|
||||
}
|
||||
|
||||
.wxsat-console-filter.active {
|
||||
border-color: var(--accent-cyan, #00d4ff);
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
background: rgba(0, 212, 255, 0.08);
|
||||
}
|
||||
|
||||
.wxsat-console-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Console entry timestamps */
|
||||
.wxsat-console-ts {
|
||||
color: var(--text-dim, #444);
|
||||
margin-right: 6px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
#wxsatConsoleToggle {
|
||||
font-size: 10px;
|
||||
width: 28px;
|
||||
|
||||
@@ -36,11 +36,111 @@ const WeatherSat = (function() {
|
||||
let imageRefreshInterval = null;
|
||||
let lastDecodeJobSignature = null;
|
||||
let lastDecodeSatellite = null;
|
||||
let consoleFilter = 'all';
|
||||
|
||||
// Timezone support
|
||||
const TZ_MAP = {
|
||||
'UTC': 'UTC',
|
||||
'local': null, // browser default
|
||||
'US/Eastern': 'America/New_York',
|
||||
'US/Central': 'America/Chicago',
|
||||
'US/Mountain': 'America/Denver',
|
||||
'US/Pacific': 'America/Los_Angeles',
|
||||
};
|
||||
let selectedTimezone = localStorage.getItem('wxsatTimezone') || 'UTC';
|
||||
|
||||
/**
|
||||
* Format an ISO date string for the selected timezone.
|
||||
* @param {string} isoString - ISO 8601 date string
|
||||
* @param {object} [opts] - Additional Intl.DateTimeFormat options
|
||||
* @returns {string} Formatted date/time string
|
||||
*/
|
||||
function formatTimeForTZ(isoString, opts = {}) {
|
||||
if (!isoString) return '--';
|
||||
try {
|
||||
const date = new Date(isoString);
|
||||
if (isNaN(date.getTime())) return isoString;
|
||||
const tz = TZ_MAP[selectedTimezone];
|
||||
const defaults = { hour: '2-digit', minute: '2-digit', hour12: false };
|
||||
const options = { ...defaults, ...opts };
|
||||
if (tz) options.timeZone = tz;
|
||||
return date.toLocaleString(undefined, options);
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a short time (HH:MM) for the selected timezone.
|
||||
*/
|
||||
function formatShortTime(isoString) {
|
||||
return formatTimeForTZ(isoString, { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date + time for the selected timezone.
|
||||
*/
|
||||
function formatDateTime(isoString) {
|
||||
return formatTimeForTZ(isoString, {
|
||||
month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a short timezone label for display.
|
||||
*/
|
||||
function getTZLabel() {
|
||||
if (selectedTimezone === 'local') return '';
|
||||
if (selectedTimezone === 'UTC') return ' UTC';
|
||||
const labels = { 'US/Eastern': ' ET', 'US/Central': ' CT', 'US/Mountain': ' MT', 'US/Pacific': ' PT' };
|
||||
return labels[selectedTimezone] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set timezone and refresh all displays.
|
||||
*/
|
||||
function setTimezone(tz) {
|
||||
selectedTimezone = tz;
|
||||
localStorage.setItem('wxsatTimezone', tz);
|
||||
const sel = document.getElementById('wxsatTimezone');
|
||||
if (sel && sel.value !== tz) sel.value = tz;
|
||||
// Refresh all time-dependent displays
|
||||
applyPassFilter();
|
||||
renderGallery();
|
||||
updateTimelineLabels();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an azimuth angle (0-360) to a cardinal direction label.
|
||||
*/
|
||||
function azToDir(az) {
|
||||
if (typeof az !== 'number' || isNaN(az)) return '?';
|
||||
const dirs = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
|
||||
return dirs[Math.round(az / 22.5) % 16];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best upcoming pass (highest max elevation).
|
||||
*/
|
||||
function findBestPass(passList) {
|
||||
const now = new Date();
|
||||
const upcoming = passList.filter(p => {
|
||||
const end = parsePassDate(p.endTimeISO);
|
||||
return end && end > now;
|
||||
});
|
||||
if (upcoming.length === 0) return null;
|
||||
return upcoming.reduce((best, p) => (p.maxEl > best.maxEl) ? p : best, upcoming[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the Weather Satellite mode
|
||||
*/
|
||||
function init() {
|
||||
// Restore timezone selector
|
||||
const tzSel = document.getElementById('wxsatTimezone');
|
||||
if (tzSel) tzSel.value = selectedTimezone;
|
||||
|
||||
if (initialized) {
|
||||
checkStatus();
|
||||
loadImages();
|
||||
@@ -643,7 +743,10 @@ const WeatherSat = (function() {
|
||||
renderPasses([]);
|
||||
renderTimeline([]);
|
||||
updateCountdownFromPasses();
|
||||
updatePassAnalysis([]);
|
||||
updateGroundTrack(null);
|
||||
const passCountEl = document.getElementById('wxsatStripPassCount');
|
||||
if (passCountEl) passCountEl.textContent = '0';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -675,7 +778,12 @@ const WeatherSat = (function() {
|
||||
selectedPassIndex = -1;
|
||||
renderPasses(passes);
|
||||
renderTimeline(passes);
|
||||
updateTimelineLabels();
|
||||
updateCountdownFromPasses();
|
||||
updatePassAnalysis(passes);
|
||||
// Update strip pass count
|
||||
const passCountEl = document.getElementById('wxsatStripPassCount');
|
||||
if (passCountEl) passCountEl.textContent = passes.length;
|
||||
if (passes.length > 0) {
|
||||
selectPass(0);
|
||||
} else {
|
||||
@@ -730,14 +838,17 @@ const WeatherSat = (function() {
|
||||
return;
|
||||
}
|
||||
|
||||
const bestPass = findBestPass(passList);
|
||||
|
||||
container.innerHTML = passList.map((pass, idx) => {
|
||||
const modeClass = pass.mode === 'APT' ? 'apt' : 'lrpt';
|
||||
const timeStr = pass.startTime || '--';
|
||||
const timeStr = formatDateTime(pass.startTimeISO) + getTZLabel();
|
||||
const now = new Date();
|
||||
const passStart = parsePassDate(pass.startTimeISO);
|
||||
const diffMs = passStart ? passStart - now : NaN;
|
||||
const diffMins = Number.isFinite(diffMs) ? Math.floor(diffMs / 60000) : NaN;
|
||||
const isSelected = idx === selectedPassIndex;
|
||||
const isBest = bestPass && pass.startTimeISO === bestPass.startTimeISO && pass.satellite === bestPass.satellite;
|
||||
|
||||
let countdown = '--';
|
||||
if (!Number.isFinite(diffMs)) {
|
||||
@@ -752,10 +863,14 @@ const WeatherSat = (function() {
|
||||
countdown = `in ${hrs}h${mins}m`;
|
||||
}
|
||||
|
||||
const riseDir = azToDir(pass.riseAz);
|
||||
const setDir = azToDir(pass.setAz);
|
||||
const bestBadge = isBest ? '<span class="wxsat-pass-best-badge">BEST</span>' : '';
|
||||
|
||||
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">
|
||||
@@ -765,8 +880,8 @@ const WeatherSat = (function() {
|
||||
<span class="wxsat-pass-detail-value">${pass.maxEl}°</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">Direction</span>
|
||||
<span class="wxsat-pass-detail-value">${riseDir} <span class="wxsat-dir-arrow">→</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 +1449,17 @@ const WeatherSat = (function() {
|
||||
if (hoursEl) hoursEl.textContent = h.toString().padStart(2, '0');
|
||||
if (minsEl) minsEl.textContent = m.toString().padStart(2, '0');
|
||||
if (secsEl) secsEl.textContent = s.toString().padStart(2, '0');
|
||||
const passTimeStr = formatShortTime(nextPass.startTimeISO) + getTZLabel();
|
||||
if (satEl) satEl.textContent = `${nextPass.name} ${nextPass.frequency} MHz`;
|
||||
if (detailEl) {
|
||||
if (isActive) {
|
||||
detailEl.textContent = `ACTIVE - ${nextPass.maxEl}\u00b0 max el`;
|
||||
} else {
|
||||
detailEl.textContent = `${nextPass.maxEl}\u00b0 max el / ${nextPass.duration} min`;
|
||||
const bestPass = findBestPass(filtered);
|
||||
const bestNote = bestPass && bestPass.startTimeISO !== nextPass.startTimeISO
|
||||
? ` | Best: ${bestPass.name} ${formatShortTime(bestPass.startTimeISO)}${getTZLabel()} (${bestPass.maxEl}\u00b0)`
|
||||
: '';
|
||||
detailEl.textContent = `${passTimeStr} / ${nextPass.maxEl}\u00b0 max el / ${nextPass.duration} min${bestNote}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1391,7 +1511,7 @@ const WeatherSat = (function() {
|
||||
marker.className = `wxsat-timeline-pass ${pass.mode === 'LRPT' ? 'lrpt' : 'apt'}`;
|
||||
marker.style.left = startPct + '%';
|
||||
marker.style.width = widthPct + '%';
|
||||
marker.title = `${pass.name} ${pass.startTime} (${pass.maxEl}\u00b0)`;
|
||||
marker.title = `${pass.name} ${formatShortTime(pass.startTimeISO)}${getTZLabel()} (${pass.maxEl}\u00b0)`;
|
||||
marker.onclick = () => selectPass(idx);
|
||||
track.appendChild(marker);
|
||||
});
|
||||
@@ -1414,6 +1534,71 @@ const WeatherSat = (function() {
|
||||
cursor.style.left = pct + '%';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update timeline hour labels to match the selected timezone.
|
||||
*/
|
||||
function updateTimelineLabels() {
|
||||
const labels = document.querySelector('.wxsat-timeline-labels');
|
||||
if (!labels) return;
|
||||
const hours = [0, 6, 12, 18, 24];
|
||||
const spans = labels.querySelectorAll('span');
|
||||
if (spans.length !== hours.length) return;
|
||||
|
||||
hours.forEach((h, i) => {
|
||||
if (selectedTimezone === 'UTC' || selectedTimezone === 'local') {
|
||||
spans[i].textContent = h === 24 ? '24:00' : `${String(h).padStart(2, '0')}:00`;
|
||||
} else {
|
||||
// Show timezone-adjusted labels
|
||||
const d = new Date();
|
||||
d.setHours(h, 0, 0, 0);
|
||||
const tz = TZ_MAP[selectedTimezone];
|
||||
const opts = { hour: '2-digit', minute: '2-digit', hour12: false };
|
||||
if (tz) opts.timeZone = tz;
|
||||
if (h === 24) {
|
||||
spans[i].textContent = '24:00';
|
||||
} else {
|
||||
spans[i].textContent = d.toLocaleTimeString(undefined, opts).slice(0, 5);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the pass analysis bar with stats about current passes.
|
||||
*/
|
||||
function updatePassAnalysis(passList) {
|
||||
const totalEl = document.getElementById('wxsatAnalysisTotal');
|
||||
const excellentEl = document.getElementById('wxsatAnalysisExcellent');
|
||||
const goodEl = document.getElementById('wxsatAnalysisGood');
|
||||
const fairEl = document.getElementById('wxsatAnalysisFair');
|
||||
const bestEl = document.getElementById('wxsatAnalysisBest');
|
||||
|
||||
const now = new Date();
|
||||
const upcoming = passList.filter(p => {
|
||||
const end = parsePassDate(p.endTimeISO);
|
||||
return end && end > now;
|
||||
});
|
||||
|
||||
const excellent = upcoming.filter(p => p.quality === 'excellent').length;
|
||||
const good = upcoming.filter(p => p.quality === 'good').length;
|
||||
const fair = upcoming.filter(p => p.quality === 'fair').length;
|
||||
|
||||
if (totalEl) totalEl.textContent = upcoming.length;
|
||||
if (excellentEl) excellentEl.textContent = excellent;
|
||||
if (goodEl) goodEl.textContent = good;
|
||||
if (fairEl) fairEl.textContent = fair;
|
||||
|
||||
const best = findBestPass(passList);
|
||||
if (bestEl) {
|
||||
if (best) {
|
||||
const t = formatShortTime(best.startTimeISO) + getTZLabel();
|
||||
bestEl.textContent = `Best: ${best.name} at ${t} (${best.maxEl}\u00b0 el, ${best.duration} min)`;
|
||||
} else {
|
||||
bestEl.textContent = 'No upcoming passes';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Auto-Scheduler
|
||||
// ========================
|
||||
@@ -1627,12 +1812,16 @@ const WeatherSat = (function() {
|
||||
return new Date(b.timestamp || 0) - new Date(a.timestamp || 0);
|
||||
});
|
||||
|
||||
// Group by date
|
||||
// Group by date (timezone-aware)
|
||||
const groups = {};
|
||||
sorted.forEach(img => {
|
||||
const dateKey = img.timestamp
|
||||
? new Date(img.timestamp).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
: 'Unknown Date';
|
||||
let dateKey = 'Unknown Date';
|
||||
if (img.timestamp) {
|
||||
const tz = TZ_MAP[selectedTimezone];
|
||||
const opts = { year: 'numeric', month: 'short', day: 'numeric' };
|
||||
if (tz) opts.timeZone = tz;
|
||||
dateKey = new Date(img.timestamp).toLocaleDateString(undefined, opts);
|
||||
}
|
||||
if (!groups[dateKey]) groups[dateKey] = [];
|
||||
groups[dateKey].push(img);
|
||||
});
|
||||
@@ -1788,11 +1977,7 @@ const WeatherSat = (function() {
|
||||
*/
|
||||
function formatTimestamp(isoString) {
|
||||
if (!isoString) return '--';
|
||||
try {
|
||||
return new Date(isoString).toLocaleString();
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
return formatDateTime(isoString) + getTZLabel();
|
||||
}
|
||||
|
||||
function ensureImageRefresh() {
|
||||
@@ -1969,11 +2154,24 @@ const WeatherSat = (function() {
|
||||
const log = document.getElementById('wxsatConsoleLog');
|
||||
if (!log) return;
|
||||
|
||||
const 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 tz = TZ_MAP[selectedTimezone];
|
||||
const tsOpts = { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false };
|
||||
if (tz) tsOpts.timeZone = tz;
|
||||
const ts = now.toLocaleTimeString(undefined, tsOpts);
|
||||
|
||||
const entry = document.createElement('div');
|
||||
entry.className = `wxsat-console-entry wxsat-log-${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 +2184,40 @@ const WeatherSat = (function() {
|
||||
log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter console entries by log type.
|
||||
*/
|
||||
function filterConsole(filter) {
|
||||
consoleFilter = filter;
|
||||
// Update filter button states
|
||||
document.querySelectorAll('.wxsat-console-filter').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.filter === filter);
|
||||
});
|
||||
// Show/hide entries
|
||||
consoleEntries.forEach(entry => {
|
||||
if (filter === 'all') {
|
||||
entry.style.display = '';
|
||||
} else {
|
||||
entry.style.display = entry.dataset.logType === filter ? '' : 'none';
|
||||
}
|
||||
});
|
||||
// Scroll to bottom
|
||||
const log = document.getElementById('wxsatConsoleLog');
|
||||
if (log) log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export console contents to clipboard.
|
||||
*/
|
||||
function exportConsole() {
|
||||
const text = consoleEntries.map(e => e.textContent).join('\n');
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showNotification('Weather Sat', 'Console log copied to clipboard');
|
||||
}).catch(() => {
|
||||
showNotification('Weather Sat', 'Failed to copy console log');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the phase indicator steps
|
||||
*/
|
||||
@@ -2048,8 +2280,14 @@ const WeatherSat = (function() {
|
||||
const log = document.getElementById('wxsatConsoleLog');
|
||||
if (log) log.innerHTML = '';
|
||||
consoleEntries = [];
|
||||
consoleFilter = 'all';
|
||||
currentPhase = 'idle';
|
||||
|
||||
// Reset filter buttons
|
||||
document.querySelectorAll('.wxsat-console-filter').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.filter === 'all');
|
||||
});
|
||||
|
||||
document.querySelectorAll('#wxsatPhaseIndicator .wxsat-phase-step').forEach(step => {
|
||||
step.classList.remove('active', 'completed', 'error');
|
||||
});
|
||||
@@ -2092,6 +2330,90 @@ const WeatherSat = (function() {
|
||||
stopStream();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load demo/sample data for UI testing without a live satellite pass.
|
||||
* Populates passes, console, and analysis bar with realistic fake data.
|
||||
*/
|
||||
function loadDemoData() {
|
||||
const now = new Date();
|
||||
|
||||
// Generate sample passes over next 24h
|
||||
const demoSats = ['METEOR-M2-3', 'METEOR-M2-4'];
|
||||
const demoPasses = [];
|
||||
|
||||
const offsets = [25, 95, 200, 340, 510, 720, 880, 1020];
|
||||
const elevations = [72, 45, 28, 63, 18, 55, 82, 35];
|
||||
const durations = [14, 12, 8, 13, 6, 11, 15, 10];
|
||||
const riseAzs = [350, 15, 200, 310, 170, 40, 280, 90];
|
||||
const setAzs = [170, 195, 20, 130, 350, 220, 100, 270];
|
||||
|
||||
offsets.forEach((offset, i) => {
|
||||
const start = new Date(now.getTime() + offset * 60000);
|
||||
const end = new Date(start.getTime() + durations[i] * 60000);
|
||||
const sat = demoSats[i % 2];
|
||||
const el = elevations[i];
|
||||
const quality = el >= 60 ? 'excellent' : el >= 30 ? 'good' : 'fair';
|
||||
|
||||
demoPasses.push({
|
||||
id: `${sat}_demo_${i}`,
|
||||
satellite: sat,
|
||||
name: sat === 'METEOR-M2-3' ? 'Meteor-M2-3' : 'Meteor-M2-4',
|
||||
frequency: 137.9,
|
||||
mode: 'LRPT',
|
||||
startTime: start.toISOString().replace('T', ' ').slice(0, 16) + ' UTC',
|
||||
startTimeISO: start.toISOString(),
|
||||
endTimeISO: end.toISOString(),
|
||||
maxEl: el,
|
||||
maxElAz: (riseAzs[i] + setAzs[i]) / 2,
|
||||
riseAz: riseAzs[i],
|
||||
setAz: setAzs[i],
|
||||
duration: durations[i],
|
||||
quality: quality,
|
||||
trajectory: [],
|
||||
groundTrack: [],
|
||||
});
|
||||
});
|
||||
|
||||
allPasses = demoPasses;
|
||||
applyPassFilter();
|
||||
|
||||
// Simulate console output
|
||||
clearConsole();
|
||||
showConsole(true);
|
||||
const demoLogs = [
|
||||
['SatDump v1.2.2 initialized', 'info'],
|
||||
['Pipeline: meteor_m2-x_lrpt', 'info'],
|
||||
['Frequency: 137.900 MHz | Sample rate: 2.4 MHz', 'info'],
|
||||
['RTL-SDR device 0 (SN: 00000101) opened', 'info'],
|
||||
['Tuning to 137900000 Hz...', 'info'],
|
||||
['Gain set to 40.0 dB', 'debug'],
|
||||
['Waiting for signal...', 'info'],
|
||||
['LRPT signal detected! SNR: 8.2 dB', 'signal'],
|
||||
['Viterbi lock acquired', 'signal'],
|
||||
['Frame sync OK - decoding frames', 'signal'],
|
||||
['Decoding LRPT... 15%', 'progress'],
|
||||
['Decoding LRPT... 30%', 'progress'],
|
||||
['Decoding LRPT... 45%', 'progress'],
|
||||
['Channel 1 (visible) - 1540 lines', 'info'],
|
||||
['Channel 2 (infrared) - 1540 lines', 'info'],
|
||||
['Decoding LRPT... 60%', 'progress'],
|
||||
['Decoding LRPT... 75%', 'progress'],
|
||||
['Decoding LRPT... 90%', 'progress'],
|
||||
['Image saved: meteor_m2-3_rgb_composite.png (2.4 MB)', 'save'],
|
||||
['Image saved: meteor_m2-3_channel_1.png (1.1 MB)', 'save'],
|
||||
['Image saved: meteor_m2-3_thermal.png (1.3 MB)', 'save'],
|
||||
['Decoding complete - 3 images produced', 'info'],
|
||||
['Signal lost - satellite below horizon', 'warning'],
|
||||
['Pass duration: 13m 42s', 'info'],
|
||||
];
|
||||
|
||||
demoLogs.forEach((entry, i) => {
|
||||
setTimeout(() => addConsoleEntry(entry[0], entry[1]), i * 120);
|
||||
});
|
||||
|
||||
showNotification('Weather Sat', 'Demo data loaded - showing sample passes and console output');
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init,
|
||||
@@ -2113,6 +2435,11 @@ const WeatherSat = (function() {
|
||||
toggleScheduler,
|
||||
invalidateMap,
|
||||
toggleConsole,
|
||||
setTimezone,
|
||||
filterConsole,
|
||||
exportConsole,
|
||||
clearConsole,
|
||||
loadDemoData,
|
||||
_getModalFilename: () => currentModalFilename,
|
||||
};
|
||||
})();
|
||||
|
||||
+54
-2
@@ -2484,6 +2484,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)">
|
||||
@@ -2515,6 +2534,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">
|
||||
@@ -2543,8 +2585,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">▼</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">▼</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wxsat-console-body" id="wxsatConsoleBody">
|
||||
<div class="wxsat-console-log" id="wxsatConsoleLog">
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
<div id="weatherSatMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3>Weather Satellite Decoder</h3>
|
||||
<div class="alpha-mode-notice">
|
||||
ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions.
|
||||
</div>
|
||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
|
||||
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.
|
||||
</p>
|
||||
<div class="alpha-mode-notice">
|
||||
ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions.
|
||||
</div>
|
||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
@@ -18,8 +18,9 @@
|
||||
<select id="weatherSatSelect" class="mode-select">
|
||||
<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>
|
||||
</select>
|
||||
</div>
|
||||
<option value="METEOR-M2-4-80K">Meteor-M2-4 80k baud (fallback)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Gain (dB)</label>
|
||||
<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>
|
||||
</ul>
|
||||
<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>
|
||||
</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);">Avoid:</strong> Metal roofs, power lines, buildings blocking the sky</li>
|
||||
<li><strong style="color: var(--text-primary);">Coax length:</strong> Keep short (<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.
|
||||
Recommended: a low-noise 137 MHz filtered LNA near the antenna feed point</li>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
@@ -162,9 +163,9 @@
|
||||
<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>
|
||||
</tr>
|
||||
<tr>
|
||||
<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>
|
||||
<tr>
|
||||
<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>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
@@ -177,29 +178,30 @@
|
||||
<span class="wxsat-collapse-icon collapsed" style="font-size: 10px; transition: transform 0.2s; display: inline-block;">▼</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.
|
||||
</p>
|
||||
<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>
|
||||
</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;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Sample Rate</label>
|
||||
<select id="wxsatTestSampleRate" class="mode-select">
|
||||
<option value="500000">500 kHz (IQ LRPT)</option>
|
||||
<option value="1000000">1 MHz (IQ narrow)</option>
|
||||
<option value="2400000" selected>2.4 MHz (INTERCEPT default)</option>
|
||||
</select>
|
||||
</div>
|
||||
<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.
|
||||
</p>
|
||||
<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;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Sample Rate</label>
|
||||
<select id="wxsatTestSampleRate" class="mode-select">
|
||||
<option value="500000">500 kHz (IQ LRPT)</option>
|
||||
<option value="1000000">1 MHz (IQ narrow)</option>
|
||||
<option value="2400000" selected>2.4 MHz (INTERCEPT default)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="mode-btn" onclick="WeatherSat.testDecode()" style="width: 100%; margin-top: 4px;">
|
||||
Test Decode
|
||||
</button>
|
||||
@@ -223,15 +225,25 @@
|
||||
</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;">
|
||||
<a href="https://github.com/SatDump/SatDump" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||
SatDump Documentation
|
||||
</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;">
|
||||
Meteor Reception Guide
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="https://github.com/SatDump/SatDump" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||
SatDump Documentation
|
||||
</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;">
|
||||
Meteor Reception Guide
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+102
-93
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user