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:
Claude
2026-02-05 21:45:33 +00:00
parent 780ba9c58b
commit 7b68c19dc5
11 changed files with 2421 additions and 15 deletions

View File

@@ -23,6 +23,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
# SSTV decoder runtime libs
libsndfile1 \
# SatDump runtime libs (weather satellite decoding)
libpng16-16 \
libtiff6 \
libjemalloc2 \
libvolk2-bin \
libnng1 \
libzstd1 \
# WiFi tools (aircrack-ng suite)
aircrack-ng \
iw \
@@ -64,6 +71,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libhackrf-dev \
liblimesuite-dev \
libfftw3-dev \
libpng-dev \
libtiff-dev \
libjemalloc-dev \
libvolk2-dev \
libnng-dev \
libzstd-dev \
libsqlite3-dev \
libcurl4-openssl-dev \
zlib1g-dev \
@@ -121,6 +134,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& make \
&& install -m 0755 slowrx /usr/local/bin/slowrx \
&& rm -rf /tmp/slowrx \
# Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT)
&& cd /tmp \
&& git clone --depth 1 https://github.com/SatDump/SatDump.git \
&& cd SatDump \
&& mkdir build && cd build \
&& cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF .. \
&& make -j$(nproc) \
&& make install \
&& ldconfig \
&& cd /tmp \
&& rm -rf /tmp/SatDump \
# Cleanup build tools to reduce image size
&& apt-get remove -y \
build-essential \
@@ -130,6 +154,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libncurses-dev \
libsndfile1-dev \
libasound2-dev \
libpng-dev \
libtiff-dev \
libjemalloc-dev \
libvolk2-dev \
libnng-dev \
libzstd-dev \
libsoapysdr-dev \
libhackrf-dev \
liblimesuite-dev \
@@ -148,7 +178,7 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Create data directory for persistence
RUN mkdir -p /app/data
RUN mkdir -p /app/data /app/data/weather_sat
# Expose web interface port
EXPOSE 5050

22
app.py
View File

@@ -105,7 +105,7 @@ def inject_offline_settings():
'enabled': get_setting('offline.enabled', False),
'assets_source': get_setting('offline.assets_source', 'cdn'),
'fonts_source': get_setting('offline.fonts_source', 'cdn'),
'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'),
'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'),
'tile_server_url': get_setting('offline.tile_server_url', '')
}
}
@@ -176,6 +176,10 @@ dsc_lock = threading.Lock()
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
tscm_lock = threading.Lock()
# Weather Satellite (NOAA/Meteor)
weather_sat_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
weather_sat_lock = threading.Lock()
# Deauth Attack Detection
deauth_detector = None
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
@@ -278,13 +282,13 @@ def get_sdr_device_status() -> dict[int, str]:
# ============================================
@app.before_request
def require_login():
# Routes that don't require login (to avoid infinite redirect loop)
allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check']
# Allow audio streaming endpoints without session auth
if request.path.startswith('/listening/audio/'):
return None
def require_login():
# Routes that don't require login (to avoid infinite redirect loop)
allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check']
# Allow audio streaming endpoints without session auth
if request.path.startswith('/listening/audio/'):
return None
# Controller API endpoints use API key auth, not session auth
# Allow agent push/pull endpoints without session login
@@ -663,7 +667,7 @@ def kill_all() -> Response:
'rtl_fm', 'multimon-ng', 'rtl_433',
'airodump-ng', 'aireplay-ng', 'airmon-ng',
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
'hcitool', 'bluetoothctl'
'hcitool', 'bluetoothctl', 'satdump'
]
for proc in processes_to_kill:

View File

@@ -191,6 +191,12 @@ SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
# Weather satellite settings
WEATHER_SAT_DEFAULT_GAIN = _get_env_float('WEATHER_SAT_GAIN', 40.0)
WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 1000000)
WEATHER_SAT_MIN_ELEVATION = _get_env_float('WEATHER_SAT_MIN_ELEVATION', 15.0)
WEATHER_SAT_PREDICTION_HOURS = _get_env_int('WEATHER_SAT_PREDICTION_HOURS', 24)
# Update checking
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)

View File

@@ -26,6 +26,7 @@ def register_blueprints(app):
from .offline import offline_bp
from .updater import updater_bp
from .sstv import sstv_bp
from .weather_sat import weather_sat_bp
app.register_blueprint(pager_bp)
app.register_blueprint(sensor_bp)
@@ -51,6 +52,7 @@ def register_blueprints(app):
app.register_blueprint(offline_bp) # Offline mode settings
app.register_blueprint(updater_bp) # GitHub update checking
app.register_blueprint(sstv_bp) # ISS SSTV decoder
app.register_blueprint(weather_sat_bp) # NOAA/Meteor weather satellite decoder
# Initialize TSCM state with queue and lock from app
import app as app_module

494
routes/weather_sat.py Normal file
View File

@@ -0,0 +1,494 @@
"""Weather Satellite decoder routes.
Provides endpoints for capturing and decoding weather satellite images
from NOAA (APT) and Meteor (LRPT) satellites using SatDump.
"""
from __future__ import annotations
import queue
import time
from typing import Generator
from flask import Blueprint, jsonify, request, Response, send_file
from utils.logging import get_logger
from utils.sse import format_sse
from utils.weather_sat import (
get_weather_sat_decoder,
is_weather_sat_available,
CaptureProgress,
WEATHER_SATELLITES,
)
logger = get_logger('intercept.weather_sat')
weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat')
# Queue for SSE progress streaming
_weather_sat_queue: queue.Queue = queue.Queue(maxsize=100)
def _progress_callback(progress: CaptureProgress) -> None:
"""Callback to queue progress updates for SSE stream."""
try:
_weather_sat_queue.put_nowait(progress.to_dict())
except queue.Full:
try:
_weather_sat_queue.get_nowait()
_weather_sat_queue.put_nowait(progress.to_dict())
except queue.Empty:
pass
@weather_sat_bp.route('/status')
def get_status():
"""Get weather satellite decoder status.
Returns:
JSON with decoder availability and current status.
"""
decoder = get_weather_sat_decoder()
return jsonify(decoder.get_status())
@weather_sat_bp.route('/satellites')
def list_satellites():
"""Get list of supported weather satellites with frequencies.
Returns:
JSON with satellite definitions.
"""
satellites = []
for key, info in WEATHER_SATELLITES.items():
satellites.append({
'key': key,
'name': info['name'],
'frequency': info['frequency'],
'mode': info['mode'],
'description': info['description'],
'active': info['active'],
})
return jsonify({
'status': 'ok',
'satellites': satellites,
})
@weather_sat_bp.route('/start', methods=['POST'])
def start_capture():
"""Start weather satellite capture and decode.
JSON body:
{
"satellite": "NOAA-18", // Required: satellite key
"device": 0, // RTL-SDR device index (default: 0)
"gain": 40.0, // SDR gain in dB (default: 40)
"bias_t": false // Enable bias-T for LNA (default: false)
}
Returns:
JSON with start status.
"""
if not is_weather_sat_available():
return jsonify({
'status': 'error',
'message': 'SatDump not installed. Build from source: https://github.com/SatDump/SatDump'
}), 400
decoder = get_weather_sat_decoder()
if decoder.is_running:
return jsonify({
'status': 'already_running',
'satellite': decoder.current_satellite,
'frequency': decoder.current_frequency,
})
data = request.get_json(silent=True) or {}
# Validate satellite
satellite = data.get('satellite')
if not satellite or satellite not in WEATHER_SATELLITES:
return jsonify({
'status': 'error',
'message': f'Invalid satellite. Must be one of: {", ".join(WEATHER_SATELLITES.keys())}'
}), 400
# Validate device index
device_index = data.get('device', 0)
try:
device_index = int(device_index)
if not (0 <= device_index <= 255):
raise ValueError
except (TypeError, ValueError):
return jsonify({
'status': 'error',
'message': 'Invalid device index (0-255)'
}), 400
# Validate gain
gain = data.get('gain', 40.0)
try:
gain = float(gain)
if not (0 <= gain <= 50):
raise ValueError
except (TypeError, ValueError):
return jsonify({
'status': 'error',
'message': 'Invalid gain (0-50 dB)'
}), 400
bias_t = bool(data.get('bias_t', False))
# Claim SDR device
try:
import app as app_module
error = app_module.claim_sdr_device(device_index, 'weather_sat')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error,
}), 409
except ImportError:
pass
# Clear queue
while not _weather_sat_queue.empty():
try:
_weather_sat_queue.get_nowait()
except queue.Empty:
break
# Set callback and start
decoder.set_callback(_progress_callback)
success = decoder.start(
satellite=satellite,
device_index=device_index,
gain=gain,
bias_t=bias_t,
)
if success:
sat_info = WEATHER_SATELLITES[satellite]
return jsonify({
'status': 'started',
'satellite': satellite,
'frequency': sat_info['frequency'],
'mode': sat_info['mode'],
'device': device_index,
})
else:
# Release device on failure
try:
import app as app_module
app_module.release_sdr_device(device_index)
except ImportError:
pass
return jsonify({
'status': 'error',
'message': 'Failed to start capture'
}), 500
@weather_sat_bp.route('/stop', methods=['POST'])
def stop_capture():
"""Stop weather satellite capture.
Returns:
JSON confirmation.
"""
decoder = get_weather_sat_decoder()
device_index = decoder._device_index
decoder.stop()
# Release SDR device
try:
import app as app_module
app_module.release_sdr_device(device_index)
except ImportError:
pass
return jsonify({'status': 'stopped'})
@weather_sat_bp.route('/images')
def list_images():
"""Get list of decoded weather satellite images.
Query parameters:
limit: Maximum number of images (default: all)
satellite: Filter by satellite key (optional)
Returns:
JSON with list of decoded images.
"""
decoder = get_weather_sat_decoder()
images = decoder.get_images()
# Filter by satellite if specified
satellite_filter = request.args.get('satellite')
if satellite_filter:
images = [img for img in images if img.satellite == satellite_filter]
# Apply limit
limit = request.args.get('limit', type=int)
if limit and limit > 0:
images = images[-limit:]
return jsonify({
'status': 'ok',
'images': [img.to_dict() for img in images],
'count': len(images),
})
@weather_sat_bp.route('/images/<filename>')
def get_image(filename: str):
"""Serve a decoded weather satellite image file.
Args:
filename: Image filename
Returns:
Image file or 404.
"""
decoder = get_weather_sat_decoder()
# Security: only allow safe filenames
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
if not (filename.endswith('.png') or filename.endswith('.jpg') or filename.endswith('.jpeg')):
return jsonify({'status': 'error', 'message': 'Only PNG/JPG files supported'}), 400
image_path = decoder._output_dir / filename
if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg'
return send_file(image_path, mimetype=mimetype)
@weather_sat_bp.route('/images/<filename>', methods=['DELETE'])
def delete_image(filename: str):
"""Delete a decoded image.
Args:
filename: Image filename
Returns:
JSON confirmation.
"""
decoder = get_weather_sat_decoder()
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
if decoder.delete_image(filename):
return jsonify({'status': 'deleted', 'filename': filename})
else:
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
@weather_sat_bp.route('/stream')
def stream_progress():
"""SSE stream of capture/decode progress.
Returns:
SSE stream (text/event-stream)
"""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
keepalive_interval = 30.0
while True:
try:
progress = _weather_sat_queue.get(timeout=1)
last_keepalive = time.time()
yield format_sse(progress)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
@weather_sat_bp.route('/passes')
def get_passes():
"""Get upcoming weather satellite passes for observer location.
Query parameters:
latitude: Observer latitude (required)
longitude: Observer longitude (required)
hours: Hours to predict ahead (default: 24, max: 72)
min_elevation: Minimum elevation in degrees (default: 15)
Returns:
JSON with upcoming passes for all weather satellites.
"""
lat = request.args.get('latitude', type=float)
lon = request.args.get('longitude', type=float)
hours = request.args.get('hours', 24, type=int)
min_elevation = request.args.get('min_elevation', 15, type=float)
if lat is None or lon is None:
return jsonify({
'status': 'error',
'message': 'latitude and longitude parameters required'
}), 400
if not (-90 <= lat <= 90):
return jsonify({'status': 'error', 'message': 'Invalid latitude'}), 400
if not (-180 <= lon <= 180):
return jsonify({'status': 'error', 'message': 'Invalid longitude'}), 400
hours = max(1, min(hours, 72))
min_elevation = max(0, min(min_elevation, 90))
try:
from skyfield.api import load, wgs84, EarthSatellite
from skyfield.almanac import find_discrete
from data.satellites import TLE_SATELLITES
ts = load.timescale()
observer = wgs84.latlon(lat, lon)
t0 = ts.now()
t1 = ts.utc(t0.utc_datetime() + __import__('datetime').timedelta(hours=hours))
all_passes = []
for sat_key, sat_info in WEATHER_SATELLITES.items():
if not sat_info['active']:
continue
tle_data = TLE_SATELLITES.get(sat_info['tle_key'])
if not tle_data:
continue
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
def above_horizon(t, _sat=satellite):
diff = _sat - observer
topocentric = diff.at(t)
alt, _, _ = topocentric.altaz()
return alt.degrees > 0
above_horizon.step_days = 1 / 720
try:
times, events = find_discrete(t0, t1, above_horizon)
except Exception:
continue
i = 0
while i < len(times):
if i < len(events) and events[i]: # Rising
rise_time = times[i]
set_time = None
for j in range(i + 1, len(times)):
if not events[j]: # Setting
set_time = times[j]
i = j
break
else:
i += 1
continue
if set_time is None:
i += 1
continue
# Calculate max elevation
max_el = 0
max_el_az = 0
duration_seconds = (
set_time.utc_datetime() - rise_time.utc_datetime()
).total_seconds()
duration_minutes = round(duration_seconds / 60, 1)
for k in range(30):
frac = k / 29
t_point = ts.utc(
rise_time.utc_datetime()
+ __import__('datetime').timedelta(
seconds=duration_seconds * frac
)
)
diff = satellite - observer
topocentric = diff.at(t_point)
alt, az, _ = topocentric.altaz()
if alt.degrees > max_el:
max_el = alt.degrees
max_el_az = az.degrees
if max_el >= min_elevation:
# Calculate rise/set azimuth
rise_diff = satellite - observer
rise_topo = rise_diff.at(rise_time)
_, rise_az, _ = rise_topo.altaz()
set_diff = satellite - observer
set_topo = set_diff.at(set_time)
_, set_az, _ = set_topo.altaz()
pass_data = {
'satellite': sat_key,
'name': sat_info['name'],
'frequency': sat_info['frequency'],
'mode': sat_info['mode'],
'startTime': rise_time.utc_datetime().strftime(
'%Y-%m-%d %H:%M UTC'
),
'startTimeISO': rise_time.utc_datetime().isoformat(),
'endTimeISO': set_time.utc_datetime().isoformat(),
'maxEl': round(max_el, 1),
'maxElAz': round(max_el_az, 1),
'riseAz': round(rise_az.degrees, 1),
'setAz': round(set_az.degrees, 1),
'duration': duration_minutes,
'quality': (
'excellent' if max_el >= 60
else 'good' if max_el >= 30
else 'fair'
),
}
all_passes.append(pass_data)
i += 1
# Sort by start time
all_passes.sort(key=lambda p: p['startTimeISO'])
return jsonify({
'status': 'ok',
'passes': all_passes,
'count': len(all_passes),
'observer': {'latitude': lat, 'longitude': lon},
'prediction_hours': hours,
'min_elevation': min_elevation,
})
except ImportError:
return jsonify({
'status': 'error',
'message': 'skyfield library not installed'
}), 503
except Exception as e:
logger.error(f"Error predicting passes: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500

View 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));
}
}

View 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}&deg;</span>
<span class="wxsat-pass-detail-label">Duration</span>
<span class="wxsat-pass-detail-value">${pass.duration} min</span>
<span class="wxsat-pass-detail-label">Freq</span>
<span class="wxsat-pass-detail-value">${pass.frequency} MHz</span>
</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()">&times;</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
});

View File

@@ -57,6 +57,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/spy-stations.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/meshtastic.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/sstv.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/weather-satellite.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/toast.css') }}">
</head>
@@ -224,6 +225,10 @@
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg></span>
<span class="mode-name">ISS SSTV</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('weathersat')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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></span>
<span class="mode-name">Weather Sat</span>
</button>
</div>
</div>
</div>
@@ -506,6 +511,8 @@
{% include 'partials/modes/sstv.html' %}
{% include 'partials/modes/weather-satellite.html' %}
{% include 'partials/modes/listening-post.html' %}
{% include 'partials/modes/tscm.html' %}
@@ -1880,6 +1887,93 @@
</div>
</div>
<!-- Weather Satellite visuals (pass predictions + image gallery) -->
<div id="weatherSatVisuals" class="wxsat-visuals-container" style="display: none;">
<!-- Stats strip -->
<div class="wxsat-stats-strip">
<div class="wxsat-strip-group">
<div class="wxsat-strip-status">
<span class="wxsat-strip-dot" id="wxsatStripDot"></span>
<span class="wxsat-strip-status-text" id="wxsatStripStatus">Idle</span>
</div>
<button class="wxsat-strip-btn start" id="wxsatStartBtn" onclick="WeatherSat.start()">Start</button>
<button class="wxsat-strip-btn stop" id="wxsatStopBtn" onclick="WeatherSat.stop()" style="display: none;">Stop</button>
</div>
<div class="wxsat-strip-divider"></div>
<div class="wxsat-strip-group">
<div class="wxsat-strip-stat">
<span class="wxsat-strip-value accent-cyan" id="wxsatStripFreq">--</span>
<span class="wxsat-strip-label">MHZ</span>
</div>
<div class="wxsat-strip-stat">
<span class="wxsat-strip-value" id="wxsatStripMode">--</span>
<span class="wxsat-strip-label">MODE</span>
</div>
<div class="wxsat-strip-stat">
<span class="wxsat-strip-value" id="wxsatStripImageCount">0</span>
<span class="wxsat-strip-label">IMAGES</span>
</div>
</div>
<div class="wxsat-strip-divider"></div>
<div class="wxsat-strip-group">
<div class="wxsat-strip-location">
<span class="wxsat-strip-label" style="margin-right: 6px;">LOC</span>
<input type="number" id="wxsatObsLat" class="wxsat-loc-input" step="0.0001" placeholder="Lat" title="Latitude">
<input type="number" id="wxsatObsLon" class="wxsat-loc-input" step="0.0001" placeholder="Lon" title="Longitude">
<button class="wxsat-strip-btn gps" onclick="WeatherSat.useGPS(this)" title="Use GPS location">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>
</button>
</div>
</div>
</div>
<!-- Capture progress -->
<div class="wxsat-capture-status" id="wxsatCaptureStatus">
<div class="wxsat-capture-info">
<span class="wxsat-capture-message" id="wxsatCaptureMsg">--</span>
<span class="wxsat-capture-elapsed" id="wxsatCaptureElapsed">0:00</span>
</div>
<div class="wxsat-progress-bar">
<div class="progress" id="wxsatProgressFill" style="width: 0%"></div>
</div>
</div>
<!-- Main content: passes + gallery -->
<div class="wxsat-content">
<!-- Pass predictions -->
<div class="wxsat-passes-panel">
<div class="wxsat-passes-header">
<span class="wxsat-passes-title">Upcoming Passes</span>
<span class="wxsat-passes-count" id="wxsatPassesCount">0</span>
</div>
<div class="wxsat-passes-list" id="wxsatPassesList">
<div class="wxsat-gallery-empty">
<p>Set location to see pass predictions</p>
</div>
</div>
</div>
<!-- Image gallery -->
<div class="wxsat-gallery-panel">
<div class="wxsat-gallery-header">
<span class="wxsat-gallery-title">Decoded Images</span>
<span class="wxsat-gallery-count" id="wxsatImageCount">0</span>
</div>
<div class="wxsat-gallery-grid" id="wxsatGallery">
<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>
</div>
</div>
</div>
</div>
<!-- Device Intelligence Dashboard (above waterfall for prominence) -->
<div class="recon-panel collapsed" id="reconPanel">
<div class="recon-header" onclick="toggleReconCollapse()" style="cursor: pointer;">
@@ -1967,6 +2061,7 @@
<script src="{{ url_for('static', filename='js/modes/spy-stations.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/meshtastic.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/sstv.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/weather-satellite.js') }}"></script>
<script>
// ============================================
@@ -2102,7 +2197,7 @@
const validModes = new Set([
'pager', 'sensor', 'rtlamr', 'aprs', 'listening',
'spystations', 'meshtastic', 'wifi', 'bluetooth',
'tscm', 'satellite', 'sstv'
'tscm', 'satellite', 'sstv', 'weathersat'
]);
function getModeFromQuery() {
@@ -2524,7 +2619,7 @@
'tscm': 'security',
'rtlamr': 'sdr', 'ais': 'sdr', 'spystations': 'sdr',
'meshtastic': 'sdr',
'satellite': 'space', 'sstv': 'space'
'satellite': 'space', 'sstv': 'space', 'weathersat': 'space'
};
// Remove has-active from all dropdowns
@@ -2606,6 +2701,7 @@
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
document.getElementById('satelliteMode')?.classList.toggle('active', mode === 'satellite');
document.getElementById('sstvMode')?.classList.toggle('active', mode === 'sstv');
document.getElementById('weatherSatMode')?.classList.toggle('active', mode === 'weathersat');
document.getElementById('wifiMode')?.classList.toggle('active', mode === 'wifi');
document.getElementById('bluetoothMode')?.classList.toggle('active', mode === 'bluetooth');
document.getElementById('listeningPostMode')?.classList.toggle('active', mode === 'listening');
@@ -2640,6 +2736,7 @@
'rtlamr': 'METERS',
'satellite': 'SATELLITE',
'sstv': 'ISS SSTV',
'weathersat': 'WEATHER SAT',
'wifi': 'WIFI',
'bluetooth': 'BLUETOOTH',
'listening': 'LISTENING POST',
@@ -2660,6 +2757,7 @@
const spyStationsVisuals = document.getElementById('spyStationsVisuals');
const meshtasticVisuals = document.getElementById('meshtasticVisuals');
const sstvVisuals = document.getElementById('sstvVisuals');
const weatherSatVisuals = document.getElementById('weatherSatVisuals');
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
@@ -2669,6 +2767,7 @@
if (spyStationsVisuals) spyStationsVisuals.style.display = mode === 'spystations' ? 'flex' : 'none';
if (meshtasticVisuals) meshtasticVisuals.style.display = mode === 'meshtastic' ? 'flex' : 'none';
if (sstvVisuals) sstvVisuals.style.display = mode === 'sstv' ? 'flex' : 'none';
if (weatherSatVisuals) weatherSatVisuals.style.display = mode === 'weathersat' ? 'flex' : 'none';
// Hide sidebar by default for Meshtastic mode, show for others
const mainContent = document.querySelector('.main-content');
@@ -2693,6 +2792,7 @@
'rtlamr': 'Utility Meter Monitor',
'satellite': 'Satellite Monitor',
'sstv': 'ISS SSTV Decoder',
'weathersat': 'Weather Satellite Decoder',
'wifi': 'WiFi Scanner',
'bluetooth': 'Bluetooth Scanner',
'listening': 'Listening Post',
@@ -2718,7 +2818,7 @@
const reconBtn = document.getElementById('reconBtn');
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
const reconPanel = document.getElementById('reconPanel');
if (mode === 'satellite' || mode === 'sstv' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic') {
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic') {
if (reconPanel) reconPanel.style.display = 'none';
if (reconBtn) reconBtn.style.display = 'none';
if (intelBtn) intelBtn.style.display = 'none';
@@ -2738,7 +2838,7 @@
// Show RTL-SDR device section for modes that use it
const rtlDeviceSection = document.getElementById('rtlDeviceSection');
if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'listening' || mode === 'aprs' || mode === 'sstv') ? 'block' : 'none';
if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'listening' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat') ? 'block' : 'none';
// Toggle mode-specific tool status displays
const toolStatusPager = document.getElementById('toolStatusPager');
@@ -2749,7 +2849,7 @@
// Hide output console for modes with their own visualizations
const outputEl = document.getElementById('output');
const statusBar = document.querySelector('.status-bar');
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic') ? 'none' : 'block';
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic') ? 'none' : 'block';
if (statusBar) statusBar.style.display = (mode === 'satellite') ? 'none' : 'flex';
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
@@ -2797,6 +2897,8 @@
}, 100);
} else if (mode === 'sstv') {
SSTV.init();
} else if (mode === 'weathersat') {
WeatherSat.init();
}
}

View File

@@ -0,0 +1,82 @@
<!-- WEATHER SATELLITE MODE -->
<div id="weatherSatMode" class="mode-content">
<div class="section">
<h3>Weather Satellite Decoder</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
Receive and decode weather images from NOAA and Meteor satellites.
Uses SatDump for live SDR capture and image processing.
</p>
</div>
<div class="section">
<h3>Satellite</h3>
<div class="form-group">
<label>Select Satellite</label>
<select id="weatherSatSelect" class="mode-select">
<option value="NOAA-15">NOAA-15 (137.620 MHz APT)</option>
<option value="NOAA-18" selected>NOAA-18 (137.9125 MHz APT)</option>
<option value="NOAA-19">NOAA-19 (137.100 MHz APT)</option>
<option value="METEOR-M2-3">Meteor-M2-3 (137.900 MHz LRPT)</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">
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 6px;">
<input type="checkbox" id="weatherSatBiasT" style="width: auto;">
Bias-T (power LNA)
</label>
</div>
</div>
<div class="section">
<h3>Antenna Guide (137 MHz)</h3>
<div style="font-size: 11px; color: var(--text-dim);">
<p style="margin-bottom: 8px; color: var(--accent-cyan);">Weather satellites transmit at ~137 MHz. Your stock SDR antenna likely won't work well at this frequency.</p>
<div style="margin-bottom: 8px;">
<strong style="color: var(--text-primary);">V-Dipole (Easiest Build)</strong>
<ul style="margin: 4px 0 0 16px; padding: 0;">
<li>Two elements, ~53.4 cm each (quarter wavelength)</li>
<li>Spread at 120 angle, laid flat or tilted</li>
<li>Connect to SDR via coax with a BNC/SMA adapter</li>
<li>Cost: ~$5 in wire</li>
</ul>
</div>
<div style="margin-bottom: 8px;">
<strong style="color: var(--text-primary);">QFH Antenna (Best)</strong>
<ul style="margin: 4px 0 0 16px; padding: 0;">
<li>Quadrifilar helix - omnidirectional RHCP</li>
<li>Best for overhead passes, rejects ground noise</li>
<li>Build from copper pipe or coax</li>
<li>Cost: ~$20-30 in materials</li>
</ul>
</div>
<div style="margin-bottom: 8px;">
<strong style="color: var(--text-primary);">Tips</strong>
<ul style="margin: 4px 0 0 16px; padding: 0;">
<li>Outdoors with clear sky view is critical</li>
<li>LNA (e.g. Nooelec SAWbird) helps a lot</li>
<li>Enable Bias-T if using a powered LNA</li>
<li>Passes >30 elevation give best images</li>
</ul>
</div>
</div>
</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-noaa-weather-satellite-images/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
NOAA Reception Guide
</a>
</div>
</div>
</div>

View File

@@ -116,6 +116,7 @@
{{ mode_item('satellite', 'Satellite', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg>', '/satellite/dashboard') }}
{% endif %}
{{ mode_item('sstv', 'ISS SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg>') }}
{{ mode_item('weathersat', 'Weather Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>') }}
</div>
</div>
@@ -182,6 +183,7 @@
{{ mobile_item('satellite', 'Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg>', '/satellite/dashboard') }}
{% endif %}
{{ mobile_item('sstv', 'SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
{{ mobile_item('weathersat', 'WxSat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>') }}
{{ mobile_item('listening', 'Scanner', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}

609
utils/weather_sat.py Normal file
View File

@@ -0,0 +1,609 @@
"""Weather Satellite decoder for NOAA APT and Meteor LRPT imagery.
Provides automated capture and decoding of weather satellite images using SatDump.
Supported satellites:
- NOAA-15: 137.620 MHz (APT)
- NOAA-18: 137.9125 MHz (APT)
- NOAA-19: 137.100 MHz (APT)
- Meteor-M2-3: 137.900 MHz (LRPT)
Uses SatDump CLI for live SDR capture and decoding, with fallback to
rtl_fm capture for manual decoding when SatDump is unavailable.
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Callable
from utils.logging import get_logger
logger = get_logger('intercept.weather_sat')
# Weather satellite definitions
WEATHER_SATELLITES = {
'NOAA-15': {
'name': 'NOAA 15',
'frequency': 137.620,
'mode': 'APT',
'pipeline': 'noaa_apt',
'tle_key': 'NOAA-15',
'description': 'NOAA-15 APT (analog weather imagery)',
'active': True,
},
'NOAA-18': {
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'pipeline': 'noaa_apt',
'tle_key': 'NOAA-18',
'description': 'NOAA-18 APT (analog weather imagery)',
'active': True,
},
'NOAA-19': {
'name': 'NOAA 19',
'frequency': 137.100,
'mode': 'APT',
'pipeline': 'noaa_apt',
'tle_key': 'NOAA-19',
'description': 'NOAA-19 APT (analog weather imagery)',
'active': True,
},
'METEOR-M2-3': {
'name': 'Meteor-M2-3',
'frequency': 137.900,
'mode': 'LRPT',
'pipeline': 'meteor_m2-x_lrpt',
'tle_key': 'METEOR-M2-3',
'description': 'Meteor-M2-3 LRPT (digital color imagery)',
'active': True,
},
}
# Default sample rate for weather satellite reception
DEFAULT_SAMPLE_RATE = 1000000 # 1 MHz
@dataclass
class WeatherSatImage:
"""Decoded weather satellite image."""
filename: str
path: Path
satellite: str
mode: str # APT or LRPT
timestamp: datetime
frequency: float
size_bytes: int = 0
product: str = '' # e.g. 'RGB', 'Thermal', 'Channel 1'
def to_dict(self) -> dict:
return {
'filename': self.filename,
'satellite': self.satellite,
'mode': self.mode,
'timestamp': self.timestamp.isoformat(),
'frequency': self.frequency,
'size_bytes': self.size_bytes,
'product': self.product,
'url': f'/weather-sat/images/{self.filename}',
}
@dataclass
class CaptureProgress:
"""Weather satellite capture/decode progress update."""
status: str # 'idle', 'capturing', 'decoding', 'complete', 'error'
satellite: str = ''
frequency: float = 0.0
mode: str = ''
message: str = ''
progress_percent: int = 0
elapsed_seconds: int = 0
image: WeatherSatImage | None = None
def to_dict(self) -> dict:
result = {
'type': 'weather_sat_progress',
'status': self.status,
'satellite': self.satellite,
'frequency': self.frequency,
'mode': self.mode,
'message': self.message,
'progress': self.progress_percent,
'elapsed_seconds': self.elapsed_seconds,
}
if self.image:
result['image'] = self.image.to_dict()
return result
class WeatherSatDecoder:
"""Weather satellite decoder using SatDump CLI.
Manages live SDR capture and decoding of NOAA APT and Meteor LRPT
satellite transmissions.
"""
def __init__(self, output_dir: str | Path | None = None):
self._process: subprocess.Popen | None = None
self._running = False
self._lock = threading.Lock()
self._callback: Callable[[CaptureProgress], None] | None = None
self._output_dir = Path(output_dir) if output_dir else Path('data/weather_sat')
self._images: list[WeatherSatImage] = []
self._reader_thread: threading.Thread | None = None
self._watcher_thread: threading.Thread | 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 = 0
self._capture_output_dir: Path | None = None
# Ensure output directory exists
self._output_dir.mkdir(parents=True, exist_ok=True)
# Detect available decoder
self._decoder = self._detect_decoder()
@property
def is_running(self) -> bool:
return self._running
@property
def decoder_available(self) -> str | None:
"""Return name of available decoder or None."""
return self._decoder
@property
def current_satellite(self) -> str:
return self._current_satellite
@property
def current_frequency(self) -> float:
return self._current_frequency
def _detect_decoder(self) -> str | None:
"""Detect which weather satellite decoder is available."""
if shutil.which('satdump'):
logger.info("SatDump decoder detected")
return 'satdump'
logger.warning(
"SatDump not found. Install SatDump for weather satellite decoding. "
"See: https://github.com/SatDump/SatDump"
)
return None
def set_callback(self, callback: Callable[[CaptureProgress], None]) -> None:
"""Set callback for capture progress updates."""
self._callback = callback
def start(
self,
satellite: str,
device_index: int = 0,
gain: float = 40.0,
sample_rate: int = DEFAULT_SAMPLE_RATE,
bias_t: bool = False,
) -> bool:
"""Start weather satellite capture and decode.
Args:
satellite: Satellite key (e.g. 'NOAA-18', 'METEOR-M2-3')
device_index: RTL-SDR device index
gain: SDR gain in dB
sample_rate: Sample rate in Hz
bias_t: Enable bias-T power for LNA
Returns:
True if started successfully
"""
with self._lock:
if self._running:
return True
if not self._decoder:
logger.error("No weather satellite decoder available")
self._emit_progress(CaptureProgress(
status='error',
message='SatDump not installed. Build from source or install via package manager.'
))
return False
sat_info = WEATHER_SATELLITES.get(satellite)
if not sat_info:
logger.error(f"Unknown satellite: {satellite}")
self._emit_progress(CaptureProgress(
status='error',
message=f'Unknown satellite: {satellite}'
))
return False
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()
try:
self._start_satdump(sat_info, device_index, gain, sample_rate, bias_t)
self._running = True
logger.info(
f"Weather satellite capture started: {satellite} "
f"({sat_info['frequency']} MHz, {sat_info['mode']})"
)
self._emit_progress(CaptureProgress(
status='capturing',
satellite=satellite,
frequency=sat_info['frequency'],
mode=sat_info['mode'],
message=f"Capturing {sat_info['name']} on {sat_info['frequency']} MHz ({sat_info['mode']})..."
))
return True
except Exception as e:
logger.error(f"Failed to start weather satellite capture: {e}")
self._emit_progress(CaptureProgress(
status='error',
satellite=satellite,
message=str(e)
))
return False
def _start_satdump(
self,
sat_info: dict,
device_index: int,
gain: float,
sample_rate: int,
bias_t: bool,
) -> None:
"""Start SatDump live capture and decode."""
# Create timestamped output directory for this capture
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
sat_name = sat_info['tle_key'].replace(' ', '_')
self._capture_output_dir = self._output_dir / f"{sat_name}_{timestamp}"
self._capture_output_dir.mkdir(parents=True, exist_ok=True)
freq_hz = int(sat_info['frequency'] * 1_000_000)
cmd = [
'satdump', 'live',
sat_info['pipeline'],
'rtlsdr',
'--source_id', str(device_index),
'--frequency', str(freq_hz),
'--samplerate', str(sample_rate),
'--gain', str(gain),
'--output_folder', str(self._capture_output_dir),
]
if bias_t:
cmd.append('--bias')
logger.info(f"Starting SatDump: {' '.join(cmd)}")
self._process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
# Start reader thread to monitor output
self._reader_thread = threading.Thread(
target=self._read_satdump_output, daemon=True
)
self._reader_thread.start()
# Start image watcher thread
self._watcher_thread = threading.Thread(
target=self._watch_images, daemon=True
)
self._watcher_thread.start()
def _read_satdump_output(self) -> None:
"""Read SatDump stdout/stderr for progress updates."""
if not self._process or not self._process.stdout:
return
try:
for line in iter(self._process.stdout.readline, ''):
if not self._running:
break
line = line.strip()
if not line:
continue
logger.debug(f"satdump: {line}")
# Parse progress from SatDump output
elapsed = int(time.time() - self._capture_start_time)
# SatDump outputs progress info - parse key indicators
if 'Progress' in line or 'progress' in line:
# Try to extract percentage
match = re.search(r'(\d+(?:\.\d+)?)\s*%', line)
pct = int(float(match.group(1))) if match else 0
self._emit_progress(CaptureProgress(
status='decoding',
satellite=self._current_satellite,
frequency=self._current_frequency,
mode=self._current_mode,
message=line,
progress_percent=pct,
elapsed_seconds=elapsed,
))
elif 'Saved' in line or 'saved' in line or 'Writing' in line:
self._emit_progress(CaptureProgress(
status='decoding',
satellite=self._current_satellite,
frequency=self._current_frequency,
mode=self._current_mode,
message=line,
elapsed_seconds=elapsed,
))
elif 'error' in line.lower() or 'fail' in line.lower():
self._emit_progress(CaptureProgress(
status='capturing',
satellite=self._current_satellite,
frequency=self._current_frequency,
mode=self._current_mode,
message=line,
elapsed_seconds=elapsed,
))
else:
# Generic progress update every ~10 seconds
if elapsed % 10 == 0:
self._emit_progress(CaptureProgress(
status='capturing',
satellite=self._current_satellite,
frequency=self._current_frequency,
mode=self._current_mode,
message=f"Capturing... ({elapsed}s elapsed)",
elapsed_seconds=elapsed,
))
except Exception as e:
logger.error(f"Error reading SatDump output: {e}")
finally:
# Process ended
if self._running:
self._running = False
elapsed = int(time.time() - self._capture_start_time)
self._emit_progress(CaptureProgress(
status='complete',
satellite=self._current_satellite,
frequency=self._current_frequency,
mode=self._current_mode,
message=f"Capture complete ({elapsed}s)",
elapsed_seconds=elapsed,
))
def _watch_images(self) -> None:
"""Watch output directory for new decoded images."""
if not self._capture_output_dir:
return
known_files: set[str] = set()
while self._running:
time.sleep(2)
try:
# Recursively scan for image files
for ext in ('*.png', '*.jpg', '*.jpeg'):
for filepath in self._capture_output_dir.rglob(ext):
if filepath.name in known_files:
continue
# Skip tiny files (likely incomplete)
try:
stat = filepath.stat()
if stat.st_size < 1000:
continue
except OSError:
continue
known_files.add(filepath.name)
# Determine product type from filename/path
product = self._parse_product_name(filepath)
# Copy image to main output dir for serving
serve_name = f"{self._current_satellite}_{filepath.stem}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
serve_path = self._output_dir / serve_name
try:
shutil.copy2(filepath, serve_path)
except OSError:
serve_path = filepath
serve_name = filepath.name
image = WeatherSatImage(
filename=serve_name,
path=serve_path,
satellite=self._current_satellite,
mode=self._current_mode,
timestamp=datetime.now(timezone.utc),
frequency=self._current_frequency,
size_bytes=stat.st_size,
product=product,
)
self._images.append(image)
logger.info(f"New weather satellite image: {serve_name} ({product})")
self._emit_progress(CaptureProgress(
status='complete',
satellite=self._current_satellite,
frequency=self._current_frequency,
mode=self._current_mode,
message=f'Image decoded: {product}',
image=image,
))
except Exception as e:
logger.error(f"Error watching images: {e}")
def _parse_product_name(self, filepath: Path) -> str:
"""Parse a human-readable product name from the image filepath."""
name = filepath.stem.lower()
parts = filepath.parts
# Common SatDump product names
if 'rgb' in name:
return 'RGB Composite'
if 'msa' in name or 'multispectral' in name:
return 'Multispectral Analysis'
if 'thermal' in name or 'temp' in name:
return 'Thermal'
if 'ndvi' in name:
return 'NDVI Vegetation'
if 'channel' in name or 'ch' in name:
match = re.search(r'(?:channel|ch)\s*(\d+)', name)
if match:
return f'Channel {match.group(1)}'
if 'avhrr' in name:
return 'AVHRR'
if 'msu' in name or 'mtvza' in name:
return 'MSU-MR'
# Check parent directories for clues
for part in parts:
if 'rgb' in part.lower():
return 'RGB Composite'
if 'channel' in part.lower():
return 'Channel Data'
return filepath.stem
def stop(self) -> None:
"""Stop weather satellite capture."""
with self._lock:
self._running = False
if self._process:
try:
self._process.terminate()
self._process.wait(timeout=5)
except subprocess.TimeoutExpired:
self._process.kill()
except Exception:
try:
self._process.kill()
except Exception:
pass
self._process = None
elapsed = int(time.time() - self._capture_start_time) if self._capture_start_time else 0
logger.info(f"Weather satellite capture stopped after {elapsed}s")
def get_images(self) -> list[WeatherSatImage]:
"""Get list of decoded images."""
self._scan_images()
return list(self._images)
def _scan_images(self) -> None:
"""Scan output directory for images not yet tracked."""
known_filenames = {img.filename for img in self._images}
for ext in ('*.png', '*.jpg', '*.jpeg'):
for filepath in self._output_dir.glob(ext):
if filepath.name in known_filenames:
continue
# Skip tiny files
try:
stat = filepath.stat()
if stat.st_size < 1000:
continue
except OSError:
continue
# Parse satellite name from filename
satellite = 'Unknown'
for sat_key in WEATHER_SATELLITES:
if sat_key in filepath.name:
satellite = sat_key
break
sat_info = WEATHER_SATELLITES.get(satellite, {})
image = WeatherSatImage(
filename=filepath.name,
path=filepath,
satellite=satellite,
mode=sat_info.get('mode', 'Unknown'),
timestamp=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
frequency=sat_info.get('frequency', 0.0),
size_bytes=stat.st_size,
product=self._parse_product_name(filepath),
)
self._images.append(image)
def delete_image(self, filename: str) -> bool:
"""Delete a decoded image."""
filepath = self._output_dir / filename
if filepath.exists():
try:
filepath.unlink()
self._images = [img for img in self._images if img.filename != filename]
return True
except OSError as e:
logger.error(f"Failed to delete image {filename}: {e}")
return False
def _emit_progress(self, progress: CaptureProgress) -> None:
"""Emit progress update to callback."""
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."""
elapsed = 0
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,
'elapsed_seconds': elapsed,
'image_count': len(self._images),
}
# Global decoder instance
_decoder: WeatherSatDecoder | None = None
def get_weather_sat_decoder() -> WeatherSatDecoder:
"""Get or create the global weather satellite decoder instance."""
global _decoder
if _decoder is None:
_decoder = WeatherSatDecoder()
return _decoder
def is_weather_sat_available() -> bool:
"""Check if weather satellite decoding is available."""
decoder = get_weather_sat_decoder()
return decoder.decoder_available is not None