mirror of
https://github.com/smittix/intercept.git
synced 2026-04-30 09:39:58 -07:00
Add weather satellite decoder for NOAA APT and Meteor LRPT
New module for receiving and decoding weather satellite images using SatDump CLI. Supports NOAA-15/18/19 (APT) and Meteor-M2-3 (LRPT) with live SDR capture, pass prediction, and image gallery. Backend: - utils/weather_sat.py: SatDump process manager with image watcher - routes/weather_sat.py: API endpoints (start/stop/images/passes/stream) - SSE streaming for real-time capture progress - Pass prediction using existing skyfield + TLE data - SDR device registry integration (prevents conflicts) Frontend: - Sidebar panel with satellite selector and antenna build guide (V-dipole and QFH instructions for 137 MHz reception) - Stats strip with status, frequency, mode, location inputs - Split-panel layout: upcoming passes list + decoded image gallery - Full-size image modal viewer - SSE-driven progress updates during capture Infrastructure: - Dockerfile: Add SatDump build from source (headless CLI mode) with runtime deps (libpng, libtiff, libjemalloc, libvolk2, libnng) - Config: WEATHER_SAT_GAIN, SAMPLE_RATE, MIN_ELEVATION, PREDICTION_HOURS - Nav: Weather Sat entry in Space group (desktop + mobile) https://claude.ai/code/session_01FjLTkyELaqh27U1wEXngFQ
This commit is contained in:
512
static/css/modes/weather-satellite.css
Normal file
512
static/css/modes/weather-satellite.css
Normal file
@@ -0,0 +1,512 @@
|
||||
/* Weather Satellite Mode Styles */
|
||||
|
||||
/* ===== Stats Strip ===== */
|
||||
.wxsat-stats-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-tertiary, #1a1f2e);
|
||||
border-bottom: 1px solid var(--border-color, #2a3040);
|
||||
flex-wrap: wrap;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.wxsat-strip-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wxsat-strip-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wxsat-strip-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.wxsat-strip-dot.capturing {
|
||||
background: #00ff88;
|
||||
animation: wxsat-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.wxsat-strip-dot.decoding {
|
||||
background: #00d4ff;
|
||||
animation: wxsat-pulse 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes wxsat-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.wxsat-strip-status-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #999);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.wxsat-strip-btn {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--border-color, #2a3040);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 11px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.wxsat-strip-btn:hover {
|
||||
background: var(--bg-hover, #252a3a);
|
||||
border-color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
.wxsat-strip-btn.stop {
|
||||
border-color: #ff4444;
|
||||
color: #ff4444;
|
||||
}
|
||||
|
||||
.wxsat-strip-btn.stop:hover {
|
||||
background: rgba(255, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.wxsat-strip-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--border-color, #2a3040);
|
||||
}
|
||||
|
||||
.wxsat-strip-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wxsat-strip-value {
|
||||
font-size: 13px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.wxsat-strip-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #666);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.wxsat-strip-value.accent-cyan {
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
/* ===== Location inputs in strip ===== */
|
||||
.wxsat-strip-location {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wxsat-loc-input {
|
||||
width: 72px;
|
||||
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: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.wxsat-loc-input:focus {
|
||||
border-color: var(--accent-cyan, #00d4ff);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ===== Main Layout ===== */
|
||||
.wxsat-visuals-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.wxsat-content {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* ===== Pass Predictions Panel ===== */
|
||||
.wxsat-passes-panel {
|
||||
flex: 0 0 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
background: var(--bg-secondary, #141820);
|
||||
border: 1px solid var(--border-color, #2a3040);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wxsat-passes-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-tertiary, #1a1f2e);
|
||||
border-bottom: 1px solid var(--border-color, #2a3040);
|
||||
}
|
||||
|
||||
.wxsat-passes-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.wxsat-passes-count {
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.wxsat-passes-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.wxsat-pass-card {
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 6px;
|
||||
background: var(--bg-primary, #0d1117);
|
||||
border: 1px solid var(--border-color, #2a3040);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.wxsat-pass-card:hover {
|
||||
border-color: var(--accent-cyan, #00d4ff);
|
||||
background: var(--bg-hover, #252a3a);
|
||||
}
|
||||
|
||||
.wxsat-pass-card.active {
|
||||
border-color: #00ff88;
|
||||
background: rgba(0, 255, 136, 0.05);
|
||||
}
|
||||
|
||||
.wxsat-pass-sat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.wxsat-pass-sat-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.wxsat-pass-mode {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.wxsat-pass-mode.apt {
|
||||
background: rgba(0, 212, 255, 0.15);
|
||||
color: #00d4ff;
|
||||
}
|
||||
|
||||
.wxsat-pass-mode.lrpt {
|
||||
background: rgba(0, 255, 136, 0.15);
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.wxsat-pass-details {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #666);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.wxsat-pass-detail-label {
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.wxsat-pass-detail-value {
|
||||
color: var(--text-secondary, #999);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.wxsat-pass-quality {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.wxsat-pass-quality.excellent {
|
||||
background: rgba(0, 255, 136, 0.15);
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.wxsat-pass-quality.good {
|
||||
background: rgba(0, 212, 255, 0.15);
|
||||
color: #00d4ff;
|
||||
}
|
||||
|
||||
.wxsat-pass-quality.fair {
|
||||
background: rgba(255, 187, 0, 0.15);
|
||||
color: #ffbb00;
|
||||
}
|
||||
|
||||
/* ===== Image Gallery Panel ===== */
|
||||
.wxsat-gallery-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
background: var(--bg-secondary, #141820);
|
||||
border: 1px solid var(--border-color, #2a3040);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wxsat-gallery-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-tertiary, #1a1f2e);
|
||||
border-bottom: 1px solid var(--border-color, #2a3040);
|
||||
}
|
||||
|
||||
.wxsat-gallery-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.wxsat-gallery-count {
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.wxsat-gallery-grid {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.wxsat-image-card {
|
||||
background: var(--bg-primary, #0d1117);
|
||||
border: 1px solid var(--border-color, #2a3040);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.wxsat-image-card:hover {
|
||||
border-color: var(--accent-cyan, #00d4ff);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.wxsat-image-preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: var(--bg-tertiary, #1a1f2e);
|
||||
}
|
||||
|
||||
.wxsat-image-info {
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--border-color, #2a3040);
|
||||
}
|
||||
|
||||
.wxsat-image-sat {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.wxsat-image-product {
|
||||
font-size: 10px;
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.wxsat-image-timestamp {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.wxsat-gallery-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--text-dim, #666);
|
||||
text-align: center;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.wxsat-gallery-empty svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.wxsat-gallery-empty p {
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ===== Capture Progress ===== */
|
||||
.wxsat-capture-status {
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-tertiary, #1a1f2e);
|
||||
border-bottom: 1px solid var(--border-color, #2a3040);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wxsat-capture-status.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wxsat-capture-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.wxsat-capture-message {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, #999);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.wxsat-capture-elapsed {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #666);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.wxsat-progress-bar {
|
||||
height: 3px;
|
||||
background: var(--bg-primary, #0d1117);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wxsat-progress-bar .progress {
|
||||
height: 100%;
|
||||
background: var(--accent-cyan, #00d4ff);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* ===== Image Modal ===== */
|
||||
.wxsat-image-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.wxsat-image-modal.show {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.wxsat-image-modal img {
|
||||
max-width: 95%;
|
||||
max-height: 95vh;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.wxsat-modal-close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 32px;
|
||||
cursor: pointer;
|
||||
z-index: 10001;
|
||||
}
|
||||
|
||||
.wxsat-modal-info {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary, #999);
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
@media (max-width: 900px) {
|
||||
.wxsat-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wxsat-passes-panel {
|
||||
flex: none;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.wxsat-gallery-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
563
static/js/modes/weather-satellite.js
Normal file
563
static/js/modes/weather-satellite.js
Normal file
@@ -0,0 +1,563 @@
|
||||
/**
|
||||
* Weather Satellite Mode
|
||||
* NOAA APT and Meteor LRPT decoder interface
|
||||
*/
|
||||
|
||||
const WeatherSat = (function() {
|
||||
// State
|
||||
let isRunning = false;
|
||||
let eventSource = null;
|
||||
let images = [];
|
||||
let passes = [];
|
||||
let currentSatellite = null;
|
||||
|
||||
/**
|
||||
* Initialize the Weather Satellite mode
|
||||
*/
|
||||
function init() {
|
||||
checkStatus();
|
||||
loadImages();
|
||||
loadLocationInputs();
|
||||
loadPasses();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load observer location into input fields
|
||||
*/
|
||||
function loadLocationInputs() {
|
||||
const latInput = document.getElementById('wxsatObsLat');
|
||||
const lonInput = document.getElementById('wxsatObsLon');
|
||||
|
||||
let storedLat = localStorage.getItem('observerLat');
|
||||
let storedLon = localStorage.getItem('observerLon');
|
||||
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
||||
const shared = ObserverLocation.getShared();
|
||||
storedLat = shared.lat.toString();
|
||||
storedLon = shared.lon.toString();
|
||||
}
|
||||
|
||||
if (latInput && storedLat) latInput.value = storedLat;
|
||||
if (lonInput && storedLon) lonInput.value = storedLon;
|
||||
|
||||
if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
|
||||
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save location from inputs and refresh passes
|
||||
*/
|
||||
function saveLocationFromInputs() {
|
||||
const latInput = document.getElementById('wxsatObsLat');
|
||||
const lonInput = document.getElementById('wxsatObsLon');
|
||||
|
||||
const lat = parseFloat(latInput?.value);
|
||||
const lon = parseFloat(lonInput?.value);
|
||||
|
||||
if (!isNaN(lat) && lat >= -90 && lat <= 90 &&
|
||||
!isNaN(lon) && lon >= -180 && lon <= 180) {
|
||||
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
||||
ObserverLocation.setShared({ lat, lon });
|
||||
} else {
|
||||
localStorage.setItem('observerLat', lat.toString());
|
||||
localStorage.setItem('observerLon', lon.toString());
|
||||
}
|
||||
loadPasses();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use GPS for location
|
||||
*/
|
||||
function useGPS(btn) {
|
||||
if (!navigator.geolocation) {
|
||||
showNotification('Weather Sat', 'GPS not available in this browser');
|
||||
return;
|
||||
}
|
||||
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = '<span style="opacity: 0.7;">...</span>';
|
||||
btn.disabled = true;
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const latInput = document.getElementById('wxsatObsLat');
|
||||
const lonInput = document.getElementById('wxsatObsLon');
|
||||
|
||||
const lat = pos.coords.latitude.toFixed(4);
|
||||
const lon = pos.coords.longitude.toFixed(4);
|
||||
|
||||
if (latInput) latInput.value = lat;
|
||||
if (lonInput) lonInput.value = lon;
|
||||
|
||||
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
||||
ObserverLocation.setShared({ lat: parseFloat(lat), lon: parseFloat(lon) });
|
||||
} else {
|
||||
localStorage.setItem('observerLat', lat);
|
||||
localStorage.setItem('observerLon', lon);
|
||||
}
|
||||
|
||||
btn.innerHTML = originalText;
|
||||
btn.disabled = false;
|
||||
showNotification('Weather Sat', 'Location updated');
|
||||
loadPasses();
|
||||
},
|
||||
(err) => {
|
||||
btn.innerHTML = originalText;
|
||||
btn.disabled = false;
|
||||
showNotification('Weather Sat', 'Failed to get location');
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 10000 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check decoder status
|
||||
*/
|
||||
async function checkStatus() {
|
||||
try {
|
||||
const response = await fetch('/weather-sat/status');
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.available) {
|
||||
updateStatusUI('unavailable', 'SatDump not installed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.running) {
|
||||
isRunning = true;
|
||||
currentSatellite = data.satellite;
|
||||
updateStatusUI('capturing', `Capturing ${data.satellite}...`);
|
||||
startStream();
|
||||
} else {
|
||||
updateStatusUI('idle', 'Idle');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check weather sat status:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start capture
|
||||
*/
|
||||
async function start() {
|
||||
const satSelect = document.getElementById('weatherSatSelect');
|
||||
const gainInput = document.getElementById('weatherSatGain');
|
||||
const biasTInput = document.getElementById('weatherSatBiasT');
|
||||
const deviceSelect = document.getElementById('deviceSelect');
|
||||
|
||||
const satellite = satSelect?.value || 'NOAA-18';
|
||||
const gain = parseFloat(gainInput?.value || '40');
|
||||
const biasT = biasTInput?.checked || false;
|
||||
const device = parseInt(deviceSelect?.value || '0', 10);
|
||||
|
||||
updateStatusUI('connecting', 'Starting...');
|
||||
|
||||
try {
|
||||
const response = await fetch('/weather-sat/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
satellite,
|
||||
device,
|
||||
gain,
|
||||
bias_t: biasT,
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'started' || data.status === 'already_running') {
|
||||
isRunning = true;
|
||||
currentSatellite = data.satellite || satellite;
|
||||
updateStatusUI('capturing', `${data.satellite} ${data.frequency} MHz`);
|
||||
updateFreqDisplay(data.frequency, data.mode);
|
||||
startStream();
|
||||
showNotification('Weather Sat', `Capturing ${data.satellite} on ${data.frequency} MHz`);
|
||||
} else {
|
||||
updateStatusUI('idle', 'Start failed');
|
||||
showNotification('Weather Sat', data.message || 'Failed to start');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to start weather sat:', err);
|
||||
updateStatusUI('idle', 'Error');
|
||||
showNotification('Weather Sat', 'Connection error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start capture for a specific pass
|
||||
*/
|
||||
function startPass(satellite) {
|
||||
const satSelect = document.getElementById('weatherSatSelect');
|
||||
if (satSelect) {
|
||||
satSelect.value = satellite;
|
||||
}
|
||||
start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop capture
|
||||
*/
|
||||
async function stop() {
|
||||
try {
|
||||
await fetch('/weather-sat/stop', { method: 'POST' });
|
||||
isRunning = false;
|
||||
stopStream();
|
||||
updateStatusUI('idle', 'Stopped');
|
||||
showNotification('Weather Sat', 'Capture stopped');
|
||||
} catch (err) {
|
||||
console.error('Failed to stop weather sat:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update status UI
|
||||
*/
|
||||
function updateStatusUI(status, text) {
|
||||
const dot = document.getElementById('wxsatStripDot');
|
||||
const statusText = document.getElementById('wxsatStripStatus');
|
||||
const startBtn = document.getElementById('wxsatStartBtn');
|
||||
const stopBtn = document.getElementById('wxsatStopBtn');
|
||||
|
||||
if (dot) {
|
||||
dot.className = 'wxsat-strip-dot';
|
||||
if (status === 'capturing') dot.classList.add('capturing');
|
||||
else if (status === 'decoding') dot.classList.add('decoding');
|
||||
}
|
||||
|
||||
if (statusText) statusText.textContent = text || status;
|
||||
|
||||
if (startBtn && stopBtn) {
|
||||
if (status === 'capturing' || status === 'decoding') {
|
||||
startBtn.style.display = 'none';
|
||||
stopBtn.style.display = 'inline-block';
|
||||
} else {
|
||||
startBtn.style.display = 'inline-block';
|
||||
stopBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update frequency display in strip
|
||||
*/
|
||||
function updateFreqDisplay(freq, mode) {
|
||||
const freqEl = document.getElementById('wxsatStripFreq');
|
||||
const modeEl = document.getElementById('wxsatStripMode');
|
||||
if (freqEl) freqEl.textContent = freq || '--';
|
||||
if (modeEl) modeEl.textContent = mode || '--';
|
||||
}
|
||||
|
||||
/**
|
||||
* Start SSE stream
|
||||
*/
|
||||
function startStream() {
|
||||
if (eventSource) eventSource.close();
|
||||
|
||||
eventSource = new EventSource('/weather-sat/stream');
|
||||
|
||||
eventSource.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'weather_sat_progress') {
|
||||
handleProgress(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse SSE:', err);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
setTimeout(() => {
|
||||
if (isRunning) startStream();
|
||||
}, 3000);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop SSE stream
|
||||
*/
|
||||
function stopStream() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle progress update
|
||||
*/
|
||||
function handleProgress(data) {
|
||||
const captureStatus = document.getElementById('wxsatCaptureStatus');
|
||||
const captureMsg = document.getElementById('wxsatCaptureMsg');
|
||||
const captureElapsed = document.getElementById('wxsatCaptureElapsed');
|
||||
const progressBar = document.getElementById('wxsatProgressFill');
|
||||
|
||||
if (data.status === 'capturing' || data.status === 'decoding') {
|
||||
updateStatusUI(data.status, `${data.status === 'decoding' ? 'Decoding' : 'Capturing'} ${data.satellite}...`);
|
||||
|
||||
if (captureStatus) captureStatus.classList.add('active');
|
||||
if (captureMsg) captureMsg.textContent = data.message || '';
|
||||
if (captureElapsed) captureElapsed.textContent = formatElapsed(data.elapsed_seconds || 0);
|
||||
if (progressBar) progressBar.style.width = (data.progress || 0) + '%';
|
||||
|
||||
} else if (data.status === 'complete') {
|
||||
if (data.image) {
|
||||
images.unshift(data.image);
|
||||
updateImageCount(images.length);
|
||||
renderGallery();
|
||||
showNotification('Weather Sat', `New image: ${data.image.product || data.image.satellite}`);
|
||||
}
|
||||
|
||||
if (!data.image) {
|
||||
// Capture ended
|
||||
isRunning = false;
|
||||
stopStream();
|
||||
updateStatusUI('idle', 'Capture complete');
|
||||
if (captureStatus) captureStatus.classList.remove('active');
|
||||
}
|
||||
|
||||
} else if (data.status === 'error') {
|
||||
updateStatusUI('idle', 'Error');
|
||||
showNotification('Weather Sat', data.message || 'Capture error');
|
||||
if (captureStatus) captureStatus.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format elapsed seconds
|
||||
*/
|
||||
function formatElapsed(seconds) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load pass predictions
|
||||
*/
|
||||
async function loadPasses() {
|
||||
const storedLat = localStorage.getItem('observerLat');
|
||||
const storedLon = localStorage.getItem('observerLon');
|
||||
|
||||
if (!storedLat || !storedLon) {
|
||||
renderPasses([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `/weather-sat/passes?latitude=${storedLat}&longitude=${storedLon}&hours=24&min_elevation=15`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'ok') {
|
||||
passes = data.passes || [];
|
||||
renderPasses(passes);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load passes:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render pass predictions list
|
||||
*/
|
||||
function renderPasses(passList) {
|
||||
const container = document.getElementById('wxsatPassesList');
|
||||
const countEl = document.getElementById('wxsatPassesCount');
|
||||
|
||||
if (countEl) countEl.textContent = passList.length;
|
||||
|
||||
if (!container) return;
|
||||
|
||||
if (passList.length === 0) {
|
||||
const hasLocation = localStorage.getItem('observerLat') !== null;
|
||||
container.innerHTML = `
|
||||
<div class="wxsat-gallery-empty">
|
||||
<p>${hasLocation ? 'No passes in next 24h' : 'Set location to see pass predictions'}</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = passList.map((pass, idx) => {
|
||||
const modeClass = pass.mode === 'APT' ? 'apt' : 'lrpt';
|
||||
const timeStr = pass.startTime || '--';
|
||||
const now = new Date();
|
||||
const passStart = new Date(pass.startTimeISO);
|
||||
const diffMs = passStart - now;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
let countdown = '';
|
||||
if (diffMs < 0) {
|
||||
countdown = 'NOW';
|
||||
} else if (diffMins < 60) {
|
||||
countdown = `in ${diffMins}m`;
|
||||
} else {
|
||||
const hrs = Math.floor(diffMins / 60);
|
||||
const mins = diffMins % 60;
|
||||
countdown = `in ${hrs}h${mins}m`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="wxsat-pass-card" onclick="WeatherSat.startPass('${escapeHtml(pass.satellite)}')">
|
||||
<div class="wxsat-pass-sat">
|
||||
<span class="wxsat-pass-sat-name">${escapeHtml(pass.name)}</span>
|
||||
<span class="wxsat-pass-mode ${modeClass}">${escapeHtml(pass.mode)}</span>
|
||||
</div>
|
||||
<div class="wxsat-pass-details">
|
||||
<span class="wxsat-pass-detail-label">Time</span>
|
||||
<span class="wxsat-pass-detail-value">${escapeHtml(timeStr)}</span>
|
||||
<span class="wxsat-pass-detail-label">Max El</span>
|
||||
<span class="wxsat-pass-detail-value">${pass.maxEl}°</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>
|
||||
</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>
|
||||
<span style="font-size: 10px; color: var(--text-dim); font-family: 'JetBrains Mono', monospace;">${countdown}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load decoded images
|
||||
*/
|
||||
async function loadImages() {
|
||||
try {
|
||||
const response = await fetch('/weather-sat/images');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'ok') {
|
||||
images = data.images || [];
|
||||
updateImageCount(images.length);
|
||||
renderGallery();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load weather sat images:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update image count
|
||||
*/
|
||||
function updateImageCount(count) {
|
||||
const countEl = document.getElementById('wxsatImageCount');
|
||||
const stripCount = document.getElementById('wxsatStripImageCount');
|
||||
if (countEl) countEl.textContent = count;
|
||||
if (stripCount) stripCount.textContent = count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render image gallery
|
||||
*/
|
||||
function renderGallery() {
|
||||
const gallery = document.getElementById('wxsatGallery');
|
||||
if (!gallery) return;
|
||||
|
||||
if (images.length === 0) {
|
||||
gallery.innerHTML = `
|
||||
<div class="wxsat-gallery-empty">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M2 12h20"/>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||
</svg>
|
||||
<p>No images decoded yet</p>
|
||||
<p style="margin-top: 4px; font-size: 11px;">Select a satellite pass and start capturing</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
gallery.innerHTML = images.map(img => `
|
||||
<div class="wxsat-image-card" onclick="WeatherSat.showImage('${escapeHtml(img.url)}', '${escapeHtml(img.satellite)}', '${escapeHtml(img.product)}')">
|
||||
<img src="${escapeHtml(img.url)}" alt="${escapeHtml(img.satellite)} ${escapeHtml(img.product)}" class="wxsat-image-preview" loading="lazy">
|
||||
<div class="wxsat-image-info">
|
||||
<div class="wxsat-image-sat">${escapeHtml(img.satellite)}</div>
|
||||
<div class="wxsat-image-product">${escapeHtml(img.product || img.mode)}</div>
|
||||
<div class="wxsat-image-timestamp">${formatTimestamp(img.timestamp)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show full-size image
|
||||
*/
|
||||
function showImage(url, satellite, product) {
|
||||
let modal = document.getElementById('wxsatImageModal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'wxsatImageModal';
|
||||
modal.className = 'wxsat-image-modal';
|
||||
modal.innerHTML = `
|
||||
<button class="wxsat-modal-close" onclick="WeatherSat.closeImage()">×</button>
|
||||
<img src="" alt="Weather Satellite Image">
|
||||
<div class="wxsat-modal-info"></div>
|
||||
`;
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) closeImage();
|
||||
});
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
modal.querySelector('img').src = url;
|
||||
const info = modal.querySelector('.wxsat-modal-info');
|
||||
if (info) {
|
||||
info.textContent = `${satellite || ''} ${product ? '// ' + product : ''}`;
|
||||
}
|
||||
modal.classList.add('show');
|
||||
}
|
||||
|
||||
/**
|
||||
* Close image modal
|
||||
*/
|
||||
function closeImage() {
|
||||
const modal = document.getElementById('wxsatImageModal');
|
||||
if (modal) modal.classList.remove('show');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp
|
||||
*/
|
||||
function formatTimestamp(isoString) {
|
||||
if (!isoString) return '--';
|
||||
try {
|
||||
return new Date(isoString).toLocaleString();
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init,
|
||||
start,
|
||||
stop,
|
||||
startPass,
|
||||
loadImages,
|
||||
loadPasses,
|
||||
showImage,
|
||||
closeImage,
|
||||
useGPS,
|
||||
};
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialization happens via selectMode when weather-satellite mode is activated
|
||||
});
|
||||
Reference in New Issue
Block a user