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 = ` + + `; + 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} +
+
+ +
+
+ `; + }).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 = ` + + `; + 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 += `
${escapeHtml(date)}
`; + html += imgs.map(img => { + const fn = escapeHtml(img.filename || img.url.split('/').pop()); + return ` +
+
+ ${escapeHtml(img.satellite)} ${escapeHtml(img.product)} +
+
${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 = ` +
+ +
+ + Weather Satellite Image +
+ `; + 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 @@ + + +
+
+ ANTENNA GUIDE + +
+ +
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 + + + +
+
+
+ -- + MHZ +
+
+ -- + MODE +
+
+ 0 + IMAGES +
+
+
+
+
+ LOC + + + +
+
+
+
+ +
+ + + +
+
+
+
--DAYS
+
--HRS
+
--MIN
+
--SEC
+
+
+ -- + No passes predicted +
+
+
+
+
+
+ 00:0006:0012:0018:0024:00 +
+
+
+ + +
+
+ -- + 0:00 +
+
+
+
+
+ + +
+
+
+ DECODER CONSOLE +
+ TUNING + + LISTENING + + SIGNAL + + DECODING + + COMPLETE +
+
+ +
+
+
+
Waiting for capture...
+
+
+
+ + +
+ +
+
+ Upcoming Passes + 0 +
+
+ +
+
+ + +
+
+
+ Polar Plot + -- +
+ +
+
+
+ Ground Track +
+
+
+
+ + + +
+ + + +
+

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 A161.975 MHz
AIS Channel B162.025 MHz
Quarter-wave length46 cm
ModulationGMSK 9600 baud
Bandwidth25 kHz
PolarizationVertical
+
+
+
+ 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 length51.5 cm
ModulationFM 1200 baud
PolarizationVertical
+
+
+
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 / Americas915 MHz
EU / UK / India868 MHz
915 MHz λ/48.2 cm
868 MHz λ/48.6 cm
ModulationLoRa (CSS)
Typical range1–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 freq929 MHz
Common VHF freq153.350 MHz
ModulationFM (NFM)
Bandwidth~12.5 kHz
PolarizationVertical
+
+
+
+ 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 λ/48.2 cm
Meter TX power~100 mW
PolarizationVertical
+
+
+
+ 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 λ/417.3 cm
868 MHz λ/48.6 cm
915 MHz λ/48.2 cm
Typical range50–300 m
PolarizationVertical
+
+
+
+ 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 frequency145.800 MHz
Quarter-wave length51 cm
ModulationFM (25 kHz)
PolarizationRHCP (circular)
Typical pass duration5–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

+
+ + +
+
+ + +
+
+ +
+
+ + +
+

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 duration10-15 min
PolarizationRHCP
NOAA (APT) bandwidth~40 kHz
Meteor (LRPT) bandwidth~140 kHz
+
+
+
+ +
+

+ Test Decode (File) + +

+ +
+ +
+

Auto-Scheduler

+

+ Automatically capture satellite passes based on predictions. + Set your location above and toggle AUTO in the strip bar. +

+
+ +
+
+ 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