diff --git a/.gitignore b/.gitignore
index 18ae397..bc822b4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,6 +55,9 @@ intercept_agent_*.cfg
/tmp/
*.tmp
+# Weather satellite runtime data (decoded images, samples, SatDump output)
+data/weather_sat/
+
# Env files
.env
.env.*
diff --git a/CLAUDE.md b/CLAUDE.md
index a885ee3..6e29477 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,11 +4,26 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
-INTERCEPT is a web-based Signal Intelligence (SIGINT) platform providing a unified Flask interface for software-defined radio (SDR) tools. It supports pager decoding, 433MHz sensors, ADS-B aircraft tracking, ACARS messaging, WiFi/Bluetooth scanning, and satellite tracking.
+INTERCEPT is a web-based Signal Intelligence (SIGINT) platform providing a unified Flask interface for software-defined radio (SDR) tools. It supports pager decoding, 433MHz sensors, ADS-B aircraft tracking, ACARS messaging, WiFi/Bluetooth scanning, satellite tracking, ISS SSTV decoding, AIS vessel tracking, weather satellite imagery (NOAA APT & Meteor LRPT), and Meshtastic mesh networking.
## Common Commands
-### Setup and Running
+### Docker (Primary)
+```bash
+# Build and run (basic profile)
+docker compose --profile basic up -d
+
+# Build and run with ADS-B history (Postgres)
+docker compose --profile history up -d
+
+# Rebuild after code changes
+docker compose --profile basic up -d --build
+
+# Multi-arch build (amd64 + arm64 for RPi)
+./build-multiarch.sh
+```
+
+### Local Setup (Alternative)
```bash
# Initial setup (installs dependencies and configures SDR tools)
./setup.sh
@@ -66,8 +81,12 @@ Each signal type has its own Flask blueprint:
- `wifi.py`, `wifi_v2.py` - WiFi scanning (legacy and unified APIs)
- `bluetooth.py`, `bluetooth_v2.py` - Bluetooth scanning (legacy and unified APIs)
- `satellite.py` - Pass prediction using TLE data
+- `sstv.py` - ISS SSTV image decoding via slowrx
+- `weather_sat.py` - NOAA APT & Meteor LRPT via SatDump
+- `ais.py` - AIS vessel tracking and VHF DSC distress monitoring
- `aprs.py` - Amateur packet radio via direwolf
- `rtlamr.py` - Utility meter reading
+- `meshtastic_routes.py` - Meshtastic LoRa mesh networking
### Core Utilities (utils/)
@@ -91,6 +110,15 @@ Each signal type has its own Flask blueprint:
- Platform-agnostic scanner with parsers for airodump-ng, nmcli, iw, iwlist, airport (macOS)
- `channel_analyzer.py` - Frequency band analysis
+**Weather Satellite** (`utils/weather_sat.py`):
+- Singleton `WeatherSatDecoder` using SatDump CLI for NOAA APT and Meteor LRPT
+- Subprocess management with stdout parsing, image watcher via rglob
+- Pass prediction using skyfield TLE data
+
+**SSTV Decoder** (`utils/sstv.py`):
+- ISS SSTV reception via slowrx with Doppler tracking
+- Singleton pattern, image gallery with timestamped filenames
+
### Key Patterns
**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages.
@@ -112,9 +140,25 @@ Each signal type has its own Flask blueprint:
| acarsdec | ACARS messages | Output parsing |
| airmon-ng/airodump-ng | WiFi scanning | Monitor mode, CSV parsing |
| bluetoothctl/hcitool | Bluetooth | Fallback when DBus unavailable |
+| slowrx | SSTV decoding | Subprocess with audio pipe |
+| SatDump | Weather satellites | CLI live mode, NOAA APT + Meteor LRPT |
+| AIS-catcher | AIS vessel tracking | JSON output parsing |
+| direwolf | APRS | TNC modem for packet radio |
+
+### Frontend Structure
+- **Templates**: `templates/index.html` (main SPA), `templates/partials/modes/*.html` (sidebar panels), `templates/partials/nav.html` (global nav)
+- **JS Modules**: `static/js/modes/*.js` - IIFE pattern per mode (e.g., `WeatherSat`, `SSTV`, `Meshtastic`)
+- **CSS**: `static/css/modes/*.css` - scoped styles per mode, CSS variables for theming (`--bg-card`, `--accent-cyan`, `--font-mono`)
+- **Mode Integration**: Each mode needs entries in `index.html` at ~12 points: CSS include, welcome card, partial include, visuals container, JS include, `validModes` set, `modeGroups` map, classList toggle, `modeNames`, visuals display toggle, titles, and init call in `switchMode()`
+
+### Docker
+- `Dockerfile` - Single-stage build with all SDR tools compiled from source (dump1090, AIS-catcher, slowrx, SatDump, etc.)
+- `docker-compose.yml` - Two profiles: `basic` (standalone) and `history` (with Postgres for ADS-B)
+- `build-multiarch.sh` - Multi-arch build script for amd64 + arm64 (RPi5)
+- Data persisted via `./data:/app/data` volume mount
### Configuration
-- `config.py` - Environment variable support with `INTERCEPT_` prefix
+- `config.py` - Environment variable support with `INTERCEPT_` prefix (e.g., `INTERCEPT_PORT`, `INTERCEPT_WEATHER_SAT_GAIN`)
- Database: SQLite in `instance/` directory for settings, baselines, history
## Testing Notes
diff --git a/Dockerfile b/Dockerfile
index 0b45b00..7068f91 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -24,6 +24,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
multimon-ng \
# Audio tools for Listening Post
ffmpeg \
+ # SSTV decoder runtime libs
+ libsndfile1 \
+ # SatDump runtime libs (weather satellite decoding)
+ libpng16-16 \
+ libtiff6 \
+ libjemalloc2 \
+ libvolk-bin \
+ libnng1 \
+ libzstd1 \
# WiFi tools (aircrack-ng suite)
aircrack-ng \
iw \
@@ -61,9 +70,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
cmake \
libncurses-dev \
libsndfile1-dev \
+ # GTK is required for slowrx (SSTV decoder GUI dependency).
+ # Note: slowrx is kept for backwards compatibility, but the pure Python
+ # SSTV decoder in utils/sstv/ is now the primary implementation.
+ # GTK can be removed if slowrx is deprecated in future releases.
+ libgtk-3-dev \
+ libasound2-dev \
libsoapysdr-dev \
libhackrf-dev \
liblimesuite-dev \
+ libfftw3-dev \
+ libpng-dev \
+ libtiff-dev \
+ libjemalloc-dev \
+ libvolk-dev \
+ libnng-dev \
+ libzstd-dev \
libsqlite3-dev \
libcurl4-openssl-dev \
zlib1g-dev \
@@ -118,6 +140,43 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& make \
&& cp acarsdec /usr/bin/acarsdec \
&& rm -rf /tmp/acarsdec \
+ # Build slowrx (SSTV decoder) — pinned to known-good commit
+ && cd /tmp \
+ && git clone https://github.com/windytan/slowrx.git \
+ && cd slowrx \
+ && git checkout ca6d7012 \
+ && make \
+ && install -m 0755 slowrx /usr/local/bin/slowrx \
+ && rm -rf /tmp/slowrx \
+ # Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) — pinned to v1.2.2
+ && cd /tmp \
+ && git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \
+ && cd SatDump \
+ && mkdir build && cd build \
+ && cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. \
+ && make -j$(nproc) \
+ && make install \
+ && ldconfig \
+ # Ensure SatDump plugins are in the expected path (handles multiarch differences)
+ && mkdir -p /usr/local/lib/satdump/plugins \
+ && if [ -z "$(ls /usr/local/lib/satdump/plugins/*.so 2>/dev/null)" ]; then \
+ for dir in /usr/local/lib/*/satdump/plugins /usr/lib/*/satdump/plugins /usr/lib/satdump/plugins; do \
+ if [ -d "$dir" ] && [ -n "$(ls "$dir"/*.so 2>/dev/null)" ]; then \
+ ln -sf "$dir"/*.so /usr/local/lib/satdump/plugins/; \
+ break; \
+ fi; \
+ done; \
+ fi \
+ && cd /tmp \
+ && rm -rf /tmp/SatDump \
+ # Build rtlamr (utility meter decoder - requires Go)
+ && cd /tmp \
+ && curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
+ && export PATH="$PATH:/usr/local/go/bin" \
+ && export GOPATH=/tmp/gopath \
+ && go install github.com/bemasher/rtlamr@latest \
+ && cp /tmp/gopath/bin/rtlamr /usr/bin/rtlamr \
+ && rm -rf /usr/local/go /tmp/gopath \
# Build mbelib (required by DSD)
&& cd /tmp \
&& git clone https://github.com/lwvmobile/mbelib.git \
@@ -140,6 +199,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& ldconfig \
&& rm -rf /tmp/dsd-fme \
# Cleanup build tools to reduce image size
+ # libgtk-3-dev is explicitly removed; runtime GTK libs remain for slowrx
&& apt-get remove -y \
build-essential \
git \
@@ -147,6 +207,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
cmake \
libncurses-dev \
libsndfile1-dev \
+ libgtk-3-dev \
+ libasound2-dev \
+ libpng-dev \
+ libtiff-dev \
+ libjemalloc-dev \
+ libvolk-dev \
+ libnng-dev \
+ libzstd-dev \
libsoapysdr-dev \
libhackrf-dev \
liblimesuite-dev \
@@ -169,7 +237,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
diff --git a/README.md b/README.md
index e3ad0d6..1ae0fe8 100644
--- a/README.md
+++ b/README.md
@@ -33,8 +33,9 @@ Support the developer of this open-source project
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
- **DMR Digital Voice** - DMR/P25/NXDN/D-STAR decoding via dsd-fme with visual synthesizer
- **Listening Post** - Frequency scanner with audio monitoring
+- **Weather Satellites** - NOAA APT and Meteor LRPT image decoding via SatDump
- **WebSDR** - Remote HF/shortwave listening via WebSDR servers
-- **ISS SSTV** - Receive slow-scan TV from the International Space Station
+- **ISS SSTV** - Slow-scan TV image reception from the International Space Station
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies
- **Satellite Tracking** - Pass prediction using TLE data
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
@@ -60,15 +61,54 @@ cd intercept
sudo -E venv/bin/python intercept.py
```
-### Docker (Alternative)
+### Docker
```bash
git clone https://github.com/smittix/intercept.git
cd intercept
-docker compose up -d
+docker compose --profile basic up -d --build
```
-> **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options.
+> **Note:** Docker requires privileged mode for USB SDR access. SDR devices are passed through via `/dev/bus/usb`.
+
+#### Multi-Architecture Builds (amd64 + arm64)
+
+Cross-compile on an x64 machine and push to a registry. This is much faster than building natively on an RPi.
+
+```bash
+# One-time setup on your x64 build machine
+docker run --privileged --rm tonistiigi/binfmt --install all
+docker buildx create --name intercept-builder --use --bootstrap
+
+# Build and push for both architectures
+REGISTRY=ghcr.io/youruser ./build-multiarch.sh --push
+
+# On the RPi5, just pull and run
+INTERCEPT_IMAGE=ghcr.io/youruser/intercept:latest docker compose --profile basic up -d
+```
+
+Build script options:
+
+| Flag | Description |
+|------|-------------|
+| `--push` | Push to container registry |
+| `--load` | Load into local Docker (single platform only) |
+| `--arm64-only` | Build arm64 only (for RPi deployment) |
+| `--amd64-only` | Build amd64 only |
+
+Environment variables: `REGISTRY`, `IMAGE_NAME`, `IMAGE_TAG`
+
+#### Using a Pre-built Image
+
+If you've pushed to a registry, you can skip building entirely on the target machine:
+
+```bash
+# Set in .env or export
+INTERCEPT_IMAGE=ghcr.io/youruser/intercept:latest
+
+# Then just run
+docker compose --profile basic up -d
+```
### ADS-B History (Optional)
@@ -200,6 +240,7 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
[acarsdec](https://github.com/TLeconte/acarsdec) |
[aircrack-ng](https://www.aircrack-ng.org/) |
[Leaflet.js](https://leafletjs.com/) |
+[SatDump](https://github.com/SatDump/SatDump) |
[Celestrak](https://celestrak.org/) |
[Priyom.org](https://priyom.org/)
diff --git a/app.py b/app.py
index cb6bab4..7a82bd5 100644
--- a/app.py
+++ b/app.py
@@ -27,7 +27,7 @@ from typing import Any
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session
from werkzeug.security import check_password_hash
-from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED
+from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
from utils.process import cleanup_stale_processes
from utils.sdr import SDRFactory
@@ -370,6 +370,8 @@ def index() -> str:
version=VERSION,
changelog=CHANGELOG,
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
+ default_latitude=DEFAULT_LATITUDE,
+ default_longitude=DEFAULT_LONGITUDE,
)
@@ -689,7 +691,7 @@ def kill_all() -> Response:
'rtl_fm', 'multimon-ng', 'rtl_433',
'airodump-ng', 'aireplay-ng', 'airmon-ng',
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
- 'hcitool', 'bluetoothctl', 'dsd',
+ 'hcitool', 'bluetoothctl', 'satdump', 'dsd',
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg'
]
@@ -867,6 +869,15 @@ def main() -> None:
from routes import register_blueprints
register_blueprints(app)
+ # Initialize TLE auto-refresh (must be after blueprint registration)
+ try:
+ from routes.satellite import init_tle_auto_refresh
+ import os
+ if not os.environ.get('TESTING'):
+ init_tle_auto_refresh()
+ except Exception as e:
+ logger.warning(f"Failed to initialize TLE auto-refresh: {e}")
+
# Update TLE data in background thread (non-blocking)
def update_tle_background():
try:
diff --git a/build-multiarch.sh b/build-multiarch.sh
new file mode 100755
index 0000000..4798be8
--- /dev/null
+++ b/build-multiarch.sh
@@ -0,0 +1,139 @@
+#!/bin/bash
+# INTERCEPT - Multi-architecture Docker image builder
+#
+# Builds for both linux/amd64 and linux/arm64 using Docker buildx.
+# Run this on your x64 machine to cross-compile the arm64 image
+# instead of building natively on the RPi5.
+#
+# Prerequisites (one-time setup):
+# docker run --privileged --rm tonistiigi/binfmt --install all
+# docker buildx create --name intercept-builder --use --bootstrap
+#
+# Usage:
+# ./build-multiarch.sh # Build both platforms, load locally
+# ./build-multiarch.sh --push # Build and push to registry
+# ./build-multiarch.sh --arm64-only # Build arm64 only (for RPi)
+# REGISTRY=ghcr.io/user ./build-multiarch.sh --push
+#
+# Environment variables:
+# REGISTRY - Container registry (default: docker.io/library)
+# IMAGE_NAME - Image name (default: intercept)
+# IMAGE_TAG - Image tag (default: latest)
+
+set -euo pipefail
+
+# Configuration
+REGISTRY="${REGISTRY:-}"
+IMAGE_NAME="${IMAGE_NAME:-intercept}"
+IMAGE_TAG="${IMAGE_TAG:-latest}"
+BUILDER_NAME="intercept-builder"
+PLATFORMS="linux/amd64,linux/arm64"
+
+# Parse arguments
+PUSH=false
+LOAD=false
+ARM64_ONLY=false
+
+for arg in "$@"; do
+ case $arg in
+ --push) PUSH=true ;;
+ --load) LOAD=true ;;
+ --arm64-only)
+ ARM64_ONLY=true
+ PLATFORMS="linux/arm64"
+ ;;
+ --amd64-only)
+ PLATFORMS="linux/amd64"
+ ;;
+ --help|-h)
+ echo "Usage: $0 [--push] [--load] [--arm64-only] [--amd64-only]"
+ echo ""
+ echo "Options:"
+ echo " --push Push to container registry"
+ echo " --load Load into local Docker (single platform only)"
+ echo " --arm64-only Build arm64 only (for RPi5 deployment)"
+ echo " --amd64-only Build amd64 only"
+ echo ""
+ echo "Environment variables:"
+ echo " REGISTRY Container registry (e.g. ghcr.io/username)"
+ echo " IMAGE_NAME Image name (default: intercept)"
+ echo " IMAGE_TAG Image tag (default: latest)"
+ echo ""
+ echo "Examples:"
+ echo " $0 --push # Build both, push"
+ echo " REGISTRY=ghcr.io/myuser $0 --push # Push to GHCR"
+ echo " $0 --arm64-only --load # Build arm64, load locally"
+ echo " $0 --arm64-only --push && ssh rpi docker pull # Build + deploy to RPi"
+ exit 0
+ ;;
+ *)
+ echo "Unknown option: $arg"
+ exit 1
+ ;;
+ esac
+done
+
+# Build full image reference
+if [ -n "$REGISTRY" ]; then
+ FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
+else
+ FULL_IMAGE="${IMAGE_NAME}:${IMAGE_TAG}"
+fi
+
+echo "============================================"
+echo " INTERCEPT Multi-Architecture Builder"
+echo "============================================"
+echo " Image: ${FULL_IMAGE}"
+echo " Platforms: ${PLATFORMS}"
+echo " Push: ${PUSH}"
+echo "============================================"
+echo ""
+
+# Check if buildx builder exists, create if not
+if ! docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
+ echo "Creating buildx builder: ${BUILDER_NAME}"
+ docker buildx create --name "$BUILDER_NAME" --use --bootstrap
+
+ # Check for QEMU support
+ if ! docker run --rm --privileged tonistiigi/binfmt --install all >/dev/null 2>&1; then
+ echo "WARNING: QEMU binfmt setup may have failed."
+ echo "Run: docker run --privileged --rm tonistiigi/binfmt --install all"
+ fi
+else
+ docker buildx use "$BUILDER_NAME"
+fi
+
+# Build command
+BUILD_CMD="docker buildx build --platform ${PLATFORMS} --tag ${FULL_IMAGE}"
+
+if [ "$PUSH" = true ]; then
+ BUILD_CMD="${BUILD_CMD} --push"
+ echo "Will push to: ${FULL_IMAGE}"
+elif [ "$LOAD" = true ]; then
+ # --load only works with single platform
+ if echo "$PLATFORMS" | grep -q ","; then
+ echo "ERROR: --load only works with a single platform."
+ echo "Use --arm64-only or --amd64-only with --load."
+ exit 1
+ fi
+ BUILD_CMD="${BUILD_CMD} --load"
+ echo "Will load into local Docker"
+fi
+
+echo ""
+echo "Building..."
+echo "Command: ${BUILD_CMD} ."
+echo ""
+
+$BUILD_CMD .
+
+echo ""
+echo "============================================"
+echo " Build complete!"
+if [ "$PUSH" = true ]; then
+ echo " Image pushed to: ${FULL_IMAGE}"
+ echo ""
+ echo " Pull on RPi5:"
+ echo " docker pull ${FULL_IMAGE}"
+fi
+echo "============================================"
diff --git a/config.py b/config.py
index 546d941..973a87f 100644
--- a/config.py
+++ b/config.py
@@ -211,12 +211,22 @@ ADSB_HISTORY_QUEUE_SIZE = _get_env_int('ADSB_HISTORY_QUEUE_SIZE', 50000)
# Observer location settings
SHARED_OBSERVER_LOCATION_ENABLED = _get_env_bool('SHARED_OBSERVER_LOCATION', True)
+DEFAULT_LATITUDE = _get_env_float('DEFAULT_LAT', 0.0)
+DEFAULT_LONGITUDE = _get_env_float('DEFAULT_LON', 0.0)
# Satellite settings
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)
+WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEATHER_SAT_SCHEDULE_REFRESH_MINUTES', 30)
+WEATHER_SAT_CAPTURE_BUFFER_SECONDS = _get_env_int('WEATHER_SAT_CAPTURE_BUFFER_SECONDS', 30)
+
# Update checking
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
diff --git a/docker-compose.yml b/docker-compose.yml
index 3636b4a..b6318ba 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,27 +1,31 @@
# INTERCEPT - Signal Intelligence Platform
# Docker Compose configuration for easy deployment
#
-# Basic usage:
-# docker compose up -d
+# Basic usage (build locally):
+# docker compose --profile basic up -d --build
+#
+# Basic usage (pre-built image from registry):
+# INTERCEPT_IMAGE=ghcr.io/user/intercept:latest docker compose --profile basic up -d
#
# With ADS-B history (Postgres):
# docker compose --profile history up -d
services:
intercept:
+ # When INTERCEPT_IMAGE is set, use that pre-built image; otherwise build locally
+ image: ${INTERCEPT_IMAGE:-intercept:latest}
build: .
container_name: intercept
ports:
- "5050:5050"
# Privileged mode required for USB SDR device access
- # Alternatively, use device mapping (see below)
privileged: true
- # USB device mapping (alternative to privileged mode)
- # devices:
- # - /dev/bus/usb:/dev/bus/usb
- # volumes:
- # Persist data directory
- # - ./data:/app/data
+ # USB device mapping for all USB devices
+ devices:
+ - /dev/bus/usb:/dev/bus/usb
+ volumes:
+ # Persist decoded images and database across container rebuilds
+ - ./data:/app/data
# Optional: mount logs directory
# - ./logs:/app/logs
environment:
@@ -40,6 +44,9 @@ services:
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
# Shared observer location across modules
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
+ # Default observer coordinates (set to your location to skip the GPS prompt)
+ # - INTERCEPT_DEFAULT_LAT=${INTERCEPT_DEFAULT_LAT:-0}
+ # - INTERCEPT_DEFAULT_LON=${INTERCEPT_DEFAULT_LON:-0}
# Network mode for WiFi scanning (requires host network)
# network_mode: host
restart: unless-stopped
@@ -53,15 +60,23 @@ services:
# ADS-B history with Postgres persistence
# Enable with: docker compose --profile history up -d
intercept-history:
+ # Same image/build fallback pattern as above
+ image: ${INTERCEPT_IMAGE:-intercept:latest}
build: .
- container_name: intercept
+ container_name: intercept-history
profiles:
- history
depends_on:
- adsb_db
ports:
- "5050:5050"
+ # Privileged mode required for USB SDR device access
privileged: true
+ # USB device mapping for all USB devices
+ devices:
+ - /dev/bus/usb:/dev/bus/usb
+ volumes:
+ - ./data:/app/data
environment:
- INTERCEPT_HOST=0.0.0.0
- INTERCEPT_PORT=5050
@@ -76,6 +91,9 @@ services:
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
# Shared observer location across modules
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
+ # Default observer coordinates (set to your location to skip the GPS prompt)
+ # - INTERCEPT_DEFAULT_LAT=${INTERCEPT_DEFAULT_LAT:-0}
+ # - INTERCEPT_DEFAULT_LON=${INTERCEPT_DEFAULT_LON:-0}
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:5050/health"]
diff --git a/download-weather-sat-samples.sh b/download-weather-sat-samples.sh
new file mode 100755
index 0000000..ce13900
--- /dev/null
+++ b/download-weather-sat-samples.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+# Download sample NOAA APT recordings for testing the weather satellite
+# test-decode feature. These are FM-demodulated audio WAV files.
+#
+# Usage:
+# ./download-weather-sat-samples.sh
+# docker exec intercept /app/download-weather-sat-samples.sh
+
+set -euo pipefail
+
+SAMPLE_DIR="$(dirname "$0")/data/weather_sat/samples"
+mkdir -p "$SAMPLE_DIR"
+
+echo "Downloading NOAA APT sample files to $SAMPLE_DIR ..."
+
+# Full satellite pass recorded over Argentina (NOAA, 11025 Hz mono WAV)
+# Source: https://github.com/martinber/noaa-apt
+if [ ! -f "$SAMPLE_DIR/noaa_apt_argentina.wav" ]; then
+ echo " -> noaa_apt_argentina.wav (18 MB) ..."
+ curl -fSL -o "$SAMPLE_DIR/noaa_apt_argentina.wav" \
+ "https://noaa-apt.mbernardi.com.ar/examples/argentina.wav"
+else
+ echo " -> noaa_apt_argentina.wav (already exists)"
+fi
+
+echo ""
+echo "Done. Test decode with:"
+echo " Satellite: NOAA-18"
+echo " File path: data/weather_sat/samples/noaa_apt_argentina.wav"
+echo " Sample rate: 11025 Hz"
diff --git a/routes/__init__.py b/routes/__init__.py
index 0ac1fc2..0cb7e1a 100644
--- a/routes/__init__.py
+++ b/routes/__init__.py
@@ -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
from .sstv_general import sstv_general_bp
from .dmr import dmr_bp
from .websdr import websdr_bp
@@ -56,6 +57,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
app.register_blueprint(sstv_general_bp) # General terrestrial SSTV
app.register_blueprint(dmr_bp) # DMR / P25 / Digital Voice
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
diff --git a/routes/satellite.py b/routes/satellite.py
index 8fb9054..e3dbefa 100644
--- a/routes/satellite.py
+++ b/routes/satellite.py
@@ -31,6 +31,23 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www
_tle_cache = dict(TLE_SATELLITES)
+def init_tle_auto_refresh():
+ """Initialize TLE auto-refresh. Called by app.py after initialization."""
+ import threading
+
+ def _auto_refresh_tle():
+ try:
+ updated = refresh_tle_data()
+ if updated:
+ logger.info(f"Auto-refreshed TLE data for: {', '.join(updated)}")
+ except Exception as e:
+ logger.warning(f"Auto TLE refresh failed: {e}")
+
+ # Start auto-refresh in background
+ threading.Timer(2.0, _auto_refresh_tle).start()
+ logger.info("TLE auto-refresh scheduled")
+
+
def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Optional[float] = None) -> Optional[dict]:
"""
Fetch real-time ISS position from external APIs.
@@ -481,7 +498,8 @@ def update_tle():
'updated': updated
})
except Exception as e:
- return jsonify({'status': 'error', 'message': str(e)})
+ logger.error(f"Error updating TLE data: {e}")
+ return jsonify({'status': 'error', 'message': 'TLE update failed'})
@satellite_bp.route('/celestrak/')
@@ -535,4 +553,5 @@ def fetch_celestrak(category):
})
except Exception as e:
- return jsonify({'status': 'error', 'message': str(e)})
+ logger.error(f"Error fetching CelesTrak data: {e}")
+ return jsonify({'status': 'error', 'message': 'Failed to fetch satellite data'})
diff --git a/routes/weather_sat.py b/routes/weather_sat.py
new file mode 100644
index 0000000..f7ae788
--- /dev/null
+++ b/routes/weather_sat.py
@@ -0,0 +1,626 @@
+"""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
+
+from flask import Blueprint, jsonify, request, Response, send_file
+
+from utils.logging import get_logger
+from utils.sse import sse_stream
+from utils.validation import validate_device_index, validate_gain, validate_latitude, validate_longitude, validate_elevation
+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 and gain
+ try:
+ device_index = validate_device_index(data.get('device', 0))
+ gain = validate_gain(data.get('gain', 40.0))
+ except ValueError as e:
+ logger.warning('Invalid parameter in start_capture: %s', e)
+ return jsonify({
+ 'status': 'error',
+ 'message': 'Invalid parameter value'
+ }), 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 on-complete handler for SDR release
+ decoder.set_callback(_progress_callback)
+
+ def _release_device():
+ try:
+ import app as app_module
+ app_module.release_sdr_device(device_index)
+ except ImportError:
+ pass
+
+ decoder.set_on_complete(_release_device)
+
+ 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
+ _release_device()
+ return jsonify({
+ 'status': 'error',
+ 'message': 'Failed to start capture'
+ }), 500
+
+
+@weather_sat_bp.route('/test-decode', methods=['POST'])
+def test_decode():
+ """Start weather satellite decode from a pre-recorded file.
+
+ No SDR hardware is required — decodes an IQ baseband or WAV file
+ using SatDump offline mode.
+
+ JSON body:
+ {
+ "satellite": "NOAA-18", // Required: satellite key
+ "input_file": "/path/to/file", // Required: server-side file path
+ "sample_rate": 1000000 // Sample rate in Hz (default: 1000000)
+ }
+
+ 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 input file
+ input_file = data.get('input_file')
+ if not input_file:
+ return jsonify({
+ 'status': 'error',
+ 'message': 'input_file is required'
+ }), 400
+
+ from pathlib import Path
+ input_path = Path(input_file)
+
+ # Security: restrict to data directory (anchored to app root, not CWD)
+ allowed_base = Path(__file__).resolve().parent.parent / 'data'
+ try:
+ resolved = input_path.resolve()
+ if not resolved.is_relative_to(allowed_base):
+ return jsonify({
+ 'status': 'error',
+ 'message': 'input_file must be under the data/ directory'
+ }), 403
+ except (OSError, ValueError):
+ return jsonify({
+ 'status': 'error',
+ 'message': 'Invalid file path'
+ }), 400
+
+ if not input_path.is_file():
+ logger.warning("Test-decode file not found")
+ return jsonify({
+ 'status': 'error',
+ 'message': 'File not found'
+ }), 404
+
+ # Validate sample rate
+ sample_rate = data.get('sample_rate', 1000000)
+ try:
+ sample_rate = int(sample_rate)
+ if sample_rate < 1000 or sample_rate > 20000000:
+ raise ValueError
+ except (TypeError, ValueError):
+ return jsonify({
+ 'status': 'error',
+ 'message': 'Invalid sample_rate (1000-20000000)'
+ }), 400
+
+ # Clear queue
+ while not _weather_sat_queue.empty():
+ try:
+ _weather_sat_queue.get_nowait()
+ except queue.Empty:
+ break
+
+ # Set callback — no on_complete needed (no SDR to release)
+ decoder.set_callback(_progress_callback)
+ decoder.set_on_complete(None)
+
+ success = decoder.start_from_file(
+ satellite=satellite,
+ input_file=input_file,
+ sample_rate=sample_rate,
+ )
+
+ if success:
+ sat_info = WEATHER_SATELLITES[satellite]
+ return jsonify({
+ 'status': 'started',
+ 'satellite': satellite,
+ 'frequency': sat_info['frequency'],
+ 'mode': sat_info['mode'],
+ 'source': 'file',
+ 'input_file': str(input_file),
+ })
+ else:
+ return jsonify({
+ 'status': 'error',
+ 'message': 'Failed to start file decode'
+ }), 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/')
+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/', 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('/images', methods=['DELETE'])
+def delete_all_images():
+ """Delete all decoded weather satellite images.
+
+ Returns:
+ JSON with count of deleted images.
+ """
+ decoder = get_weather_sat_decoder()
+ count = decoder.delete_all_images()
+ return jsonify({'status': 'ok', 'deleted': count})
+
+
+@weather_sat_bp.route('/stream')
+def stream_progress():
+ """SSE stream of capture/decode progress.
+
+ Returns:
+ SSE stream (text/event-stream)
+ """
+ response = Response(sse_stream(_weather_sat_queue), 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)
+ trajectory: Include az/el trajectory points (default: false)
+ ground_track: Include lat/lon ground track points (default: false)
+
+ Returns:
+ JSON with upcoming passes for all weather satellites.
+ """
+ include_trajectory = request.args.get('trajectory', 'false').lower() in ('true', '1')
+ include_ground_track = request.args.get('ground_track', 'false').lower() in ('true', '1')
+
+ raw_lat = request.args.get('latitude')
+ raw_lon = request.args.get('longitude')
+
+ if raw_lat is None or raw_lon is None:
+ return jsonify({
+ 'status': 'error',
+ 'message': 'latitude and longitude parameters required'
+ }), 400
+
+ try:
+ lat = validate_latitude(raw_lat)
+ lon = validate_longitude(raw_lon)
+ except ValueError as e:
+ logger.warning('Invalid coordinates in get_passes: %s', e)
+ return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400
+
+ hours = max(1, min(request.args.get('hours', 24, type=int), 72))
+ min_elevation = max(0, min(request.args.get('min_elevation', 15, type=float), 90))
+
+ try:
+ from utils.weather_sat_predict import predict_passes
+
+ all_passes = predict_passes(
+ lat=lat,
+ lon=lon,
+ hours=hours,
+ min_elevation=min_elevation,
+ include_trajectory=include_trajectory,
+ include_ground_track=include_ground_track,
+ )
+
+ 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': 'Pass prediction failed'
+ }), 500
+
+
+# ========================
+# Auto-Scheduler Endpoints
+# ========================
+
+
+def _scheduler_event_callback(event: dict) -> None:
+ """Forward scheduler events to the SSE queue."""
+ try:
+ _weather_sat_queue.put_nowait(event)
+ except queue.Full:
+ try:
+ _weather_sat_queue.get_nowait()
+ _weather_sat_queue.put_nowait(event)
+ except queue.Empty:
+ pass
+
+
+@weather_sat_bp.route('/schedule/enable', methods=['POST'])
+def enable_schedule():
+ """Enable auto-scheduling of weather satellite captures.
+
+ JSON body:
+ {
+ "latitude": 51.5, // Required
+ "longitude": -0.1, // Required
+ "min_elevation": 15, // Minimum pass elevation (default: 15)
+ "device": 0, // RTL-SDR device index (default: 0)
+ "gain": 40.0, // SDR gain (default: 40)
+ "bias_t": false // Enable bias-T (default: false)
+ }
+
+ Returns:
+ JSON with scheduler status.
+ """
+ from utils.weather_sat_scheduler import get_weather_sat_scheduler
+
+ data = request.get_json(silent=True) or {}
+
+ if data.get('latitude') is None or data.get('longitude') is None:
+ return jsonify({
+ 'status': 'error',
+ 'message': 'latitude and longitude required'
+ }), 400
+
+ try:
+ lat = validate_latitude(data.get('latitude'))
+ lon = validate_longitude(data.get('longitude'))
+ min_elev = validate_elevation(data.get('min_elevation', 15))
+ device = validate_device_index(data.get('device', 0))
+ gain_val = validate_gain(data.get('gain', 40.0))
+ except ValueError as e:
+ logger.warning('Invalid parameter in enable_schedule: %s', e)
+ return jsonify({
+ 'status': 'error',
+ 'message': 'Invalid parameter value'
+ }), 400
+
+ scheduler = get_weather_sat_scheduler()
+ scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
+
+ result = scheduler.enable(
+ lat=lat,
+ lon=lon,
+ min_elevation=min_elev,
+ device=device,
+ gain=gain_val,
+ bias_t=bool(data.get('bias_t', False)),
+ )
+
+ return jsonify({'status': 'ok', **result})
+
+
+@weather_sat_bp.route('/schedule/disable', methods=['POST'])
+def disable_schedule():
+ """Disable auto-scheduling."""
+ from utils.weather_sat_scheduler import get_weather_sat_scheduler
+
+ scheduler = get_weather_sat_scheduler()
+ result = scheduler.disable()
+ return jsonify(result)
+
+
+@weather_sat_bp.route('/schedule/status')
+def schedule_status():
+ """Get current scheduler state."""
+ from utils.weather_sat_scheduler import get_weather_sat_scheduler
+
+ scheduler = get_weather_sat_scheduler()
+ return jsonify(scheduler.get_status())
+
+
+@weather_sat_bp.route('/schedule/passes')
+def schedule_passes():
+ """List scheduled passes."""
+ from utils.weather_sat_scheduler import get_weather_sat_scheduler
+
+ scheduler = get_weather_sat_scheduler()
+ passes = scheduler.get_passes()
+ return jsonify({
+ 'status': 'ok',
+ 'passes': passes,
+ 'count': len(passes),
+ })
+
+
+@weather_sat_bp.route('/schedule/skip/', methods=['POST'])
+def skip_pass(pass_id: str):
+ """Skip a scheduled pass."""
+ from utils.weather_sat_scheduler import get_weather_sat_scheduler
+
+ if not pass_id.replace('_', '').replace('-', '').isalnum():
+ return jsonify({'status': 'error', 'message': 'Invalid pass ID'}), 400
+
+ scheduler = get_weather_sat_scheduler()
+ if scheduler.skip_pass(pass_id):
+ return jsonify({'status': 'skipped', 'pass_id': pass_id})
+ else:
+ return jsonify({'status': 'error', 'message': 'Pass not found or already processed'}), 404
diff --git a/static/css/modes/weather-satellite.css b/static/css/modes/weather-satellite.css
new file mode 100644
index 0000000..940b5f9
--- /dev/null
+++ b/static/css/modes/weather-satellite.css
@@ -0,0 +1,1083 @@
+/* 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);
+}
+
+/* ===== Auto-Schedule Toggle ===== */
+.wxsat-schedule-toggle {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ cursor: pointer;
+ font-size: 10px;
+ font-family: 'JetBrains Mono', monospace;
+ color: var(--text-dim, #666);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.wxsat-schedule-toggle input[type="checkbox"] {
+ width: 14px;
+ height: 14px;
+ cursor: pointer;
+ accent-color: #00ff88;
+}
+
+.wxsat-schedule-toggle input:checked + .wxsat-toggle-label {
+ color: #00ff88;
+}
+
+/* ===== 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: 12px;
+ padding: 12px;
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+}
+
+/* ===== Countdown Bar ===== */
+.wxsat-countdown-bar {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 10px 16px;
+ background: var(--bg-secondary, #141820);
+ border-bottom: 1px solid var(--border-color, #2a3040);
+}
+
+.wxsat-countdown-next {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-shrink: 0;
+}
+
+.wxsat-countdown-boxes {
+ display: flex;
+ gap: 4px;
+}
+
+.wxsat-countdown-box {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 4px 8px;
+ background: var(--bg-primary, #0d1117);
+ border: 1px solid var(--border-color, #2a3040);
+ border-radius: 4px;
+ min-width: 40px;
+}
+
+.wxsat-countdown-box.imminent {
+ border-color: #ffbb00;
+ box-shadow: 0 0 8px rgba(255, 187, 0, 0.2);
+}
+
+.wxsat-countdown-box.active {
+ border-color: #00ff88;
+ box-shadow: 0 0 8px rgba(0, 255, 136, 0.3);
+ animation: wxsat-glow 1.5s ease-in-out infinite;
+}
+
+@keyframes wxsat-glow {
+ 0%, 100% { box-shadow: 0 0 8px rgba(0, 255, 136, 0.3); }
+ 50% { box-shadow: 0 0 16px rgba(0, 255, 136, 0.5); }
+}
+
+.wxsat-cd-value {
+ font-size: 16px;
+ font-weight: 700;
+ font-family: 'JetBrains Mono', monospace;
+ color: var(--text-primary, #e0e0e0);
+ line-height: 1;
+}
+
+.wxsat-cd-unit {
+ font-size: 8px;
+ color: var(--text-dim, #666);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-top: 2px;
+}
+
+.wxsat-countdown-info {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.wxsat-countdown-sat {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--accent-cyan, #00d4ff);
+ font-family: 'JetBrains Mono', monospace;
+}
+
+.wxsat-countdown-detail {
+ font-size: 10px;
+ color: var(--text-dim, #666);
+ font-family: 'JetBrains Mono', monospace;
+}
+
+/* ===== Timeline ===== */
+.wxsat-timeline {
+ flex: 1;
+ position: relative;
+ height: 36px;
+ min-width: 200px;
+}
+
+.wxsat-timeline-track {
+ position: absolute;
+ top: 4px;
+ left: 0;
+ right: 0;
+ height: 16px;
+ background: var(--bg-primary, #0d1117);
+ border: 1px solid var(--border-color, #2a3040);
+ border-radius: 3px;
+ overflow: hidden;
+}
+
+.wxsat-timeline-pass {
+ position: absolute;
+ top: 0;
+ height: 100%;
+ border-radius: 2px;
+ cursor: pointer;
+ opacity: 0.8;
+ transition: opacity 0.2s;
+}
+
+.wxsat-timeline-pass:hover {
+ opacity: 1;
+}
+
+.wxsat-timeline-pass.apt { background: rgba(0, 212, 255, 0.6); }
+.wxsat-timeline-pass.lrpt { background: rgba(0, 255, 136, 0.6); }
+.wxsat-timeline-pass.scheduled { border: 1px solid #ffbb00; }
+
+.wxsat-timeline-cursor {
+ position: absolute;
+ top: 2px;
+ width: 2px;
+ height: 20px;
+ background: #ff4444;
+ border-radius: 1px;
+ z-index: 2;
+}
+
+.wxsat-timeline-labels {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ display: flex;
+ justify-content: space-between;
+ font-size: 8px;
+ color: var(--text-dim, #666);
+ font-family: 'JetBrains Mono', monospace;
+}
+
+/* ===== Pass Predictions Panel ===== */
+.wxsat-passes-panel {
+ flex: 0 0 280px;
+ 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,
+.wxsat-pass-card.selected {
+ border-color: #00ff88;
+ background: rgba(0, 255, 136, 0.05);
+}
+
+.wxsat-pass-card .wxsat-scheduled-badge {
+ display: inline-block;
+ font-size: 8px;
+ padding: 1px 4px;
+ border-radius: 2px;
+ background: rgba(255, 187, 0, 0.15);
+ color: #ffbb00;
+ margin-left: 6px;
+ font-family: 'JetBrains Mono', monospace;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.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;
+}
+
+/* ===== Center Panel (Polar + Map) ===== */
+.wxsat-center-panel {
+ flex: 0 0 320px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.wxsat-polar-container,
+.wxsat-map-container {
+ background: var(--bg-secondary, #141820);
+ border: 1px solid var(--border-color, #2a3040);
+ border-radius: 6px;
+ overflow: hidden;
+}
+
+.wxsat-panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 14px;
+ background: var(--bg-tertiary, #1a1f2e);
+ border-bottom: 1px solid var(--border-color, #2a3040);
+}
+
+.wxsat-panel-title {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-primary, #e0e0e0);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.wxsat-panel-subtitle {
+ font-size: 10px;
+ color: var(--accent-cyan, #00d4ff);
+ font-family: 'JetBrains Mono', monospace;
+}
+
+#wxsatPolarCanvas {
+ display: block;
+ width: 100%;
+ height: auto;
+ max-height: 300px;
+}
+
+.wxsat-ground-map {
+ height: 200px;
+ background: var(--bg-primary, #0d1117);
+}
+
+/* ===== 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;
+ gap: 8px;
+ 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(180px, 1fr));
+ gap: 12px;
+ align-content: start;
+}
+
+.wxsat-image-card {
+ position: relative;
+ 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-clickable {
+ display: block;
+}
+
+.wxsat-image-actions {
+ position: absolute;
+ top: 6px;
+ right: 6px;
+ opacity: 0;
+ transition: opacity 0.2s;
+ z-index: 2;
+}
+
+.wxsat-image-card:hover .wxsat-image-actions {
+ opacity: 1;
+}
+
+.wxsat-image-actions button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: none;
+ border-radius: 4px;
+ background: rgba(0, 0, 0, 0.7);
+ color: var(--text-secondary, #999);
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.wxsat-image-actions button:hover {
+ background: rgba(255, 68, 68, 0.9);
+ color: #fff;
+}
+
+.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;
+}
+
+/* Date group headers */
+.wxsat-date-header {
+ grid-column: 1 / -1;
+ font-size: 11px;
+ font-family: 'JetBrains Mono', monospace;
+ color: var(--text-dim, #666);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ padding: 8px 0 4px;
+ border-bottom: 1px solid var(--border-color, #2a3040);
+ margin-bottom: 4px;
+}
+
+.wxsat-date-header:first-child {
+ padding-top: 0;
+}
+
+/* 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;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ flex: 1;
+ margin-right: 12px;
+}
+
+.wxsat-capture-elapsed {
+ font-size: 11px;
+ color: var(--text-dim, #666);
+ font-family: 'JetBrains Mono', monospace;
+ flex-shrink: 0;
+}
+
+.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;
+}
+
+.wxsat-modal-toolbar {
+ position: absolute;
+ top: 16px;
+ left: 24px;
+ z-index: 10001;
+ display: flex;
+ gap: 8px;
+}
+
+.wxsat-modal-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ padding: 0;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 6px;
+ background: rgba(0, 0, 0, 0.6);
+ color: var(--text-secondary, #999);
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.wxsat-modal-btn.delete:hover {
+ background: rgba(255, 68, 68, 0.9);
+ border-color: #ff4444;
+ color: #fff;
+}
+
+/* Gallery clear-all button */
+.wxsat-gallery-clear-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-left: auto;
+ padding: 4px;
+ border: none;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--text-dim, #666);
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.wxsat-gallery-clear-btn:hover {
+ color: #ff4444;
+ background: rgba(255, 68, 68, 0.1);
+}
+
+/* ===== Responsive ===== */
+@media (max-width: 1100px) {
+ .wxsat-content {
+ flex-direction: column;
+ }
+
+ .wxsat-passes-panel {
+ flex: none;
+ max-height: 250px;
+ }
+
+ .wxsat-center-panel {
+ flex: none;
+ flex-direction: row;
+ gap: 12px;
+ }
+
+ .wxsat-polar-container,
+ .wxsat-map-container {
+ flex: 1;
+ }
+
+ .wxsat-countdown-bar {
+ flex-wrap: wrap;
+ }
+
+ .wxsat-timeline {
+ min-width: 0;
+ flex: 1 1 200px;
+ }
+}
+
+@media (max-width: 768px) {
+ .wxsat-center-panel {
+ flex-direction: column;
+ }
+
+ .wxsat-countdown-boxes {
+ gap: 2px;
+ }
+
+ .wxsat-countdown-box {
+ min-width: 32px;
+ padding: 3px 5px;
+ }
+
+ .wxsat-cd-value {
+ font-size: 13px;
+ }
+
+ .wxsat-gallery-grid {
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+ }
+
+ .wxsat-phase-indicator {
+ display: none;
+ }
+}
+
+/* ===== Signal Console ===== */
+.wxsat-signal-console {
+ display: none;
+ flex-direction: column;
+ border-bottom: 1px solid var(--border-color, #2a3040);
+ background: var(--bg-secondary, #141820);
+}
+
+.wxsat-signal-console.active {
+ display: flex;
+}
+
+.wxsat-console-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 6px 16px;
+ background: var(--bg-tertiary, #1a1f2e);
+ border-bottom: 1px solid var(--border-color, #2a3040);
+ min-height: 32px;
+}
+
+.wxsat-console-title-group {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex: 1;
+ min-width: 0;
+}
+
+.wxsat-console-title {
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--text-dim, #666);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ flex-shrink: 0;
+}
+
+.wxsat-phase-indicator {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-family: 'JetBrains Mono', monospace;
+}
+
+.wxsat-phase-step {
+ font-size: 9px;
+ padding: 2px 6px;
+ border-radius: 3px;
+ color: var(--text-dim, #555);
+ background: transparent;
+ border: 1px solid var(--border-color, #2a3040);
+ transition: all 0.3s ease;
+ letter-spacing: 0.5px;
+}
+
+.wxsat-phase-step.active {
+ color: #00ff88;
+ border-color: #00ff88;
+ background: rgba(0, 255, 136, 0.1);
+ box-shadow: 0 0 8px rgba(0, 255, 136, 0.2);
+}
+
+.wxsat-phase-step.completed {
+ color: var(--accent-cyan, #00d4ff);
+ border-color: rgba(0, 212, 255, 0.3);
+ background: rgba(0, 212, 255, 0.05);
+ opacity: 0.7;
+}
+
+.wxsat-phase-step.error {
+ color: #ff4444;
+ border-color: #ff4444;
+ background: rgba(255, 68, 68, 0.1);
+ box-shadow: 0 0 8px rgba(255, 68, 68, 0.2);
+}
+
+.wxsat-phase-arrow {
+ font-size: 8px;
+ color: var(--text-dim, #444);
+}
+
+#wxsatConsoleToggle {
+ font-size: 10px;
+ width: 28px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ flex-shrink: 0;
+ transition: transform 0.2s;
+}
+
+#wxsatConsoleToggle.collapsed {
+ transform: rotate(-90deg);
+}
+
+.wxsat-console-body {
+ max-height: 160px;
+ overflow: hidden;
+ transition: max-height 0.3s ease;
+}
+
+.wxsat-console-body.collapsed {
+ max-height: 0;
+}
+
+.wxsat-console-log {
+ overflow-y: auto;
+ max-height: 160px;
+ padding: 6px 12px;
+ background: var(--bg-primary, #0d1117);
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 10px;
+ line-height: 1.6;
+}
+
+.wxsat-console-entry {
+ padding: 1px 0 1px 8px;
+ border-left: 2px solid transparent;
+ color: var(--text-secondary, #999);
+ word-break: break-all;
+}
+
+.wxsat-console-entry.wxsat-log-info {
+ border-left-color: var(--border-color, #2a3040);
+ color: var(--text-dim, #777);
+}
+
+.wxsat-console-entry.wxsat-log-signal {
+ border-left-color: #00ff88;
+ color: #00ff88;
+}
+
+.wxsat-console-entry.wxsat-log-progress {
+ border-left-color: var(--accent-cyan, #00d4ff);
+ color: var(--accent-cyan, #00d4ff);
+}
+
+.wxsat-console-entry.wxsat-log-save {
+ border-left-color: #ffbb00;
+ color: #ffbb00;
+}
+
+.wxsat-console-entry.wxsat-log-error {
+ border-left-color: #ff4444;
+ color: #ff4444;
+}
+
+.wxsat-console-entry.wxsat-log-warning {
+ border-left-color: #ff8800;
+ color: #ff8800;
+}
+
+.wxsat-console-entry.wxsat-log-debug {
+ border-left-color: transparent;
+ color: var(--text-dim, #555);
+}
+
+/* Test Decode collapsible section */
+.wxsat-test-decode-body {
+ transition: max-height 0.3s ease, opacity 0.2s ease, margin 0.3s ease;
+ max-height: 400px;
+ opacity: 1;
+ margin-top: 8px;
+}
+
+.wxsat-test-decode-body.collapsed {
+ max-height: 0;
+ opacity: 0;
+ margin-top: 0;
+ overflow: hidden;
+}
+
+.wxsat-collapse-icon {
+ transition: transform 0.2s ease;
+}
+
+.wxsat-collapse-icon.collapsed {
+ transform: rotate(-90deg);
+}
diff --git a/static/js/core/observer-location.js b/static/js/core/observer-location.js
index 018c2b4..53b8c5d 100644
--- a/static/js/core/observer-location.js
+++ b/static/js/core/observer-location.js
@@ -1,7 +1,9 @@
// Shared observer location helper for map-based modules.
// Default: shared location enabled unless explicitly disabled via config.
window.ObserverLocation = (function() {
- const DEFAULT_LOCATION = { lat: 51.5074, lon: -0.1278 };
+ const DEFAULT_LOCATION = (window.INTERCEPT_DEFAULT_LAT && window.INTERCEPT_DEFAULT_LON)
+ ? { lat: window.INTERCEPT_DEFAULT_LAT, lon: window.INTERCEPT_DEFAULT_LON }
+ : { lat: 51.5074, lon: -0.1278 };
const SHARED_KEY = 'observerLocation';
const AIS_KEY = 'ais_observerLocation';
const LEGACY_LAT_KEY = 'observerLat';
@@ -41,6 +43,10 @@ window.ObserverLocation = (function() {
return normalize(lat, lon);
}
+ function hasStoredLocation() {
+ return !!(readKey(SHARED_KEY) || readKey(AIS_KEY) || readLegacyLatLon());
+ }
+
function getShared() {
const current = readKey(SHARED_KEY);
if (current) return current;
@@ -93,6 +99,7 @@ window.ObserverLocation = (function() {
return {
isSharedEnabled,
+ hasStoredLocation,
getShared,
setShared,
getForModule,
diff --git a/static/js/modes/weather-satellite.js b/static/js/modes/weather-satellite.js
new file mode 100644
index 0000000..51ecaa9
--- /dev/null
+++ b/static/js/modes/weather-satellite.js
@@ -0,0 +1,1393 @@
+/**
+ * Weather Satellite Mode
+ * NOAA APT and Meteor LRPT decoder interface with auto-scheduler,
+ * polar plot, ground track map, countdown, and timeline.
+ */
+
+const WeatherSat = (function() {
+ // State
+ let isRunning = false;
+ let eventSource = null;
+ let images = [];
+ let passes = [];
+ let selectedPassIndex = -1;
+ let currentSatellite = null;
+ let countdownInterval = null;
+ let schedulerEnabled = false;
+ let groundMap = null;
+ let groundTrackLayer = null;
+ let observerMarker = null;
+ let consoleEntries = [];
+ let consoleCollapsed = false;
+ let currentPhase = 'idle';
+ let consoleAutoHideTimer = null;
+ let currentModalFilename = null;
+
+ /**
+ * Initialize the Weather Satellite mode
+ */
+ function init() {
+ checkStatus();
+ loadImages();
+ loadLocationInputs();
+ loadPasses();
+ startCountdownTimer();
+ checkSchedulerStatus();
+ initGroundMap();
+ }
+
+ /**
+ * 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 = '... ';
+ 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);
+
+ clearConsole();
+ showConsole(true);
+ updatePhaseIndicator('tuning');
+ addConsoleEntry('Starting capture...', 'info');
+ 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);
+ }
+ }
+
+ /**
+ * Start test decode from a pre-recorded file
+ */
+ async function testDecode() {
+ const satSelect = document.getElementById('wxsatTestSatSelect');
+ const fileInput = document.getElementById('wxsatTestFilePath');
+ const rateSelect = document.getElementById('wxsatTestSampleRate');
+
+ const satellite = satSelect?.value || 'NOAA-18';
+ const inputFile = (fileInput?.value || '').trim();
+ const sampleRate = parseInt(rateSelect?.value || '1000000', 10);
+
+ if (!inputFile) {
+ showNotification('Weather Sat', 'Enter a file path');
+ return;
+ }
+
+ clearConsole();
+ showConsole(true);
+ updatePhaseIndicator('decoding');
+ addConsoleEntry(`Test decode: ${inputFile}`, 'info');
+ updateStatusUI('connecting', 'Starting file decode...');
+
+ try {
+ const response = await fetch('/weather-sat/test-decode', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ satellite,
+ input_file: inputFile,
+ sample_rate: sampleRate,
+ })
+ });
+
+ const data = await response.json();
+
+ if (data.status === 'started' || data.status === 'already_running') {
+ isRunning = true;
+ currentSatellite = data.satellite || satellite;
+ updateStatusUI('decoding', `Decoding ${data.satellite} from file`);
+ updateFreqDisplay(data.frequency, data.mode);
+ startStream();
+ showNotification('Weather Sat', `Decoding ${data.satellite} from file`);
+ } else {
+ updateStatusUI('idle', 'Decode failed');
+ showNotification('Weather Sat', data.message || 'Failed to start decode');
+ addConsoleEntry(data.message || 'Failed to start decode', 'error');
+ }
+ } catch (err) {
+ console.error('Failed to start test decode:', err);
+ updateStatusUI('idle', 'Error');
+ showNotification('Weather Sat', 'Connection error');
+ }
+ }
+
+ /**
+ * 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);
+ } else if (data.type && data.type.startsWith('schedule_')) {
+ handleSchedulerSSE(data);
+ }
+ } catch (err) {
+ console.error('Failed to parse SSE:', err);
+ }
+ };
+
+ eventSource.onerror = () => {
+ setTimeout(() => {
+ if (isRunning || schedulerEnabled) 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) + '%';
+
+ // Console updates
+ showConsole(true);
+ if (data.message) addConsoleEntry(data.message, data.log_type || 'info');
+ if (data.capture_phase) updatePhaseIndicator(data.capture_phase);
+
+ } 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;
+ if (!schedulerEnabled) stopStream();
+ updateStatusUI('idle', 'Capture complete');
+ if (captureStatus) captureStatus.classList.remove('active');
+
+ addConsoleEntry('Capture complete', 'signal');
+ updatePhaseIndicator('complete');
+ consoleAutoHideTimer = setTimeout(() => showConsole(false), 30000);
+ }
+
+ } else if (data.status === 'error') {
+ updateStatusUI('idle', 'Error');
+ showNotification('Weather Sat', data.message || 'Capture error');
+ if (captureStatus) captureStatus.classList.remove('active');
+
+ if (data.message) addConsoleEntry(data.message, 'error');
+ updatePhaseIndicator('error');
+ consoleAutoHideTimer = setTimeout(() => showConsole(false), 15000);
+ }
+ }
+
+ /**
+ * Handle scheduler SSE events
+ */
+ function handleSchedulerSSE(data) {
+ if (data.type === 'schedule_capture_start') {
+ isRunning = true;
+ const p = data.pass || {};
+ currentSatellite = p.satellite;
+ updateStatusUI('capturing', `Auto: ${p.name || p.satellite} ${p.frequency} MHz`);
+ showNotification('Weather Sat', `Auto-capture started: ${p.name || p.satellite}`);
+ } else if (data.type === 'schedule_capture_complete') {
+ showNotification('Weather Sat', `Auto-capture complete: ${(data.pass || {}).name || ''}`);
+ loadImages();
+ } else if (data.type === 'schedule_capture_skipped') {
+ const reason = data.reason || 'unknown';
+ const p = data.pass || {};
+ showNotification('Weather Sat', `Pass skipped (${reason}): ${p.name || p.satellite}`);
+ }
+ }
+
+ /**
+ * 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 (with trajectory + ground track)
+ */
+ async function loadPasses() {
+ let storedLat, storedLon;
+
+ // Use ObserverLocation if available, otherwise fall back to localStorage
+ if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
+ const shared = ObserverLocation.getShared();
+ storedLat = shared?.lat?.toString();
+ storedLon = shared?.lon?.toString();
+ } else {
+ storedLat = localStorage.getItem('observerLat');
+ storedLon = localStorage.getItem('observerLon');
+ }
+
+ if (!storedLat || !storedLon) {
+ renderPasses([]);
+ return;
+ }
+
+ try {
+ const url = `/weather-sat/passes?latitude=${storedLat}&longitude=${storedLon}&hours=24&min_elevation=15&trajectory=true&ground_track=true`;
+ const response = await fetch(url);
+ const data = await response.json();
+
+ if (data.status === 'ok') {
+ passes = data.passes || [];
+ renderPasses(passes);
+ renderTimeline(passes);
+ updateCountdownFromPasses();
+ // Auto-select first pass
+ if (passes.length > 0 && selectedPassIndex < 0) {
+ selectPass(0);
+ }
+ }
+ } catch (err) {
+ console.error('Failed to load passes:', err);
+ }
+ }
+
+ /**
+ * Select a pass to display in polar plot and map
+ */
+ function selectPass(index) {
+ if (index < 0 || index >= passes.length) return;
+ selectedPassIndex = index;
+ const pass = passes[index];
+
+ // Highlight active card
+ document.querySelectorAll('.wxsat-pass-card').forEach((card, i) => {
+ card.classList.toggle('selected', i === index);
+ });
+
+ // Update polar plot
+ drawPolarPlot(pass);
+
+ // Update ground track
+ updateGroundTrack(pass);
+
+ // Update polar panel subtitle
+ const polarSat = document.getElementById('wxsatPolarSat');
+ if (polarSat) polarSat.textContent = `${pass.name} ${pass.maxEl}\u00b0`;
+ }
+
+ /**
+ * 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 = `
+
+
${hasLocation ? 'No passes in next 24h' : 'Set location to see pass predictions'}
+
+ `;
+ 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);
+ const isSelected = idx === selectedPassIndex;
+
+ 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 `
+
+
+ ${escapeHtml(pass.name)}
+ ${escapeHtml(pass.mode)}
+
+
+ Time
+ ${escapeHtml(timeStr)}
+ Max El
+ ${pass.maxEl}°
+ Duration
+ ${pass.duration} min
+ Freq
+ ${pass.frequency} MHz
+
+
+ ${pass.quality}
+ ${countdown}
+
+
+ Capture
+
+
+ `;
+ }).join('');
+ }
+
+ // ========================
+ // Polar Plot
+ // ========================
+
+ /**
+ * Draw polar plot for a pass trajectory
+ */
+ function drawPolarPlot(pass) {
+ const canvas = document.getElementById('wxsatPolarCanvas');
+ if (!canvas) return;
+
+ const ctx = canvas.getContext('2d');
+ const w = canvas.width;
+ const h = canvas.height;
+ const cx = w / 2;
+ const cy = h / 2;
+ const r = Math.min(cx, cy) - 20;
+
+ ctx.clearRect(0, 0, w, h);
+
+ // Background
+ ctx.fillStyle = '#0d1117';
+ ctx.fillRect(0, 0, w, h);
+
+ // Grid circles (30, 60, 90 deg elevation)
+ ctx.strokeStyle = '#2a3040';
+ ctx.lineWidth = 0.5;
+ [90, 60, 30].forEach((el, i) => {
+ const gr = r * (1 - el / 90);
+ ctx.beginPath();
+ ctx.arc(cx, cy, gr, 0, Math.PI * 2);
+ ctx.stroke();
+ // Label
+ ctx.fillStyle = '#555';
+ ctx.font = '9px JetBrains Mono, monospace';
+ ctx.textAlign = 'left';
+ ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2);
+ });
+
+ // Horizon circle
+ ctx.strokeStyle = '#3a4050';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
+ ctx.stroke();
+
+ // Cardinal directions
+ ctx.fillStyle = '#666';
+ ctx.font = '10px JetBrains Mono, monospace';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText('N', cx, cy - r - 10);
+ ctx.fillText('S', cx, cy + r + 10);
+ ctx.fillText('E', cx + r + 10, cy);
+ ctx.fillText('W', cx - r - 10, cy);
+
+ // Cross hairs
+ ctx.strokeStyle = '#2a3040';
+ ctx.lineWidth = 0.5;
+ ctx.beginPath();
+ ctx.moveTo(cx, cy - r);
+ ctx.lineTo(cx, cy + r);
+ ctx.moveTo(cx - r, cy);
+ ctx.lineTo(cx + r, cy);
+ ctx.stroke();
+
+ // Trajectory
+ const trajectory = pass.trajectory;
+ if (!trajectory || trajectory.length === 0) return;
+
+ const color = pass.mode === 'LRPT' ? '#00ff88' : '#00d4ff';
+
+ ctx.beginPath();
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 2;
+
+ trajectory.forEach((pt, i) => {
+ const elRad = (90 - pt.el) / 90;
+ const azRad = (pt.az - 90) * Math.PI / 180; // offset: N is up
+ const px = cx + r * elRad * Math.cos(azRad);
+ const py = cy + r * elRad * Math.sin(azRad);
+
+ if (i === 0) ctx.moveTo(px, py);
+ else ctx.lineTo(px, py);
+ });
+ ctx.stroke();
+
+ // Start point (green dot)
+ const start = trajectory[0];
+ const startR = (90 - start.el) / 90;
+ const startAz = (start.az - 90) * Math.PI / 180;
+ ctx.fillStyle = '#00ff88';
+ ctx.beginPath();
+ ctx.arc(cx + r * startR * Math.cos(startAz), cy + r * startR * Math.sin(startAz), 4, 0, Math.PI * 2);
+ ctx.fill();
+
+ // End point (red dot)
+ const end = trajectory[trajectory.length - 1];
+ const endR = (90 - end.el) / 90;
+ const endAz = (end.az - 90) * Math.PI / 180;
+ ctx.fillStyle = '#ff4444';
+ ctx.beginPath();
+ ctx.arc(cx + r * endR * Math.cos(endAz), cy + r * endR * Math.sin(endAz), 4, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Max elevation marker
+ let maxEl = 0;
+ let maxPt = trajectory[0];
+ trajectory.forEach(pt => { if (pt.el > maxEl) { maxEl = pt.el; maxPt = pt; } });
+ const maxR = (90 - maxPt.el) / 90;
+ const maxAz = (maxPt.az - 90) * Math.PI / 180;
+ ctx.fillStyle = color;
+ ctx.beginPath();
+ ctx.arc(cx + r * maxR * Math.cos(maxAz), cy + r * maxR * Math.sin(maxAz), 3, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.fillStyle = color;
+ ctx.font = '9px JetBrains Mono, monospace';
+ ctx.textAlign = 'center';
+ ctx.fillText(Math.round(maxEl) + '\u00b0', cx + r * maxR * Math.cos(maxAz), cy + r * maxR * Math.sin(maxAz) - 8);
+ }
+
+ // ========================
+ // Ground Track Map
+ // ========================
+
+ /**
+ * Initialize Leaflet ground track map
+ */
+ function initGroundMap() {
+ const container = document.getElementById('wxsatGroundMap');
+ if (!container || groundMap) return;
+ if (typeof L === 'undefined') return;
+
+ groundMap = L.map(container, {
+ center: [20, 0],
+ zoom: 2,
+ zoomControl: false,
+ attributionControl: false,
+ });
+
+ // Check tile provider from settings
+ let tileUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
+ try {
+ const provider = localStorage.getItem('tileProvider');
+ if (provider === 'osm') {
+ tileUrl = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
+ }
+ } catch (e) {}
+
+ L.tileLayer(tileUrl, { maxZoom: 10 }).addTo(groundMap);
+
+ groundTrackLayer = L.layerGroup().addTo(groundMap);
+
+ // Delayed invalidation to fix sizing
+ setTimeout(() => { if (groundMap) groundMap.invalidateSize(); }, 200);
+ }
+
+ /**
+ * Update ground track on the map
+ */
+ function updateGroundTrack(pass) {
+ if (!groundMap || !groundTrackLayer) return;
+
+ groundTrackLayer.clearLayers();
+
+ const track = pass.groundTrack;
+ if (!track || track.length === 0) return;
+
+ const color = pass.mode === 'LRPT' ? '#00ff88' : '#00d4ff';
+
+ // Draw polyline
+ const latlngs = track.map(p => [p.lat, p.lon]);
+ L.polyline(latlngs, { color, weight: 2, opacity: 0.8 }).addTo(groundTrackLayer);
+
+ // Start marker
+ L.circleMarker(latlngs[0], {
+ radius: 5, color: '#00ff88', fillColor: '#00ff88', fillOpacity: 1, weight: 0,
+ }).addTo(groundTrackLayer);
+
+ // End marker
+ L.circleMarker(latlngs[latlngs.length - 1], {
+ radius: 5, color: '#ff4444', fillColor: '#ff4444', fillOpacity: 1, weight: 0,
+ }).addTo(groundTrackLayer);
+
+ // Observer marker
+ const lat = parseFloat(localStorage.getItem('observerLat'));
+ const lon = parseFloat(localStorage.getItem('observerLon'));
+ if (!isNaN(lat) && !isNaN(lon)) {
+ L.circleMarker([lat, lon], {
+ radius: 6, color: '#ffbb00', fillColor: '#ffbb00', fillOpacity: 0.8, weight: 1,
+ }).addTo(groundTrackLayer);
+ }
+
+ // Fit bounds
+ try {
+ const bounds = L.latLngBounds(latlngs);
+ if (!isNaN(lat) && !isNaN(lon)) bounds.extend([lat, lon]);
+ groundMap.fitBounds(bounds, { padding: [20, 20] });
+ } catch (e) {}
+ }
+
+ // ========================
+ // Countdown
+ // ========================
+
+ /**
+ * Start the countdown interval timer
+ */
+ function startCountdownTimer() {
+ if (countdownInterval) clearInterval(countdownInterval);
+ countdownInterval = setInterval(updateCountdownFromPasses, 1000);
+ }
+
+ /**
+ * Update countdown display from passes array
+ */
+ function updateCountdownFromPasses() {
+ const now = new Date();
+ let nextPass = null;
+ let isActive = false;
+
+ for (const pass of passes) {
+ const start = new Date(pass.startTimeISO);
+ const end = new Date(pass.endTimeISO);
+ if (end > now) {
+ nextPass = pass;
+ isActive = start <= now;
+ break;
+ }
+ }
+
+ const daysEl = document.getElementById('wxsatCdDays');
+ const hoursEl = document.getElementById('wxsatCdHours');
+ const minsEl = document.getElementById('wxsatCdMins');
+ const secsEl = document.getElementById('wxsatCdSecs');
+ const satEl = document.getElementById('wxsatCountdownSat');
+ const detailEl = document.getElementById('wxsatCountdownDetail');
+ const boxes = document.getElementById('wxsatCountdownBoxes');
+
+ if (!nextPass) {
+ if (daysEl) daysEl.textContent = '--';
+ if (hoursEl) hoursEl.textContent = '--';
+ if (minsEl) minsEl.textContent = '--';
+ if (secsEl) secsEl.textContent = '--';
+ if (satEl) satEl.textContent = '--';
+ if (detailEl) detailEl.textContent = 'No passes predicted';
+ if (boxes) boxes.querySelectorAll('.wxsat-countdown-box').forEach(b => {
+ b.classList.remove('imminent', 'active');
+ });
+ return;
+ }
+
+ const target = new Date(nextPass.startTimeISO);
+ let diffMs = target - now;
+
+ if (isActive) {
+ diffMs = 0;
+ }
+
+ const totalSec = Math.max(0, Math.floor(diffMs / 1000));
+ const d = Math.floor(totalSec / 86400);
+ const h = Math.floor((totalSec % 86400) / 3600);
+ const m = Math.floor((totalSec % 3600) / 60);
+ const s = totalSec % 60;
+
+ if (daysEl) daysEl.textContent = d.toString().padStart(2, '0');
+ if (hoursEl) hoursEl.textContent = h.toString().padStart(2, '0');
+ if (minsEl) minsEl.textContent = m.toString().padStart(2, '0');
+ if (secsEl) secsEl.textContent = s.toString().padStart(2, '0');
+ if (satEl) satEl.textContent = `${nextPass.name} ${nextPass.frequency} MHz`;
+ if (detailEl) {
+ if (isActive) {
+ detailEl.textContent = `ACTIVE - ${nextPass.maxEl}\u00b0 max el`;
+ } else {
+ detailEl.textContent = `${nextPass.maxEl}\u00b0 max el / ${nextPass.duration} min`;
+ }
+ }
+
+ // Countdown box states
+ if (boxes) {
+ const isImminent = totalSec < 600 && totalSec > 0; // < 10 min
+ boxes.querySelectorAll('.wxsat-countdown-box').forEach(b => {
+ b.classList.toggle('imminent', isImminent);
+ b.classList.toggle('active', isActive);
+ });
+ }
+ }
+
+ // ========================
+ // Timeline
+ // ========================
+
+ /**
+ * Render 24h timeline with pass markers
+ */
+ function renderTimeline(passList) {
+ const track = document.getElementById('wxsatTimelineTrack');
+ const cursor = document.getElementById('wxsatTimelineCursor');
+ if (!track) return;
+
+ // Clear existing pass markers
+ track.querySelectorAll('.wxsat-timeline-pass').forEach(el => el.remove());
+
+ const now = new Date();
+ const dayStart = new Date(now);
+ dayStart.setHours(0, 0, 0, 0);
+ const dayMs = 24 * 60 * 60 * 1000;
+
+ passList.forEach((pass, idx) => {
+ const start = new Date(pass.startTimeISO);
+ const end = new Date(pass.endTimeISO);
+
+ const startPct = Math.max(0, Math.min(100, ((start - dayStart) / dayMs) * 100));
+ const endPct = Math.max(0, Math.min(100, ((end - dayStart) / dayMs) * 100));
+ const widthPct = Math.max(0.5, endPct - startPct);
+
+ const marker = document.createElement('div');
+ marker.className = `wxsat-timeline-pass ${pass.mode === 'LRPT' ? 'lrpt' : 'apt'}`;
+ marker.style.left = startPct + '%';
+ marker.style.width = widthPct + '%';
+ marker.title = `${pass.name} ${pass.startTime} (${pass.maxEl}\u00b0)`;
+ marker.onclick = () => selectPass(idx);
+ track.appendChild(marker);
+ });
+
+ // Update cursor position
+ updateTimelineCursor();
+ }
+
+ /**
+ * Update timeline cursor to current time
+ */
+ function updateTimelineCursor() {
+ const cursor = document.getElementById('wxsatTimelineCursor');
+ if (!cursor) return;
+
+ const now = new Date();
+ const dayStart = new Date(now);
+ dayStart.setHours(0, 0, 0, 0);
+ const pct = ((now - dayStart) / (24 * 60 * 60 * 1000)) * 100;
+ cursor.style.left = pct + '%';
+ }
+
+ // ========================
+ // Auto-Scheduler
+ // ========================
+
+ /**
+ * Toggle auto-scheduler
+ */
+ async function toggleScheduler() {
+ const stripCheckbox = document.getElementById('wxsatAutoSchedule');
+ const sidebarCheckbox = document.getElementById('wxsatSidebarAutoSchedule');
+ const checked = stripCheckbox?.checked || sidebarCheckbox?.checked;
+
+ // Sync both checkboxes
+ if (stripCheckbox) stripCheckbox.checked = checked;
+ if (sidebarCheckbox) sidebarCheckbox.checked = checked;
+
+ if (checked) {
+ await enableScheduler();
+ } else {
+ await disableScheduler();
+ }
+ }
+
+ /**
+ * Enable auto-scheduler
+ */
+ async function enableScheduler() {
+ const lat = parseFloat(localStorage.getItem('observerLat'));
+ const lon = parseFloat(localStorage.getItem('observerLon'));
+
+ if (isNaN(lat) || isNaN(lon)) {
+ showNotification('Weather Sat', 'Set observer location first');
+ const stripCheckbox = document.getElementById('wxsatAutoSchedule');
+ const sidebarCheckbox = document.getElementById('wxsatSidebarAutoSchedule');
+ if (stripCheckbox) stripCheckbox.checked = false;
+ if (sidebarCheckbox) sidebarCheckbox.checked = false;
+ return;
+ }
+
+ const deviceSelect = document.getElementById('deviceSelect');
+ const gainInput = document.getElementById('weatherSatGain');
+ const biasTInput = document.getElementById('weatherSatBiasT');
+
+ try {
+ const response = await fetch('/weather-sat/schedule/enable', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ latitude: lat,
+ longitude: lon,
+ device: parseInt(deviceSelect?.value || '0', 10),
+ gain: parseFloat(gainInput?.value || '40'),
+ bias_t: biasTInput?.checked || false,
+ }),
+ });
+
+ const data = await response.json();
+ schedulerEnabled = true;
+ updateSchedulerUI(data);
+ startStream();
+ showNotification('Weather Sat', `Auto-scheduler enabled (${data.scheduled_count || 0} passes)`);
+ } catch (err) {
+ console.error('Failed to enable scheduler:', err);
+ showNotification('Weather Sat', 'Failed to enable auto-scheduler');
+ }
+ }
+
+ /**
+ * Disable auto-scheduler
+ */
+ async function disableScheduler() {
+ try {
+ await fetch('/weather-sat/schedule/disable', { method: 'POST' });
+ schedulerEnabled = false;
+ updateSchedulerUI({ enabled: false });
+ if (!isRunning) stopStream();
+ showNotification('Weather Sat', 'Auto-scheduler disabled');
+ } catch (err) {
+ console.error('Failed to disable scheduler:', err);
+ }
+ }
+
+ /**
+ * Check current scheduler status
+ */
+ async function checkSchedulerStatus() {
+ try {
+ const response = await fetch('/weather-sat/schedule/status');
+ const data = await response.json();
+ schedulerEnabled = data.enabled;
+ updateSchedulerUI(data);
+ if (schedulerEnabled) startStream();
+ } catch (err) {
+ // Scheduler endpoint may not exist yet
+ }
+ }
+
+ /**
+ * Update scheduler UI elements
+ */
+ function updateSchedulerUI(data) {
+ const stripCheckbox = document.getElementById('wxsatAutoSchedule');
+ const sidebarCheckbox = document.getElementById('wxsatSidebarAutoSchedule');
+ const statusEl = document.getElementById('wxsatSchedulerStatus');
+
+ if (stripCheckbox) stripCheckbox.checked = data.enabled;
+ if (sidebarCheckbox) sidebarCheckbox.checked = data.enabled;
+ if (statusEl) {
+ if (data.enabled) {
+ statusEl.textContent = `Active: ${data.scheduled_count || 0} passes queued`;
+ statusEl.style.color = '#00ff88';
+ } else {
+ statusEl.textContent = 'Disabled';
+ statusEl.style.color = '';
+ }
+ }
+ }
+
+ // ========================
+ // Images
+ // ========================
+
+ /**
+ * 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 grouped by date
+ */
+ function renderGallery() {
+ const gallery = document.getElementById('wxsatGallery');
+ if (!gallery) return;
+
+ if (images.length === 0) {
+ gallery.innerHTML = `
+
+
+
+
+
+
+
No images decoded yet
+
Select a satellite pass and start capturing
+
+ `;
+ return;
+ }
+
+ // Sort by timestamp descending
+ const sorted = [...images].sort((a, b) => {
+ return new Date(b.timestamp || 0) - new Date(a.timestamp || 0);
+ });
+
+ // Group by date
+ const groups = {};
+ sorted.forEach(img => {
+ const dateKey = img.timestamp
+ ? new Date(img.timestamp).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
+ : 'Unknown Date';
+ if (!groups[dateKey]) groups[dateKey] = [];
+ groups[dateKey].push(img);
+ });
+
+ let html = '';
+ for (const [date, imgs] of Object.entries(groups)) {
+ html += ``;
+ html += imgs.map(img => {
+ const fn = escapeHtml(img.filename || img.url.split('/').pop());
+ return `
+
+
+
+
+
${escapeHtml(img.satellite)}
+
${escapeHtml(img.product || img.mode)}
+
${formatTimestamp(img.timestamp)}
+
+
+
+
`;
+ }).join('');
+ }
+
+ gallery.innerHTML = html;
+ }
+
+ /**
+ * Show full-size image
+ */
+ function showImage(url, satellite, product, filename) {
+ currentModalFilename = filename || null;
+
+ let modal = document.getElementById('wxsatImageModal');
+ if (!modal) {
+ modal = document.createElement('div');
+ modal.id = 'wxsatImageModal';
+ modal.className = 'wxsat-image-modal';
+ modal.innerHTML = `
+
+ ×
+
+
+ `;
+ 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');
+ }
+
+ /**
+ * Delete a single image
+ */
+ async function deleteImage(filename) {
+ if (!filename) return;
+ if (!confirm(`Delete this image?`)) return;
+
+ try {
+ const response = await fetch(`/weather-sat/images/${encodeURIComponent(filename)}`, { method: 'DELETE' });
+ const data = await response.json();
+
+ if (data.status === 'deleted') {
+ images = images.filter(img => {
+ const imgFn = img.filename || img.url.split('/').pop();
+ return imgFn !== filename;
+ });
+ updateImageCount(images.length);
+ renderGallery();
+ closeImage();
+ } else {
+ showNotification('Weather Sat', data.message || 'Failed to delete image');
+ }
+ } catch (err) {
+ console.error('Failed to delete image:', err);
+ showNotification('Weather Sat', 'Failed to delete image');
+ }
+ }
+
+ /**
+ * Delete all images
+ */
+ async function deleteAllImages() {
+ if (images.length === 0) return;
+ if (!confirm(`Delete all ${images.length} decoded images?`)) return;
+
+ try {
+ const response = await fetch('/weather-sat/images', { method: 'DELETE' });
+ const data = await response.json();
+
+ if (data.status === 'ok') {
+ images = [];
+ updateImageCount(0);
+ renderGallery();
+ showNotification('Weather Sat', `Deleted ${data.deleted} images`);
+ } else {
+ showNotification('Weather Sat', 'Failed to delete images');
+ }
+ } catch (err) {
+ console.error('Failed to delete all images:', err);
+ showNotification('Weather Sat', 'Failed to delete images');
+ }
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * Invalidate ground map size (call after container becomes visible)
+ */
+ function invalidateMap() {
+ if (groundMap) {
+ setTimeout(() => groundMap.invalidateSize(), 100);
+ }
+ }
+
+ // ========================
+ // Decoder Console
+ // ========================
+
+ /**
+ * Add an entry to the decoder console log
+ */
+ function addConsoleEntry(message, logType) {
+ const log = document.getElementById('wxsatConsoleLog');
+ if (!log) return;
+
+ const entry = document.createElement('div');
+ entry.className = `wxsat-console-entry wxsat-log-${logType || 'info'}`;
+ entry.textContent = message;
+ log.appendChild(entry);
+
+ consoleEntries.push(entry);
+
+ // Cap at 200 entries
+ while (consoleEntries.length > 200) {
+ const old = consoleEntries.shift();
+ if (old.parentNode) old.parentNode.removeChild(old);
+ }
+
+ // Auto-scroll to bottom
+ log.scrollTop = log.scrollHeight;
+ }
+
+ /**
+ * Update the phase indicator steps
+ */
+ function updatePhaseIndicator(phase) {
+ if (!phase || phase === currentPhase) return;
+ currentPhase = phase;
+
+ const phases = ['tuning', 'listening', 'signal_detected', 'decoding', 'complete'];
+ const phaseIndex = phases.indexOf(phase);
+ const isError = phase === 'error';
+
+ document.querySelectorAll('#wxsatPhaseIndicator .wxsat-phase-step').forEach(step => {
+ const stepPhase = step.dataset.phase;
+ const stepIndex = phases.indexOf(stepPhase);
+
+ step.classList.remove('active', 'completed', 'error');
+
+ if (isError) {
+ if (stepPhase === currentPhase || stepIndex === phaseIndex) {
+ step.classList.add('error');
+ }
+ } else if (stepIndex === phaseIndex) {
+ step.classList.add('active');
+ } else if (stepIndex < phaseIndex && phaseIndex >= 0) {
+ step.classList.add('completed');
+ }
+ });
+ }
+
+ /**
+ * Show or hide the decoder console
+ */
+ function showConsole(visible) {
+ const el = document.getElementById('wxsatSignalConsole');
+ if (el) el.classList.toggle('active', visible);
+
+ if (consoleAutoHideTimer) {
+ clearTimeout(consoleAutoHideTimer);
+ consoleAutoHideTimer = null;
+ }
+ }
+
+ /**
+ * Toggle console body collapsed state
+ */
+ function toggleConsole() {
+ const body = document.getElementById('wxsatConsoleBody');
+ const btn = document.getElementById('wxsatConsoleToggle');
+ if (!body) return;
+
+ consoleCollapsed = !consoleCollapsed;
+ body.classList.toggle('collapsed', consoleCollapsed);
+ if (btn) btn.classList.toggle('collapsed', consoleCollapsed);
+ }
+
+ /**
+ * Clear console entries and reset phase indicator
+ */
+ function clearConsole() {
+ const log = document.getElementById('wxsatConsoleLog');
+ if (log) log.innerHTML = '';
+ consoleEntries = [];
+ currentPhase = 'idle';
+
+ document.querySelectorAll('#wxsatPhaseIndicator .wxsat-phase-step').forEach(step => {
+ step.classList.remove('active', 'completed', 'error');
+ });
+
+ if (consoleAutoHideTimer) {
+ clearTimeout(consoleAutoHideTimer);
+ consoleAutoHideTimer = null;
+ }
+ }
+
+ // Public API
+ return {
+ init,
+ start,
+ stop,
+ startPass,
+ selectPass,
+ testDecode,
+ loadImages,
+ loadPasses,
+ showImage,
+ closeImage,
+ deleteImage,
+ deleteAllImages,
+ useGPS,
+ toggleScheduler,
+ invalidateMap,
+ toggleConsole,
+ _getModalFilename: () => currentModalFilename,
+ };
+})();
+
+document.addEventListener('DOMContentLoaded', function() {
+ // Initialization happens via selectMode when weather-satellite mode is activated
+});
diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html
index 9a12a1c..c9371fd 100644
--- a/templates/adsb_dashboard.html
+++ b/templates/adsb_dashboard.html
@@ -223,6 +223,88 @@
+
+
+
+
+
+
+ 1090 MHz — stock SDR antenna can work but is not ideal
+
+
+
+
Stock Telescopic Antenna
+
+ 1090 MHz: Collapse to ~6.9 cm (quarter-wave). It works for nearby aircraft
+ Range: Expect ~50 NM (90 km) indoors, ~100 NM outdoors
+
+
+
+
+
Recommended: 1090 MHz Collinear (~$10-20 DIY)
+
+ Design: 8 coaxial collinear elements from RG-6 coax cable
+ Element length: ~6.9 cm segments soldered alternating center/shield
+ Gain: ~5–7 dBi omnidirectional, ideal for 360° coverage
+ Range: 150–250+ NM depending on height and LOS
+
+
+
+
+
Commercial Options
+
+ FlightAware antenna: ~$35, 1090 MHz tuned, 66cm fiberglass whip
+ ADSBexchange whip: ~$40, similar performance
+ Jetvision A3: ~$50, high-gain 1090 MHz collinear
+
+
+
+
+
Placement & LNA
+
+ Location: OUTDOORS, as high as possible. Roof or mast mount
+ Height: Every 3m higher adds ~10 NM range (line-of-sight)
+ LNA: 1090 MHz filtered LNA at antenna feed (e.g. Uputronics, ~$30)
+ Filter: A 1090 MHz bandpass filter removes cell/FM interference
+ Coax: Keep short. At 1090 MHz, RG-58 loses ~10 dB per 10m
+ Bias-T: Enable Bias-T in controls above if LNA is powered via coax
+
+
+
+
+
Quick Reference
+
+
+ ADS-B frequency
+ 1090 MHz
+
+
+ Quarter-wave length
+ 6.9 cm
+
+
+ Modulation
+ PPM (pulse)
+
+
+ Polarization
+ Vertical
+
+
+ Bandwidth
+ ~2 MHz
+
+
+ Typical range (outdoor)
+ 100–250 NM
+
+
+
+
+
diff --git a/templates/index.html b/templates/index.html
index 26af3ca..ae5062d 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -21,6 +21,8 @@
@@ -59,6 +61,7 @@
+
@@ -236,6 +239,10 @@
ISS SSTV
+
+
+ Weather Sat
+
HF SSTV
@@ -522,6 +529,8 @@
{% include 'partials/modes/sstv.html' %}
+ {% include 'partials/modes/weather-satellite.html' %}
+
{% include 'partials/modes/sstv-general.html' %}
{% include 'partials/modes/listening-post.html' %}
@@ -2140,6 +2149,172 @@
+
+
+
+
+
+
+
+ Idle
+
+
Start
+
Stop
+
+
+
+
+ --
+ MHZ
+
+
+ --
+ MODE
+
+
+ 0
+ IMAGES
+
+
+
+
+
+
+
+
+ AUTO
+
+
+
+
+
+
+
+
+
-- DAYS
+
-- HRS
+
-- MIN
+
-- SEC
+
+
+ --
+ No passes predicted
+
+
+
+
+
+
+ 00:00 06:00 12:00 18:00 24:00
+
+
+
+
+
+
+
+
+
+
+
+
+
Waiting for capture...
+
+
+
+
+
+
+
+
+
+
+
+
Set location to see pass predictions
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No images decoded yet
+
Select a satellite pass and start capturing
+
+
+
+
+
+
@@ -2359,6 +2534,7 @@
+
@@ -2497,7 +2673,7 @@
const validModes = new Set([
'pager', 'sensor', 'rtlamr', 'aprs', 'listening',
'spystations', 'meshtastic', 'wifi', 'bluetooth',
- 'tscm', 'satellite', 'sstv', 'sstv_general', 'dmr', 'websdr'
+ 'tscm', 'satellite', 'sstv', 'weathersat', 'sstv_general', 'dmr', 'websdr'
]);
function getModeFromQuery() {
@@ -2919,7 +3095,7 @@
'tscm': 'security',
'rtlamr': 'sdr', 'ais': 'sdr', 'spystations': 'sdr',
'meshtastic': 'sdr',
- 'satellite': 'space', 'sstv': 'space', 'sstv_general': 'space'
+ 'satellite': 'space', 'sstv': 'space', 'weathersat': 'space', 'sstv_general': 'space'
};
// Remove has-active from all dropdowns
@@ -3002,6 +3178,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('sstvGeneralMode')?.classList.toggle('active', mode === 'sstv_general');
document.getElementById('wifiMode')?.classList.toggle('active', mode === 'wifi');
document.getElementById('bluetoothMode')?.classList.toggle('active', mode === 'bluetooth');
@@ -3039,6 +3216,7 @@
'rtlamr': 'METERS',
'satellite': 'SATELLITE',
'sstv': 'ISS SSTV',
+ 'weathersat': 'WEATHER SAT',
'sstv_general': 'HF SSTV',
'wifi': 'WIFI',
'bluetooth': 'BLUETOOTH',
@@ -3062,6 +3240,7 @@
const spyStationsVisuals = document.getElementById('spyStationsVisuals');
const meshtasticVisuals = document.getElementById('meshtasticVisuals');
const sstvVisuals = document.getElementById('sstvVisuals');
+ const weatherSatVisuals = document.getElementById('weatherSatVisuals');
const sstvGeneralVisuals = document.getElementById('sstvGeneralVisuals');
const dmrVisuals = document.getElementById('dmrVisuals');
const websdrVisuals = document.getElementById('websdrVisuals');
@@ -3074,6 +3253,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';
if (sstvGeneralVisuals) sstvGeneralVisuals.style.display = mode === 'sstv_general' ? 'flex' : 'none';
if (dmrVisuals) dmrVisuals.style.display = mode === 'dmr' ? 'flex' : 'none';
if (websdrVisuals) websdrVisuals.style.display = mode === 'websdr' ? 'flex' : 'none';
@@ -3101,6 +3281,7 @@
'rtlamr': 'Utility Meter Monitor',
'satellite': 'Satellite Monitor',
'sstv': 'ISS SSTV Decoder',
+ 'weathersat': 'Weather Satellite Decoder',
'sstv_general': 'HF SSTV Decoder',
'wifi': 'WiFi Scanner',
'bluetooth': 'Bluetooth Scanner',
@@ -3129,7 +3310,7 @@
const reconBtn = document.getElementById('reconBtn');
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
const reconPanel = document.getElementById('reconPanel');
- if (mode === 'satellite' || mode === 'sstv' || mode === 'sstv_general' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr') {
+ if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr') {
if (reconPanel) reconPanel.style.display = 'none';
if (reconBtn) reconBtn.style.display = 'none';
if (intelBtn) intelBtn.style.display = 'none';
@@ -3149,7 +3330,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' || mode === 'sstv_general' || mode === 'dmr') ? 'block' : 'none';
+ if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'listening' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'dmr') ? 'block' : 'none';
// Show waterfall panel if running in listening mode
const waterfallPanel = document.getElementById('waterfallPanel');
@@ -3167,7 +3348,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 === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr') ? 'none' : 'block';
+ if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr') ? 'none' : 'block';
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'dmr') ? 'none' : 'flex';
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
@@ -3215,6 +3396,11 @@
}, 100);
} else if (mode === 'sstv') {
SSTV.init();
+ } else if (mode === 'weathersat') {
+ WeatherSat.init();
+ setTimeout(() => {
+ WeatherSat.invalidateMap();
+ }, 100);
} else if (mode === 'sstv_general') {
SSTVGeneral.init();
} else if (mode === 'dmr') {
diff --git a/templates/partials/modes/ais.html b/templates/partials/modes/ais.html
index 0896aec..a2155c0 100644
--- a/templates/partials/modes/ais.html
+++ b/templates/partials/modes/ais.html
@@ -26,6 +26,75 @@
+
+
+
Antenna Guide
+
+
+ Marine VHF band (162 MHz) — stock SDR antenna will NOT work well
+
+
+
+
Simple Dipole (Cheapest)
+
+ Element length: ~46 cm each (quarter-wave at 162 MHz)
+ Material: Wire, coat hanger, or copper rod
+ Orientation: Vertical (AIS is vertically polarized)
+ Placement: As high as possible with clear view of the water/harbor
+
+
+
+
+
Commercial Options
+
+ Marine VHF whip: ~$20–50, designed for 156–163 MHz band
+ Discone: ~$30–50, wideband coverage including marine VHF
+ Collinear: Higher gain (~6 dBi), best for coastal monitoring
+
+
+
+
+
Placement Tips
+
+ Height is critical: AIS is line-of-sight. Roof or mast mount is ideal
+ Range: At 10m height, expect ~25 NM (46 km) range over water
+ LNA: Nooelec Lana or similar broadband LNA, mount at antenna
+ Coax: Keep cable short. RG-58 loses ~4 dB per 10m at 162 MHz
+
+
+
+
+
Quick Reference
+
+
+ AIS Channel A
+ 161.975 MHz
+
+
+ AIS Channel B
+ 162.025 MHz
+
+
+ Quarter-wave length
+ 46 cm
+
+
+ Modulation
+ GMSK 9600 baud
+
+
+ Bandwidth
+ 25 kHz
+
+
+ Polarization
+ Vertical
+
+
+
+
+
+
Start AIS Tracking
diff --git a/templates/partials/modes/aprs.html b/templates/partials/modes/aprs.html
index 9dceb97..f1053ee 100644
--- a/templates/partials/modes/aprs.html
+++ b/templates/partials/modes/aprs.html
@@ -13,4 +13,59 @@
Controls in function bar above map
+
+
+
+
Antenna Guide
+
+
+ 2m band (144–148 MHz) — stock SDR antenna will NOT work
+
+
+
+
Simple Dipole (Easiest)
+
+ Element length: ~51.5 cm each (quarter-wave at 144.39 MHz)
+ Material: Wire, coat hanger, or copper rod
+ Orientation: Vertical (APRS is FM, vertically polarized)
+ Connection: Center conductor to one element, shield to the other
+
+
+
+
+
Commercial Options
+
+ Mag-mount 2m whip: ~$15–25, good mobile/portable option
+ 2m/70cm dual-band: ~$20–40, also covers 70cm ham band
+ Discone: ~$30–50, wideband but lower gain on 2m
+
+
+
+
+
Quick Reference
+
+
+ APRS freq (N. America)
+ 144.390 MHz
+
+
+ APRS freq (Europe)
+ 144.800 MHz
+
+
+ Quarter-wave length
+ 51.5 cm
+
+
+ Modulation
+ FM 1200 baud
+
+
+ Polarization
+ Vertical
+
+
+
+
+
diff --git a/templates/partials/modes/meshtastic.html b/templates/partials/modes/meshtastic.html
index fcf3f9a..19bc102 100644
--- a/templates/partials/modes/meshtastic.html
+++ b/templates/partials/modes/meshtastic.html
@@ -55,6 +55,74 @@
+
+
+
+
Antenna Guide
+
+
+ LoRa ISM band — frequency depends on region
+
+
+
+
Stock Device Antenna
+
+ Most devices: Ship with a small 915/868 MHz stubby antenna
+ Works for: Short range (< 1 km) urban, indoor testing
+ Upgrade: Replace with tuned antenna for 5–20x range improvement
+
+
+
+
+
Recommended Upgrades
+
+ Whip antenna: ~$8–15, tuned 915/868 MHz, SMA connector
+ Ground plane: 8.2 cm vertical + 4 radials (915 MHz) on SMA
+ Yagi: ~$15–30, directional, great for point-to-point links
+ Collinear: ~$20–40, omnidirectional with higher gain (~5–8 dBi)
+
+
+
+
+
Placement Tips
+
+ Height wins: Elevating antenna 10m can double or triple range
+ Line of sight: LoRa works best with clear LOS to other nodes
+ Connector: Most devices use SMA or RP-SMA — check before buying
+
+
+
+
+
Quick Reference
+
+
+ US / Americas
+ 915 MHz
+
+
+ EU / UK / India
+ 868 MHz
+
+
+ 915 MHz λ/4
+ 8.2 cm
+
+
+ 868 MHz λ/4
+ 8.6 cm
+
+
+ Modulation
+ LoRa (CSS)
+
+
+ Typical range
+ 1–15 km
+
+
+
+
+
diff --git a/templates/partials/modes/pager.html b/templates/partials/modes/pager.html
index b9ba229..b9bef14 100644
--- a/templates/partials/modes/pager.html
+++ b/templates/partials/modes/pager.html
@@ -75,6 +75,62 @@
+
+
+
Antenna Guide
+
+
+ Pager frequencies vary by region (130–930 MHz)
+
+
+
+
Stock Telescopic Antenna
+
+ Works for: UHF pager bands (~900 MHz) — the stock antenna is tuned near 1 GHz
+ Extend to: ~8 cm for 929 MHz (quarter-wave)
+ For VHF (~150 MHz): Stock antenna is too short. Build a dipole (see below)
+
+
+
+
+
Simple Dipole (Best for VHF Pagers)
+
+ For 153 MHz: Two elements, each ~49 cm (quarter-wave)
+ For 929 MHz: Two elements, each ~8 cm
+ Formula: Element length (cm) = 7500 / frequency (MHz)
+ Material: Any wire, coat hanger, or copper rod
+ Orientation: Vertical (pager signals are vertically polarized)
+
+
+
+
+
Quick Reference
+
+
+ Common UHF freq
+ 929 MHz
+
+
+ Common VHF freq
+ 153.350 MHz
+
+
+ Modulation
+ FM (NFM)
+
+
+ Bandwidth
+ ~12.5 kHz
+
+
+ Polarization
+ Vertical
+
+
+
+
+
+
Start Decoding
diff --git a/templates/partials/modes/rtlamr.html b/templates/partials/modes/rtlamr.html
index af92918..55de5cd 100644
--- a/templates/partials/modes/rtlamr.html
+++ b/templates/partials/modes/rtlamr.html
@@ -58,6 +58,59 @@
+
+
+
Antenna Guide
+
+
+ ISM 900 MHz band — stock antenna is close but not optimal
+
+
+
+
Stock Telescopic Antenna
+
+ 912 MHz: Extend to ~8.2 cm (quarter-wave). The stock antenna is close enough to work
+ Range: Most meters transmit at ~100 mW, expect 50–200 m range with stock antenna
+
+
+
+
+
Upgraded Options
+
+ Ground Plane: 8.2 cm vertical + four 8.2 cm radials at 45° on SMA connector
+ Yagi: Directional for targeting specific meters at distance (~$15–25)
+ Placement: Near a window facing the meters. Line-of-sight matters most
+
+
+
+
+
Quick Reference
+
+
+ Frequency (NA)
+ 912 MHz
+
+
+ Frequency (EU)
+ 868 MHz
+
+
+ 912 MHz λ/4
+ 8.2 cm
+
+
+ Meter TX power
+ ~100 mW
+
+
+ Polarization
+ Vertical
+
+
+
+
+
+
Start Listening
diff --git a/templates/partials/modes/sensor.html b/templates/partials/modes/sensor.html
index 48b9238..c28e553 100644
--- a/templates/partials/modes/sensor.html
+++ b/templates/partials/modes/sensor.html
@@ -39,6 +39,61 @@
+
+
+
Antenna Guide
+
+
+ ISM band devices (433 / 868 / 915 MHz)
+
+
+
+
Stock Telescopic Antenna
+
+ 433 MHz: Extend to ~17 cm (quarter-wave). Stock antenna works but isn't ideal
+ 868/915 MHz: Extend to ~8 cm. Stock antenna is nearly tuned for this
+
+
+
+
+
Quarter-Wave Ground Plane (Best)
+
+ 433 MHz: Vertical element 17.3 cm + four 17.3 cm radials at 45°
+ 868 MHz: Vertical element 8.6 cm + four 8.6 cm radials
+ 915 MHz: Vertical element 8.2 cm + four 8.2 cm radials
+ Material: Stiff copper wire soldered to an SMA connector
+ Placement: Outdoors or near a window. Higher is better for range
+
+
+
+
+
Quick Reference
+
+
+ 433 MHz λ/4
+ 17.3 cm
+
+
+ 868 MHz λ/4
+ 8.6 cm
+
+
+ 915 MHz λ/4
+ 8.2 cm
+
+
+ Typical range
+ 50–300 m
+
+
+ Polarization
+ Vertical
+
+
+
+
+
+
Start Listening
diff --git a/templates/partials/modes/sstv.html b/templates/partials/modes/sstv.html
index 236c4eb..e264936 100644
--- a/templates/partials/modes/sstv.html
+++ b/templates/partials/modes/sstv.html
@@ -39,4 +39,64 @@
Common modes: PD120, PD180, Martin1, Scottie1
+
+
+
+
Antenna Guide
+
+
+ 2m band (145.800 MHz) — stock SDR antenna will NOT work
+
+
+
+
V-Dipole (Easiest — ~$5)
+
+ Element length: ~51 cm each (quarter-wave at 145.8 MHz)
+ Angle: 120° between elements for partial RHCP
+ Orientation: Lay flat, angled toward the ISS pass direction
+ Material: Wire, coat hanger, or copper rod
+
+
+ Same antenna as weather satellites (similar frequency). A QFH or turnstile for 137 MHz also works well here.
+
+
+
+
+
Tips for ISS Reception
+
+ ISS altitude: ~420 km, overhead passes last 5–10 minutes
+ Best passes: Elevation > 30° for clear signal
+ Outdoors: Clear sky view is essential. Roof or open field
+ LNA: Optional but helps — 2m filtered LNA at antenna feed
+ Doppler: ISS moves fast — signal shifts ±3.5 kHz during pass
+
+
+
+
+
Quick Reference
+
+
+ ISS SSTV frequency
+ 145.800 MHz
+
+
+ Quarter-wave length
+ 51 cm
+
+
+ Modulation
+ FM (25 kHz)
+
+
+ Polarization
+ RHCP (circular)
+
+
+ Typical pass duration
+ 5–10 min
+
+
+
+
+
diff --git a/templates/partials/modes/weather-satellite.html b/templates/partials/modes/weather-satellite.html
new file mode 100644
index 0000000..18aeee1
--- /dev/null
+++ b/templates/partials/modes/weather-satellite.html
@@ -0,0 +1,244 @@
+
+
+
+
Weather Satellite Decoder
+
+ Receive and decode weather images from NOAA and Meteor satellites.
+ Uses SatDump for live SDR capture and image processing.
+
+
+
+
+
Satellite
+
+ Select Satellite
+
+ 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)
+
+
+
+ Gain (dB)
+
+
+
+
+
+ Bias-T (power LNA)
+
+
+
+
+
+
+
Antenna Guide
+
+
+
+ 137 MHz band — your stock SDR antenna will NOT work.
+
+
+ Weather satellites transmit at 137.1–137.9 MHz. The quarter-wave
+ at this frequency is ~53 cm ,
+ far longer than the small telescopic antenna shipped with most SDRs
+ (tuned for ~1 GHz). You need a purpose-built antenna.
+
+
+
+
+
V-Dipole (Easiest — ~$5)
+
+
coax to SDR
+ |
+ ===+=== feed point
+ / \
+ / 120 \
+ / \
+/ deg \
+53.4cm 53.4cm
+
+
+ Element length: 53.4 cm each (quarter wavelength at 137 MHz)
+ Angle: 120° between elements (not 180°)
+ Material: Any stiff wire, coat hanger, or copper rod
+ Orientation: Lay flat or tilt 30° toward expected pass direction
+ Polarization: The 120° angle gives partial RHCP match to satellite signal
+ Connection: Solder elements to coax center + shield, connect to SDR via SMA
+
+
+ Best starter antenna. Good enough for clear NOAA images with a direct overhead pass.
+
+
+
+
+
+
Turnstile / Crossed Dipole (~$10-15)
+
+
53.4cm
+ <--------->
+ ====+==== dipole 1
+ |
+ ====+==== dipole 2
+ <--------->
+ 90 deg rotated
+ + reflector below
+
+
+ Elements: Two crossed dipoles, each 53.4 cm per side (4 elements total)
+ Angle: 90° between the two dipole pairs
+ Phasing: Feed dipole 2 with a 90° delay (quarter-wave coax section ~37 cm of RG-58)
+ Reflector: Place ~52 cm below elements (ground plane or wire grid)
+ Polarization: Circular (RHCP) — matches satellite transmission
+
+
+ Better than V-dipole. The reflector rejects ground noise and the RHCP phasing matches the satellite signal.
+
+
+
+
+
+
QFH — Quadrifilar Helix (Best — ~$20-30)
+
+
___
+ / \ two helix loops
+ | | | twisted 90 deg
+ | | | around a mast
+ \___/
+ |
+ coax
+
+
+ Design: Two bifilar helical loops, offset 90°
+ Material: Copper pipe (10mm), copper wire, or coax outer shield
+ Total height: ~46 cm (for 137 MHz)
+ Loop dimensions: Use a QFH calculator for exact bending measurements
+ Polarization: True RHCP omnidirectional — ideal for overhead satellite passes
+ Gain pattern: Hemispherical upward coverage, rejects ground interference
+
+
+ Gold standard for weather satellite reception. No tracking needed — covers the whole sky.
+
+
+
+
+
+
Placement & LNA
+
+ Location: OUTDOORS with clear sky view is critical. Roof/balcony/open field.
+ Height: Higher is better but not critical — clear horizon line matters more
+ Antenna up: Point the antenna straight UP (zenith) for best overhead coverage
+ Avoid: Metal roofs, power lines, buildings blocking the sky
+ Coax length: Keep short (<10m). Signal loss at 137 MHz is ~3 dB per 10m of RG-58
+ LNA: Mount at the antenna feed point, NOT at the SDR end.
+ Recommended: Nooelec SAWbird+ NOAA (137 MHz filtered LNA, ~$30)
+ Bias-T: Enable the Bias-T checkbox above if your LNA is powered via the coax from the SDR
+
+
+
+
+
+
Quick Reference
+
+
+ Wavelength (137 MHz)
+ 218.8 cm
+
+
+ Quarter wave (element length)
+ 53.4 cm
+
+
+ Best pass elevation
+ > 30°
+
+
+ Typical pass duration
+ 10-15 min
+
+
+ Polarization
+ RHCP
+
+
+ NOAA (APT) bandwidth
+ ~40 kHz
+
+
+ Meteor (LRPT) bandwidth
+ ~140 kHz
+
+
+
+
+
+
+
+
+ Test Decode (File)
+ ▼
+
+
+
+ Decode a pre-recorded IQ or WAV file without SDR hardware.
+ Run ./download-weather-sat-samples.sh to fetch sample files.
+
+
+ Satellite
+
+ NOAA-15 (APT)
+ NOAA-18 (APT)
+ NOAA-19 (APT)
+ Meteor-M2-3 (LRPT)
+
+
+
+ File Path (server-side)
+
+
+
+ Sample Rate
+
+ 11025 Hz (WAV audio APT)
+ 48000 Hz (WAV audio APT)
+ 500 kHz (IQ LRPT)
+ 1 MHz (IQ default)
+ 2 MHz (IQ wideband)
+
+
+
+ Test Decode
+
+
+
+
+
+
Auto-Scheduler
+
+ Automatically capture satellite passes based on predictions.
+ Set your location above and toggle AUTO in the strip bar.
+
+
+
+
+ Enable Auto-Capture
+
+
+
+ Disabled
+
+
+
+
+
diff --git a/templates/partials/nav.html b/templates/partials/nav.html
index 42c942d..79e1cb4 100644
--- a/templates/partials/nav.html
+++ b/templates/partials/nav.html
@@ -118,6 +118,7 @@
{{ mode_item('satellite', 'Satellite', ' ', '/satellite/dashboard') }}
{% endif %}
{{ mode_item('sstv', 'ISS SSTV', ' ') }}
+ {{ mode_item('weathersat', 'Weather Sat', ' ') }}
{{ mode_item('sstv_general', 'HF SSTV', ' ') }}
@@ -185,6 +186,7 @@
{{ mobile_item('satellite', 'Sat', ' ', '/satellite/dashboard') }}
{% endif %}
{{ mobile_item('sstv', 'SSTV', ' ') }}
+ {{ mobile_item('weathersat', 'WxSat', ' ') }}
{{ mobile_item('sstv_general', 'HF SSTV', ' ') }}
{{ mobile_item('listening', 'Scanner', ' ') }}
{{ mobile_item('spystations', 'Spy', ' ') }}
diff --git a/tests/test_weather_sat_decoder.py b/tests/test_weather_sat_decoder.py
new file mode 100644
index 0000000..1f48642
--- /dev/null
+++ b/tests/test_weather_sat_decoder.py
@@ -0,0 +1,643 @@
+"""Tests for WeatherSatDecoder class.
+
+Covers WeatherSatDecoder methods, subprocess management, progress callbacks,
+and image handling.
+"""
+
+from __future__ import annotations
+
+import os
+import tempfile
+import threading
+import time
+from datetime import datetime, timezone
+from pathlib import Path
+from unittest.mock import patch, MagicMock, call, mock_open
+import pytest
+
+from utils.weather_sat import (
+ WeatherSatDecoder,
+ WeatherSatImage,
+ CaptureProgress,
+ WEATHER_SATELLITES,
+ get_weather_sat_decoder,
+ is_weather_sat_available,
+)
+
+
+class TestWeatherSatDecoder:
+ """Tests for WeatherSatDecoder class."""
+
+ def test_decoder_initialization(self):
+ """Decoder should initialize with default output directory."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'):
+ decoder = WeatherSatDecoder()
+ assert decoder.is_running is False
+ assert decoder.decoder_available == 'satdump'
+ assert decoder.current_satellite == ''
+ assert decoder.current_frequency == 0.0
+
+ def test_decoder_initialization_no_satdump(self):
+ """Decoder should detect when SatDump is unavailable."""
+ with patch('shutil.which', return_value=None):
+ decoder = WeatherSatDecoder()
+ assert decoder.decoder_available is None
+
+ def test_decoder_custom_output_dir(self):
+ """Decoder should accept custom output directory."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'):
+ custom_dir = '/tmp/custom_output'
+ decoder = WeatherSatDecoder(output_dir=custom_dir)
+ assert decoder._output_dir == Path(custom_dir)
+
+ def test_set_callback(self):
+ """Decoder should accept progress callback."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'):
+ decoder = WeatherSatDecoder()
+ callback = MagicMock()
+ decoder.set_callback(callback)
+ assert decoder._callback == callback
+
+ def test_set_on_complete(self):
+ """Decoder should accept on_complete callback."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'):
+ decoder = WeatherSatDecoder()
+ callback = MagicMock()
+ decoder.set_on_complete(callback)
+ assert decoder._on_complete_callback == callback
+
+ def test_start_no_decoder(self):
+ """start() should fail when no decoder available."""
+ with patch('shutil.which', return_value=None):
+ decoder = WeatherSatDecoder()
+ callback = MagicMock()
+ decoder.set_callback(callback)
+
+ success = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0)
+
+ assert success is False
+ callback.assert_called()
+ progress = callback.call_args[0][0]
+ assert progress.status == 'error'
+ assert 'SatDump' in progress.message
+
+ def test_start_invalid_satellite(self):
+ """start() should fail with invalid satellite."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'):
+ decoder = WeatherSatDecoder()
+ callback = MagicMock()
+ decoder.set_callback(callback)
+
+ success = decoder.start(satellite='FAKE-SAT', device_index=0, gain=40.0)
+
+ assert success is False
+ callback.assert_called()
+ progress = callback.call_args[0][0]
+ assert progress.status == 'error'
+ assert 'Unknown satellite' in progress.message
+
+ @patch('subprocess.Popen')
+ @patch('pty.openpty')
+ @patch('utils.weather_sat.register_process')
+ def test_start_success(self, mock_register, mock_pty, mock_popen):
+ """start() should successfully start SatDump."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'), \
+ patch('utils.weather_sat.WeatherSatDecoder._resolve_device_id', return_value='0'):
+
+ mock_pty.return_value = (10, 11)
+ mock_process = MagicMock()
+ mock_process.poll.return_value = None
+ mock_popen.return_value = mock_process
+
+ decoder = WeatherSatDecoder()
+ callback = MagicMock()
+ decoder.set_callback(callback)
+
+ success = decoder.start(
+ satellite='NOAA-18',
+ device_index=0,
+ gain=40.0,
+ bias_t=True,
+ )
+
+ assert success is True
+ assert decoder.is_running is True
+ assert decoder.current_satellite == 'NOAA-18'
+ assert decoder.current_frequency == 137.9125
+ assert decoder.current_mode == 'APT'
+ assert decoder.device_index == 0
+
+ mock_popen.assert_called_once()
+ cmd = mock_popen.call_args[0][0]
+ assert cmd[0] == 'satdump'
+ assert 'live' in cmd
+ assert 'noaa_apt' in cmd
+ assert '--bias' in cmd
+
+ @patch('subprocess.Popen')
+ @patch('pty.openpty')
+ def test_start_already_running(self, mock_pty, mock_popen):
+ """start() should return True when already running."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'):
+ decoder = WeatherSatDecoder()
+ decoder._running = True
+
+ success = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0)
+
+ assert success is True
+ mock_popen.assert_not_called()
+
+ @patch('subprocess.Popen')
+ @patch('pty.openpty')
+ def test_start_exception_handling(self, mock_pty, mock_popen):
+ """start() should handle exceptions gracefully."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'):
+ mock_pty.return_value = (10, 11)
+ mock_popen.side_effect = OSError('Device not found')
+
+ decoder = WeatherSatDecoder()
+ callback = MagicMock()
+ decoder.set_callback(callback)
+
+ success = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0)
+
+ assert success is False
+ assert decoder.is_running is False
+ callback.assert_called()
+ progress = callback.call_args[0][0]
+ assert progress.status == 'error'
+
+ def test_start_from_file_no_decoder(self):
+ """start_from_file() should fail when no decoder available."""
+ with patch('shutil.which', return_value=None):
+ decoder = WeatherSatDecoder()
+ callback = MagicMock()
+ decoder.set_callback(callback)
+
+ success = decoder.start_from_file(
+ satellite='NOAA-18',
+ input_file='data/test.wav',
+ )
+
+ assert success is False
+ callback.assert_called()
+
+ @patch('subprocess.Popen')
+ @patch('pty.openpty')
+ @patch('pathlib.Path.is_file', return_value=True)
+ @patch('pathlib.Path.resolve')
+ def test_start_from_file_success(self, mock_resolve, mock_is_file, mock_pty, mock_popen):
+ """start_from_file() should successfully decode from file."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'), \
+ patch('utils.weather_sat.register_process'):
+
+ # Mock path resolution
+ mock_path = MagicMock()
+ mock_path.is_relative_to.return_value = True
+ mock_path.suffix = '.wav'
+ mock_resolve.return_value = mock_path
+
+ mock_pty.return_value = (10, 11)
+ mock_process = MagicMock()
+ mock_popen.return_value = mock_process
+
+ decoder = WeatherSatDecoder()
+ callback = MagicMock()
+ decoder.set_callback(callback)
+
+ success = decoder.start_from_file(
+ satellite='NOAA-18',
+ input_file='data/test.wav',
+ sample_rate=1000000,
+ )
+
+ assert success is True
+ assert decoder.is_running is True
+ assert decoder.current_satellite == 'NOAA-18'
+
+ mock_popen.assert_called_once()
+ cmd = mock_popen.call_args[0][0]
+ assert cmd[0] == 'satdump'
+ assert 'noaa_apt' in cmd
+ assert 'audio_wav' in cmd
+ assert '--samplerate' in cmd
+
+ @patch('pathlib.Path.resolve')
+ def test_start_from_file_path_traversal(self, mock_resolve):
+ """start_from_file() should block path traversal."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'):
+ # Mock path outside allowed directory
+ mock_path = MagicMock()
+ mock_path.is_relative_to.return_value = False
+ mock_resolve.return_value = mock_path
+
+ decoder = WeatherSatDecoder()
+ callback = MagicMock()
+ decoder.set_callback(callback)
+
+ success = decoder.start_from_file(
+ satellite='NOAA-18',
+ input_file='/etc/passwd',
+ )
+
+ assert success is False
+ callback.assert_called()
+ progress = callback.call_args[0][0]
+ assert 'data/ directory' in progress.message
+
+ @patch('pathlib.Path.is_file', return_value=False)
+ @patch('pathlib.Path.resolve')
+ def test_start_from_file_not_found(self, mock_resolve, mock_is_file):
+ """start_from_file() should fail when file not found."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'):
+ mock_path = MagicMock()
+ mock_path.is_relative_to.return_value = True
+ mock_resolve.return_value = mock_path
+
+ decoder = WeatherSatDecoder()
+ callback = MagicMock()
+ decoder.set_callback(callback)
+
+ success = decoder.start_from_file(
+ satellite='NOAA-18',
+ input_file='data/missing.wav',
+ )
+
+ assert success is False
+ callback.assert_called()
+ progress = callback.call_args[0][0]
+ assert 'not found' in progress.message.lower()
+
+ def test_stop_not_running(self):
+ """stop() should be safe when not running."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'):
+ decoder = WeatherSatDecoder()
+ decoder.stop() # Should not raise
+
+ @patch('utils.weather_sat.safe_terminate')
+ def test_stop_running(self, mock_terminate):
+ """stop() should terminate process."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'):
+ decoder = WeatherSatDecoder()
+ mock_process = MagicMock()
+ decoder._process = mock_process
+ decoder._running = True
+ decoder._pty_master_fd = 10
+
+ with patch('os.close') as mock_close:
+ decoder.stop()
+
+ assert decoder._running is False
+ mock_terminate.assert_called_once_with(mock_process)
+ mock_close.assert_called_once_with(10)
+
+ def test_get_images_empty(self):
+ """get_images() should return empty list initially."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'):
+ decoder = WeatherSatDecoder()
+ images = decoder.get_images()
+ assert images == []
+
+ @patch('pathlib.Path.glob')
+ @patch('pathlib.Path.stat')
+ def test_get_images_scans_directory(self, mock_stat, mock_glob):
+ """get_images() should scan output directory."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'):
+ decoder = WeatherSatDecoder()
+
+ # Mock image files
+ mock_file = MagicMock()
+ mock_file.name = 'NOAA-18_test.png'
+ mock_file.stat.return_value.st_size = 10000
+ mock_file.stat.return_value.st_mtime = time.time()
+ mock_glob.return_value = [mock_file]
+
+ images = decoder.get_images()
+
+ assert len(images) == 1
+ assert images[0].filename == 'NOAA-18_test.png'
+ assert images[0].satellite == 'NOAA-18'
+
+ def test_delete_image_success(self):
+ """delete_image() should delete file."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'):
+ decoder = WeatherSatDecoder()
+
+ with patch('pathlib.Path.exists', return_value=True), \
+ patch('pathlib.Path.unlink') as mock_unlink:
+
+ result = decoder.delete_image('test.png')
+
+ assert result is True
+ mock_unlink.assert_called_once()
+
+ def test_delete_image_not_found(self):
+ """delete_image() should return False for non-existent file."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'):
+ decoder = WeatherSatDecoder()
+
+ with patch('pathlib.Path.exists', return_value=False):
+ result = decoder.delete_image('missing.png')
+
+ assert result is False
+
+ def test_delete_all_images(self):
+ """delete_all_images() should delete all images."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'):
+ decoder = WeatherSatDecoder()
+
+ mock_files = [MagicMock() for _ in range(3)]
+ with patch('pathlib.Path.glob', return_value=mock_files):
+ count = decoder.delete_all_images()
+
+ assert count == 3
+ for f in mock_files:
+ f.unlink.assert_called_once()
+
+ def test_get_status_idle(self):
+ """get_status() should return idle status."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'):
+ decoder = WeatherSatDecoder()
+ status = decoder.get_status()
+
+ assert status['available'] is True
+ assert status['decoder'] == 'satdump'
+ assert status['running'] is False
+ assert status['satellite'] == ''
+
+ def test_get_status_running(self):
+ """get_status() should return running status."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'):
+ decoder = WeatherSatDecoder()
+ decoder._running = True
+ decoder._current_satellite = 'NOAA-18'
+ decoder._current_frequency = 137.9125
+ decoder._current_mode = 'APT'
+ decoder._capture_start_time = time.time() - 60
+
+ status = decoder.get_status()
+
+ assert status['running'] is True
+ assert status['satellite'] == 'NOAA-18'
+ assert status['frequency'] == 137.9125
+ assert status['mode'] == 'APT'
+ assert status['elapsed_seconds'] >= 60
+
+ def test_classify_log_type_error(self):
+ """_classify_log_type() should detect errors."""
+ assert WeatherSatDecoder._classify_log_type('(E) Error occurred') == 'error'
+ assert WeatherSatDecoder._classify_log_type('Failed to open device') == 'error'
+
+ def test_classify_log_type_progress(self):
+ """_classify_log_type() should detect progress."""
+ assert WeatherSatDecoder._classify_log_type('Progress: 50%') == 'progress'
+
+ def test_classify_log_type_save(self):
+ """_classify_log_type() should detect save events."""
+ assert WeatherSatDecoder._classify_log_type('Saved image: test.png') == 'save'
+ assert WeatherSatDecoder._classify_log_type('Writing output file') == 'save'
+
+ def test_classify_log_type_signal(self):
+ """_classify_log_type() should detect signal events."""
+ assert WeatherSatDecoder._classify_log_type('Signal detected') == 'signal'
+ assert WeatherSatDecoder._classify_log_type('Lock acquired') == 'signal'
+
+ def test_classify_log_type_warning(self):
+ """_classify_log_type() should detect warnings."""
+ assert WeatherSatDecoder._classify_log_type('(W) Low signal quality') == 'warning'
+
+ def test_classify_log_type_debug(self):
+ """_classify_log_type() should detect debug messages."""
+ assert WeatherSatDecoder._classify_log_type('(D) Debug info') == 'debug'
+
+ @patch('subprocess.run')
+ def test_resolve_device_id_success(self, mock_run):
+ """_resolve_device_id() should extract serial from rtl_test."""
+ mock_result = MagicMock()
+ mock_result.stdout = 'Found 1 device(s):\n 0: RTLSDRBlog, SN: 00004000'
+ mock_result.stderr = ''
+ mock_run.return_value = mock_result
+
+ serial = WeatherSatDecoder._resolve_device_id(0)
+
+ assert serial == '00004000'
+ mock_run.assert_called_once()
+
+ @patch('subprocess.run')
+ def test_resolve_device_id_fallback(self, mock_run):
+ """_resolve_device_id() should fall back to index string."""
+ mock_run.side_effect = FileNotFoundError
+
+ serial = WeatherSatDecoder._resolve_device_id(0)
+
+ assert serial == '0'
+
+ def test_parse_product_name_rgb(self):
+ """_parse_product_name() should identify RGB composite."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'):
+ decoder = WeatherSatDecoder()
+ product = decoder._parse_product_name(Path('/tmp/output/rgb_composite.png'))
+ assert product == 'RGB Composite'
+
+ def test_parse_product_name_thermal(self):
+ """_parse_product_name() should identify thermal imagery."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'):
+ decoder = WeatherSatDecoder()
+ product = decoder._parse_product_name(Path('/tmp/output/thermal_image.png'))
+ assert product == 'Thermal'
+
+ def test_parse_product_name_channel(self):
+ """_parse_product_name() should identify channel images."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'):
+ decoder = WeatherSatDecoder()
+ product = decoder._parse_product_name(Path('/tmp/output/channel_3.png'))
+ assert product == 'Channel 3'
+
+ def test_parse_product_name_unknown(self):
+ """_parse_product_name() should return stem for unknown products."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'):
+ decoder = WeatherSatDecoder()
+ product = decoder._parse_product_name(Path('/tmp/output/unknown_image.png'))
+ assert product == 'unknown_image'
+
+ def test_emit_progress(self):
+ """_emit_progress() should call callback."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'):
+ decoder = WeatherSatDecoder()
+ callback = MagicMock()
+ decoder.set_callback(callback)
+
+ progress = CaptureProgress(status='capturing', message='Test')
+ decoder._emit_progress(progress)
+
+ callback.assert_called_once_with(progress)
+
+ def test_emit_progress_no_callback(self):
+ """_emit_progress() should handle missing callback."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'):
+ decoder = WeatherSatDecoder()
+ progress = CaptureProgress(status='capturing', message='Test')
+ decoder._emit_progress(progress) # Should not raise
+
+ def test_emit_progress_callback_exception(self):
+ """_emit_progress() should handle callback exceptions."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'):
+ decoder = WeatherSatDecoder()
+ callback = MagicMock(side_effect=Exception('Callback error'))
+ decoder.set_callback(callback)
+
+ progress = CaptureProgress(status='capturing', message='Test')
+ decoder._emit_progress(progress) # Should not raise
+
+
+class TestWeatherSatImage:
+ """Tests for WeatherSatImage dataclass."""
+
+ def test_to_dict(self):
+ """WeatherSatImage.to_dict() should serialize correctly."""
+ image = WeatherSatImage(
+ filename='test.png',
+ path=Path('/tmp/test.png'),
+ satellite='NOAA-18',
+ mode='APT',
+ timestamp=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
+ frequency=137.9125,
+ size_bytes=12345,
+ product='RGB Composite',
+ )
+
+ data = image.to_dict()
+
+ assert data['filename'] == 'test.png'
+ assert data['satellite'] == 'NOAA-18'
+ assert data['mode'] == 'APT'
+ assert data['timestamp'] == '2024-01-01T12:00:00+00:00'
+ assert data['frequency'] == 137.9125
+ assert data['size_bytes'] == 12345
+ assert data['product'] == 'RGB Composite'
+ assert data['url'] == '/weather-sat/images/test.png'
+
+
+class TestCaptureProgress:
+ """Tests for CaptureProgress dataclass."""
+
+ def test_to_dict_minimal(self):
+ """CaptureProgress.to_dict() with minimal fields."""
+ progress = CaptureProgress(status='idle')
+ data = progress.to_dict()
+
+ assert data['type'] == 'weather_sat_progress'
+ assert data['status'] == 'idle'
+ assert data['satellite'] == ''
+ assert data['message'] == ''
+ assert data['progress'] == 0
+
+ def test_to_dict_complete(self):
+ """CaptureProgress.to_dict() with all fields."""
+ image = WeatherSatImage(
+ filename='test.png',
+ path=Path('/tmp/test.png'),
+ satellite='NOAA-18',
+ mode='APT',
+ timestamp=datetime.now(timezone.utc),
+ frequency=137.9125,
+ )
+
+ progress = CaptureProgress(
+ status='complete',
+ satellite='NOAA-18',
+ frequency=137.9125,
+ mode='APT',
+ message='Capture complete',
+ progress_percent=100,
+ elapsed_seconds=600,
+ image=image,
+ log_type='info',
+ capture_phase='complete',
+ )
+
+ data = progress.to_dict()
+
+ assert data['status'] == 'complete'
+ assert data['satellite'] == 'NOAA-18'
+ assert data['frequency'] == 137.9125
+ assert data['mode'] == 'APT'
+ assert data['message'] == 'Capture complete'
+ assert data['progress'] == 100
+ assert data['elapsed_seconds'] == 600
+ assert 'image' in data
+ assert data['log_type'] == 'info'
+ assert data['capture_phase'] == 'complete'
+
+
+class TestGlobalFunctions:
+ """Tests for global utility functions."""
+
+ def test_get_weather_sat_decoder_singleton(self):
+ """get_weather_sat_decoder() should return singleton."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'):
+ import utils.weather_sat as mod
+ old = mod._decoder
+ mod._decoder = None
+
+ try:
+ decoder1 = get_weather_sat_decoder()
+ decoder2 = get_weather_sat_decoder()
+
+ assert decoder1 is decoder2
+ finally:
+ mod._decoder = old
+
+ def test_is_weather_sat_available_true(self):
+ """is_weather_sat_available() should return True when available."""
+ with patch('shutil.which', return_value='/usr/bin/satdump'):
+ import utils.weather_sat as mod
+ old = mod._decoder
+ mod._decoder = None
+
+ try:
+ assert is_weather_sat_available() is True
+ finally:
+ mod._decoder = old
+
+ def test_is_weather_sat_available_false(self):
+ """is_weather_sat_available() should return False when unavailable."""
+ with patch('shutil.which', return_value=None):
+ import utils.weather_sat as mod
+ old = mod._decoder
+ mod._decoder = None
+
+ try:
+ assert is_weather_sat_available() is False
+ finally:
+ mod._decoder = old
+
+
+class TestWeatherSatellitesConstant:
+ """Tests for WEATHER_SATELLITES constant."""
+
+ def test_weather_satellites_structure(self):
+ """WEATHER_SATELLITES should have correct structure."""
+ assert 'NOAA-18' in WEATHER_SATELLITES
+ sat = WEATHER_SATELLITES['NOAA-18']
+
+ assert 'name' in sat
+ assert 'frequency' in sat
+ assert 'mode' in sat
+ assert 'pipeline' in sat
+ assert 'tle_key' in sat
+ assert 'description' in sat
+ assert 'active' in sat
+
+ def test_noaa_satellites(self):
+ """NOAA satellites should have correct frequencies."""
+ assert WEATHER_SATELLITES['NOAA-15']['frequency'] == 137.620
+ assert WEATHER_SATELLITES['NOAA-18']['frequency'] == 137.9125
+ assert WEATHER_SATELLITES['NOAA-19']['frequency'] == 137.100
+
+ def test_meteor_satellite(self):
+ """Meteor satellite should use LRPT mode."""
+ meteor = WEATHER_SATELLITES['METEOR-M2-3']
+ assert meteor['mode'] == 'LRPT'
+ assert meteor['frequency'] == 137.900
+ assert meteor['pipeline'] == 'meteor_m2-x_lrpt'
diff --git a/tests/test_weather_sat_predict.py b/tests/test_weather_sat_predict.py
new file mode 100644
index 0000000..97c2e29
--- /dev/null
+++ b/tests/test_weather_sat_predict.py
@@ -0,0 +1,675 @@
+"""Tests for weather satellite pass prediction.
+
+Covers predict_passes() function, TLE handling, trajectory computation,
+and ground track generation.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone, timedelta
+from unittest.mock import patch, MagicMock
+import pytest
+
+from utils.weather_sat_predict import predict_passes
+
+
+class TestPredictPasses:
+ """Tests for predict_passes() function."""
+
+ @patch('utils.weather_sat_predict.load')
+ @patch('utils.weather_sat_predict.TLE_SATELLITES')
+ def test_predict_passes_no_tle_data(self, mock_tle, mock_load):
+ """predict_passes() should handle missing TLE data."""
+ mock_tle.get.return_value = None
+ mock_ts = MagicMock()
+ mock_ts.now.return_value = MagicMock()
+ mock_ts.utc.return_value = MagicMock()
+ mock_load.timescale.return_value = mock_ts
+
+ passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
+
+ assert passes == []
+
+ @patch('utils.weather_sat_predict.load')
+ @patch('utils.weather_sat_predict.TLE_SATELLITES')
+ @patch('utils.weather_sat_predict.wgs84')
+ @patch('utils.weather_sat_predict.EarthSatellite')
+ @patch('utils.weather_sat_predict.find_discrete')
+ def test_predict_passes_basic(self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load):
+ """predict_passes() should predict basic passes."""
+ # Mock timescale
+ mock_ts = MagicMock()
+ now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
+ mock_now = MagicMock()
+ mock_now.utc_datetime.return_value = now
+ mock_ts.now.return_value = mock_now
+ mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
+ mock_load.timescale.return_value = mock_ts
+
+ # Mock TLE data
+ mock_tle.get.return_value = (
+ 'NOAA-18',
+ '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
+ '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
+ )
+
+ # Mock observer
+ mock_observer = MagicMock()
+ mock_wgs84.latlon.return_value = mock_observer
+
+ # Mock satellite
+ mock_satellite_obj = MagicMock()
+ mock_sat.return_value = mock_satellite_obj
+
+ # Mock pass detection - one pass
+ rise_time = MagicMock()
+ rise_time.utc_datetime.return_value = now + timedelta(hours=2)
+ set_time = MagicMock()
+ set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
+
+ mock_find.return_value = ([rise_time, set_time], [True, False])
+
+ # Mock topocentric calculations
+ def mock_topocentric(t):
+ topo = MagicMock()
+ alt = MagicMock()
+ alt.degrees = 45.0
+ az = MagicMock()
+ az.degrees = 180.0
+ topo.altaz.return_value = (alt, az, MagicMock())
+ return topo
+
+ mock_diff = MagicMock()
+ mock_diff.at.side_effect = mock_topocentric
+ mock_satellite_obj.__sub__.return_value = mock_diff
+
+ passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
+
+ assert len(passes) == 1
+ pass_data = passes[0]
+ assert pass_data['satellite'] == 'NOAA-18'
+ assert pass_data['name'] == 'NOAA 18'
+ assert pass_data['frequency'] == 137.9125
+ assert pass_data['mode'] == 'APT'
+ assert 'maxEl' in pass_data
+ assert 'duration' in pass_data
+ assert 'quality' in pass_data
+
+ @patch('utils.weather_sat_predict.load')
+ @patch('utils.weather_sat_predict.TLE_SATELLITES')
+ @patch('utils.weather_sat_predict.wgs84')
+ @patch('utils.weather_sat_predict.EarthSatellite')
+ @patch('utils.weather_sat_predict.find_discrete')
+ def test_predict_passes_below_min_elevation(
+ self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
+ ):
+ """predict_passes() should filter passes below min elevation."""
+ mock_ts = MagicMock()
+ now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
+ mock_now = MagicMock()
+ mock_now.utc_datetime.return_value = now
+ mock_ts.now.return_value = mock_now
+ mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
+ mock_load.timescale.return_value = mock_ts
+
+ mock_tle.get.return_value = (
+ 'NOAA-18',
+ '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
+ '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
+ )
+
+ mock_observer = MagicMock()
+ mock_wgs84.latlon.return_value = mock_observer
+
+ mock_satellite_obj = MagicMock()
+ mock_sat.return_value = mock_satellite_obj
+
+ rise_time = MagicMock()
+ rise_time.utc_datetime.return_value = now + timedelta(hours=2)
+ set_time = MagicMock()
+ set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
+
+ mock_find.return_value = ([rise_time, set_time], [True, False])
+
+ # Mock low elevation pass
+ def mock_topocentric(t):
+ topo = MagicMock()
+ alt = MagicMock()
+ alt.degrees = 10.0 # Below min_elevation of 15
+ az = MagicMock()
+ az.degrees = 180.0
+ topo.altaz.return_value = (alt, az, MagicMock())
+ return topo
+
+ mock_diff = MagicMock()
+ mock_diff.at.side_effect = mock_topocentric
+ mock_satellite_obj.__sub__.return_value = mock_diff
+
+ passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
+
+ assert len(passes) == 0
+
+ @patch('utils.weather_sat_predict.load')
+ @patch('utils.weather_sat_predict.TLE_SATELLITES')
+ @patch('utils.weather_sat_predict.wgs84')
+ @patch('utils.weather_sat_predict.EarthSatellite')
+ @patch('utils.weather_sat_predict.find_discrete')
+ def test_predict_passes_with_trajectory(
+ self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
+ ):
+ """predict_passes() should include trajectory when requested."""
+ mock_ts = MagicMock()
+ now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
+ mock_now = MagicMock()
+ mock_now.utc_datetime.return_value = now
+ mock_ts.now.return_value = mock_now
+ mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
+ mock_load.timescale.return_value = mock_ts
+
+ mock_tle.get.return_value = (
+ 'NOAA-18',
+ '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
+ '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
+ )
+
+ mock_observer = MagicMock()
+ mock_wgs84.latlon.return_value = mock_observer
+
+ mock_satellite_obj = MagicMock()
+ mock_sat.return_value = mock_satellite_obj
+
+ rise_time = MagicMock()
+ rise_time.utc_datetime.return_value = now + timedelta(hours=2)
+ set_time = MagicMock()
+ set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
+
+ mock_find.return_value = ([rise_time, set_time], [True, False])
+
+ def mock_topocentric(t):
+ topo = MagicMock()
+ alt = MagicMock()
+ alt.degrees = 45.0
+ az = MagicMock()
+ az.degrees = 180.0
+ topo.altaz.return_value = (alt, az, MagicMock())
+ return topo
+
+ mock_diff = MagicMock()
+ mock_diff.at.side_effect = mock_topocentric
+ mock_satellite_obj.__sub__.return_value = mock_diff
+
+ passes = predict_passes(
+ lat=51.5, lon=-0.1, hours=24, min_elevation=15, include_trajectory=True
+ )
+
+ assert len(passes) == 1
+ assert 'trajectory' in passes[0]
+ assert len(passes[0]['trajectory']) == 30
+
+ @patch('utils.weather_sat_predict.load')
+ @patch('utils.weather_sat_predict.TLE_SATELLITES')
+ @patch('utils.weather_sat_predict.wgs84')
+ @patch('utils.weather_sat_predict.EarthSatellite')
+ @patch('utils.weather_sat_predict.find_discrete')
+ def test_predict_passes_with_ground_track(
+ self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
+ ):
+ """predict_passes() should include ground track when requested."""
+ mock_ts = MagicMock()
+ now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
+ mock_now = MagicMock()
+ mock_now.utc_datetime.return_value = now
+ mock_ts.now.return_value = mock_now
+ mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
+ mock_load.timescale.return_value = mock_ts
+
+ mock_tle.get.return_value = (
+ 'NOAA-18',
+ '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
+ '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
+ )
+
+ mock_observer = MagicMock()
+ mock_wgs84.latlon.return_value = mock_observer
+
+ mock_satellite_obj = MagicMock()
+ mock_sat.return_value = mock_satellite_obj
+
+ rise_time = MagicMock()
+ rise_time.utc_datetime.return_value = now + timedelta(hours=2)
+ set_time = MagicMock()
+ set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
+
+ mock_find.return_value = ([rise_time, set_time], [True, False])
+
+ def mock_topocentric(t):
+ topo = MagicMock()
+ alt = MagicMock()
+ alt.degrees = 45.0
+ az = MagicMock()
+ az.degrees = 180.0
+ topo.altaz.return_value = (alt, az, MagicMock())
+ return topo
+
+ mock_diff = MagicMock()
+ mock_diff.at.side_effect = mock_topocentric
+ mock_satellite_obj.__sub__.return_value = mock_diff
+
+ # Mock geocentric position
+ def mock_at(t):
+ geocentric = MagicMock()
+ return geocentric
+
+ mock_satellite_obj.at.side_effect = mock_at
+
+ # Mock subpoint
+ mock_subpoint = MagicMock()
+ mock_lat = MagicMock()
+ mock_lat.degrees = 51.5
+ mock_lon = MagicMock()
+ mock_lon.degrees = -0.1
+ mock_subpoint.latitude = mock_lat
+ mock_subpoint.longitude = mock_lon
+ mock_wgs84.subpoint.return_value = mock_subpoint
+
+ passes = predict_passes(
+ lat=51.5, lon=-0.1, hours=24, min_elevation=15, include_ground_track=True
+ )
+
+ assert len(passes) == 1
+ assert 'groundTrack' in passes[0]
+ assert len(passes[0]['groundTrack']) == 60
+
+ @patch('utils.weather_sat_predict.load')
+ @patch('utils.weather_sat_predict.TLE_SATELLITES')
+ @patch('utils.weather_sat_predict.wgs84')
+ @patch('utils.weather_sat_predict.EarthSatellite')
+ @patch('utils.weather_sat_predict.find_discrete')
+ def test_predict_passes_quality_excellent(
+ self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
+ ):
+ """predict_passes() should mark high elevation passes as excellent."""
+ mock_ts = MagicMock()
+ now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
+ mock_now = MagicMock()
+ mock_now.utc_datetime.return_value = now
+ mock_ts.now.return_value = mock_now
+ mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
+ mock_load.timescale.return_value = mock_ts
+
+ mock_tle.get.return_value = (
+ 'NOAA-18',
+ '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
+ '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
+ )
+
+ mock_observer = MagicMock()
+ mock_wgs84.latlon.return_value = mock_observer
+
+ mock_satellite_obj = MagicMock()
+ mock_sat.return_value = mock_satellite_obj
+
+ rise_time = MagicMock()
+ rise_time.utc_datetime.return_value = now + timedelta(hours=2)
+ set_time = MagicMock()
+ set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
+
+ mock_find.return_value = ([rise_time, set_time], [True, False])
+
+ def mock_topocentric(t):
+ topo = MagicMock()
+ alt = MagicMock()
+ alt.degrees = 75.0 # Excellent pass
+ az = MagicMock()
+ az.degrees = 180.0
+ topo.altaz.return_value = (alt, az, MagicMock())
+ return topo
+
+ mock_diff = MagicMock()
+ mock_diff.at.side_effect = mock_topocentric
+ mock_satellite_obj.__sub__.return_value = mock_diff
+
+ passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
+
+ assert len(passes) == 1
+ assert passes[0]['quality'] == 'excellent'
+ assert passes[0]['maxEl'] >= 60
+
+ @patch('utils.weather_sat_predict.load')
+ @patch('utils.weather_sat_predict.TLE_SATELLITES')
+ @patch('utils.weather_sat_predict.wgs84')
+ @patch('utils.weather_sat_predict.EarthSatellite')
+ @patch('utils.weather_sat_predict.find_discrete')
+ def test_predict_passes_quality_good(
+ self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
+ ):
+ """predict_passes() should mark medium elevation passes as good."""
+ mock_ts = MagicMock()
+ now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
+ mock_now = MagicMock()
+ mock_now.utc_datetime.return_value = now
+ mock_ts.now.return_value = mock_now
+ mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
+ mock_load.timescale.return_value = mock_ts
+
+ mock_tle.get.return_value = (
+ 'NOAA-18',
+ '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
+ '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
+ )
+
+ mock_observer = MagicMock()
+ mock_wgs84.latlon.return_value = mock_observer
+
+ mock_satellite_obj = MagicMock()
+ mock_sat.return_value = mock_satellite_obj
+
+ rise_time = MagicMock()
+ rise_time.utc_datetime.return_value = now + timedelta(hours=2)
+ set_time = MagicMock()
+ set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
+
+ mock_find.return_value = ([rise_time, set_time], [True, False])
+
+ def mock_topocentric(t):
+ topo = MagicMock()
+ alt = MagicMock()
+ alt.degrees = 45.0 # Good pass
+ az = MagicMock()
+ az.degrees = 180.0
+ topo.altaz.return_value = (alt, az, MagicMock())
+ return topo
+
+ mock_diff = MagicMock()
+ mock_diff.at.side_effect = mock_topocentric
+ mock_satellite_obj.__sub__.return_value = mock_diff
+
+ passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
+
+ assert len(passes) == 1
+ assert passes[0]['quality'] == 'good'
+ assert 30 <= passes[0]['maxEl'] < 60
+
+ @patch('utils.weather_sat_predict.load')
+ @patch('utils.weather_sat_predict.TLE_SATELLITES')
+ @patch('utils.weather_sat_predict.wgs84')
+ @patch('utils.weather_sat_predict.EarthSatellite')
+ @patch('utils.weather_sat_predict.find_discrete')
+ def test_predict_passes_quality_fair(
+ self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
+ ):
+ """predict_passes() should mark low elevation passes as fair."""
+ mock_ts = MagicMock()
+ now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
+ mock_now = MagicMock()
+ mock_now.utc_datetime.return_value = now
+ mock_ts.now.return_value = mock_now
+ mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
+ mock_load.timescale.return_value = mock_ts
+
+ mock_tle.get.return_value = (
+ 'NOAA-18',
+ '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
+ '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
+ )
+
+ mock_observer = MagicMock()
+ mock_wgs84.latlon.return_value = mock_observer
+
+ mock_satellite_obj = MagicMock()
+ mock_sat.return_value = mock_satellite_obj
+
+ rise_time = MagicMock()
+ rise_time.utc_datetime.return_value = now + timedelta(hours=2)
+ set_time = MagicMock()
+ set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
+
+ mock_find.return_value = ([rise_time, set_time], [True, False])
+
+ def mock_topocentric(t):
+ topo = MagicMock()
+ alt = MagicMock()
+ alt.degrees = 20.0 # Fair pass
+ az = MagicMock()
+ az.degrees = 180.0
+ topo.altaz.return_value = (alt, az, MagicMock())
+ return topo
+
+ mock_diff = MagicMock()
+ mock_diff.at.side_effect = mock_topocentric
+ mock_satellite_obj.__sub__.return_value = mock_diff
+
+ passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
+
+ assert len(passes) == 1
+ assert passes[0]['quality'] == 'fair'
+ assert passes[0]['maxEl'] < 30
+
+ @patch('utils.weather_sat_predict.load')
+ @patch('utils.weather_sat_predict.TLE_SATELLITES')
+ @patch('utils.weather_sat_predict.wgs84')
+ @patch('utils.weather_sat_predict.EarthSatellite')
+ @patch('utils.weather_sat_predict.find_discrete')
+ def test_predict_passes_inactive_satellite(
+ self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
+ ):
+ """predict_passes() should skip inactive satellites."""
+ mock_ts = MagicMock()
+ now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
+ mock_now = MagicMock()
+ mock_now.utc_datetime.return_value = now
+ mock_ts.now.return_value = mock_now
+ mock_load.timescale.return_value = mock_ts
+
+ # Temporarily mark satellite as inactive
+ from utils.weather_sat import WEATHER_SATELLITES
+ original_active = WEATHER_SATELLITES['NOAA-18']['active']
+ WEATHER_SATELLITES['NOAA-18']['active'] = False
+
+ try:
+ passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
+ # Should not include NOAA-18
+ noaa_18_passes = [p for p in passes if p['satellite'] == 'NOAA-18']
+ assert len(noaa_18_passes) == 0
+ finally:
+ WEATHER_SATELLITES['NOAA-18']['active'] = original_active
+
+ @patch('utils.weather_sat_predict.load')
+ @patch('utils.weather_sat_predict.TLE_SATELLITES')
+ @patch('utils.weather_sat_predict.wgs84')
+ @patch('utils.weather_sat_predict.EarthSatellite')
+ @patch('utils.weather_sat_predict.find_discrete')
+ def test_predict_passes_exception_handling(
+ self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
+ ):
+ """predict_passes() should handle exceptions gracefully."""
+ mock_ts = MagicMock()
+ now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
+ mock_now = MagicMock()
+ mock_now.utc_datetime.return_value = now
+ mock_ts.now.return_value = mock_now
+ mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
+ mock_load.timescale.return_value = mock_ts
+
+ mock_tle.get.return_value = (
+ 'NOAA-18',
+ '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
+ '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
+ )
+
+ mock_observer = MagicMock()
+ mock_wgs84.latlon.return_value = mock_observer
+
+ mock_satellite_obj = MagicMock()
+ mock_sat.return_value = mock_satellite_obj
+
+ # Make find_discrete raise exception
+ mock_find.side_effect = Exception('Computation error')
+
+ # Should not raise, just skip this satellite
+ passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
+ # May include passes from other satellites or be empty
+ assert isinstance(passes, list)
+
+ @patch('utils.weather_sat_predict.load')
+ @patch('utils.weather_sat_predict.TLE_SATELLITES')
+ def test_predict_passes_uses_tle_cache(self, mock_tle, mock_load):
+ """predict_passes() should use live TLE cache if available."""
+ with patch('utils.weather_sat_predict._tle_cache', {'NOAA-18': ('NOAA-18', 'line1', 'line2')}):
+ mock_ts = MagicMock()
+ mock_ts.now.return_value = MagicMock()
+ mock_ts.utc.return_value = MagicMock()
+ mock_load.timescale.return_value = mock_ts
+
+ # Even though TLE_SATELLITES is mocked, should use _tle_cache
+ with patch('utils.weather_sat_predict.wgs84'), \
+ patch('utils.weather_sat_predict.EarthSatellite'), \
+ patch('utils.weather_sat_predict.find_discrete', return_value=([], [])):
+
+ passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
+ # Should not raise
+
+ @patch('utils.weather_sat_predict.load')
+ @patch('utils.weather_sat_predict.TLE_SATELLITES')
+ @patch('utils.weather_sat_predict.wgs84')
+ @patch('utils.weather_sat_predict.EarthSatellite')
+ @patch('utils.weather_sat_predict.find_discrete')
+ def test_predict_passes_sorted_by_time(
+ self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
+ ):
+ """predict_passes() should return passes sorted by start time."""
+ mock_ts = MagicMock()
+ now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
+ mock_now = MagicMock()
+ mock_now.utc_datetime.return_value = now
+ mock_ts.now.return_value = mock_now
+ mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
+ mock_load.timescale.return_value = mock_ts
+
+ mock_tle.get.return_value = (
+ 'NOAA-18',
+ '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
+ '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
+ )
+
+ mock_observer = MagicMock()
+ mock_wgs84.latlon.return_value = mock_observer
+
+ mock_satellite_obj = MagicMock()
+ mock_sat.return_value = mock_satellite_obj
+
+ # Two passes
+ rise1 = MagicMock()
+ rise1.utc_datetime.return_value = now + timedelta(hours=4)
+ set1 = MagicMock()
+ set1.utc_datetime.return_value = now + timedelta(hours=4, minutes=15)
+ rise2 = MagicMock()
+ rise2.utc_datetime.return_value = now + timedelta(hours=2)
+ set2 = MagicMock()
+ set2.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
+
+ # Return in non-chronological order
+ mock_find.return_value = ([rise1, set1, rise2, set2], [True, False, True, False])
+
+ def mock_topocentric(t):
+ topo = MagicMock()
+ alt = MagicMock()
+ alt.degrees = 45.0
+ az = MagicMock()
+ az.degrees = 180.0
+ topo.altaz.return_value = (alt, az, MagicMock())
+ return topo
+
+ mock_diff = MagicMock()
+ mock_diff.at.side_effect = mock_topocentric
+ mock_satellite_obj.__sub__.return_value = mock_diff
+
+ passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
+
+ # Should be sorted with earliest pass first
+ if len(passes) >= 2:
+ assert passes[0]['startTimeISO'] < passes[1]['startTimeISO']
+
+ @staticmethod
+ def _mock_time(dt):
+ """Helper to create mock time object."""
+ mock_t = MagicMock()
+ if isinstance(dt, datetime):
+ mock_t.utc_datetime.return_value = dt
+ else:
+ mock_t.utc_datetime.return_value = datetime.now(timezone.utc)
+ return mock_t
+
+
+class TestPassDataStructure:
+ """Tests for pass data structure."""
+
+ @patch('utils.weather_sat_predict.load')
+ @patch('utils.weather_sat_predict.TLE_SATELLITES')
+ @patch('utils.weather_sat_predict.wgs84')
+ @patch('utils.weather_sat_predict.EarthSatellite')
+ @patch('utils.weather_sat_predict.find_discrete')
+ def test_pass_data_fields(
+ self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
+ ):
+ """Pass data should contain all required fields."""
+ mock_ts = MagicMock()
+ now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
+ mock_now = MagicMock()
+ mock_now.utc_datetime.return_value = now
+ mock_ts.now.return_value = mock_now
+ mock_ts.utc.side_effect = lambda dt: TestPredictPasses._mock_time(dt)
+ mock_load.timescale.return_value = mock_ts
+
+ mock_tle.get.return_value = (
+ 'NOAA-18',
+ '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
+ '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
+ )
+
+ mock_observer = MagicMock()
+ mock_wgs84.latlon.return_value = mock_observer
+
+ mock_satellite_obj = MagicMock()
+ mock_sat.return_value = mock_satellite_obj
+
+ rise_time = MagicMock()
+ rise_time.utc_datetime.return_value = now + timedelta(hours=2)
+ set_time = MagicMock()
+ set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
+
+ mock_find.return_value = ([rise_time, set_time], [True, False])
+
+ def mock_topocentric(t):
+ topo = MagicMock()
+ alt = MagicMock()
+ alt.degrees = 45.0
+ az = MagicMock()
+ az.degrees = 180.0
+ topo.altaz.return_value = (alt, az, MagicMock())
+ return topo
+
+ mock_diff = MagicMock()
+ mock_diff.at.side_effect = mock_topocentric
+ mock_satellite_obj.__sub__.return_value = mock_diff
+
+ passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
+
+ assert len(passes) == 1
+ pass_data = passes[0]
+
+ # Check all required fields
+ required_fields = [
+ 'id', 'satellite', 'name', 'frequency', 'mode',
+ 'startTime', 'startTimeISO', 'endTimeISO',
+ 'maxEl', 'maxElAz', 'riseAz', 'setAz',
+ 'duration', 'quality'
+ ]
+ for field in required_fields:
+ assert field in pass_data, f"Missing required field: {field}"
+
+ def test_import_error_propagates(self):
+ """predict_passes() should raise ImportError if skyfield unavailable."""
+ with patch.dict('sys.modules', {'skyfield': None, 'skyfield.api': None}):
+ with pytest.raises((ImportError, AttributeError)):
+ predict_passes(lat=51.5, lon=-0.1)
diff --git a/tests/test_weather_sat_routes.py b/tests/test_weather_sat_routes.py
new file mode 100644
index 0000000..7f13aca
--- /dev/null
+++ b/tests/test_weather_sat_routes.py
@@ -0,0 +1,801 @@
+"""Tests for weather satellite routes.
+
+Covers all weather_sat endpoints: /status, /satellites, /start, /test-decode,
+/stop, /images, /passes, and scheduler endpoints.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from unittest.mock import patch, MagicMock, mock_open
+import pytest
+
+from utils.weather_sat import WeatherSatImage, WEATHER_SATELLITES
+from datetime import datetime, timezone
+
+
+class TestWeatherSatRoutes:
+ """Tests for weather satellite routes."""
+
+ def test_get_status(self, client):
+ """GET /weather-sat/status returns decoder status."""
+ with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
+ mock_decoder = MagicMock()
+ mock_decoder.get_status.return_value = {
+ 'available': True,
+ 'decoder': 'satdump',
+ 'running': False,
+ 'satellite': '',
+ 'frequency': 0.0,
+ 'mode': '',
+ 'elapsed_seconds': 0,
+ 'image_count': 0,
+ }
+ mock_get.return_value = mock_decoder
+
+ response = client.get('/weather-sat/status')
+ assert response.status_code == 200
+ data = response.get_json()
+ assert data['available'] is True
+ assert data['decoder'] == 'satdump'
+ assert data['running'] is False
+
+ def test_list_satellites(self, client):
+ """GET /weather-sat/satellites returns satellite list."""
+ response = client.get('/weather-sat/satellites')
+ assert response.status_code == 200
+ data = response.get_json()
+ assert data['status'] == 'ok'
+ assert 'satellites' in data
+ assert len(data['satellites']) > 0
+
+ # Check structure
+ sat = data['satellites'][0]
+ assert 'key' in sat
+ assert 'name' in sat
+ assert 'frequency' in sat
+ assert 'mode' in sat
+ assert 'description' in sat
+ assert 'active' in sat
+
+ # Verify NOAA-18 is in list
+ noaa_18 = next((s for s in data['satellites'] if s['key'] == 'NOAA-18'), None)
+ assert noaa_18 is not None
+ assert noaa_18['frequency'] == 137.9125
+ assert noaa_18['mode'] == 'APT'
+
+ def test_start_capture_success(self, client):
+ """POST /weather-sat/start successfully starts capture."""
+ with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
+ patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \
+ patch('routes.weather_sat.queue.Queue') as mock_queue:
+
+ mock_decoder = MagicMock()
+ mock_decoder.is_running = False
+ mock_decoder.start.return_value = True
+ mock_get.return_value = mock_decoder
+
+ payload = {
+ 'satellite': 'NOAA-18',
+ 'device': 0,
+ 'gain': 40.0,
+ 'bias_t': False,
+ }
+
+ response = client.post(
+ '/weather-sat/start',
+ data=json.dumps(payload),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 200
+ data = response.get_json()
+ assert data['status'] == 'started'
+ assert data['satellite'] == 'NOAA-18'
+ assert data['frequency'] == 137.9125
+ assert data['mode'] == 'APT'
+ assert data['device'] == 0
+
+ mock_decoder.start.assert_called_once_with(
+ satellite='NOAA-18',
+ device_index=0,
+ gain=40.0,
+ bias_t=False,
+ )
+
+ def test_start_capture_no_satdump(self, client):
+ """POST /weather-sat/start returns error when SatDump unavailable."""
+ with patch('routes.weather_sat.is_weather_sat_available', return_value=False):
+ payload = {'satellite': 'NOAA-18'}
+ response = client.post(
+ '/weather-sat/start',
+ data=json.dumps(payload),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.get_json()
+ assert data['status'] == 'error'
+ assert 'SatDump not installed' in data['message']
+
+ def test_start_capture_already_running(self, client):
+ """POST /weather-sat/start when already running."""
+ with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
+ patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
+
+ mock_decoder = MagicMock()
+ mock_decoder.is_running = True
+ mock_decoder.current_satellite = 'NOAA-19'
+ mock_decoder.current_frequency = 137.100
+ mock_get.return_value = mock_decoder
+
+ payload = {'satellite': 'NOAA-18'}
+ response = client.post(
+ '/weather-sat/start',
+ data=json.dumps(payload),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 200
+ data = response.get_json()
+ assert data['status'] == 'already_running'
+ assert data['satellite'] == 'NOAA-19'
+
+ def test_start_capture_invalid_satellite(self, client):
+ """POST /weather-sat/start with invalid satellite."""
+ with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
+ patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
+
+ mock_decoder = MagicMock()
+ mock_decoder.is_running = False
+ mock_get.return_value = mock_decoder
+
+ payload = {'satellite': 'FAKE-SAT-99'}
+ response = client.post(
+ '/weather-sat/start',
+ data=json.dumps(payload),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.get_json()
+ assert data['status'] == 'error'
+ assert 'Invalid satellite' in data['message']
+
+ def test_start_capture_invalid_device(self, client):
+ """POST /weather-sat/start with invalid device index."""
+ with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
+ patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
+
+ mock_decoder = MagicMock()
+ mock_decoder.is_running = False
+ mock_get.return_value = mock_decoder
+
+ payload = {'satellite': 'NOAA-18', 'device': -1}
+ response = client.post(
+ '/weather-sat/start',
+ data=json.dumps(payload),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.get_json()
+ assert data['status'] == 'error'
+
+ def test_start_capture_invalid_gain(self, client):
+ """POST /weather-sat/start with invalid gain."""
+ with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
+ patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
+
+ mock_decoder = MagicMock()
+ mock_decoder.is_running = False
+ mock_get.return_value = mock_decoder
+
+ payload = {'satellite': 'NOAA-18', 'gain': 999}
+ response = client.post(
+ '/weather-sat/start',
+ data=json.dumps(payload),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.get_json()
+ assert data['status'] == 'error'
+
+ def test_start_capture_device_busy(self, client):
+ """POST /weather-sat/start when SDR device is busy."""
+ with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
+ patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \
+ patch('app.claim_sdr_device', return_value='Device busy with pager') as mock_claim:
+
+ mock_decoder = MagicMock()
+ mock_decoder.is_running = False
+ mock_get.return_value = mock_decoder
+
+ payload = {'satellite': 'NOAA-18'}
+ response = client.post(
+ '/weather-sat/start',
+ data=json.dumps(payload),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 409
+ data = response.get_json()
+ assert data['status'] == 'error'
+ assert data['error_type'] == 'DEVICE_BUSY'
+ assert 'Device busy' in data['message']
+
+ def test_start_capture_start_failure(self, client):
+ """POST /weather-sat/start when decoder.start() fails."""
+ with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
+ patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
+
+ mock_decoder = MagicMock()
+ mock_decoder.is_running = False
+ mock_decoder.start.return_value = False
+ mock_get.return_value = mock_decoder
+
+ payload = {'satellite': 'NOAA-18'}
+ response = client.post(
+ '/weather-sat/start',
+ data=json.dumps(payload),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 500
+ data = response.get_json()
+ assert data['status'] == 'error'
+ assert 'Failed to start capture' in data['message']
+
+ def test_test_decode_success(self, client):
+ """POST /weather-sat/test-decode successfully starts file decode."""
+ with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
+ patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \
+ patch('pathlib.Path.is_file', return_value=True), \
+ patch('pathlib.Path.resolve') as mock_resolve:
+
+ # Mock path resolution to be under data/
+ mock_path = MagicMock()
+ mock_path.is_relative_to.return_value = True
+ mock_resolve.return_value = mock_path
+
+ mock_decoder = MagicMock()
+ mock_decoder.is_running = False
+ mock_decoder.start_from_file.return_value = True
+ mock_get.return_value = mock_decoder
+
+ payload = {
+ 'satellite': 'NOAA-18',
+ 'input_file': 'data/weather_sat/test.wav',
+ 'sample_rate': 1000000,
+ }
+
+ response = client.post(
+ '/weather-sat/test-decode',
+ data=json.dumps(payload),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 200
+ data = response.get_json()
+ assert data['status'] == 'started'
+ assert data['satellite'] == 'NOAA-18'
+ assert data['source'] == 'file'
+
+ def test_test_decode_invalid_path(self, client):
+ """POST /weather-sat/test-decode with path outside data/."""
+ with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
+ patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \
+ patch('pathlib.Path.resolve') as mock_resolve:
+
+ # Mock path outside allowed directory
+ mock_path = MagicMock()
+ mock_path.is_relative_to.return_value = False
+ mock_resolve.return_value = mock_path
+
+ mock_decoder = MagicMock()
+ mock_decoder.is_running = False
+ mock_get.return_value = mock_decoder
+
+ payload = {
+ 'satellite': 'NOAA-18',
+ 'input_file': '/etc/passwd',
+ }
+
+ response = client.post(
+ '/weather-sat/test-decode',
+ data=json.dumps(payload),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 403
+ data = response.get_json()
+ assert data['status'] == 'error'
+ assert 'data/ directory' in data['message']
+
+ def test_test_decode_file_not_found(self, client):
+ """POST /weather-sat/test-decode with non-existent file."""
+ with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
+ patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \
+ patch('pathlib.Path.is_file', return_value=False), \
+ patch('pathlib.Path.resolve') as mock_resolve:
+
+ mock_path = MagicMock()
+ mock_path.is_relative_to.return_value = True
+ mock_resolve.return_value = mock_path
+
+ mock_decoder = MagicMock()
+ mock_decoder.is_running = False
+ mock_get.return_value = mock_decoder
+
+ payload = {
+ 'satellite': 'NOAA-18',
+ 'input_file': 'data/missing.wav',
+ }
+
+ response = client.post(
+ '/weather-sat/test-decode',
+ data=json.dumps(payload),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 404
+ data = response.get_json()
+ assert data['status'] == 'error'
+ assert 'not found' in data['message'].lower()
+
+ def test_test_decode_invalid_sample_rate(self, client):
+ """POST /weather-sat/test-decode with invalid sample rate."""
+ with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
+ patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
+
+ mock_decoder = MagicMock()
+ mock_decoder.is_running = False
+ mock_get.return_value = mock_decoder
+
+ payload = {
+ 'satellite': 'NOAA-18',
+ 'input_file': 'data/test.wav',
+ 'sample_rate': 100, # Too low
+ }
+
+ response = client.post(
+ '/weather-sat/test-decode',
+ data=json.dumps(payload),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.get_json()
+ assert data['status'] == 'error'
+ assert 'sample_rate' in data['message']
+
+ def test_stop_capture(self, client):
+ """POST /weather-sat/stop stops capture."""
+ with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
+ mock_decoder = MagicMock()
+ mock_decoder.device_index = 0
+ mock_get.return_value = mock_decoder
+
+ response = client.post('/weather-sat/stop')
+ assert response.status_code == 200
+ data = response.get_json()
+ assert data['status'] == 'stopped'
+ mock_decoder.stop.assert_called_once()
+
+ def test_list_images_empty(self, client):
+ """GET /weather-sat/images with no images."""
+ with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
+ mock_decoder = MagicMock()
+ mock_decoder.get_images.return_value = []
+ mock_get.return_value = mock_decoder
+
+ response = client.get('/weather-sat/images')
+ assert response.status_code == 200
+ data = response.get_json()
+ assert data['status'] == 'ok'
+ assert data['images'] == []
+ assert data['count'] == 0
+
+ def test_list_images_with_data(self, client):
+ """GET /weather-sat/images with images."""
+ with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
+ mock_decoder = MagicMock()
+ image = WeatherSatImage(
+ filename='NOAA-18_test.png',
+ path=Path('/tmp/test.png'),
+ satellite='NOAA-18',
+ mode='APT',
+ timestamp=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
+ frequency=137.9125,
+ size_bytes=12345,
+ product='RGB Composite',
+ )
+ mock_decoder.get_images.return_value = [image]
+ mock_get.return_value = mock_decoder
+
+ response = client.get('/weather-sat/images')
+ assert response.status_code == 200
+ data = response.get_json()
+ assert data['status'] == 'ok'
+ assert data['count'] == 1
+ assert data['images'][0]['filename'] == 'NOAA-18_test.png'
+ assert data['images'][0]['satellite'] == 'NOAA-18'
+
+ def test_list_images_with_filter(self, client):
+ """GET /weather-sat/images with satellite filter."""
+ with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
+ mock_decoder = MagicMock()
+ image1 = WeatherSatImage(
+ filename='NOAA-18_test.png',
+ path=Path('/tmp/test1.png'),
+ satellite='NOAA-18',
+ mode='APT',
+ timestamp=datetime.now(timezone.utc),
+ frequency=137.9125,
+ )
+ image2 = WeatherSatImage(
+ filename='NOAA-19_test.png',
+ path=Path('/tmp/test2.png'),
+ satellite='NOAA-19',
+ mode='APT',
+ timestamp=datetime.now(timezone.utc),
+ frequency=137.100,
+ )
+ mock_decoder.get_images.return_value = [image1, image2]
+ mock_get.return_value = mock_decoder
+
+ response = client.get('/weather-sat/images?satellite=NOAA-18')
+ assert response.status_code == 200
+ data = response.get_json()
+ assert data['count'] == 1
+ assert data['images'][0]['satellite'] == 'NOAA-18'
+
+ def test_list_images_with_limit(self, client):
+ """GET /weather-sat/images with limit."""
+ with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
+ mock_decoder = MagicMock()
+ images = [
+ WeatherSatImage(
+ filename=f'test{i}.png',
+ path=Path(f'/tmp/test{i}.png'),
+ satellite='NOAA-18',
+ mode='APT',
+ timestamp=datetime.now(timezone.utc),
+ frequency=137.9125,
+ )
+ for i in range(10)
+ ]
+ mock_decoder.get_images.return_value = images
+ mock_get.return_value = mock_decoder
+
+ response = client.get('/weather-sat/images?limit=5')
+ assert response.status_code == 200
+ data = response.get_json()
+ assert data['count'] == 5
+
+ def test_get_image_success(self, client):
+ """GET /weather-sat/images/ serves image."""
+ with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \
+ patch('routes.weather_sat.send_file') as mock_send, \
+ patch('pathlib.Path.exists', return_value=True):
+
+ mock_decoder = MagicMock()
+ mock_decoder._output_dir = Path('/tmp')
+ mock_get.return_value = mock_decoder
+ mock_send.return_value = MagicMock()
+
+ response = client.get('/weather-sat/images/test_image.png')
+ mock_send.assert_called_once()
+ call_args = mock_send.call_args
+ assert call_args[1]['mimetype'] == 'image/png'
+
+ def test_get_image_invalid_filename(self, client):
+ """GET /weather-sat/images/ with invalid filename."""
+ with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
+ mock_decoder = MagicMock()
+ mock_get.return_value = mock_decoder
+
+ response = client.get('/weather-sat/images/../../../etc/passwd')
+ assert response.status_code == 400
+ data = response.get_json()
+ assert data['status'] == 'error'
+ assert 'Invalid filename' in data['message']
+
+ def test_get_image_wrong_extension(self, client):
+ """GET /weather-sat/images/ with wrong extension."""
+ with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
+ mock_decoder = MagicMock()
+ mock_get.return_value = mock_decoder
+
+ response = client.get('/weather-sat/images/test.txt')
+ assert response.status_code == 400
+ data = response.get_json()
+ assert 'PNG/JPG' in data['message']
+
+ def test_get_image_not_found(self, client):
+ """GET /weather-sat/images/ for non-existent image."""
+ with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \
+ patch('pathlib.Path.exists', return_value=False):
+
+ mock_decoder = MagicMock()
+ mock_decoder._output_dir = Path('/tmp')
+ mock_get.return_value = mock_decoder
+
+ response = client.get('/weather-sat/images/missing.png')
+ assert response.status_code == 404
+
+ def test_delete_image_success(self, client):
+ """DELETE /weather-sat/images/ deletes image."""
+ with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
+ mock_decoder = MagicMock()
+ mock_decoder.delete_image.return_value = True
+ mock_get.return_value = mock_decoder
+
+ response = client.delete('/weather-sat/images/test.png')
+ assert response.status_code == 200
+ data = response.get_json()
+ assert data['status'] == 'deleted'
+ assert data['filename'] == 'test.png'
+
+ def test_delete_image_not_found(self, client):
+ """DELETE /weather-sat/images/ for non-existent image."""
+ with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
+ mock_decoder = MagicMock()
+ mock_decoder.delete_image.return_value = False
+ mock_get.return_value = mock_decoder
+
+ response = client.delete('/weather-sat/images/missing.png')
+ assert response.status_code == 404
+
+ def test_delete_all_images(self, client):
+ """DELETE /weather-sat/images deletes all images."""
+ with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
+ mock_decoder = MagicMock()
+ mock_decoder.delete_all_images.return_value = 5
+ mock_get.return_value = mock_decoder
+
+ response = client.delete('/weather-sat/images')
+ assert response.status_code == 200
+ data = response.get_json()
+ assert data['status'] == 'ok'
+ assert data['deleted'] == 5
+
+ def test_stream_progress(self, client):
+ """GET /weather-sat/stream returns SSE stream."""
+ response = client.get('/weather-sat/stream')
+ assert response.status_code == 200
+ assert response.mimetype == 'text/event-stream'
+ assert response.headers['Cache-Control'] == 'no-cache'
+
+ def test_get_passes_missing_params(self, client):
+ """GET /weather-sat/passes without required params."""
+ response = client.get('/weather-sat/passes')
+ assert response.status_code == 400
+ data = response.get_json()
+ assert data['status'] == 'error'
+ assert 'latitude and longitude' in data['message']
+
+ def test_get_passes_invalid_coords(self, client):
+ """GET /weather-sat/passes with invalid coordinates."""
+ response = client.get('/weather-sat/passes?latitude=999&longitude=0')
+ assert response.status_code == 400
+ data = response.get_json()
+ assert data['status'] == 'error'
+
+ def test_get_passes_success(self, client):
+ """GET /weather-sat/passes successfully predicts passes."""
+ with patch('routes.weather_sat.predict_passes') as mock_predict:
+ mock_predict.return_value = [
+ {
+ 'id': 'NOAA-18_202401011200',
+ 'satellite': 'NOAA-18',
+ 'name': 'NOAA 18',
+ 'frequency': 137.9125,
+ 'mode': 'APT',
+ 'startTime': '2024-01-01 12:00 UTC',
+ 'startTimeISO': '2024-01-01T12:00:00+00:00',
+ 'endTimeISO': '2024-01-01T12:15:00+00:00',
+ 'maxEl': 45.0,
+ 'maxElAz': 180.0,
+ 'riseAz': 160.0,
+ 'setAz': 200.0,
+ 'duration': 15.0,
+ 'quality': 'good',
+ }
+ ]
+
+ response = client.get('/weather-sat/passes?latitude=51.5&longitude=-0.1')
+ assert response.status_code == 200
+ data = response.get_json()
+ assert data['status'] == 'ok'
+ assert data['count'] == 1
+ assert data['passes'][0]['satellite'] == 'NOAA-18'
+
+ def test_get_passes_with_options(self, client):
+ """GET /weather-sat/passes with trajectory and ground track."""
+ with patch('routes.weather_sat.predict_passes') as mock_predict:
+ mock_predict.return_value = []
+
+ response = client.get(
+ '/weather-sat/passes?latitude=51.5&longitude=-0.1&'
+ 'hours=48&min_elevation=20&trajectory=true&ground_track=true'
+ )
+ assert response.status_code == 200
+
+ mock_predict.assert_called_once()
+ call_kwargs = mock_predict.call_args[1]
+ assert call_kwargs['lat'] == 51.5
+ assert call_kwargs['lon'] == -0.1
+ assert call_kwargs['hours'] == 48
+ assert call_kwargs['min_elevation'] == 20.0
+ assert call_kwargs['include_trajectory'] is True
+ assert call_kwargs['include_ground_track'] is True
+
+ def test_get_passes_import_error(self, client):
+ """GET /weather-sat/passes when skyfield not installed."""
+ with patch('routes.weather_sat.predict_passes', side_effect=ImportError):
+ response = client.get('/weather-sat/passes?latitude=51.5&longitude=-0.1')
+ assert response.status_code == 503
+ data = response.get_json()
+ assert data['status'] == 'error'
+ assert 'skyfield' in data['message']
+
+ def test_get_passes_prediction_error(self, client):
+ """GET /weather-sat/passes when prediction fails."""
+ with patch('routes.weather_sat.predict_passes', side_effect=Exception('TLE error')):
+ response = client.get('/weather-sat/passes?latitude=51.5&longitude=-0.1')
+ assert response.status_code == 500
+ data = response.get_json()
+ assert data['status'] == 'error'
+
+
+class TestWeatherSatScheduler:
+ """Tests for weather satellite scheduler endpoints."""
+
+ def test_enable_schedule_success(self, client):
+ """POST /weather-sat/schedule/enable enables scheduler."""
+ with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get:
+ mock_scheduler = MagicMock()
+ mock_scheduler.enable.return_value = {
+ 'enabled': True,
+ 'observer': {'latitude': 51.5, 'longitude': -0.1},
+ 'device': 0,
+ 'gain': 40.0,
+ 'bias_t': False,
+ 'min_elevation': 15.0,
+ 'scheduled_count': 3,
+ 'total_passes': 3,
+ }
+ mock_get.return_value = mock_scheduler
+
+ payload = {
+ 'latitude': 51.5,
+ 'longitude': -0.1,
+ 'min_elevation': 15,
+ 'device': 0,
+ 'gain': 40.0,
+ 'bias_t': False,
+ }
+
+ response = client.post(
+ '/weather-sat/schedule/enable',
+ data=json.dumps(payload),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 200
+ data = response.get_json()
+ assert data['status'] == 'ok'
+ assert data['enabled'] is True
+
+ def test_enable_schedule_missing_coords(self, client):
+ """POST /weather-sat/schedule/enable without coordinates."""
+ payload = {'device': 0}
+ response = client.post(
+ '/weather-sat/schedule/enable',
+ data=json.dumps(payload),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.get_json()
+ assert data['status'] == 'error'
+ assert 'latitude and longitude' in data['message']
+
+ def test_enable_schedule_invalid_coords(self, client):
+ """POST /weather-sat/schedule/enable with invalid coordinates."""
+ payload = {'latitude': 999, 'longitude': 0}
+ response = client.post(
+ '/weather-sat/schedule/enable',
+ data=json.dumps(payload),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.get_json()
+ assert data['status'] == 'error'
+
+ def test_disable_schedule(self, client):
+ """POST /weather-sat/schedule/disable disables scheduler."""
+ with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get:
+ mock_scheduler = MagicMock()
+ mock_scheduler.disable.return_value = {'status': 'disabled'}
+ mock_get.return_value = mock_scheduler
+
+ response = client.post('/weather-sat/schedule/disable')
+ assert response.status_code == 200
+ data = response.get_json()
+ assert data['status'] == 'disabled'
+
+ def test_schedule_status(self, client):
+ """GET /weather-sat/schedule/status returns scheduler status."""
+ with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get:
+ mock_scheduler = MagicMock()
+ mock_scheduler.get_status.return_value = {
+ 'enabled': False,
+ 'observer': {'latitude': 0, 'longitude': 0},
+ 'device': 0,
+ 'gain': 40.0,
+ 'bias_t': False,
+ 'min_elevation': 15.0,
+ 'scheduled_count': 0,
+ 'total_passes': 0,
+ }
+ mock_get.return_value = mock_scheduler
+
+ response = client.get('/weather-sat/schedule/status')
+ assert response.status_code == 200
+ data = response.get_json()
+ assert 'enabled' in data
+
+ def test_schedule_passes(self, client):
+ """GET /weather-sat/schedule/passes lists scheduled passes."""
+ with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get:
+ mock_scheduler = MagicMock()
+ mock_scheduler.get_passes.return_value = [
+ {
+ 'id': 'NOAA-18_202401011200',
+ 'satellite': 'NOAA-18',
+ 'status': 'scheduled',
+ }
+ ]
+ mock_get.return_value = mock_scheduler
+
+ response = client.get('/weather-sat/schedule/passes')
+ assert response.status_code == 200
+ data = response.get_json()
+ assert data['status'] == 'ok'
+ assert data['count'] == 1
+
+ def test_skip_pass_success(self, client):
+ """POST /weather-sat/schedule/skip/ skips a pass."""
+ with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get:
+ mock_scheduler = MagicMock()
+ mock_scheduler.skip_pass.return_value = True
+ mock_get.return_value = mock_scheduler
+
+ response = client.post('/weather-sat/schedule/skip/NOAA-18_202401011200')
+ assert response.status_code == 200
+ data = response.get_json()
+ assert data['status'] == 'skipped'
+ assert data['pass_id'] == 'NOAA-18_202401011200'
+
+ def test_skip_pass_not_found(self, client):
+ """POST /weather-sat/schedule/skip/ for non-existent pass."""
+ with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get:
+ mock_scheduler = MagicMock()
+ mock_scheduler.skip_pass.return_value = False
+ mock_get.return_value = mock_scheduler
+
+ response = client.post('/weather-sat/schedule/skip/nonexistent')
+ assert response.status_code == 404
+
+ def test_skip_pass_invalid_id(self, client):
+ """POST /weather-sat/schedule/skip/ with invalid ID."""
+ response = client.post('/weather-sat/schedule/skip/../../../etc/passwd')
+ assert response.status_code == 400
+ data = response.get_json()
+ assert data['status'] == 'error'
+ assert 'Invalid pass ID' in data['message']
diff --git a/tests/test_weather_sat_scheduler.py b/tests/test_weather_sat_scheduler.py
new file mode 100644
index 0000000..6f079b3
--- /dev/null
+++ b/tests/test_weather_sat_scheduler.py
@@ -0,0 +1,779 @@
+"""Tests for weather satellite auto-scheduler.
+
+Covers WeatherSatScheduler class, pass scheduling, timer management,
+and automatic capture execution.
+"""
+
+from __future__ import annotations
+
+import threading
+import time
+from datetime import datetime, timezone, timedelta
+from unittest.mock import patch, MagicMock, call
+import pytest
+
+from utils.weather_sat_scheduler import (
+ WeatherSatScheduler,
+ ScheduledPass,
+ get_weather_sat_scheduler,
+)
+
+
+class TestScheduledPass:
+ """Tests for ScheduledPass class."""
+
+ def test_scheduled_pass_initialization(self):
+ """ScheduledPass should initialize from pass data."""
+ pass_data = {
+ 'id': 'NOAA-18_202401011200',
+ 'satellite': 'NOAA-18',
+ 'name': 'NOAA 18',
+ 'frequency': 137.9125,
+ 'mode': 'APT',
+ 'startTimeISO': '2024-01-01T12:00:00+00:00',
+ 'endTimeISO': '2024-01-01T12:15:00+00:00',
+ 'maxEl': 45.0,
+ 'duration': 15.0,
+ 'quality': 'good',
+ }
+
+ sp = ScheduledPass(pass_data)
+
+ assert sp.id == 'NOAA-18_202401011200'
+ assert sp.satellite == 'NOAA-18'
+ assert sp.name == 'NOAA 18'
+ assert sp.frequency == 137.9125
+ assert sp.mode == 'APT'
+ assert sp.max_el == 45.0
+ assert sp.duration == 15.0
+ assert sp.quality == 'good'
+ assert sp.status == 'scheduled'
+ assert sp.skipped is False
+
+ def test_scheduled_pass_start_dt(self):
+ """ScheduledPass.start_dt should parse ISO datetime."""
+ pass_data = {
+ 'satellite': 'NOAA-18',
+ 'name': 'NOAA 18',
+ 'frequency': 137.9125,
+ 'mode': 'APT',
+ 'startTimeISO': '2024-01-01T12:00:00+00:00',
+ 'endTimeISO': '2024-01-01T12:15:00+00:00',
+ 'maxEl': 45.0,
+ 'duration': 15.0,
+ 'quality': 'good',
+ }
+
+ sp = ScheduledPass(pass_data)
+
+ assert sp.start_dt.year == 2024
+ assert sp.start_dt.month == 1
+ assert sp.start_dt.day == 1
+ assert sp.start_dt.hour == 12
+ assert sp.start_dt.tzinfo == timezone.utc
+
+ def test_scheduled_pass_end_dt(self):
+ """ScheduledPass.end_dt should parse ISO datetime."""
+ pass_data = {
+ 'satellite': 'NOAA-18',
+ 'name': 'NOAA 18',
+ 'frequency': 137.9125,
+ 'mode': 'APT',
+ 'startTimeISO': '2024-01-01T12:00:00+00:00',
+ 'endTimeISO': '2024-01-01T12:15:00+00:00',
+ 'maxEl': 45.0,
+ 'duration': 15.0,
+ 'quality': 'good',
+ }
+
+ sp = ScheduledPass(pass_data)
+
+ assert sp.end_dt.year == 2024
+ assert sp.end_dt.minute == 15
+
+ def test_scheduled_pass_to_dict(self):
+ """ScheduledPass.to_dict() should serialize correctly."""
+ pass_data = {
+ 'id': 'NOAA-18_202401011200',
+ 'satellite': 'NOAA-18',
+ 'name': 'NOAA 18',
+ 'frequency': 137.9125,
+ 'mode': 'APT',
+ 'startTimeISO': '2024-01-01T12:00:00+00:00',
+ 'endTimeISO': '2024-01-01T12:15:00+00:00',
+ 'maxEl': 45.0,
+ 'duration': 15.0,
+ 'quality': 'good',
+ }
+
+ sp = ScheduledPass(pass_data)
+ sp.status = 'complete'
+
+ data = sp.to_dict()
+
+ assert data['id'] == 'NOAA-18_202401011200'
+ assert data['satellite'] == 'NOAA-18'
+ assert data['status'] == 'complete'
+ assert data['skipped'] is False
+
+
+class TestWeatherSatScheduler:
+ """Tests for WeatherSatScheduler class."""
+
+ def test_scheduler_initialization(self):
+ """Scheduler should initialize with defaults."""
+ scheduler = WeatherSatScheduler()
+
+ assert scheduler.enabled is False
+ assert scheduler._lat == 0.0
+ assert scheduler._lon == 0.0
+ assert scheduler._min_elevation == 15.0
+ assert scheduler._device == 0
+ assert scheduler._gain == 40.0
+ assert scheduler._bias_t is False
+ assert scheduler._passes == []
+
+ def test_set_callbacks(self):
+ """Scheduler should accept callbacks."""
+ scheduler = WeatherSatScheduler()
+ progress_cb = MagicMock()
+ event_cb = MagicMock()
+
+ scheduler.set_callbacks(progress_cb, event_cb)
+
+ assert scheduler._progress_callback == progress_cb
+ assert scheduler._event_callback == event_cb
+
+ @patch('utils.weather_sat_scheduler.WeatherSatScheduler._refresh_passes')
+ def test_enable(self, mock_refresh):
+ """enable() should start scheduler."""
+ scheduler = WeatherSatScheduler()
+
+ result = scheduler.enable(
+ lat=51.5,
+ lon=-0.1,
+ min_elevation=20.0,
+ device=1,
+ gain=35.0,
+ bias_t=True,
+ )
+
+ assert scheduler._enabled is True
+ assert scheduler._lat == 51.5
+ assert scheduler._lon == -0.1
+ assert scheduler._min_elevation == 20.0
+ assert scheduler._device == 1
+ assert scheduler._gain == 35.0
+ assert scheduler._bias_t is True
+ mock_refresh.assert_called_once()
+ assert 'enabled' in result
+
+ def test_disable(self):
+ """disable() should stop scheduler and cancel timers."""
+ scheduler = WeatherSatScheduler()
+ scheduler._enabled = True
+
+ # Add mock timer
+ mock_timer = MagicMock()
+ scheduler._refresh_timer = mock_timer
+
+ # Add pass with timer
+ pass_data = {
+ 'satellite': 'NOAA-18',
+ 'name': 'NOAA 18',
+ 'frequency': 137.9125,
+ 'mode': 'APT',
+ 'startTimeISO': '2024-01-01T12:00:00+00:00',
+ 'endTimeISO': '2024-01-01T12:15:00+00:00',
+ 'maxEl': 45.0,
+ 'duration': 15.0,
+ 'quality': 'good',
+ }
+ sp = ScheduledPass(pass_data)
+ sp._timer = MagicMock()
+ sp._stop_timer = MagicMock()
+ scheduler._passes = [sp]
+
+ result = scheduler.disable()
+
+ assert scheduler._enabled is False
+ assert scheduler._passes == []
+ mock_timer.cancel.assert_called_once()
+ sp._timer.cancel.assert_called_once()
+ sp._stop_timer.cancel.assert_called_once()
+ assert result['status'] == 'disabled'
+
+ def test_skip_pass_success(self):
+ """skip_pass() should skip a scheduled pass."""
+ scheduler = WeatherSatScheduler()
+ event_cb = MagicMock()
+ scheduler.set_callbacks(MagicMock(), event_cb)
+
+ pass_data = {
+ 'id': 'NOAA-18_202401011200',
+ 'satellite': 'NOAA-18',
+ 'name': 'NOAA 18',
+ 'frequency': 137.9125,
+ 'mode': 'APT',
+ 'startTimeISO': '2024-01-01T12:00:00+00:00',
+ 'endTimeISO': '2024-01-01T12:15:00+00:00',
+ 'maxEl': 45.0,
+ 'duration': 15.0,
+ 'quality': 'good',
+ }
+ sp = ScheduledPass(pass_data)
+ sp._timer = MagicMock()
+ scheduler._passes = [sp]
+
+ result = scheduler.skip_pass('NOAA-18_202401011200')
+
+ assert result is True
+ assert sp.status == 'skipped'
+ assert sp.skipped is True
+ sp._timer.cancel.assert_called_once()
+ event_cb.assert_called_once()
+
+ def test_skip_pass_not_found(self):
+ """skip_pass() should return False for non-existent pass."""
+ scheduler = WeatherSatScheduler()
+
+ result = scheduler.skip_pass('NONEXISTENT')
+
+ assert result is False
+
+ def test_skip_pass_already_complete(self):
+ """skip_pass() should not skip already complete passes."""
+ scheduler = WeatherSatScheduler()
+
+ pass_data = {
+ 'id': 'NOAA-18_202401011200',
+ 'satellite': 'NOAA-18',
+ 'name': 'NOAA 18',
+ 'frequency': 137.9125,
+ 'mode': 'APT',
+ 'startTimeISO': '2024-01-01T12:00:00+00:00',
+ 'endTimeISO': '2024-01-01T12:15:00+00:00',
+ 'maxEl': 45.0,
+ 'duration': 15.0,
+ 'quality': 'good',
+ }
+ sp = ScheduledPass(pass_data)
+ sp.status = 'complete'
+ scheduler._passes = [sp]
+
+ result = scheduler.skip_pass('NOAA-18_202401011200')
+
+ assert result is False
+ assert sp.status == 'complete'
+
+ def test_get_status(self):
+ """get_status() should return scheduler state."""
+ scheduler = WeatherSatScheduler()
+ scheduler._enabled = True
+ scheduler._lat = 51.5
+ scheduler._lon = -0.1
+ scheduler._device = 0
+ scheduler._gain = 40.0
+ scheduler._bias_t = False
+ scheduler._min_elevation = 15.0
+
+ pass_data = {
+ 'satellite': 'NOAA-18',
+ 'name': 'NOAA 18',
+ 'frequency': 137.9125,
+ 'mode': 'APT',
+ 'startTimeISO': '2024-01-01T12:00:00+00:00',
+ 'endTimeISO': '2024-01-01T12:15:00+00:00',
+ 'maxEl': 45.0,
+ 'duration': 15.0,
+ 'quality': 'good',
+ }
+ sp = ScheduledPass(pass_data)
+ scheduler._passes = [sp]
+
+ status = scheduler.get_status()
+
+ assert status['enabled'] is True
+ assert status['observer']['latitude'] == 51.5
+ assert status['observer']['longitude'] == -0.1
+ assert status['device'] == 0
+ assert status['gain'] == 40.0
+ assert status['bias_t'] is False
+ assert status['min_elevation'] == 15.0
+ assert status['scheduled_count'] == 1
+ assert status['total_passes'] == 1
+
+ def test_get_passes(self):
+ """get_passes() should return list of scheduled passes."""
+ scheduler = WeatherSatScheduler()
+
+ pass_data = {
+ 'id': 'NOAA-18_202401011200',
+ 'satellite': 'NOAA-18',
+ 'name': 'NOAA 18',
+ 'frequency': 137.9125,
+ 'mode': 'APT',
+ 'startTimeISO': '2024-01-01T12:00:00+00:00',
+ 'endTimeISO': '2024-01-01T12:15:00+00:00',
+ 'maxEl': 45.0,
+ 'duration': 15.0,
+ 'quality': 'good',
+ }
+ sp = ScheduledPass(pass_data)
+ scheduler._passes = [sp]
+
+ passes = scheduler.get_passes()
+
+ assert len(passes) == 1
+ assert passes[0]['id'] == 'NOAA-18_202401011200'
+
+ @patch('utils.weather_sat_scheduler.predict_passes')
+ @patch('threading.Timer')
+ def test_refresh_passes(self, mock_timer, mock_predict):
+ """_refresh_passes() should schedule future passes."""
+ now = datetime.now(timezone.utc)
+ future_pass = {
+ 'id': 'NOAA-18_202401011200',
+ 'satellite': 'NOAA-18',
+ 'name': 'NOAA 18',
+ 'frequency': 137.9125,
+ 'mode': 'APT',
+ 'startTimeISO': (now + timedelta(hours=2)).isoformat(),
+ 'endTimeISO': (now + timedelta(hours=2, minutes=15)).isoformat(),
+ 'maxEl': 45.0,
+ 'duration': 15.0,
+ 'quality': 'good',
+ }
+ mock_predict.return_value = [future_pass]
+
+ mock_timer_instance = MagicMock()
+ mock_timer.return_value = mock_timer_instance
+
+ scheduler = WeatherSatScheduler()
+ scheduler._enabled = True
+ scheduler._lat = 51.5
+ scheduler._lon = -0.1
+
+ scheduler._refresh_passes()
+
+ mock_predict.assert_called_once()
+ assert len(scheduler._passes) == 1
+ assert scheduler._passes[0].satellite == 'NOAA-18'
+ mock_timer_instance.start.assert_called()
+
+ @patch('utils.weather_sat_scheduler.predict_passes')
+ def test_refresh_passes_skip_past(self, mock_predict):
+ """_refresh_passes() should skip passes that already started."""
+ now = datetime.now(timezone.utc)
+ past_pass = {
+ 'id': 'NOAA-18_202401011200',
+ 'satellite': 'NOAA-18',
+ 'name': 'NOAA 18',
+ 'frequency': 137.9125,
+ 'mode': 'APT',
+ 'startTimeISO': (now - timedelta(hours=1)).isoformat(),
+ 'endTimeISO': (now - timedelta(hours=1) + timedelta(minutes=15)).isoformat(),
+ 'maxEl': 45.0,
+ 'duration': 15.0,
+ 'quality': 'good',
+ }
+ mock_predict.return_value = [past_pass]
+
+ scheduler = WeatherSatScheduler()
+ scheduler._enabled = True
+ scheduler._lat = 51.5
+ scheduler._lon = -0.1
+
+ scheduler._refresh_passes()
+
+ # Should not schedule past passes
+ assert len(scheduler._passes) == 0
+
+ @patch('utils.weather_sat_scheduler.predict_passes')
+ def test_refresh_passes_disabled(self, mock_predict):
+ """_refresh_passes() should do nothing when disabled."""
+ scheduler = WeatherSatScheduler()
+ scheduler._enabled = False
+
+ scheduler._refresh_passes()
+
+ mock_predict.assert_not_called()
+
+ @patch('utils.weather_sat_scheduler.predict_passes')
+ def test_refresh_passes_error_handling(self, mock_predict):
+ """_refresh_passes() should handle prediction errors."""
+ mock_predict.side_effect = Exception('TLE error')
+
+ scheduler = WeatherSatScheduler()
+ scheduler._enabled = True
+ scheduler._lat = 51.5
+ scheduler._lon = -0.1
+
+ # Should not raise
+ scheduler._refresh_passes()
+
+ assert len(scheduler._passes) == 0
+
+ @patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
+ def test_execute_capture_disabled(self, mock_get):
+ """_execute_capture() should do nothing when disabled."""
+ scheduler = WeatherSatScheduler()
+ scheduler._enabled = False
+
+ pass_data = {
+ 'satellite': 'NOAA-18',
+ 'name': 'NOAA 18',
+ 'frequency': 137.9125,
+ 'mode': 'APT',
+ 'startTimeISO': '2024-01-01T12:00:00+00:00',
+ 'endTimeISO': '2024-01-01T12:15:00+00:00',
+ 'maxEl': 45.0,
+ 'duration': 15.0,
+ 'quality': 'good',
+ }
+ sp = ScheduledPass(pass_data)
+
+ scheduler._execute_capture(sp)
+
+ mock_get.assert_not_called()
+
+ @patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
+ def test_execute_capture_skipped(self, mock_get):
+ """_execute_capture() should do nothing for skipped passes."""
+ scheduler = WeatherSatScheduler()
+ scheduler._enabled = True
+
+ pass_data = {
+ 'satellite': 'NOAA-18',
+ 'name': 'NOAA 18',
+ 'frequency': 137.9125,
+ 'mode': 'APT',
+ 'startTimeISO': '2024-01-01T12:00:00+00:00',
+ 'endTimeISO': '2024-01-01T12:15:00+00:00',
+ 'maxEl': 45.0,
+ 'duration': 15.0,
+ 'quality': 'good',
+ }
+ sp = ScheduledPass(pass_data)
+ sp.skipped = True
+
+ scheduler._execute_capture(sp)
+
+ mock_get.assert_not_called()
+
+ @patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
+ def test_execute_capture_decoder_busy(self, mock_get):
+ """_execute_capture() should skip when decoder is busy."""
+ scheduler = WeatherSatScheduler()
+ scheduler._enabled = True
+ event_cb = MagicMock()
+ scheduler.set_callbacks(MagicMock(), event_cb)
+
+ mock_decoder = MagicMock()
+ mock_decoder.is_running = True
+ mock_get.return_value = mock_decoder
+
+ pass_data = {
+ 'satellite': 'NOAA-18',
+ 'name': 'NOAA 18',
+ 'frequency': 137.9125,
+ 'mode': 'APT',
+ 'startTimeISO': '2024-01-01T12:00:00+00:00',
+ 'endTimeISO': '2024-01-01T12:15:00+00:00',
+ 'maxEl': 45.0,
+ 'duration': 15.0,
+ 'quality': 'good',
+ }
+ sp = ScheduledPass(pass_data)
+
+ scheduler._execute_capture(sp)
+
+ assert sp.status == 'skipped'
+ assert sp.skipped is True
+ event_cb.assert_called_once()
+ event_data = event_cb.call_args[0][0]
+ assert event_data['type'] == 'schedule_capture_skipped'
+ assert event_data['reason'] == 'sdr_busy'
+
+ @patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
+ @patch('threading.Timer')
+ def test_execute_capture_success(self, mock_timer, mock_get):
+ """_execute_capture() should start capture."""
+ scheduler = WeatherSatScheduler()
+ scheduler._enabled = True
+ scheduler._device = 0
+ scheduler._gain = 40.0
+ scheduler._bias_t = False
+ progress_cb = MagicMock()
+ event_cb = MagicMock()
+ scheduler.set_callbacks(progress_cb, event_cb)
+
+ mock_decoder = MagicMock()
+ mock_decoder.is_running = False
+ mock_decoder.start.return_value = True
+ mock_get.return_value = mock_decoder
+
+ mock_timer_instance = MagicMock()
+ mock_timer.return_value = mock_timer_instance
+
+ now = datetime.now(timezone.utc)
+ pass_data = {
+ 'satellite': 'NOAA-18',
+ 'name': 'NOAA 18',
+ 'frequency': 137.9125,
+ 'mode': 'APT',
+ 'startTimeISO': (now + timedelta(seconds=10)).isoformat(),
+ 'endTimeISO': (now + timedelta(minutes=15)).isoformat(),
+ 'maxEl': 45.0,
+ 'duration': 15.0,
+ 'quality': 'good',
+ }
+ sp = ScheduledPass(pass_data)
+
+ scheduler._execute_capture(sp)
+
+ assert sp.status == 'capturing'
+ mock_decoder.set_callback.assert_called_once_with(progress_cb)
+ mock_decoder.start.assert_called_once_with(
+ satellite='NOAA-18',
+ device_index=0,
+ gain=40.0,
+ bias_t=False,
+ )
+ event_cb.assert_called_once()
+ event_data = event_cb.call_args[0][0]
+ assert event_data['type'] == 'schedule_capture_start'
+
+ @patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
+ def test_execute_capture_start_failed(self, mock_get):
+ """_execute_capture() should handle start failure."""
+ scheduler = WeatherSatScheduler()
+ scheduler._enabled = True
+ event_cb = MagicMock()
+ scheduler.set_callbacks(MagicMock(), event_cb)
+
+ mock_decoder = MagicMock()
+ mock_decoder.is_running = False
+ mock_decoder.start.return_value = False
+ mock_get.return_value = mock_decoder
+
+ pass_data = {
+ 'satellite': 'NOAA-18',
+ 'name': 'NOAA 18',
+ 'frequency': 137.9125,
+ 'mode': 'APT',
+ 'startTimeISO': '2024-01-01T12:00:00+00:00',
+ 'endTimeISO': '2024-01-01T12:15:00+00:00',
+ 'maxEl': 45.0,
+ 'duration': 15.0,
+ 'quality': 'good',
+ }
+ sp = ScheduledPass(pass_data)
+
+ scheduler._execute_capture(sp)
+
+ assert sp.status == 'skipped'
+ event_cb.assert_called_once()
+ event_data = event_cb.call_args[0][0]
+ assert event_data['reason'] == 'start_failed'
+
+ @patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
+ def test_stop_capture(self, mock_get):
+ """_stop_capture() should stop decoder."""
+ scheduler = WeatherSatScheduler()
+
+ mock_decoder = MagicMock()
+ mock_decoder.is_running = True
+ mock_get.return_value = mock_decoder
+
+ pass_data = {
+ 'satellite': 'NOAA-18',
+ 'name': 'NOAA 18',
+ 'frequency': 137.9125,
+ 'mode': 'APT',
+ 'startTimeISO': '2024-01-01T12:00:00+00:00',
+ 'endTimeISO': '2024-01-01T12:15:00+00:00',
+ 'maxEl': 45.0,
+ 'duration': 15.0,
+ 'quality': 'good',
+ }
+ sp = ScheduledPass(pass_data)
+
+ scheduler._stop_capture(sp)
+
+ mock_decoder.stop.assert_called_once()
+
+ def test_on_capture_complete(self):
+ """_on_capture_complete() should mark pass complete and emit event."""
+ scheduler = WeatherSatScheduler()
+ event_cb = MagicMock()
+ scheduler.set_callbacks(MagicMock(), event_cb)
+ release_fn = MagicMock()
+
+ pass_data = {
+ 'satellite': 'NOAA-18',
+ 'name': 'NOAA 18',
+ 'frequency': 137.9125,
+ 'mode': 'APT',
+ 'startTimeISO': '2024-01-01T12:00:00+00:00',
+ 'endTimeISO': '2024-01-01T12:15:00+00:00',
+ 'maxEl': 45.0,
+ 'duration': 15.0,
+ 'quality': 'good',
+ }
+ sp = ScheduledPass(pass_data)
+
+ scheduler._on_capture_complete(sp, release_fn)
+
+ assert sp.status == 'complete'
+ release_fn.assert_called_once()
+ event_cb.assert_called_once()
+ event_data = event_cb.call_args[0][0]
+ assert event_data['type'] == 'schedule_capture_complete'
+
+ def test_emit_event(self):
+ """_emit_event() should call event callback."""
+ scheduler = WeatherSatScheduler()
+ event_cb = MagicMock()
+ scheduler.set_callbacks(MagicMock(), event_cb)
+
+ event = {'type': 'test_event', 'data': 'test'}
+ scheduler._emit_event(event)
+
+ event_cb.assert_called_once_with(event)
+
+ def test_emit_event_no_callback(self):
+ """_emit_event() should handle missing callback."""
+ scheduler = WeatherSatScheduler()
+
+ event = {'type': 'test_event'}
+ scheduler._emit_event(event) # Should not raise
+
+ def test_emit_event_callback_exception(self):
+ """_emit_event() should handle callback exceptions."""
+ scheduler = WeatherSatScheduler()
+ event_cb = MagicMock(side_effect=Exception('Callback error'))
+ scheduler.set_callbacks(MagicMock(), event_cb)
+
+ event = {'type': 'test_event'}
+ scheduler._emit_event(event) # Should not raise
+
+
+class TestGlobalScheduler:
+ """Tests for global scheduler singleton."""
+
+ def test_get_weather_sat_scheduler_singleton(self):
+ """get_weather_sat_scheduler() should return singleton."""
+ import utils.weather_sat_scheduler as mod
+ old = mod._scheduler
+ mod._scheduler = None
+
+ try:
+ scheduler1 = get_weather_sat_scheduler()
+ scheduler2 = get_weather_sat_scheduler()
+
+ assert scheduler1 is scheduler2
+ finally:
+ mod._scheduler = old
+
+ def test_get_weather_sat_scheduler_thread_safe(self):
+ """get_weather_sat_scheduler() should be thread-safe."""
+ import utils.weather_sat_scheduler as mod
+ old = mod._scheduler
+ mod._scheduler = None
+
+ schedulers = []
+
+ def create_scheduler():
+ schedulers.append(get_weather_sat_scheduler())
+
+ try:
+ threads = [threading.Thread(target=create_scheduler) for _ in range(10)]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+
+ # All should be the same instance
+ assert all(s is schedulers[0] for s in schedulers)
+ finally:
+ mod._scheduler = old
+
+
+class TestSchedulerConfiguration:
+ """Tests for scheduler configuration constants."""
+
+ def test_config_constants(self):
+ """Scheduler should have configuration constants."""
+ from utils.weather_sat_scheduler import (
+ WEATHER_SAT_SCHEDULE_REFRESH_MINUTES,
+ WEATHER_SAT_CAPTURE_BUFFER_SECONDS,
+ )
+
+ assert isinstance(WEATHER_SAT_SCHEDULE_REFRESH_MINUTES, int)
+ assert isinstance(WEATHER_SAT_CAPTURE_BUFFER_SECONDS, int)
+ assert WEATHER_SAT_SCHEDULE_REFRESH_MINUTES > 0
+ assert WEATHER_SAT_CAPTURE_BUFFER_SECONDS >= 0
+
+
+class TestSchedulerIntegration:
+ """Integration tests for scheduler."""
+
+ @patch('utils.weather_sat_scheduler.predict_passes')
+ @patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
+ @patch('threading.Timer')
+ def test_full_scheduling_cycle(self, mock_timer, mock_get_decoder, mock_predict):
+ """Test complete scheduling cycle from enable to execute."""
+ now = datetime.now(timezone.utc)
+ future_pass = {
+ 'id': 'NOAA-18_202401011200',
+ 'satellite': 'NOAA-18',
+ 'name': 'NOAA 18',
+ 'frequency': 137.9125,
+ 'mode': 'APT',
+ 'startTimeISO': (now + timedelta(hours=2)).isoformat(),
+ 'endTimeISO': (now + timedelta(hours=2, minutes=15)).isoformat(),
+ 'maxEl': 45.0,
+ 'duration': 15.0,
+ 'quality': 'good',
+ }
+ mock_predict.return_value = [future_pass]
+
+ mock_timer_instance = MagicMock()
+ mock_timer.return_value = mock_timer_instance
+
+ mock_decoder = MagicMock()
+ mock_decoder.is_running = False
+ mock_decoder.start.return_value = True
+ mock_get_decoder.return_value = mock_decoder
+
+ scheduler = WeatherSatScheduler()
+ progress_cb = MagicMock()
+ event_cb = MagicMock()
+ scheduler.set_callbacks(progress_cb, event_cb)
+
+ # Enable scheduler
+ result = scheduler.enable(lat=51.5, lon=-0.1)
+
+ assert result['enabled'] is True
+ assert len(scheduler._passes) == 1
+ assert scheduler._passes[0].satellite == 'NOAA-18'
+
+ # Simulate timer firing (capture start)
+ scheduler._execute_capture(scheduler._passes[0])
+
+ assert scheduler._passes[0].status == 'capturing'
+ mock_decoder.start.assert_called_once()
+
+ # Simulate completion
+ release_fn = MagicMock()
+ scheduler._on_capture_complete(scheduler._passes[0], release_fn)
+
+ assert scheduler._passes[0].status == 'complete'
+ release_fn.assert_called_once()
+
+ # Disable scheduler
+ scheduler.disable()
+
+ assert scheduler.enabled is False
+ assert len(scheduler._passes) == 0
diff --git a/utils/weather_sat.py b/utils/weather_sat.py
new file mode 100644
index 0000000..347c0a9
--- /dev/null
+++ b/utils/weather_sat.py
@@ -0,0 +1,1041 @@
+"""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 io
+import os
+import pty
+import re
+import select
+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
+from utils.process import register_process, safe_terminate
+
+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
+ log_type: str = '' # 'info', 'debug', 'progress', 'error', 'signal', 'save', 'warning'
+ capture_phase: str = '' # 'tuning', 'listening', 'signal_detected', 'decoding', 'complete', 'error'
+
+ 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,
+ 'log_type': self.log_type,
+ 'capture_phase': self.capture_phase,
+ }
+ 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._images_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._pty_master_fd: int | None = None
+ self._current_satellite: str = ''
+ self._current_frequency: float = 0.0
+ self._current_mode: str = ''
+ self._capture_start_time: float = 0
+ self._device_index: int = 0
+ self._capture_output_dir: Path | None = None
+ self._on_complete_callback: Callable[[], None] | None = None
+ self._capture_phase: str = 'idle'
+
+ # 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
+
+ @property
+ def device_index(self) -> int:
+ """Return current device index."""
+ return self._device_index
+
+ 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 set_on_complete(self, callback: Callable[[], None]) -> None:
+ """Set callback invoked when capture process ends (for SDR release)."""
+ self._on_complete_callback = callback
+
+ def start_from_file(
+ self,
+ satellite: str,
+ input_file: str | Path,
+ sample_rate: int = DEFAULT_SAMPLE_RATE,
+ ) -> bool:
+ """Start weather satellite decode from a pre-recorded IQ/WAV file.
+
+ No SDR hardware is required — SatDump runs in offline mode.
+
+ Args:
+ satellite: Satellite key (e.g. 'NOAA-18', 'METEOR-M2-3')
+ input_file: Path to IQ baseband or WAV audio file
+ sample_rate: Sample rate of the recording in Hz
+
+ 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
+
+ input_path = Path(input_file)
+
+ # Security: restrict to data directory
+ allowed_base = Path(__file__).resolve().parent.parent / 'data'
+ try:
+ resolved = input_path.resolve()
+ if not resolved.is_relative_to(allowed_base):
+ logger.warning(f"Path traversal blocked in start_from_file: {input_file}")
+ self._emit_progress(CaptureProgress(
+ status='error',
+ message='Input file must be under the data/ directory'
+ ))
+ return False
+ except (OSError, ValueError):
+ self._emit_progress(CaptureProgress(
+ status='error',
+ message='Invalid file path'
+ ))
+ return False
+
+ if not input_path.is_file():
+ logger.error(f"Input file not found: {input_file}")
+ self._emit_progress(CaptureProgress(
+ status='error',
+ message='Input file not found'
+ ))
+ return False
+
+ self._current_satellite = satellite
+ self._current_frequency = sat_info['frequency']
+ self._current_mode = sat_info['mode']
+ self._capture_start_time = time.time()
+ self._capture_phase = 'decoding'
+
+ try:
+ self._running = True
+ self._start_satdump_offline(
+ sat_info, input_path, sample_rate,
+ )
+
+ logger.info(
+ f"Weather satellite file decode started: {satellite} "
+ f"({sat_info['mode']}) from {input_file}"
+ )
+ self._emit_progress(CaptureProgress(
+ status='decoding',
+ satellite=satellite,
+ frequency=sat_info['frequency'],
+ mode=sat_info['mode'],
+ message=f"Decoding {sat_info['name']} from file ({sat_info['mode']})...",
+ log_type='info',
+ capture_phase='decoding',
+ ))
+
+ return True
+
+ except Exception as e:
+ self._running = False
+ logger.error(f"Failed to start file decode: {e}")
+ self._emit_progress(CaptureProgress(
+ status='error',
+ satellite=satellite,
+ message=str(e)
+ ))
+ return False
+
+ 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()
+ self._capture_phase = 'tuning'
+
+ try:
+ self._running = True
+ self._start_satdump(sat_info, device_index, gain, sample_rate, bias_t)
+
+ 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']})...",
+ log_type='info',
+ capture_phase=self._capture_phase,
+ ))
+
+ return True
+
+ except Exception as e:
+ self._running = False
+ 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)
+
+ # SatDump v1.2+ uses string source_id (device serial) not numeric index.
+ # Auto-detect serial by querying rtl_eeprom, fall back to string index.
+ source_id = self._resolve_device_id(device_index)
+
+ cmd = [
+ 'satdump', 'live',
+ sat_info['pipeline'],
+ str(self._capture_output_dir),
+ '--source', 'rtlsdr',
+ '--samplerate', str(sample_rate),
+ '--frequency', str(freq_hz),
+ '--gain', str(int(gain)),
+ '--source_id', source_id,
+ ]
+
+ if bias_t:
+ cmd.append('--bias')
+
+ logger.info(f"Starting SatDump: {' '.join(cmd)}")
+
+ # Use a pseudo-terminal so SatDump thinks it's writing to a real
+ # terminal. C/C++ runtimes disable buffering on TTYs, which lets
+ # us see output (including \r progress lines) in real time.
+ master_fd, slave_fd = pty.openpty()
+ self._pty_master_fd = master_fd
+
+ self._process = subprocess.Popen(
+ cmd,
+ stdout=slave_fd,
+ stderr=slave_fd,
+ stdin=subprocess.DEVNULL,
+ close_fds=True,
+ )
+ register_process(self._process)
+ os.close(slave_fd) # parent doesn't need the slave side
+
+ # Check for early exit asynchronously (avoid blocking /start for 3s)
+ def _check_early_exit():
+ """Poll once after 3s; if SatDump died, emit an error event."""
+ time.sleep(3)
+ process = self._process
+ if process is None or process.poll() is None:
+ return # still running or already cleaned up
+ retcode = process.returncode
+ output = b''
+ try:
+ while True:
+ r, _, _ = select.select([master_fd], [], [], 0.1)
+ if not r:
+ break
+ chunk = os.read(master_fd, 4096)
+ if not chunk:
+ break
+ output += chunk
+ except OSError:
+ pass
+ output_str = output.decode('utf-8', errors='replace')
+ error_msg = f"SatDump exited immediately (code {retcode})"
+ if output_str:
+ for line in output_str.strip().splitlines():
+ if 'error' in line.lower() or 'could not' in line.lower() or 'cannot' in line.lower():
+ error_msg = line.strip()
+ break
+ logger.error(f"SatDump output:\n{output_str}")
+ self._emit_progress(CaptureProgress(
+ status='error',
+ satellite=self._current_satellite,
+ frequency=self._current_frequency,
+ mode=self._current_mode,
+ message=error_msg,
+ log_type='error',
+ capture_phase='error',
+ ))
+
+ threading.Thread(target=_check_early_exit, daemon=True).start()
+
+ # 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 _start_satdump_offline(
+ self,
+ sat_info: dict,
+ input_file: Path,
+ sample_rate: int,
+ ) -> None:
+ """Start SatDump offline decode from a recorded file."""
+ # Create timestamped output directory for this decode
+ 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)
+
+ # Determine input level from file extension.
+ # WAV audio files (FM-demodulated) use 'audio_wav' level.
+ # Raw IQ baseband files use 'baseband' level.
+ suffix = input_file.suffix.lower()
+ if suffix in ('.wav', '.wave'):
+ input_level = 'audio_wav'
+ else:
+ input_level = 'baseband'
+
+ cmd = [
+ 'satdump',
+ sat_info['pipeline'],
+ input_level,
+ str(input_file),
+ str(self._capture_output_dir),
+ '--samplerate', str(sample_rate),
+ ]
+
+ logger.info(f"Starting SatDump offline: {' '.join(cmd)}")
+
+ # Use a pseudo-terminal so SatDump thinks it's writing to a real
+ # terminal — same approach as live mode for unbuffered output.
+ master_fd, slave_fd = pty.openpty()
+ self._pty_master_fd = master_fd
+
+ self._process = subprocess.Popen(
+ cmd,
+ stdout=slave_fd,
+ stderr=slave_fd,
+ stdin=subprocess.DEVNULL,
+ close_fds=True,
+ )
+ register_process(self._process)
+ os.close(slave_fd) # parent doesn't need the slave side
+
+ # For offline mode, don't check for early exit — file decoding
+ # may complete very quickly and exit code 0 is normal success.
+ # The reader thread will handle output and detect errors.
+
+ # 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()
+
+ @staticmethod
+ def _classify_log_type(line: str) -> str:
+ """Classify a SatDump output line into a log type."""
+ lower = line.lower()
+ if '(e)' in lower or 'error' in lower or 'fail' in lower:
+ return 'error'
+ if 'progress' in lower and '%' in line:
+ return 'progress'
+ if 'saved' in lower or 'writing' in lower:
+ return 'save'
+ if 'detected' in lower or 'lock' in lower or 'sync' in lower:
+ return 'signal'
+ if '(w)' in lower:
+ return 'warning'
+ if '(d)' in lower:
+ return 'debug'
+ return 'info'
+
+ @staticmethod
+ def _resolve_device_id(device_index: int) -> str:
+ """Resolve RTL-SDR device index to serial number string for SatDump v1.2+.
+
+ SatDump v1.2+ expects --source_id as a device serial string, not a
+ numeric index. Try to look up the serial via rtl_test, fall back to
+ the string representation of the index.
+ """
+ try:
+ result = subprocess.run(
+ ['rtl_test', '-d', str(device_index), '-t'],
+ capture_output=True, text=True, timeout=5,
+ )
+ # rtl_test outputs: "Found 2 device(s):" then
+ # " 0: RTLSDRBlog, Blog V4, SN: 00004000"
+ output = result.stdout + result.stderr
+ for line in output.splitlines():
+ # Match SN: pattern
+ match = re.search(r'SN:\s*(\S+)', line)
+ if match:
+ serial = match.group(1)
+ logger.info(f"RTL-SDR device {device_index} serial: {serial}")
+ return serial
+ # Also match "Using device #N: ..." then "Serial number is "
+ match = re.search(r'Serial number is\s+(\S+)', line)
+ if match:
+ serial = match.group(1)
+ logger.info(f"RTL-SDR device {device_index} serial: {serial}")
+ return serial
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
+ logger.debug(f"Could not detect device serial: {e}")
+
+ # Fall back to string index
+ return str(device_index)
+
+ def _read_pty_lines(self):
+ """Read lines from the PTY master fd, splitting on \\n and \\r.
+
+ SatDump uses \\r carriage returns for progress updates. A PTY gives
+ us unbuffered output. We use select() to detect data availability
+ and os.read() for raw bytes, then split on line boundaries.
+ """
+ master_fd = self._pty_master_fd
+ if master_fd is None:
+ return
+
+ buf = b''
+ while self._running:
+ try:
+ r, _, _ = select.select([master_fd], [], [], 1.0)
+ if not r:
+ # Timeout — check if process is still alive
+ if self._process and self._process.poll() is not None:
+ break
+ continue
+ chunk = os.read(master_fd, 4096)
+ if not chunk:
+ break
+ buf += chunk
+ # Split on \r and \n
+ while b'\n' in buf or b'\r' in buf:
+ # Find earliest delimiter
+ idx_n = buf.find(b'\n')
+ idx_r = buf.find(b'\r')
+ if idx_n == -1:
+ idx = idx_r
+ elif idx_r == -1:
+ idx = idx_n
+ else:
+ idx = min(idx_n, idx_r)
+ line = buf[:idx]
+ buf = buf[idx + 1:]
+ # Skip empty lines
+ text = line.decode('utf-8', errors='replace').strip()
+ # Strip ANSI escape codes that terminals produce
+ text = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', text)
+ if text:
+ yield text
+ except OSError:
+ break
+ # Drain remaining buffer
+ text = buf.decode('utf-8', errors='replace').strip()
+ if text:
+ text = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', text)
+ if text:
+ yield text
+
+ def _read_satdump_output(self) -> None:
+ """Read SatDump stdout/stderr for progress updates."""
+ if not self._process or self._pty_master_fd is None:
+ return
+
+ last_emit_time = 0.0
+
+ try:
+ for line in self._read_pty_lines():
+ if not self._running:
+ break
+
+ line = line.strip()
+ if not line:
+ continue
+
+ logger.debug(f"satdump: {line}")
+
+ elapsed = int(time.time() - self._capture_start_time)
+ now = time.time()
+ log_type = self._classify_log_type(line)
+
+ # Track phase transitions
+ lower = line.lower()
+ if log_type == 'signal':
+ self._capture_phase = 'signal_detected'
+ elif log_type == 'progress':
+ self._capture_phase = 'decoding'
+ elif self._capture_phase == 'tuning' and (
+ 'freq' in lower or 'processing' in lower
+ or 'starting' in lower or 'source' in lower
+ ):
+ self._capture_phase = 'listening'
+
+ # Parse progress from SatDump output
+ if log_type == 'progress':
+ 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,
+ log_type=log_type,
+ capture_phase=self._capture_phase,
+ ))
+ last_emit_time = now
+ elif log_type == 'save':
+ self._emit_progress(CaptureProgress(
+ status='decoding',
+ satellite=self._current_satellite,
+ frequency=self._current_frequency,
+ mode=self._current_mode,
+ message=line,
+ elapsed_seconds=elapsed,
+ log_type=log_type,
+ capture_phase=self._capture_phase,
+ ))
+ last_emit_time = now
+ elif log_type == 'error':
+ self._emit_progress(CaptureProgress(
+ status='capturing',
+ satellite=self._current_satellite,
+ frequency=self._current_frequency,
+ mode=self._current_mode,
+ message=line,
+ elapsed_seconds=elapsed,
+ log_type=log_type,
+ capture_phase=self._capture_phase,
+ ))
+ last_emit_time = now
+ elif log_type == 'signal':
+ self._emit_progress(CaptureProgress(
+ status='capturing',
+ satellite=self._current_satellite,
+ frequency=self._current_frequency,
+ mode=self._current_mode,
+ message=line,
+ elapsed_seconds=elapsed,
+ log_type=log_type,
+ capture_phase=self._capture_phase,
+ ))
+ last_emit_time = now
+ else:
+ # Emit other lines, throttled to every 0.5 seconds
+ if now - last_emit_time >= 0.5:
+ self._emit_progress(CaptureProgress(
+ status='capturing',
+ satellite=self._current_satellite,
+ frequency=self._current_frequency,
+ mode=self._current_mode,
+ message=line,
+ elapsed_seconds=elapsed,
+ log_type=log_type,
+ capture_phase=self._capture_phase,
+ ))
+ last_emit_time = now
+
+ except Exception as e:
+ logger.error(f"Error reading SatDump output: {e}")
+ finally:
+ # Close PTY master fd
+ if self._pty_master_fd is not None:
+ try:
+ os.close(self._pty_master_fd)
+ except OSError:
+ pass
+ self._pty_master_fd = None
+
+ # Process ended — release resources
+ was_running = self._running
+ self._running = False
+ elapsed = int(time.time() - self._capture_start_time) if self._capture_start_time else 0
+
+ if was_running:
+ self._capture_phase = 'complete'
+ 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,
+ log_type='info',
+ capture_phase='complete',
+ ))
+
+ # Notify route layer to release SDR device
+ if self._on_complete_callback:
+ try:
+ self._on_complete_callback()
+ except Exception as e:
+ logger.error(f"Error in on_complete callback: {e}")
+
+ 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):
+ file_key = str(filepath)
+ if file_key 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(file_key)
+
+ # 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,
+ )
+ with self._images_lock:
+ 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._pty_master_fd is not None:
+ try:
+ os.close(self._pty_master_fd)
+ except OSError:
+ pass
+ self._pty_master_fd = None
+
+ if self._process:
+ safe_terminate(self._process)
+ 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."""
+ with self._images_lock:
+ self._scan_images()
+ return list(self._images)
+
+ def _scan_images(self) -> None:
+ """Scan output directory for images not yet tracked.
+
+ Must be called with self._images_lock held.
+ """
+ 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()
+ with self._images_lock:
+ 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 delete_all_images(self) -> int:
+ """Delete all decoded images."""
+ count = 0
+ for ext in ('*.png', '*.jpg', '*.jpeg'):
+ for filepath in self._output_dir.glob(ext):
+ try:
+ filepath.unlink()
+ count += 1
+ except OSError:
+ pass
+ with self._images_lock:
+ self._images.clear()
+ return count
+
+ 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
+_decoder_lock = threading.Lock()
+
+
+def get_weather_sat_decoder() -> WeatherSatDecoder:
+ """Get or create the global weather satellite decoder instance."""
+ global _decoder
+ if _decoder is None:
+ with _decoder_lock:
+ 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
diff --git a/utils/weather_sat_predict.py b/utils/weather_sat_predict.py
new file mode 100644
index 0000000..8d6432f
--- /dev/null
+++ b/utils/weather_sat_predict.py
@@ -0,0 +1,188 @@
+"""Weather satellite pass prediction utility.
+
+Shared prediction logic used by both the API endpoint and the auto-scheduler.
+"""
+
+from __future__ import annotations
+
+import datetime
+from typing import Any
+
+from utils.logging import get_logger
+from utils.weather_sat import WEATHER_SATELLITES
+
+logger = get_logger('intercept.weather_sat_predict')
+
+
+def predict_passes(
+ lat: float,
+ lon: float,
+ hours: int = 24,
+ min_elevation: float = 15.0,
+ include_trajectory: bool = False,
+ include_ground_track: bool = False,
+) -> list[dict[str, Any]]:
+ """Predict upcoming weather satellite passes for an observer location.
+
+ Args:
+ lat: Observer latitude (-90 to 90)
+ lon: Observer longitude (-180 to 180)
+ hours: Hours ahead to predict (1-72)
+ min_elevation: Minimum max elevation in degrees (0-90)
+ include_trajectory: Include az/el trajectory points (30 points)
+ include_ground_track: Include lat/lon ground track points (60 points)
+
+ Returns:
+ List of pass dicts sorted by start time.
+
+ Raises:
+ ImportError: If skyfield is not installed.
+ """
+ from skyfield.api import load, wgs84, EarthSatellite
+ from skyfield.almanac import find_discrete
+ from data.satellites import TLE_SATELLITES
+
+ # Use live TLE cache from satellite module if available (refreshed from CelesTrak)
+ tle_source = TLE_SATELLITES
+ try:
+ from routes.satellite import _tle_cache
+ if _tle_cache:
+ tle_source = _tle_cache
+ except ImportError:
+ pass
+
+ ts = load.timescale()
+ observer = wgs84.latlon(lat, lon)
+ t0 = ts.now()
+ t1 = ts.utc(t0.utc_datetime() + datetime.timedelta(hours=hours))
+
+ all_passes: list[dict[str, Any]] = []
+
+ for sat_key, sat_info in WEATHER_SATELLITES.items():
+ if not sat_info['active']:
+ continue
+
+ tle_data = tle_source.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
+
+ duration_seconds = (
+ set_time.utc_datetime() - rise_time.utc_datetime()
+ ).total_seconds()
+ duration_minutes = round(duration_seconds / 60, 1)
+
+ # Calculate max elevation and trajectory
+ max_el = 0.0
+ max_el_az = 0.0
+ trajectory: list[dict[str, float]] = []
+ num_traj_points = 30
+
+ for k in range(num_traj_points):
+ frac = k / (num_traj_points - 1)
+ t_point = ts.utc(
+ rise_time.utc_datetime()
+ + 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 include_trajectory:
+ trajectory.append({
+ 'el': float(max(0, alt.degrees)),
+ 'az': float(az.degrees),
+ })
+
+ if max_el < min_elevation:
+ i += 1
+ continue
+
+ # Rise/set azimuths
+ rise_topo = (satellite - observer).at(rise_time)
+ _, rise_az, _ = rise_topo.altaz()
+
+ set_topo = (satellite - observer).at(set_time)
+ _, set_az, _ = set_topo.altaz()
+
+ pass_data: dict[str, Any] = {
+ 'id': f"{sat_key}_{rise_time.utc_datetime().strftime('%Y%m%d%H%M')}",
+ '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'
+ ),
+ }
+
+ if include_trajectory:
+ pass_data['trajectory'] = trajectory
+
+ if include_ground_track:
+ ground_track: list[dict[str, float]] = []
+ for k in range(60):
+ frac = k / 59
+ t_point = ts.utc(
+ rise_time.utc_datetime()
+ + datetime.timedelta(seconds=duration_seconds * frac)
+ )
+ geocentric = satellite.at(t_point)
+ subpoint = wgs84.subpoint(geocentric)
+ ground_track.append({
+ 'lat': float(subpoint.latitude.degrees),
+ 'lon': float(subpoint.longitude.degrees),
+ })
+ pass_data['groundTrack'] = ground_track
+
+ all_passes.append(pass_data)
+
+ i += 1
+
+ all_passes.sort(key=lambda p: p['startTimeISO'])
+ return all_passes
diff --git a/utils/weather_sat_scheduler.py b/utils/weather_sat_scheduler.py
new file mode 100644
index 0000000..6f16a54
--- /dev/null
+++ b/utils/weather_sat_scheduler.py
@@ -0,0 +1,396 @@
+"""Weather satellite auto-scheduler.
+
+Automatically captures satellite passes based on predicted pass times.
+Uses threading.Timer for scheduling — no external dependencies required.
+"""
+
+from __future__ import annotations
+
+import threading
+import time
+import uuid
+from datetime import datetime, timezone, timedelta
+from typing import Any, Callable
+
+from utils.logging import get_logger
+from utils.weather_sat import get_weather_sat_decoder, WEATHER_SATELLITES, CaptureProgress
+
+logger = get_logger('intercept.weather_sat_scheduler')
+
+# Import config defaults
+try:
+ from config import (
+ WEATHER_SAT_SCHEDULE_REFRESH_MINUTES,
+ WEATHER_SAT_CAPTURE_BUFFER_SECONDS,
+ )
+except ImportError:
+ WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = 30
+ WEATHER_SAT_CAPTURE_BUFFER_SECONDS = 30
+
+
+class ScheduledPass:
+ """A pass scheduled for automatic capture."""
+
+ def __init__(self, pass_data: dict[str, Any]):
+ self.id: str = pass_data.get('id', str(uuid.uuid4())[:8])
+ self.satellite: str = pass_data['satellite']
+ self.name: str = pass_data['name']
+ self.frequency: float = pass_data['frequency']
+ self.mode: str = pass_data['mode']
+ self.start_time: str = pass_data['startTimeISO']
+ self.end_time: str = pass_data['endTimeISO']
+ self.max_el: float = pass_data['maxEl']
+ self.duration: float = pass_data['duration']
+ self.quality: str = pass_data['quality']
+ self.status: str = 'scheduled' # scheduled, capturing, complete, skipped
+ self.skipped: bool = False
+ self._timer: threading.Timer | None = None
+ self._stop_timer: threading.Timer | None = None
+
+ @property
+ def start_dt(self) -> datetime:
+ dt = datetime.fromisoformat(self.start_time)
+ if dt.tzinfo is None:
+ # Naive datetime - assume UTC
+ dt = dt.replace(tzinfo=timezone.utc)
+ return dt.astimezone(timezone.utc)
+
+ @property
+ def end_dt(self) -> datetime:
+ dt = datetime.fromisoformat(self.end_time)
+ if dt.tzinfo is None:
+ # Naive datetime - assume UTC
+ dt = dt.replace(tzinfo=timezone.utc)
+ return dt.astimezone(timezone.utc)
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ 'id': self.id,
+ 'satellite': self.satellite,
+ 'name': self.name,
+ 'frequency': self.frequency,
+ 'mode': self.mode,
+ 'startTimeISO': self.start_time,
+ 'endTimeISO': self.end_time,
+ 'maxEl': self.max_el,
+ 'duration': self.duration,
+ 'quality': self.quality,
+ 'status': self.status,
+ 'skipped': self.skipped,
+ }
+
+
+class WeatherSatScheduler:
+ """Auto-scheduler for weather satellite captures."""
+
+ def __init__(self):
+ self._enabled = False
+ self._lock = threading.Lock()
+ self._passes: list[ScheduledPass] = []
+ self._refresh_timer: threading.Timer | None = None
+ self._lat: float = 0.0
+ self._lon: float = 0.0
+ self._min_elevation: float = 15.0
+ self._device: int = 0
+ self._gain: float = 40.0
+ self._bias_t: bool = False
+ self._progress_callback: Callable[[CaptureProgress], None] | None = None
+ self._event_callback: Callable[[dict[str, Any]], None] | None = None
+
+ @property
+ def enabled(self) -> bool:
+ return self._enabled
+
+ def set_callbacks(
+ self,
+ progress_callback: Callable[[CaptureProgress], None],
+ event_callback: Callable[[dict[str, Any]], None],
+ ) -> None:
+ """Set callbacks for progress and scheduler events."""
+ self._progress_callback = progress_callback
+ self._event_callback = event_callback
+
+ def enable(
+ self,
+ lat: float,
+ lon: float,
+ min_elevation: float = 15.0,
+ device: int = 0,
+ gain: float = 40.0,
+ bias_t: bool = False,
+ ) -> dict[str, Any]:
+ """Enable auto-scheduling.
+
+ Args:
+ lat: Observer latitude
+ lon: Observer longitude
+ min_elevation: Minimum pass elevation to capture
+ device: RTL-SDR device index
+ gain: SDR gain in dB
+ bias_t: Enable bias-T
+
+ Returns:
+ Status dict with scheduled passes.
+ """
+ with self._lock:
+ self._lat = lat
+ self._lon = lon
+ self._min_elevation = min_elevation
+ self._device = device
+ self._gain = gain
+ self._bias_t = bias_t
+ self._enabled = True
+
+ self._refresh_passes()
+
+ return self.get_status()
+
+ def disable(self) -> dict[str, Any]:
+ """Disable auto-scheduling and cancel all timers."""
+ with self._lock:
+ self._enabled = False
+
+ # Cancel refresh timer
+ if self._refresh_timer:
+ self._refresh_timer.cancel()
+ self._refresh_timer = None
+
+ # Cancel all pass timers
+ for p in self._passes:
+ if p._timer:
+ p._timer.cancel()
+ p._timer = None
+ if p._stop_timer:
+ p._stop_timer.cancel()
+ p._stop_timer = None
+
+ self._passes.clear()
+
+ logger.info("Weather satellite auto-scheduler disabled")
+ return {'status': 'disabled'}
+
+ def skip_pass(self, pass_id: str) -> bool:
+ """Manually skip a scheduled pass."""
+ with self._lock:
+ for p in self._passes:
+ if p.id == pass_id and p.status == 'scheduled':
+ p.skipped = True
+ p.status = 'skipped'
+ if p._timer:
+ p._timer.cancel()
+ p._timer = None
+ logger.info(f"Skipped pass: {p.satellite} at {p.start_time}")
+ self._emit_event({
+ 'type': 'schedule_capture_skipped',
+ 'pass': p.to_dict(),
+ 'reason': 'manual',
+ })
+ return True
+ return False
+
+ def get_status(self) -> dict[str, Any]:
+ """Get current scheduler status."""
+ with self._lock:
+ return {
+ 'enabled': self._enabled,
+ 'observer': {'latitude': self._lat, 'longitude': self._lon},
+ 'device': self._device,
+ 'gain': self._gain,
+ 'bias_t': self._bias_t,
+ 'min_elevation': self._min_elevation,
+ 'scheduled_count': sum(
+ 1 for p in self._passes if p.status == 'scheduled'
+ ),
+ 'total_passes': len(self._passes),
+ }
+
+ def get_passes(self) -> list[dict[str, Any]]:
+ """Get list of scheduled passes."""
+ with self._lock:
+ return [p.to_dict() for p in self._passes]
+
+ def _refresh_passes(self) -> None:
+ """Recompute passes and schedule timers."""
+ if not self._enabled:
+ return
+
+ try:
+ from utils.weather_sat_predict import predict_passes
+
+ passes = predict_passes(
+ lat=self._lat,
+ lon=self._lon,
+ hours=24,
+ min_elevation=self._min_elevation,
+ )
+ except Exception as e:
+ logger.error(f"Failed to predict passes for scheduler: {e}")
+ passes = []
+
+ with self._lock:
+ # Cancel existing timers
+ for p in self._passes:
+ if p._timer:
+ p._timer.cancel()
+ if p._stop_timer:
+ p._stop_timer.cancel()
+
+ # Keep completed/skipped for history, replace scheduled
+ history = [p for p in self._passes if p.status in ('complete', 'skipped', 'capturing')]
+ self._passes = history
+
+ now = datetime.now(timezone.utc)
+ buffer = WEATHER_SAT_CAPTURE_BUFFER_SECONDS
+
+ for pass_data in passes:
+ sp = ScheduledPass(pass_data)
+
+ # Skip passes that already started
+ if sp.start_dt - timedelta(seconds=buffer) <= now:
+ continue
+
+ # Check if already in history
+ if any(h.id == sp.id for h in history):
+ continue
+
+ # Schedule capture timer
+ delay = (sp.start_dt - timedelta(seconds=buffer) - now).total_seconds()
+ if delay > 0:
+ sp._timer = threading.Timer(delay, self._execute_capture, args=[sp])
+ sp._timer.daemon = True
+ sp._timer.start()
+ self._passes.append(sp)
+
+ logger.info(
+ f"Scheduler refreshed: {sum(1 for p in self._passes if p.status == 'scheduled')} "
+ f"passes scheduled"
+ )
+
+ # Schedule next refresh
+ if self._refresh_timer:
+ self._refresh_timer.cancel()
+ self._refresh_timer = threading.Timer(
+ WEATHER_SAT_SCHEDULE_REFRESH_MINUTES * 60,
+ self._refresh_passes,
+ )
+ self._refresh_timer.daemon = True
+ self._refresh_timer.start()
+
+ def _execute_capture(self, sp: ScheduledPass) -> None:
+ """Execute capture for a scheduled pass."""
+ if not self._enabled or sp.skipped:
+ return
+
+ decoder = get_weather_sat_decoder()
+
+ if decoder.is_running:
+ logger.info(f"SDR busy, skipping scheduled pass: {sp.satellite}")
+ sp.status = 'skipped'
+ sp.skipped = True
+ self._emit_event({
+ 'type': 'schedule_capture_skipped',
+ 'pass': sp.to_dict(),
+ 'reason': 'sdr_busy',
+ })
+ return
+
+ # Claim SDR device
+ try:
+ import app as app_module
+ error = app_module.claim_sdr_device(self._device, 'weather_sat')
+ if error:
+ logger.info(f"SDR device busy, skipping: {sp.satellite} - {error}")
+ sp.status = 'skipped'
+ sp.skipped = True
+ self._emit_event({
+ 'type': 'schedule_capture_skipped',
+ 'pass': sp.to_dict(),
+ 'reason': 'device_busy',
+ })
+ return
+ except ImportError:
+ pass
+
+ sp.status = 'capturing'
+
+ # Set up callbacks
+ if self._progress_callback:
+ decoder.set_callback(self._progress_callback)
+
+ def _release_device():
+ try:
+ import app as app_module
+ app_module.release_sdr_device(self._device)
+ except ImportError:
+ pass
+
+ decoder.set_on_complete(lambda: self._on_capture_complete(sp, _release_device))
+
+ success = decoder.start(
+ satellite=sp.satellite,
+ device_index=self._device,
+ gain=self._gain,
+ bias_t=self._bias_t,
+ )
+
+ if success:
+ logger.info(f"Auto-scheduler started capture: {sp.satellite}")
+ self._emit_event({
+ 'type': 'schedule_capture_start',
+ 'pass': sp.to_dict(),
+ })
+
+ # Schedule stop timer at pass end + buffer
+ now = datetime.now(timezone.utc)
+ stop_delay = (sp.end_dt + timedelta(seconds=WEATHER_SAT_CAPTURE_BUFFER_SECONDS) - now).total_seconds()
+ if stop_delay > 0:
+ sp._stop_timer = threading.Timer(stop_delay, self._stop_capture, args=[sp])
+ sp._stop_timer.daemon = True
+ sp._stop_timer.start()
+ else:
+ sp.status = 'skipped'
+ _release_device()
+ self._emit_event({
+ 'type': 'schedule_capture_skipped',
+ 'pass': sp.to_dict(),
+ 'reason': 'start_failed',
+ })
+
+ def _stop_capture(self, sp: ScheduledPass) -> None:
+ """Stop capture at pass end."""
+ decoder = get_weather_sat_decoder()
+ if decoder.is_running:
+ decoder.stop()
+ logger.info(f"Auto-scheduler stopped capture: {sp.satellite}")
+
+ def _on_capture_complete(self, sp: ScheduledPass, release_fn: Callable) -> None:
+ """Handle capture completion."""
+ sp.status = 'complete'
+ release_fn()
+ self._emit_event({
+ 'type': 'schedule_capture_complete',
+ 'pass': sp.to_dict(),
+ })
+
+ def _emit_event(self, event: dict[str, Any]) -> None:
+ """Emit scheduler event to callback."""
+ if self._event_callback:
+ try:
+ self._event_callback(event)
+ except Exception as e:
+ logger.error(f"Error in scheduler event callback: {e}")
+
+
+# Singleton
+_scheduler: WeatherSatScheduler | None = None
+_scheduler_lock = threading.Lock()
+
+
+def get_weather_sat_scheduler() -> WeatherSatScheduler:
+ """Get or create the global weather satellite scheduler instance."""
+ global _scheduler
+ if _scheduler is None:
+ with _scheduler_lock:
+ if _scheduler is None:
+ _scheduler = WeatherSatScheduler()
+ return _scheduler