mirror of
https://github.com/smittix/intercept.git
synced 2026-06-11 07:23:30 -07:00
feat: Add weather satellite decoder (NOAA APT & Meteor LRPT)
feat: Add weather satellite decoder (NOAA APT & Meteor LRPT) - Alpha
This commit is contained in:
@@ -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.*
|
||||
|
||||
@@ -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
|
||||
|
||||
+69
-1
@@ -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
|
||||
|
||||
@@ -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/)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Executable
+139
@@ -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 "============================================"
|
||||
@@ -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)
|
||||
|
||||
+28
-10
@@ -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"]
|
||||
|
||||
Executable
+30
@@ -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"
|
||||
@@ -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
|
||||
|
||||
+21
-2
@@ -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/<category>')
|
||||
@@ -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'})
|
||||
|
||||
@@ -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/<filename>')
|
||||
def get_image(filename: str):
|
||||
"""Serve a decoded weather satellite image file.
|
||||
|
||||
Args:
|
||||
filename: Image filename
|
||||
|
||||
Returns:
|
||||
Image file or 404.
|
||||
"""
|
||||
decoder = get_weather_sat_decoder()
|
||||
|
||||
# Security: only allow safe filenames
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
|
||||
if not (filename.endswith('.png') or filename.endswith('.jpg') or filename.endswith('.jpeg')):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG/JPG files supported'}), 400
|
||||
|
||||
image_path = decoder._output_dir / filename
|
||||
|
||||
if not image_path.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
|
||||
mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg'
|
||||
return send_file(image_path, mimetype=mimetype)
|
||||
|
||||
|
||||
@weather_sat_bp.route('/images/<filename>', methods=['DELETE'])
|
||||
def delete_image(filename: str):
|
||||
"""Delete a decoded image.
|
||||
|
||||
Args:
|
||||
filename: Image filename
|
||||
|
||||
Returns:
|
||||
JSON confirmation.
|
||||
"""
|
||||
decoder = get_weather_sat_decoder()
|
||||
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
|
||||
if decoder.delete_image(filename):
|
||||
return jsonify({'status': 'deleted', 'filename': filename})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
|
||||
|
||||
@weather_sat_bp.route('/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/<pass_id>', 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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -223,6 +223,88 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Antenna Guide Panel -->
|
||||
<div class="panel" id="antennaGuidePanel">
|
||||
<div class="panel-header" style="cursor: pointer;" onclick="document.getElementById('antennaGuideContent').style.display = document.getElementById('antennaGuideContent').style.display === 'none' ? 'block' : 'none'; this.querySelector('.panel-toggle').textContent = document.getElementById('antennaGuideContent').style.display === 'none' ? '▶' : '▼';">
|
||||
<span>ANTENNA GUIDE</span>
|
||||
<span class="panel-toggle" style="font-size: 10px; color: var(--text-muted);">▶</span>
|
||||
</div>
|
||||
<div id="antennaGuideContent" style="display: none; padding: 10px; font-size: 11px; color: var(--text-secondary); line-height: 1.5;">
|
||||
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
||||
1090 MHz — stock SDR antenna can work but is not ideal
|
||||
</p>
|
||||
|
||||
<div style="background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px; margin-bottom: 8px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 11px;">Stock Telescopic Antenna</strong>
|
||||
<ul style="margin: 4px 0 0 14px; padding: 0; font-size: 10px;">
|
||||
<li><strong style="color: var(--text-primary);">1090 MHz:</strong> Collapse to ~6.9 cm (quarter-wave). It works for nearby aircraft</li>
|
||||
<li><strong style="color: var(--text-primary);">Range:</strong> Expect ~50 NM (90 km) indoors, ~100 NM outdoors</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px; margin-bottom: 8px;">
|
||||
<strong style="color: #00ff88; font-size: 11px;">Recommended: 1090 MHz Collinear (~$10-20 DIY)</strong>
|
||||
<ul style="margin: 4px 0 0 14px; padding: 0; font-size: 10px;">
|
||||
<li><strong style="color: var(--text-primary);">Design:</strong> 8 coaxial collinear elements from RG-6 coax cable</li>
|
||||
<li><strong style="color: var(--text-primary);">Element length:</strong> ~6.9 cm segments soldered alternating center/shield</li>
|
||||
<li><strong style="color: var(--text-primary);">Gain:</strong> ~5–7 dBi omnidirectional, ideal for 360° coverage</li>
|
||||
<li><strong style="color: var(--text-primary);">Range:</strong> 150–250+ NM depending on height and LOS</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px; margin-bottom: 8px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 11px;">Commercial Options</strong>
|
||||
<ul style="margin: 4px 0 0 14px; padding: 0; font-size: 10px;">
|
||||
<li><strong style="color: var(--text-primary);">FlightAware antenna:</strong> ~$35, 1090 MHz tuned, 66cm fiberglass whip</li>
|
||||
<li><strong style="color: var(--text-primary);">ADSBexchange whip:</strong> ~$40, similar performance</li>
|
||||
<li><strong style="color: var(--text-primary);">Jetvision A3:</strong> ~$50, high-gain 1090 MHz collinear</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px; margin-bottom: 8px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 11px;">Placement & LNA</strong>
|
||||
<ul style="margin: 4px 0 0 14px; padding: 0; font-size: 10px;">
|
||||
<li><strong style="color: var(--text-primary);">Location:</strong> OUTDOORS, as high as possible. Roof or mast mount</li>
|
||||
<li><strong style="color: var(--text-primary);">Height:</strong> Every 3m higher adds ~10 NM range (line-of-sight)</li>
|
||||
<li><strong style="color: var(--text-primary);">LNA:</strong> 1090 MHz filtered LNA at antenna feed (e.g. Uputronics, ~$30)</li>
|
||||
<li><strong style="color: var(--text-primary);">Filter:</strong> A 1090 MHz bandpass filter removes cell/FM interference</li>
|
||||
<li><strong style="color: var(--text-primary);">Coax:</strong> Keep short. At 1090 MHz, RG-58 loses ~10 dB per 10m</li>
|
||||
<li><strong style="color: var(--text-primary);">Bias-T:</strong> Enable Bias-T in controls above if LNA is powered via coax</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 11px;">Quick Reference</strong>
|
||||
<table style="width: 100%; margin-top: 4px; font-size: 10px; border-collapse: collapse;">
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 2px 4px; color: var(--text-dim);">ADS-B frequency</td>
|
||||
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">1090 MHz</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 2px 4px; color: var(--text-dim);">Quarter-wave length</td>
|
||||
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">6.9 cm</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 2px 4px; color: var(--text-dim);">Modulation</td>
|
||||
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">PPM (pulse)</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 2px 4px; color: var(--text-dim);">Polarization</td>
|
||||
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 2px 4px; color: var(--text-dim);">Bandwidth</td>
|
||||
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">~2 MHz</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 2px 4px; color: var(--text-dim);">Typical range (outdoor)</td>
|
||||
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">100–250 NM</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls Bar - Reorganized -->
|
||||
|
||||
+191
-5
@@ -21,6 +21,8 @@
|
||||
</script>
|
||||
<script>
|
||||
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
|
||||
window.INTERCEPT_DEFAULT_LAT = {{ default_latitude | tojson }};
|
||||
window.INTERCEPT_DEFAULT_LON = {{ default_longitude | tojson }};
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
|
||||
<!-- Fonts - Conditional CDN/Local loading -->
|
||||
@@ -59,6 +61,7 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/spy-stations.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/meshtastic.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/sstv.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/weather-satellite.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/sstv-general.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/function-strip.css') }}">
|
||||
@@ -236,6 +239,10 @@
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg></span>
|
||||
<span class="mode-name">ISS SSTV</span>
|
||||
</button>
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('weathersat')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span>
|
||||
<span class="mode-name">Weather Sat</span>
|
||||
</button>
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('sstv_general')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg></span>
|
||||
<span class="mode-name">HF SSTV</span>
|
||||
@@ -522,6 +529,8 @@
|
||||
|
||||
{% include 'partials/modes/sstv.html' %}
|
||||
|
||||
{% include 'partials/modes/weather-satellite.html' %}
|
||||
|
||||
{% include 'partials/modes/sstv-general.html' %}
|
||||
|
||||
{% include 'partials/modes/listening-post.html' %}
|
||||
@@ -2140,6 +2149,172 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Weather Satellite visuals (pass predictions + image gallery) -->
|
||||
<div id="weatherSatVisuals" class="wxsat-visuals-container" style="display: none;">
|
||||
<!-- Stats strip -->
|
||||
<div class="wxsat-stats-strip">
|
||||
<div class="wxsat-strip-group">
|
||||
<div class="wxsat-strip-status">
|
||||
<span class="wxsat-strip-dot" id="wxsatStripDot"></span>
|
||||
<span class="wxsat-strip-status-text" id="wxsatStripStatus">Idle</span>
|
||||
</div>
|
||||
<button class="wxsat-strip-btn start" id="wxsatStartBtn" onclick="WeatherSat.start()">Start</button>
|
||||
<button class="wxsat-strip-btn stop" id="wxsatStopBtn" onclick="WeatherSat.stop()" style="display: none;">Stop</button>
|
||||
</div>
|
||||
<div class="wxsat-strip-divider"></div>
|
||||
<div class="wxsat-strip-group">
|
||||
<div class="wxsat-strip-stat">
|
||||
<span class="wxsat-strip-value accent-cyan" id="wxsatStripFreq">--</span>
|
||||
<span class="wxsat-strip-label">MHZ</span>
|
||||
</div>
|
||||
<div class="wxsat-strip-stat">
|
||||
<span class="wxsat-strip-value" id="wxsatStripMode">--</span>
|
||||
<span class="wxsat-strip-label">MODE</span>
|
||||
</div>
|
||||
<div class="wxsat-strip-stat">
|
||||
<span class="wxsat-strip-value" id="wxsatStripImageCount">0</span>
|
||||
<span class="wxsat-strip-label">IMAGES</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wxsat-strip-divider"></div>
|
||||
<div class="wxsat-strip-group">
|
||||
<div class="wxsat-strip-location">
|
||||
<span class="wxsat-strip-label" style="margin-right: 6px;">LOC</span>
|
||||
<input type="number" id="wxsatObsLat" class="wxsat-loc-input" step="0.0001" placeholder="Lat" title="Latitude">
|
||||
<input type="number" id="wxsatObsLon" class="wxsat-loc-input" step="0.0001" placeholder="Lon" title="Longitude">
|
||||
<button class="wxsat-strip-btn gps" onclick="WeatherSat.useGPS(this)" title="Use GPS location">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wxsat-strip-divider"></div>
|
||||
<div class="wxsat-strip-group">
|
||||
<label class="wxsat-schedule-toggle" title="Auto-capture passes">
|
||||
<input type="checkbox" id="wxsatAutoSchedule" onchange="WeatherSat.toggleScheduler()">
|
||||
<span class="wxsat-toggle-label">AUTO</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Countdown + Timeline -->
|
||||
<div class="wxsat-countdown-bar">
|
||||
<div class="wxsat-countdown-next">
|
||||
<div class="wxsat-countdown-boxes" id="wxsatCountdownBoxes">
|
||||
<div class="wxsat-countdown-box"><span class="wxsat-cd-value" id="wxsatCdDays">--</span><span class="wxsat-cd-unit">DAYS</span></div>
|
||||
<div class="wxsat-countdown-box"><span class="wxsat-cd-value" id="wxsatCdHours">--</span><span class="wxsat-cd-unit">HRS</span></div>
|
||||
<div class="wxsat-countdown-box"><span class="wxsat-cd-value" id="wxsatCdMins">--</span><span class="wxsat-cd-unit">MIN</span></div>
|
||||
<div class="wxsat-countdown-box"><span class="wxsat-cd-value" id="wxsatCdSecs">--</span><span class="wxsat-cd-unit">SEC</span></div>
|
||||
</div>
|
||||
<div class="wxsat-countdown-info" id="wxsatCountdownInfo">
|
||||
<span class="wxsat-countdown-sat" id="wxsatCountdownSat">--</span>
|
||||
<span class="wxsat-countdown-detail" id="wxsatCountdownDetail">No passes predicted</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wxsat-timeline" id="wxsatTimeline">
|
||||
<div class="wxsat-timeline-track" id="wxsatTimelineTrack"></div>
|
||||
<div class="wxsat-timeline-cursor" id="wxsatTimelineCursor"></div>
|
||||
<div class="wxsat-timeline-labels">
|
||||
<span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Capture progress -->
|
||||
<div class="wxsat-capture-status" id="wxsatCaptureStatus">
|
||||
<div class="wxsat-capture-info">
|
||||
<span class="wxsat-capture-message" id="wxsatCaptureMsg">--</span>
|
||||
<span class="wxsat-capture-elapsed" id="wxsatCaptureElapsed">0:00</span>
|
||||
</div>
|
||||
<div class="wxsat-progress-bar">
|
||||
<div class="progress" id="wxsatProgressFill" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Decoder Console -->
|
||||
<div class="wxsat-signal-console" id="wxsatSignalConsole">
|
||||
<div class="wxsat-console-header">
|
||||
<div class="wxsat-console-title-group">
|
||||
<span class="wxsat-console-title">DECODER CONSOLE</span>
|
||||
<div class="wxsat-phase-indicator" id="wxsatPhaseIndicator">
|
||||
<span class="wxsat-phase-step" data-phase="tuning">TUNING</span>
|
||||
<span class="wxsat-phase-arrow">▸</span>
|
||||
<span class="wxsat-phase-step" data-phase="listening">LISTENING</span>
|
||||
<span class="wxsat-phase-arrow">▸</span>
|
||||
<span class="wxsat-phase-step" data-phase="signal_detected">SIGNAL</span>
|
||||
<span class="wxsat-phase-arrow">▸</span>
|
||||
<span class="wxsat-phase-step" data-phase="decoding">DECODING</span>
|
||||
<span class="wxsat-phase-arrow">▸</span>
|
||||
<span class="wxsat-phase-step" data-phase="complete">COMPLETE</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="wxsat-strip-btn" id="wxsatConsoleToggle"
|
||||
onclick="WeatherSat.toggleConsole()" title="Toggle console">▼</button>
|
||||
</div>
|
||||
<div class="wxsat-console-body" id="wxsatConsoleBody">
|
||||
<div class="wxsat-console-log" id="wxsatConsoleLog">
|
||||
<div class="wxsat-console-entry wxsat-log-info">Waiting for capture...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content: 3-column layout -->
|
||||
<div class="wxsat-content">
|
||||
<!-- Left: Pass predictions -->
|
||||
<div class="wxsat-passes-panel">
|
||||
<div class="wxsat-passes-header">
|
||||
<span class="wxsat-passes-title">Upcoming Passes</span>
|
||||
<span class="wxsat-passes-count" id="wxsatPassesCount">0</span>
|
||||
</div>
|
||||
<div class="wxsat-passes-list" id="wxsatPassesList">
|
||||
<div class="wxsat-gallery-empty">
|
||||
<p>Set location to see pass predictions</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center: Polar plot + Ground track map -->
|
||||
<div class="wxsat-center-panel">
|
||||
<div class="wxsat-polar-container">
|
||||
<div class="wxsat-panel-header">
|
||||
<span class="wxsat-panel-title">Polar Plot</span>
|
||||
<span class="wxsat-panel-subtitle" id="wxsatPolarSat">--</span>
|
||||
</div>
|
||||
<canvas id="wxsatPolarCanvas" width="300" height="300"></canvas>
|
||||
</div>
|
||||
<div class="wxsat-map-container">
|
||||
<div class="wxsat-panel-header">
|
||||
<span class="wxsat-panel-title">Ground Track</span>
|
||||
</div>
|
||||
<div id="wxsatGroundMap" class="wxsat-ground-map"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Image gallery -->
|
||||
<div class="wxsat-gallery-panel">
|
||||
<div class="wxsat-gallery-header">
|
||||
<span class="wxsat-gallery-title">Decoded Images</span>
|
||||
<span class="wxsat-gallery-count" id="wxsatImageCount">0</span>
|
||||
<button class="wxsat-gallery-clear-btn" onclick="WeatherSat.deleteAllImages()" title="Delete all images">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="wxsat-gallery-grid" id="wxsatGallery">
|
||||
<div class="wxsat-gallery-empty">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M2 12h20"/>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||
</svg>
|
||||
<p>No images decoded yet</p>
|
||||
<p style="margin-top: 4px; font-size: 11px;">Select a satellite pass and start capturing</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSTV General Decoder Dashboard -->
|
||||
<div id="sstvGeneralVisuals" class="sstv-general-visuals-container" style="display: none;">
|
||||
<!-- Status Strip -->
|
||||
@@ -2359,6 +2534,7 @@
|
||||
<script src="{{ url_for('static', filename='js/modes/spy-stations.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/meshtastic.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/sstv.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/weather-satellite.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/sstv-general.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/dmr.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/websdr.js') }}"></script>
|
||||
@@ -2497,7 +2673,7 @@
|
||||
const validModes = new Set([
|
||||
'pager', 'sensor', 'rtlamr', 'aprs', 'listening',
|
||||
'spystations', 'meshtastic', 'wifi', 'bluetooth',
|
||||
'tscm', 'satellite', 'sstv', 'sstv_general', 'dmr', 'websdr'
|
||||
'tscm', 'satellite', 'sstv', 'weathersat', 'sstv_general', 'dmr', 'websdr'
|
||||
]);
|
||||
|
||||
function getModeFromQuery() {
|
||||
@@ -2919,7 +3095,7 @@
|
||||
'tscm': 'security',
|
||||
'rtlamr': 'sdr', 'ais': 'sdr', 'spystations': 'sdr',
|
||||
'meshtastic': 'sdr',
|
||||
'satellite': 'space', 'sstv': 'space', 'sstv_general': 'space'
|
||||
'satellite': 'space', 'sstv': 'space', 'weathersat': 'space', 'sstv_general': 'space'
|
||||
};
|
||||
|
||||
// Remove has-active from all dropdowns
|
||||
@@ -3002,6 +3178,7 @@
|
||||
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
|
||||
document.getElementById('satelliteMode')?.classList.toggle('active', mode === 'satellite');
|
||||
document.getElementById('sstvMode')?.classList.toggle('active', mode === 'sstv');
|
||||
document.getElementById('weatherSatMode')?.classList.toggle('active', mode === 'weathersat');
|
||||
document.getElementById('sstvGeneralMode')?.classList.toggle('active', mode === 'sstv_general');
|
||||
document.getElementById('wifiMode')?.classList.toggle('active', mode === 'wifi');
|
||||
document.getElementById('bluetoothMode')?.classList.toggle('active', mode === 'bluetooth');
|
||||
@@ -3039,6 +3216,7 @@
|
||||
'rtlamr': 'METERS',
|
||||
'satellite': 'SATELLITE',
|
||||
'sstv': 'ISS SSTV',
|
||||
'weathersat': 'WEATHER SAT',
|
||||
'sstv_general': 'HF SSTV',
|
||||
'wifi': 'WIFI',
|
||||
'bluetooth': 'BLUETOOTH',
|
||||
@@ -3062,6 +3240,7 @@
|
||||
const spyStationsVisuals = document.getElementById('spyStationsVisuals');
|
||||
const meshtasticVisuals = document.getElementById('meshtasticVisuals');
|
||||
const sstvVisuals = document.getElementById('sstvVisuals');
|
||||
const weatherSatVisuals = document.getElementById('weatherSatVisuals');
|
||||
const sstvGeneralVisuals = document.getElementById('sstvGeneralVisuals');
|
||||
const dmrVisuals = document.getElementById('dmrVisuals');
|
||||
const websdrVisuals = document.getElementById('websdrVisuals');
|
||||
@@ -3074,6 +3253,7 @@
|
||||
if (spyStationsVisuals) spyStationsVisuals.style.display = mode === 'spystations' ? 'flex' : 'none';
|
||||
if (meshtasticVisuals) meshtasticVisuals.style.display = mode === 'meshtastic' ? 'flex' : 'none';
|
||||
if (sstvVisuals) sstvVisuals.style.display = mode === 'sstv' ? 'flex' : 'none';
|
||||
if (weatherSatVisuals) weatherSatVisuals.style.display = mode === 'weathersat' ? 'flex' : 'none';
|
||||
if (sstvGeneralVisuals) sstvGeneralVisuals.style.display = mode === 'sstv_general' ? 'flex' : 'none';
|
||||
if (dmrVisuals) dmrVisuals.style.display = mode === 'dmr' ? 'flex' : 'none';
|
||||
if (websdrVisuals) websdrVisuals.style.display = mode === 'websdr' ? 'flex' : 'none';
|
||||
@@ -3101,6 +3281,7 @@
|
||||
'rtlamr': 'Utility Meter Monitor',
|
||||
'satellite': 'Satellite Monitor',
|
||||
'sstv': 'ISS SSTV Decoder',
|
||||
'weathersat': 'Weather Satellite Decoder',
|
||||
'sstv_general': 'HF SSTV Decoder',
|
||||
'wifi': 'WiFi Scanner',
|
||||
'bluetooth': 'Bluetooth Scanner',
|
||||
@@ -3129,7 +3310,7 @@
|
||||
const reconBtn = document.getElementById('reconBtn');
|
||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||
const reconPanel = document.getElementById('reconPanel');
|
||||
if (mode === 'satellite' || mode === 'sstv' || mode === 'sstv_general' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr') {
|
||||
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr') {
|
||||
if (reconPanel) reconPanel.style.display = 'none';
|
||||
if (reconBtn) reconBtn.style.display = 'none';
|
||||
if (intelBtn) intelBtn.style.display = 'none';
|
||||
@@ -3149,7 +3330,7 @@
|
||||
|
||||
// Show RTL-SDR device section for modes that use it
|
||||
const rtlDeviceSection = document.getElementById('rtlDeviceSection');
|
||||
if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'listening' || mode === 'aprs' || mode === 'sstv' || mode === 'sstv_general' || mode === 'dmr') ? 'block' : 'none';
|
||||
if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'listening' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'dmr') ? 'block' : 'none';
|
||||
|
||||
// Show waterfall panel if running in listening mode
|
||||
const waterfallPanel = document.getElementById('waterfallPanel');
|
||||
@@ -3167,7 +3348,7 @@
|
||||
// Hide output console for modes with their own visualizations
|
||||
const outputEl = document.getElementById('output');
|
||||
const statusBar = document.querySelector('.status-bar');
|
||||
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr') ? 'none' : 'block';
|
||||
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr') ? 'none' : 'block';
|
||||
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'dmr') ? 'none' : 'flex';
|
||||
|
||||
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
|
||||
@@ -3215,6 +3396,11 @@
|
||||
}, 100);
|
||||
} else if (mode === 'sstv') {
|
||||
SSTV.init();
|
||||
} else if (mode === 'weathersat') {
|
||||
WeatherSat.init();
|
||||
setTimeout(() => {
|
||||
WeatherSat.invalidateMap();
|
||||
}, 100);
|
||||
} else if (mode === 'sstv_general') {
|
||||
SSTVGeneral.init();
|
||||
} else if (mode === 'dmr') {
|
||||
|
||||
@@ -26,6 +26,75 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Antenna Guide -->
|
||||
<div class="section">
|
||||
<h3>Antenna Guide</h3>
|
||||
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
||||
Marine VHF band (162 MHz) — stock SDR antenna will NOT work well
|
||||
</p>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Simple Dipole (Cheapest)</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">Element length:</strong> ~46 cm each (quarter-wave at 162 MHz)</li>
|
||||
<li><strong style="color: var(--text-primary);">Material:</strong> Wire, coat hanger, or copper rod</li>
|
||||
<li><strong style="color: var(--text-primary);">Orientation:</strong> Vertical (AIS is vertically polarized)</li>
|
||||
<li><strong style="color: var(--text-primary);">Placement:</strong> As high as possible with clear view of the water/harbor</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Commercial Options</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">Marine VHF whip:</strong> ~$20–50, designed for 156–163 MHz band</li>
|
||||
<li><strong style="color: var(--text-primary);">Discone:</strong> ~$30–50, wideband coverage including marine VHF</li>
|
||||
<li><strong style="color: var(--text-primary);">Collinear:</strong> Higher gain (~6 dBi), best for coastal monitoring</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Placement Tips</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">Height is critical:</strong> AIS is line-of-sight. Roof or mast mount is ideal</li>
|
||||
<li><strong style="color: var(--text-primary);">Range:</strong> At 10m height, expect ~25 NM (46 km) range over water</li>
|
||||
<li><strong style="color: var(--text-primary);">LNA:</strong> Nooelec Lana or similar broadband LNA, mount at antenna</li>
|
||||
<li><strong style="color: var(--text-primary);">Coax:</strong> Keep cable short. RG-58 loses ~4 dB per 10m at 162 MHz</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
|
||||
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">AIS Channel A</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">161.975 MHz</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">AIS Channel B</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">162.025 MHz</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter-wave length</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">46 cm</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Modulation</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">GMSK 9600 baud</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Bandwidth</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">25 kHz</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="run-btn" id="startAisBtn" onclick="startAisTracking()">
|
||||
Start AIS Tracking
|
||||
</button>
|
||||
|
||||
@@ -13,4 +13,59 @@
|
||||
<span style="color: var(--accent-cyan);">Controls in function bar above map</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Antenna Guide -->
|
||||
<div class="section">
|
||||
<h3>Antenna Guide</h3>
|
||||
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
||||
2m band (144–148 MHz) — stock SDR antenna will NOT work
|
||||
</p>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Simple Dipole (Easiest)</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">Element length:</strong> ~51.5 cm each (quarter-wave at 144.39 MHz)</li>
|
||||
<li><strong style="color: var(--text-primary);">Material:</strong> Wire, coat hanger, or copper rod</li>
|
||||
<li><strong style="color: var(--text-primary);">Orientation:</strong> Vertical (APRS is FM, vertically polarized)</li>
|
||||
<li><strong style="color: var(--text-primary);">Connection:</strong> Center conductor to one element, shield to the other</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Commercial Options</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">Mag-mount 2m whip:</strong> ~$15–25, good mobile/portable option</li>
|
||||
<li><strong style="color: var(--text-primary);">2m/70cm dual-band:</strong> ~$20–40, also covers 70cm ham band</li>
|
||||
<li><strong style="color: var(--text-primary);">Discone:</strong> ~$30–50, wideband but lower gain on 2m</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
|
||||
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">APRS freq (N. America)</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">144.390 MHz</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">APRS freq (Europe)</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">144.800 MHz</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter-wave length</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">51.5 cm</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Modulation</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">FM 1200 baud</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,6 +55,74 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Antenna Guide -->
|
||||
<div class="section">
|
||||
<h3>Antenna Guide</h3>
|
||||
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
||||
LoRa ISM band — frequency depends on region
|
||||
</p>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Stock Device Antenna</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">Most devices:</strong> Ship with a small 915/868 MHz stubby antenna</li>
|
||||
<li><strong style="color: var(--text-primary);">Works for:</strong> Short range (< 1 km) urban, indoor testing</li>
|
||||
<li><strong style="color: var(--text-primary);">Upgrade:</strong> Replace with tuned antenna for 5–20x range improvement</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: #00ff88; font-size: 12px;">Recommended Upgrades</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">Whip antenna:</strong> ~$8–15, tuned 915/868 MHz, SMA connector</li>
|
||||
<li><strong style="color: var(--text-primary);">Ground plane:</strong> 8.2 cm vertical + 4 radials (915 MHz) on SMA</li>
|
||||
<li><strong style="color: var(--text-primary);">Yagi:</strong> ~$15–30, directional, great for point-to-point links</li>
|
||||
<li><strong style="color: var(--text-primary);">Collinear:</strong> ~$20–40, omnidirectional with higher gain (~5–8 dBi)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Placement Tips</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">Height wins:</strong> Elevating antenna 10m can double or triple range</li>
|
||||
<li><strong style="color: var(--text-primary);">Line of sight:</strong> LoRa works best with clear LOS to other nodes</li>
|
||||
<li><strong style="color: var(--text-primary);">Connector:</strong> Most devices use SMA or RP-SMA — check before buying</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
|
||||
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">US / Americas</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">915 MHz</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">EU / UK / India</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">868 MHz</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">915 MHz λ/4</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">8.2 cm</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">868 MHz λ/4</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">8.6 cm</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Modulation</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">LoRa (CSS)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Typical range</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">1–15 km</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -75,6 +75,62 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Antenna Guide -->
|
||||
<div class="section">
|
||||
<h3>Antenna Guide</h3>
|
||||
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
||||
Pager frequencies vary by region (130–930 MHz)
|
||||
</p>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Stock Telescopic Antenna</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">Works for:</strong> UHF pager bands (~900 MHz) — the stock antenna is tuned near 1 GHz</li>
|
||||
<li><strong style="color: var(--text-primary);">Extend to:</strong> ~8 cm for 929 MHz (quarter-wave)</li>
|
||||
<li><strong style="color: var(--text-primary);">For VHF (~150 MHz):</strong> Stock antenna is too short. Build a dipole (see below)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Simple Dipole (Best for VHF Pagers)</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">For 153 MHz:</strong> Two elements, each ~49 cm (quarter-wave)</li>
|
||||
<li><strong style="color: var(--text-primary);">For 929 MHz:</strong> Two elements, each ~8 cm</li>
|
||||
<li><strong style="color: var(--text-primary);">Formula:</strong> Element length (cm) = 7500 / frequency (MHz)</li>
|
||||
<li><strong style="color: var(--text-primary);">Material:</strong> Any wire, coat hanger, or copper rod</li>
|
||||
<li><strong style="color: var(--text-primary);">Orientation:</strong> Vertical (pager signals are vertically polarized)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
|
||||
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Common UHF freq</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">929 MHz</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Common VHF freq</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">153.350 MHz</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Modulation</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">FM (NFM)</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Bandwidth</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~12.5 kHz</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="run-btn" id="startBtn" onclick="startDecoding()">
|
||||
Start Decoding
|
||||
</button>
|
||||
|
||||
@@ -58,6 +58,59 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Antenna Guide -->
|
||||
<div class="section">
|
||||
<h3>Antenna Guide</h3>
|
||||
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
||||
ISM 900 MHz band — stock antenna is close but not optimal
|
||||
</p>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Stock Telescopic Antenna</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">912 MHz:</strong> Extend to ~8.2 cm (quarter-wave). The stock antenna is close enough to work</li>
|
||||
<li><strong style="color: var(--text-primary);">Range:</strong> Most meters transmit at ~100 mW, expect 50–200 m range with stock antenna</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Upgraded Options</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">Ground Plane:</strong> 8.2 cm vertical + four 8.2 cm radials at 45° on SMA connector</li>
|
||||
<li><strong style="color: var(--text-primary);">Yagi:</strong> Directional for targeting specific meters at distance (~$15–25)</li>
|
||||
<li><strong style="color: var(--text-primary);">Placement:</strong> Near a window facing the meters. Line-of-sight matters most</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
|
||||
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Frequency (NA)</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">912 MHz</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Frequency (EU)</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">868 MHz</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">912 MHz λ/4</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">8.2 cm</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Meter TX power</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~100 mW</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="run-btn" id="startRtlamrBtn" onclick="startRtlamrDecoding()">
|
||||
Start Listening
|
||||
</button>
|
||||
|
||||
@@ -39,6 +39,61 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Antenna Guide -->
|
||||
<div class="section">
|
||||
<h3>Antenna Guide</h3>
|
||||
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
||||
ISM band devices (433 / 868 / 915 MHz)
|
||||
</p>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Stock Telescopic Antenna</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">433 MHz:</strong> Extend to ~17 cm (quarter-wave). Stock antenna works but isn't ideal</li>
|
||||
<li><strong style="color: var(--text-primary);">868/915 MHz:</strong> Extend to ~8 cm. Stock antenna is nearly tuned for this</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Quarter-Wave Ground Plane (Best)</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">433 MHz:</strong> Vertical element 17.3 cm + four 17.3 cm radials at 45°</li>
|
||||
<li><strong style="color: var(--text-primary);">868 MHz:</strong> Vertical element 8.6 cm + four 8.6 cm radials</li>
|
||||
<li><strong style="color: var(--text-primary);">915 MHz:</strong> Vertical element 8.2 cm + four 8.2 cm radials</li>
|
||||
<li><strong style="color: var(--text-primary);">Material:</strong> Stiff copper wire soldered to an SMA connector</li>
|
||||
<li><strong style="color: var(--text-primary);">Placement:</strong> Outdoors or near a window. Higher is better for range</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
|
||||
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">433 MHz λ/4</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">17.3 cm</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">868 MHz λ/4</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">8.6 cm</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">915 MHz λ/4</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">8.2 cm</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Typical range</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">50–300 m</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="run-btn" id="startSensorBtn" onclick="startSensorDecoding()">
|
||||
Start Listening
|
||||
</button>
|
||||
|
||||
@@ -39,4 +39,64 @@
|
||||
Common modes: PD120, PD180, Martin1, Scottie1
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Antenna Guide -->
|
||||
<div class="section">
|
||||
<h3>Antenna Guide</h3>
|
||||
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
||||
2m band (145.800 MHz) — stock SDR antenna will NOT work
|
||||
</p>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">V-Dipole (Easiest — ~$5)</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">Element length:</strong> ~51 cm each (quarter-wave at 145.8 MHz)</li>
|
||||
<li><strong style="color: var(--text-primary);">Angle:</strong> 120° between elements for partial RHCP</li>
|
||||
<li><strong style="color: var(--text-primary);">Orientation:</strong> Lay flat, angled toward the ISS pass direction</li>
|
||||
<li><strong style="color: var(--text-primary);">Material:</strong> Wire, coat hanger, or copper rod</li>
|
||||
</ul>
|
||||
<p style="margin-top: 6px; color: var(--text-dim); font-style: italic;">
|
||||
Same antenna as weather satellites (similar frequency). A QFH or turnstile for 137 MHz also works well here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Tips for ISS Reception</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">ISS altitude:</strong> ~420 km, overhead passes last 5–10 minutes</li>
|
||||
<li><strong style="color: var(--text-primary);">Best passes:</strong> Elevation > 30° for clear signal</li>
|
||||
<li><strong style="color: var(--text-primary);">Outdoors:</strong> Clear sky view is essential. Roof or open field</li>
|
||||
<li><strong style="color: var(--text-primary);">LNA:</strong> Optional but helps — 2m filtered LNA at antenna feed</li>
|
||||
<li><strong style="color: var(--text-primary);">Doppler:</strong> ISS moves fast — signal shifts ±3.5 kHz during pass</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
|
||||
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">ISS SSTV frequency</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">145.800 MHz</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter-wave length</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">51 cm</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Modulation</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">FM (25 kHz)</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">RHCP (circular)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Typical pass duration</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">5–10 min</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
<!-- WEATHER SATELLITE MODE -->
|
||||
<div id="weatherSatMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3>Weather Satellite Decoder</h3>
|
||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
|
||||
Receive and decode weather images from NOAA and Meteor satellites.
|
||||
Uses SatDump for live SDR capture and image processing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Satellite</h3>
|
||||
<div class="form-group">
|
||||
<label>Select Satellite</label>
|
||||
<select id="weatherSatSelect" class="mode-select">
|
||||
<option value="NOAA-15">NOAA-15 (137.620 MHz APT)</option>
|
||||
<option value="NOAA-18" selected>NOAA-18 (137.9125 MHz APT)</option>
|
||||
<option value="NOAA-19">NOAA-19 (137.100 MHz APT)</option>
|
||||
<option value="METEOR-M2-3">Meteor-M2-3 (137.900 MHz LRPT)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Gain (dB)</label>
|
||||
<input type="number" id="weatherSatGain" value="40" step="0.1" min="0" max="50">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display: flex; align-items: center; gap: 6px;">
|
||||
<input type="checkbox" id="weatherSatBiasT" style="width: auto;">
|
||||
Bias-T (power LNA)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Antenna Guide - detailed -->
|
||||
<div class="section">
|
||||
<h3>Antenna Guide</h3>
|
||||
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||
|
||||
<p style="margin-bottom: 10px; color: var(--accent-cyan); font-weight: 600;">
|
||||
137 MHz band — your stock SDR antenna will NOT work.
|
||||
</p>
|
||||
<p style="margin-bottom: 10px;">
|
||||
Weather satellites transmit at 137.1–137.9 MHz. The quarter-wave
|
||||
at this frequency is <strong style="color: var(--text-primary);">~53 cm</strong>,
|
||||
far longer than the small telescopic antenna shipped with most SDRs
|
||||
(tuned for ~1 GHz). You need a purpose-built antenna.
|
||||
</p>
|
||||
|
||||
<!-- V-Dipole -->
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">V-Dipole (Easiest — ~$5)</strong>
|
||||
|
||||
<div style="margin: 8px 0; padding: 8px; background: var(--bg-tertiary); border-radius: 3px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text-secondary); white-space: pre; line-height: 1.3; text-align: center;"> coax to SDR
|
||||
|
|
||||
===+=== feed point
|
||||
/ \
|
||||
/ 120 \
|
||||
/ \
|
||||
/ deg \
|
||||
53.4cm 53.4cm</div>
|
||||
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">Element length:</strong> 53.4 cm each (quarter wavelength at 137 MHz)</li>
|
||||
<li><strong style="color: var(--text-primary);">Angle:</strong> 120° between elements (not 180°)</li>
|
||||
<li><strong style="color: var(--text-primary);">Material:</strong> Any stiff wire, coat hanger, or copper rod</li>
|
||||
<li><strong style="color: var(--text-primary);">Orientation:</strong> Lay flat or tilt 30° toward expected pass direction</li>
|
||||
<li><strong style="color: var(--text-primary);">Polarization:</strong> The 120° angle gives partial RHCP match to satellite signal</li>
|
||||
<li><strong style="color: var(--text-primary);">Connection:</strong> Solder elements to coax center + shield, connect to SDR via SMA</li>
|
||||
</ul>
|
||||
<p style="margin-top: 6px; color: var(--text-dim); font-style: italic;">
|
||||
Best starter antenna. Good enough for clear NOAA images with a direct overhead pass.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Turnstile -->
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Turnstile / Crossed Dipole (~$10-15)</strong>
|
||||
|
||||
<div style="margin: 8px 0; padding: 8px; background: var(--bg-tertiary); border-radius: 3px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text-secondary); white-space: pre; line-height: 1.3; text-align: center;"> 53.4cm
|
||||
<--------->
|
||||
====+==== dipole 1
|
||||
|
|
||||
====+==== dipole 2
|
||||
<--------->
|
||||
90 deg rotated
|
||||
+ reflector below</div>
|
||||
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">Elements:</strong> Two crossed dipoles, each 53.4 cm per side (4 elements total)</li>
|
||||
<li><strong style="color: var(--text-primary);">Angle:</strong> 90° between the two dipole pairs</li>
|
||||
<li><strong style="color: var(--text-primary);">Phasing:</strong> Feed dipole 2 with a 90° delay (quarter-wave coax section ~37 cm of RG-58)</li>
|
||||
<li><strong style="color: var(--text-primary);">Reflector:</strong> Place ~52 cm below elements (ground plane or wire grid)</li>
|
||||
<li><strong style="color: var(--text-primary);">Polarization:</strong> Circular (RHCP) — matches satellite transmission</li>
|
||||
</ul>
|
||||
<p style="margin-top: 6px; color: var(--text-dim); font-style: italic;">
|
||||
Better than V-dipole. The reflector rejects ground noise and the RHCP phasing matches the satellite signal.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- QFH -->
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: #00ff88; font-size: 12px;">QFH — Quadrifilar Helix (Best — ~$20-30)</strong>
|
||||
|
||||
<div style="margin: 8px 0; padding: 8px; background: var(--bg-tertiary); border-radius: 3px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text-secondary); white-space: pre; line-height: 1.3; text-align: center;"> ___
|
||||
/ \ two helix loops
|
||||
| | | twisted 90 deg
|
||||
| | | around a mast
|
||||
\___/
|
||||
|
|
||||
coax</div>
|
||||
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">Design:</strong> Two bifilar helical loops, offset 90°</li>
|
||||
<li><strong style="color: var(--text-primary);">Material:</strong> Copper pipe (10mm), copper wire, or coax outer shield</li>
|
||||
<li><strong style="color: var(--text-primary);">Total height:</strong> ~46 cm (for 137 MHz)</li>
|
||||
<li><strong style="color: var(--text-primary);">Loop dimensions:</strong> Use a QFH calculator for exact bending measurements</li>
|
||||
<li><strong style="color: var(--text-primary);">Polarization:</strong> True RHCP omnidirectional — ideal for overhead satellite passes</li>
|
||||
<li><strong style="color: var(--text-primary);">Gain pattern:</strong> Hemispherical upward coverage, rejects ground interference</li>
|
||||
</ul>
|
||||
<p style="margin-top: 6px; color: var(--text-dim); font-style: italic;">
|
||||
Gold standard for weather satellite reception. No tracking needed — covers the whole sky.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Placement & LNA -->
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Placement & LNA</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">Location:</strong> OUTDOORS with clear sky view is critical. Roof/balcony/open field.</li>
|
||||
<li><strong style="color: var(--text-primary);">Height:</strong> Higher is better but not critical — clear horizon line matters more</li>
|
||||
<li><strong style="color: var(--text-primary);">Antenna up:</strong> Point the antenna straight UP (zenith) for best overhead coverage</li>
|
||||
<li><strong style="color: var(--text-primary);">Avoid:</strong> Metal roofs, power lines, buildings blocking the sky</li>
|
||||
<li><strong style="color: var(--text-primary);">Coax length:</strong> Keep short (<10m). Signal loss at 137 MHz is ~3 dB per 10m of RG-58</li>
|
||||
<li><strong style="color: var(--text-primary);">LNA:</strong> Mount at the antenna feed point, NOT at the SDR end.
|
||||
Recommended: Nooelec SAWbird+ NOAA (137 MHz filtered LNA, ~$30)</li>
|
||||
<li><strong style="color: var(--text-primary);">Bias-T:</strong> Enable the Bias-T checkbox above if your LNA is powered via the coax from the SDR</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Quick reference -->
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
|
||||
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Wavelength (137 MHz)</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">218.8 cm</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter wave (element length)</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">53.4 cm</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Best pass elevation</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">> 30°</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Typical pass duration</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">10-15 min</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">RHCP</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">NOAA (APT) bandwidth</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~40 kHz</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Meteor (LRPT) bandwidth</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~140 kHz</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3 onclick="this.parentElement.querySelector('.wxsat-test-decode-body').classList.toggle('collapsed'); this.querySelector('.wxsat-collapse-icon').classList.toggle('collapsed')" style="cursor: pointer; display: flex; align-items: center; justify-content: space-between; user-select: none;">
|
||||
Test Decode (File)
|
||||
<span class="wxsat-collapse-icon collapsed" style="font-size: 10px; transition: transform 0.2s; display: inline-block;">▼</span>
|
||||
</h3>
|
||||
<div class="wxsat-test-decode-body collapsed" style="overflow: hidden;">
|
||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">
|
||||
Decode a pre-recorded IQ or WAV file without SDR hardware.
|
||||
Run <code style="font-size: 10px;">./download-weather-sat-samples.sh</code> to fetch sample files.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label>Satellite</label>
|
||||
<select id="wxsatTestSatSelect" class="mode-select">
|
||||
<option value="NOAA-15">NOAA-15 (APT)</option>
|
||||
<option value="NOAA-18" selected>NOAA-18 (APT)</option>
|
||||
<option value="NOAA-19">NOAA-19 (APT)</option>
|
||||
<option value="METEOR-M2-3">Meteor-M2-3 (LRPT)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>File Path (server-side)</label>
|
||||
<input type="text" id="wxsatTestFilePath" value="data/weather_sat/samples/noaa_apt_argentina.wav" style="font-family: 'JetBrains Mono', monospace; font-size: 11px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Sample Rate</label>
|
||||
<select id="wxsatTestSampleRate" class="mode-select">
|
||||
<option value="11025">11025 Hz (WAV audio APT)</option>
|
||||
<option value="48000">48000 Hz (WAV audio APT)</option>
|
||||
<option value="500000">500 kHz (IQ LRPT)</option>
|
||||
<option value="1000000" selected>1 MHz (IQ default)</option>
|
||||
<option value="2000000">2 MHz (IQ wideband)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="mode-btn" onclick="WeatherSat.testDecode()" style="width: 100%; margin-top: 4px;">
|
||||
Test Decode
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Auto-Scheduler</h3>
|
||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">
|
||||
Automatically capture satellite passes based on predictions.
|
||||
Set your location above and toggle AUTO in the strip bar.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label style="display: flex; align-items: center; gap: 6px;">
|
||||
<input type="checkbox" id="wxsatSidebarAutoSchedule" onchange="WeatherSat.toggleScheduler()" style="width: auto;">
|
||||
Enable Auto-Capture
|
||||
</label>
|
||||
</div>
|
||||
<div id="wxsatSchedulerStatus" style="font-size: 11px; color: var(--text-dim); font-family: 'JetBrains Mono', monospace; margin-top: 4px;">
|
||||
Disabled
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Resources</h3>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<a href="https://github.com/SatDump/SatDump" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||
SatDump Documentation
|
||||
</a>
|
||||
<a href="https://www.rtl-sdr.com/rtl-sdr-tutorial-receiving-noaa-weather-satellite-images/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||
NOAA Reception Guide
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,6 +118,7 @@
|
||||
{{ mode_item('satellite', 'Satellite', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg>', '/satellite/dashboard') }}
|
||||
{% endif %}
|
||||
{{ mode_item('sstv', 'ISS SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg>') }}
|
||||
{{ mode_item('weathersat', 'Weather Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
||||
{{ mode_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -185,6 +186,7 @@
|
||||
{{ mobile_item('satellite', 'Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg>', '/satellite/dashboard') }}
|
||||
{% endif %}
|
||||
{{ mobile_item('sstv', 'SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
|
||||
{{ mobile_item('weathersat', 'WxSat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
||||
{{ mobile_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
|
||||
{{ mobile_item('listening', 'Scanner', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
|
||||
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
||||
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
@@ -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/<filename> 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/<filename> 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/<filename> 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/<filename> 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/<filename> 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/<filename> 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/<id> 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/<id> 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/<id> 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']
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user