From 780ba9c58ba9fdf7428dd360b54b7a4e308493d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Feb 2026 19:46:54 +0000 Subject: [PATCH 01/28] Update Docker config for dual-SDR setup and arm64 compatibility - Add slowrx SSTV decoder build with required deps (libsndfile1, libgtk-3-dev, libasound2-dev, libfftw3-dev) for arm64/RPi5 support - Enable USB device passthrough (/dev/bus/usb) on both service profiles - Add 'basic' profile to main intercept service for explicit selection - Fix intercept-history container_name conflict (was duplicating 'intercept') https://claude.ai/code/session_01FjLTkyELaqh27U1wEXngFQ --- Dockerfile | 13 +++++++++++++ docker-compose.yml | 17 +++++++++++------ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5447b09..3d4bdc8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ multimon-ng \ # Audio tools for Listening Post ffmpeg \ + # SSTV decoder runtime libs + libsndfile1 \ # WiFi tools (aircrack-ng suite) aircrack-ng \ iw \ @@ -56,9 +58,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ cmake \ libncurses-dev \ libsndfile1-dev \ + libgtk-3-dev \ + libasound2-dev \ libsoapysdr-dev \ libhackrf-dev \ liblimesuite-dev \ + libfftw3-dev \ libsqlite3-dev \ libcurl4-openssl-dev \ zlib1g-dev \ @@ -109,6 +114,13 @@ 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) + && cd /tmp \ + && git clone --depth 1 https://github.com/windytan/slowrx.git \ + && cd slowrx \ + && make \ + && install -m 0755 slowrx /usr/local/bin/slowrx \ + && rm -rf /tmp/slowrx \ # Cleanup build tools to reduce image size && apt-get remove -y \ build-essential \ @@ -117,6 +129,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ cmake \ libncurses-dev \ libsndfile1-dev \ + libasound2-dev \ libsoapysdr-dev \ libhackrf-dev \ liblimesuite-dev \ diff --git a/docker-compose.yml b/docker-compose.yml index 234eb3e..ba4ba49 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ # Docker Compose configuration for easy deployment # # Basic usage: -# docker compose up -d +# docker compose --profile basic up -d # # With ADS-B history (Postgres): # docker compose --profile history up -d @@ -11,14 +11,15 @@ services: intercept: build: . container_name: intercept + profiles: + - basic 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 + # USB device mapping for all USB devices + devices: + - /dev/bus/usb:/dev/bus/usb # volumes: # Persist data directory # - ./data:/app/data @@ -54,14 +55,18 @@ services: # Enable with: docker compose --profile history up -d intercept-history: 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 environment: - INTERCEPT_HOST=0.0.0.0 - INTERCEPT_PORT=5050 From 7b68c19dc533c2319f9e716508cf9d4a0e36eabe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Feb 2026 21:45:33 +0000 Subject: [PATCH 02/28] Add weather satellite decoder for NOAA APT and Meteor LRPT New module for receiving and decoding weather satellite images using SatDump CLI. Supports NOAA-15/18/19 (APT) and Meteor-M2-3 (LRPT) with live SDR capture, pass prediction, and image gallery. Backend: - utils/weather_sat.py: SatDump process manager with image watcher - routes/weather_sat.py: API endpoints (start/stop/images/passes/stream) - SSE streaming for real-time capture progress - Pass prediction using existing skyfield + TLE data - SDR device registry integration (prevents conflicts) Frontend: - Sidebar panel with satellite selector and antenna build guide (V-dipole and QFH instructions for 137 MHz reception) - Stats strip with status, frequency, mode, location inputs - Split-panel layout: upcoming passes list + decoded image gallery - Full-size image modal viewer - SSE-driven progress updates during capture Infrastructure: - Dockerfile: Add SatDump build from source (headless CLI mode) with runtime deps (libpng, libtiff, libjemalloc, libvolk2, libnng) - Config: WEATHER_SAT_GAIN, SAMPLE_RATE, MIN_ELEVATION, PREDICTION_HOURS - Nav: Weather Sat entry in Space group (desktop + mobile) https://claude.ai/code/session_01FjLTkyELaqh27U1wEXngFQ --- Dockerfile | 32 +- app.py | 22 +- config.py | 6 + routes/__init__.py | 2 + routes/weather_sat.py | 494 ++++++++++++++ static/css/modes/weather-satellite.css | 512 +++++++++++++++ static/js/modes/weather-satellite.js | 563 ++++++++++++++++ templates/index.html | 112 +++- .../partials/modes/weather-satellite.html | 82 +++ templates/partials/nav.html | 2 + utils/weather_sat.py | 609 ++++++++++++++++++ 11 files changed, 2421 insertions(+), 15 deletions(-) create mode 100644 routes/weather_sat.py create mode 100644 static/css/modes/weather-satellite.css create mode 100644 static/js/modes/weather-satellite.js create mode 100644 templates/partials/modes/weather-satellite.html create mode 100644 utils/weather_sat.py diff --git a/Dockerfile b/Dockerfile index 3d4bdc8..040582f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ffmpeg \ # SSTV decoder runtime libs libsndfile1 \ + # SatDump runtime libs (weather satellite decoding) + libpng16-16 \ + libtiff6 \ + libjemalloc2 \ + libvolk2-bin \ + libnng1 \ + libzstd1 \ # WiFi tools (aircrack-ng suite) aircrack-ng \ iw \ @@ -64,6 +71,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libhackrf-dev \ liblimesuite-dev \ libfftw3-dev \ + libpng-dev \ + libtiff-dev \ + libjemalloc-dev \ + libvolk2-dev \ + libnng-dev \ + libzstd-dev \ libsqlite3-dev \ libcurl4-openssl-dev \ zlib1g-dev \ @@ -121,6 +134,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && make \ && install -m 0755 slowrx /usr/local/bin/slowrx \ && rm -rf /tmp/slowrx \ + # Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) + && cd /tmp \ + && git clone --depth 1 https://github.com/SatDump/SatDump.git \ + && cd SatDump \ + && mkdir build && cd build \ + && cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF .. \ + && make -j$(nproc) \ + && make install \ + && ldconfig \ + && cd /tmp \ + && rm -rf /tmp/SatDump \ # Cleanup build tools to reduce image size && apt-get remove -y \ build-essential \ @@ -130,6 +154,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libncurses-dev \ libsndfile1-dev \ libasound2-dev \ + libpng-dev \ + libtiff-dev \ + libjemalloc-dev \ + libvolk2-dev \ + libnng-dev \ + libzstd-dev \ libsoapysdr-dev \ libhackrf-dev \ liblimesuite-dev \ @@ -148,7 +178,7 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . # Create data directory for persistence -RUN mkdir -p /app/data +RUN mkdir -p /app/data /app/data/weather_sat # Expose web interface port EXPOSE 5050 diff --git a/app.py b/app.py index 373b368..de1e5ee 100644 --- a/app.py +++ b/app.py @@ -105,7 +105,7 @@ def inject_offline_settings(): 'enabled': get_setting('offline.enabled', False), 'assets_source': get_setting('offline.assets_source', 'cdn'), 'fonts_source': get_setting('offline.fonts_source', 'cdn'), - 'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'), + 'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'), 'tile_server_url': get_setting('offline.tile_server_url', '') } } @@ -176,6 +176,10 @@ dsc_lock = threading.Lock() tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) tscm_lock = threading.Lock() +# Weather Satellite (NOAA/Meteor) +weather_sat_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) +weather_sat_lock = threading.Lock() + # Deauth Attack Detection deauth_detector = None deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) @@ -278,13 +282,13 @@ def get_sdr_device_status() -> dict[int, str]: # ============================================ @app.before_request -def require_login(): - # Routes that don't require login (to avoid infinite redirect loop) - allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check'] - - # Allow audio streaming endpoints without session auth - if request.path.startswith('/listening/audio/'): - return None +def require_login(): + # Routes that don't require login (to avoid infinite redirect loop) + allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check'] + + # Allow audio streaming endpoints without session auth + if request.path.startswith('/listening/audio/'): + return None # Controller API endpoints use API key auth, not session auth # Allow agent push/pull endpoints without session login @@ -663,7 +667,7 @@ def kill_all() -> Response: 'rtl_fm', 'multimon-ng', 'rtl_433', 'airodump-ng', 'aireplay-ng', 'airmon-ng', 'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher', - 'hcitool', 'bluetoothctl' + 'hcitool', 'bluetoothctl', 'satdump' ] for proc in processes_to_kill: diff --git a/config.py b/config.py index 029ef2c..09de9c6 100644 --- a/config.py +++ b/config.py @@ -191,6 +191,12 @@ SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30) SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30) SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45) +# Weather satellite settings +WEATHER_SAT_DEFAULT_GAIN = _get_env_float('WEATHER_SAT_GAIN', 40.0) +WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 1000000) +WEATHER_SAT_MIN_ELEVATION = _get_env_float('WEATHER_SAT_MIN_ELEVATION', 15.0) +WEATHER_SAT_PREDICTION_HOURS = _get_env_int('WEATHER_SAT_PREDICTION_HOURS', 24) + # Update checking GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept') UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True) diff --git a/routes/__init__.py b/routes/__init__.py index 8436739..b35ffbb 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -26,6 +26,7 @@ def register_blueprints(app): from .offline import offline_bp from .updater import updater_bp from .sstv import sstv_bp + from .weather_sat import weather_sat_bp app.register_blueprint(pager_bp) app.register_blueprint(sensor_bp) @@ -51,6 +52,7 @@ def register_blueprints(app): app.register_blueprint(offline_bp) # Offline mode settings app.register_blueprint(updater_bp) # GitHub update checking app.register_blueprint(sstv_bp) # ISS SSTV decoder + app.register_blueprint(weather_sat_bp) # NOAA/Meteor weather satellite decoder # Initialize TSCM state with queue and lock from app import app as app_module diff --git a/routes/weather_sat.py b/routes/weather_sat.py new file mode 100644 index 0000000..4c9b32d --- /dev/null +++ b/routes/weather_sat.py @@ -0,0 +1,494 @@ +"""Weather Satellite decoder routes. + +Provides endpoints for capturing and decoding weather satellite images +from NOAA (APT) and Meteor (LRPT) satellites using SatDump. +""" + +from __future__ import annotations + +import queue +import time +from typing import Generator + +from flask import Blueprint, jsonify, request, Response, send_file + +from utils.logging import get_logger +from utils.sse import format_sse +from utils.weather_sat import ( + get_weather_sat_decoder, + is_weather_sat_available, + CaptureProgress, + WEATHER_SATELLITES, +) + +logger = get_logger('intercept.weather_sat') + +weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat') + +# Queue for SSE progress streaming +_weather_sat_queue: queue.Queue = queue.Queue(maxsize=100) + + +def _progress_callback(progress: CaptureProgress) -> None: + """Callback to queue progress updates for SSE stream.""" + try: + _weather_sat_queue.put_nowait(progress.to_dict()) + except queue.Full: + try: + _weather_sat_queue.get_nowait() + _weather_sat_queue.put_nowait(progress.to_dict()) + except queue.Empty: + pass + + +@weather_sat_bp.route('/status') +def get_status(): + """Get weather satellite decoder status. + + Returns: + JSON with decoder availability and current status. + """ + decoder = get_weather_sat_decoder() + return jsonify(decoder.get_status()) + + +@weather_sat_bp.route('/satellites') +def list_satellites(): + """Get list of supported weather satellites with frequencies. + + Returns: + JSON with satellite definitions. + """ + satellites = [] + for key, info in WEATHER_SATELLITES.items(): + satellites.append({ + 'key': key, + 'name': info['name'], + 'frequency': info['frequency'], + 'mode': info['mode'], + 'description': info['description'], + 'active': info['active'], + }) + + return jsonify({ + 'status': 'ok', + 'satellites': satellites, + }) + + +@weather_sat_bp.route('/start', methods=['POST']) +def start_capture(): + """Start weather satellite capture and decode. + + JSON body: + { + "satellite": "NOAA-18", // Required: satellite key + "device": 0, // RTL-SDR device index (default: 0) + "gain": 40.0, // SDR gain in dB (default: 40) + "bias_t": false // Enable bias-T for LNA (default: false) + } + + Returns: + JSON with start status. + """ + if not is_weather_sat_available(): + return jsonify({ + 'status': 'error', + 'message': 'SatDump not installed. Build from source: https://github.com/SatDump/SatDump' + }), 400 + + decoder = get_weather_sat_decoder() + + if decoder.is_running: + return jsonify({ + 'status': 'already_running', + 'satellite': decoder.current_satellite, + 'frequency': decoder.current_frequency, + }) + + data = request.get_json(silent=True) or {} + + # Validate satellite + satellite = data.get('satellite') + if not satellite or satellite not in WEATHER_SATELLITES: + return jsonify({ + 'status': 'error', + 'message': f'Invalid satellite. Must be one of: {", ".join(WEATHER_SATELLITES.keys())}' + }), 400 + + # Validate device index + device_index = data.get('device', 0) + try: + device_index = int(device_index) + if not (0 <= device_index <= 255): + raise ValueError + except (TypeError, ValueError): + return jsonify({ + 'status': 'error', + 'message': 'Invalid device index (0-255)' + }), 400 + + # Validate gain + gain = data.get('gain', 40.0) + try: + gain = float(gain) + if not (0 <= gain <= 50): + raise ValueError + except (TypeError, ValueError): + return jsonify({ + 'status': 'error', + 'message': 'Invalid gain (0-50 dB)' + }), 400 + + bias_t = bool(data.get('bias_t', False)) + + # Claim SDR device + try: + import app as app_module + error = app_module.claim_sdr_device(device_index, 'weather_sat') + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error, + }), 409 + except ImportError: + pass + + # Clear queue + while not _weather_sat_queue.empty(): + try: + _weather_sat_queue.get_nowait() + except queue.Empty: + break + + # Set callback and start + decoder.set_callback(_progress_callback) + success = decoder.start( + satellite=satellite, + device_index=device_index, + gain=gain, + bias_t=bias_t, + ) + + if success: + sat_info = WEATHER_SATELLITES[satellite] + return jsonify({ + 'status': 'started', + 'satellite': satellite, + 'frequency': sat_info['frequency'], + 'mode': sat_info['mode'], + 'device': device_index, + }) + else: + # Release device on failure + try: + import app as app_module + app_module.release_sdr_device(device_index) + except ImportError: + pass + return jsonify({ + 'status': 'error', + 'message': 'Failed to start capture' + }), 500 + + +@weather_sat_bp.route('/stop', methods=['POST']) +def stop_capture(): + """Stop weather satellite capture. + + Returns: + JSON confirmation. + """ + decoder = get_weather_sat_decoder() + device_index = decoder._device_index + + decoder.stop() + + # Release SDR device + try: + import app as app_module + app_module.release_sdr_device(device_index) + except ImportError: + pass + + return jsonify({'status': 'stopped'}) + + +@weather_sat_bp.route('/images') +def list_images(): + """Get list of decoded weather satellite images. + + Query parameters: + limit: Maximum number of images (default: all) + satellite: Filter by satellite key (optional) + + Returns: + JSON with list of decoded images. + """ + decoder = get_weather_sat_decoder() + images = decoder.get_images() + + # Filter by satellite if specified + satellite_filter = request.args.get('satellite') + if satellite_filter: + images = [img for img in images if img.satellite == satellite_filter] + + # Apply limit + limit = request.args.get('limit', type=int) + if limit and limit > 0: + images = images[-limit:] + + return jsonify({ + 'status': 'ok', + 'images': [img.to_dict() for img in images], + 'count': len(images), + }) + + +@weather_sat_bp.route('/images/') +def get_image(filename: str): + """Serve a decoded weather satellite image file. + + Args: + filename: Image filename + + Returns: + Image file or 404. + """ + decoder = get_weather_sat_decoder() + + # Security: only allow safe filenames + if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): + return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 + + if not (filename.endswith('.png') or filename.endswith('.jpg') or filename.endswith('.jpeg')): + return jsonify({'status': 'error', 'message': 'Only PNG/JPG files supported'}), 400 + + image_path = decoder._output_dir / filename + + if not image_path.exists(): + return jsonify({'status': 'error', 'message': 'Image not found'}), 404 + + mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg' + return send_file(image_path, mimetype=mimetype) + + +@weather_sat_bp.route('/images/', methods=['DELETE']) +def delete_image(filename: str): + """Delete a decoded image. + + Args: + filename: Image filename + + Returns: + JSON confirmation. + """ + decoder = get_weather_sat_decoder() + + if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): + return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 + + if decoder.delete_image(filename): + return jsonify({'status': 'deleted', 'filename': filename}) + else: + return jsonify({'status': 'error', 'message': 'Image not found'}), 404 + + +@weather_sat_bp.route('/stream') +def stream_progress(): + """SSE stream of capture/decode progress. + + Returns: + SSE stream (text/event-stream) + """ + def generate() -> Generator[str, None, None]: + last_keepalive = time.time() + keepalive_interval = 30.0 + + while True: + try: + progress = _weather_sat_queue.get(timeout=1) + last_keepalive = time.time() + yield format_sse(progress) + except queue.Empty: + now = time.time() + if now - last_keepalive >= keepalive_interval: + yield format_sse({'type': 'keepalive'}) + last_keepalive = now + + response = Response(generate(), mimetype='text/event-stream') + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + response.headers['Connection'] = 'keep-alive' + return response + + +@weather_sat_bp.route('/passes') +def get_passes(): + """Get upcoming weather satellite passes for observer location. + + Query parameters: + latitude: Observer latitude (required) + longitude: Observer longitude (required) + hours: Hours to predict ahead (default: 24, max: 72) + min_elevation: Minimum elevation in degrees (default: 15) + + Returns: + JSON with upcoming passes for all weather satellites. + """ + lat = request.args.get('latitude', type=float) + lon = request.args.get('longitude', type=float) + hours = request.args.get('hours', 24, type=int) + min_elevation = request.args.get('min_elevation', 15, type=float) + + if lat is None or lon is None: + return jsonify({ + 'status': 'error', + 'message': 'latitude and longitude parameters required' + }), 400 + + if not (-90 <= lat <= 90): + return jsonify({'status': 'error', 'message': 'Invalid latitude'}), 400 + if not (-180 <= lon <= 180): + return jsonify({'status': 'error', 'message': 'Invalid longitude'}), 400 + + hours = max(1, min(hours, 72)) + min_elevation = max(0, min(min_elevation, 90)) + + try: + from skyfield.api import load, wgs84, EarthSatellite + from skyfield.almanac import find_discrete + from data.satellites import TLE_SATELLITES + + ts = load.timescale() + observer = wgs84.latlon(lat, lon) + t0 = ts.now() + t1 = ts.utc(t0.utc_datetime() + __import__('datetime').timedelta(hours=hours)) + + all_passes = [] + + for sat_key, sat_info in WEATHER_SATELLITES.items(): + if not sat_info['active']: + continue + + tle_data = TLE_SATELLITES.get(sat_info['tle_key']) + if not tle_data: + continue + + satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts) + + def above_horizon(t, _sat=satellite): + diff = _sat - observer + topocentric = diff.at(t) + alt, _, _ = topocentric.altaz() + return alt.degrees > 0 + + above_horizon.step_days = 1 / 720 + + try: + times, events = find_discrete(t0, t1, above_horizon) + except Exception: + continue + + i = 0 + while i < len(times): + if i < len(events) and events[i]: # Rising + rise_time = times[i] + set_time = None + + for j in range(i + 1, len(times)): + if not events[j]: # Setting + set_time = times[j] + i = j + break + else: + i += 1 + continue + + if set_time is None: + i += 1 + continue + + # Calculate max elevation + max_el = 0 + max_el_az = 0 + duration_seconds = ( + set_time.utc_datetime() - rise_time.utc_datetime() + ).total_seconds() + duration_minutes = round(duration_seconds / 60, 1) + + for k in range(30): + frac = k / 29 + t_point = ts.utc( + rise_time.utc_datetime() + + __import__('datetime').timedelta( + seconds=duration_seconds * frac + ) + ) + diff = satellite - observer + topocentric = diff.at(t_point) + alt, az, _ = topocentric.altaz() + if alt.degrees > max_el: + max_el = alt.degrees + max_el_az = az.degrees + + if max_el >= min_elevation: + # Calculate rise/set azimuth + rise_diff = satellite - observer + rise_topo = rise_diff.at(rise_time) + _, rise_az, _ = rise_topo.altaz() + + set_diff = satellite - observer + set_topo = set_diff.at(set_time) + _, set_az, _ = set_topo.altaz() + + pass_data = { + 'satellite': sat_key, + 'name': sat_info['name'], + 'frequency': sat_info['frequency'], + 'mode': sat_info['mode'], + 'startTime': rise_time.utc_datetime().strftime( + '%Y-%m-%d %H:%M UTC' + ), + 'startTimeISO': rise_time.utc_datetime().isoformat(), + 'endTimeISO': set_time.utc_datetime().isoformat(), + 'maxEl': round(max_el, 1), + 'maxElAz': round(max_el_az, 1), + 'riseAz': round(rise_az.degrees, 1), + 'setAz': round(set_az.degrees, 1), + 'duration': duration_minutes, + 'quality': ( + 'excellent' if max_el >= 60 + else 'good' if max_el >= 30 + else 'fair' + ), + } + all_passes.append(pass_data) + + i += 1 + + # Sort by start time + all_passes.sort(key=lambda p: p['startTimeISO']) + + return jsonify({ + 'status': 'ok', + 'passes': all_passes, + 'count': len(all_passes), + 'observer': {'latitude': lat, 'longitude': lon}, + 'prediction_hours': hours, + 'min_elevation': min_elevation, + }) + + except ImportError: + return jsonify({ + 'status': 'error', + 'message': 'skyfield library not installed' + }), 503 + + except Exception as e: + logger.error(f"Error predicting passes: {e}") + return jsonify({ + 'status': 'error', + 'message': str(e) + }), 500 diff --git a/static/css/modes/weather-satellite.css b/static/css/modes/weather-satellite.css new file mode 100644 index 0000000..5e16c37 --- /dev/null +++ b/static/css/modes/weather-satellite.css @@ -0,0 +1,512 @@ +/* Weather Satellite Mode Styles */ + +/* ===== Stats Strip ===== */ +.wxsat-stats-strip { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 16px; + background: var(--bg-tertiary, #1a1f2e); + border-bottom: 1px solid var(--border-color, #2a3040); + flex-wrap: wrap; + min-height: 44px; +} + +.wxsat-strip-group { + display: flex; + align-items: center; + gap: 8px; +} + +.wxsat-strip-status { + display: flex; + align-items: center; + gap: 6px; +} + +.wxsat-strip-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-dim, #666); +} + +.wxsat-strip-dot.capturing { + background: #00ff88; + animation: wxsat-pulse 1.5s ease-in-out infinite; +} + +.wxsat-strip-dot.decoding { + background: #00d4ff; + animation: wxsat-pulse 0.8s ease-in-out infinite; +} + +@keyframes wxsat-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.wxsat-strip-status-text { + font-size: 12px; + color: var(--text-secondary, #999); + font-family: 'JetBrains Mono', monospace; +} + +.wxsat-strip-btn { + padding: 4px 12px; + border: 1px solid var(--border-color, #2a3040); + border-radius: 4px; + background: transparent; + color: var(--text-primary, #e0e0e0); + font-size: 11px; + font-family: 'JetBrains Mono', monospace; + cursor: pointer; + transition: all 0.2s; +} + +.wxsat-strip-btn:hover { + background: var(--bg-hover, #252a3a); + border-color: var(--accent-cyan, #00d4ff); +} + +.wxsat-strip-btn.stop { + border-color: #ff4444; + color: #ff4444; +} + +.wxsat-strip-btn.stop:hover { + background: rgba(255, 68, 68, 0.1); +} + +.wxsat-strip-divider { + width: 1px; + height: 24px; + background: var(--border-color, #2a3040); +} + +.wxsat-strip-stat { + display: flex; + flex-direction: column; + align-items: center; +} + +.wxsat-strip-value { + font-size: 13px; + font-family: 'JetBrains Mono', monospace; + color: var(--text-primary, #e0e0e0); +} + +.wxsat-strip-label { + font-size: 9px; + color: var(--text-dim, #666); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.wxsat-strip-value.accent-cyan { + color: var(--accent-cyan, #00d4ff); +} + +/* ===== Location inputs in strip ===== */ +.wxsat-strip-location { + display: flex; + align-items: center; + gap: 4px; +} + +.wxsat-loc-input { + width: 72px; + padding: 3px 6px; + background: var(--bg-primary, #0d1117); + border: 1px solid var(--border-color, #2a3040); + border-radius: 3px; + color: var(--text-primary, #e0e0e0); + font-size: 11px; + font-family: 'JetBrains Mono', monospace; +} + +.wxsat-loc-input:focus { + border-color: var(--accent-cyan, #00d4ff); + outline: none; +} + +/* ===== Main Layout ===== */ +.wxsat-visuals-container { + display: flex; + flex-direction: column; + gap: 0; + width: 100%; + flex: 1; + min-height: 0; +} + +.wxsat-content { + display: flex; + gap: 16px; + padding: 16px; + flex: 1; + min-height: 0; + overflow: auto; +} + +/* ===== Pass Predictions Panel ===== */ +.wxsat-passes-panel { + flex: 0 0 320px; + display: flex; + flex-direction: column; + gap: 0; + background: var(--bg-secondary, #141820); + border: 1px solid var(--border-color, #2a3040); + border-radius: 6px; + overflow: hidden; +} + +.wxsat-passes-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background: var(--bg-tertiary, #1a1f2e); + border-bottom: 1px solid var(--border-color, #2a3040); +} + +.wxsat-passes-title { + font-size: 12px; + font-weight: 600; + color: var(--text-primary, #e0e0e0); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.wxsat-passes-count { + font-size: 11px; + color: var(--accent-cyan, #00d4ff); + font-family: 'JetBrains Mono', monospace; +} + +.wxsat-passes-list { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +.wxsat-pass-card { + padding: 10px 12px; + margin-bottom: 6px; + background: var(--bg-primary, #0d1117); + border: 1px solid var(--border-color, #2a3040); + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; +} + +.wxsat-pass-card:hover { + border-color: var(--accent-cyan, #00d4ff); + background: var(--bg-hover, #252a3a); +} + +.wxsat-pass-card.active { + border-color: #00ff88; + background: rgba(0, 255, 136, 0.05); +} + +.wxsat-pass-sat { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} + +.wxsat-pass-sat-name { + font-size: 12px; + font-weight: 600; + color: var(--text-primary, #e0e0e0); +} + +.wxsat-pass-mode { + font-size: 10px; + padding: 2px 6px; + border-radius: 3px; + font-family: 'JetBrains Mono', monospace; +} + +.wxsat-pass-mode.apt { + background: rgba(0, 212, 255, 0.15); + color: #00d4ff; +} + +.wxsat-pass-mode.lrpt { + background: rgba(0, 255, 136, 0.15); + color: #00ff88; +} + +.wxsat-pass-details { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px; + font-size: 11px; + color: var(--text-dim, #666); + font-family: 'JetBrains Mono', monospace; +} + +.wxsat-pass-detail-label { + color: var(--text-dim, #666); +} + +.wxsat-pass-detail-value { + color: var(--text-secondary, #999); + text-align: right; +} + +.wxsat-pass-quality { + display: inline-block; + font-size: 10px; + padding: 1px 6px; + border-radius: 3px; + margin-top: 4px; +} + +.wxsat-pass-quality.excellent { + background: rgba(0, 255, 136, 0.15); + color: #00ff88; +} + +.wxsat-pass-quality.good { + background: rgba(0, 212, 255, 0.15); + color: #00d4ff; +} + +.wxsat-pass-quality.fair { + background: rgba(255, 187, 0, 0.15); + color: #ffbb00; +} + +/* ===== Image Gallery Panel ===== */ +.wxsat-gallery-panel { + flex: 1; + display: flex; + flex-direction: column; + gap: 0; + background: var(--bg-secondary, #141820); + border: 1px solid var(--border-color, #2a3040); + border-radius: 6px; + overflow: hidden; + min-width: 0; +} + +.wxsat-gallery-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background: var(--bg-tertiary, #1a1f2e); + border-bottom: 1px solid var(--border-color, #2a3040); +} + +.wxsat-gallery-title { + font-size: 12px; + font-weight: 600; + color: var(--text-primary, #e0e0e0); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.wxsat-gallery-count { + font-size: 11px; + color: var(--accent-cyan, #00d4ff); + font-family: 'JetBrains Mono', monospace; +} + +.wxsat-gallery-grid { + flex: 1; + overflow-y: auto; + padding: 12px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; + align-content: start; +} + +.wxsat-image-card { + background: var(--bg-primary, #0d1117); + border: 1px solid var(--border-color, #2a3040); + border-radius: 6px; + overflow: hidden; + cursor: pointer; + transition: all 0.2s; +} + +.wxsat-image-card:hover { + border-color: var(--accent-cyan, #00d4ff); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.wxsat-image-preview { + width: 100%; + aspect-ratio: 4/3; + object-fit: cover; + display: block; + background: var(--bg-tertiary, #1a1f2e); +} + +.wxsat-image-info { + padding: 8px 10px; + border-top: 1px solid var(--border-color, #2a3040); +} + +.wxsat-image-sat { + font-size: 11px; + font-weight: 600; + color: var(--text-primary, #e0e0e0); + margin-bottom: 2px; +} + +.wxsat-image-product { + font-size: 10px; + color: var(--accent-cyan, #00d4ff); + font-family: 'JetBrains Mono', monospace; +} + +.wxsat-image-timestamp { + font-size: 10px; + color: var(--text-dim, #666); + margin-top: 2px; +} + +/* Empty state */ +.wxsat-gallery-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + color: var(--text-dim, #666); + text-align: center; + grid-column: 1 / -1; +} + +.wxsat-gallery-empty svg { + width: 48px; + height: 48px; + margin-bottom: 12px; + opacity: 0.3; +} + +.wxsat-gallery-empty p { + font-size: 12px; + margin: 0; +} + +/* ===== Capture Progress ===== */ +.wxsat-capture-status { + padding: 12px 16px; + background: var(--bg-tertiary, #1a1f2e); + border-bottom: 1px solid var(--border-color, #2a3040); + display: none; +} + +.wxsat-capture-status.active { + display: block; +} + +.wxsat-capture-info { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} + +.wxsat-capture-message { + font-size: 11px; + color: var(--text-secondary, #999); + font-family: 'JetBrains Mono', monospace; +} + +.wxsat-capture-elapsed { + font-size: 11px; + color: var(--text-dim, #666); + font-family: 'JetBrains Mono', monospace; +} + +.wxsat-progress-bar { + height: 3px; + background: var(--bg-primary, #0d1117); + border-radius: 2px; + overflow: hidden; +} + +.wxsat-progress-bar .progress { + height: 100%; + background: var(--accent-cyan, #00d4ff); + border-radius: 2px; + transition: width 0.3s ease; +} + +/* ===== Image Modal ===== */ +.wxsat-image-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.9); + display: none; + align-items: center; + justify-content: center; + z-index: 10000; + padding: 20px; +} + +.wxsat-image-modal.show { + display: flex; +} + +.wxsat-image-modal img { + max-width: 95%; + max-height: 95vh; + border-radius: 4px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} + +.wxsat-modal-close { + position: absolute; + top: 16px; + right: 24px; + background: none; + border: none; + color: white; + font-size: 32px; + cursor: pointer; + z-index: 10001; +} + +.wxsat-modal-info { + position: absolute; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.8); + padding: 8px 16px; + border-radius: 4px; + color: var(--text-secondary, #999); + font-size: 12px; + font-family: 'JetBrains Mono', monospace; + text-align: center; +} + +/* ===== Responsive ===== */ +@media (max-width: 900px) { + .wxsat-content { + flex-direction: column; + } + + .wxsat-passes-panel { + flex: none; + max-height: 300px; + } + + .wxsat-gallery-grid { + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + } +} diff --git a/static/js/modes/weather-satellite.js b/static/js/modes/weather-satellite.js new file mode 100644 index 0000000..a845dda --- /dev/null +++ b/static/js/modes/weather-satellite.js @@ -0,0 +1,563 @@ +/** + * Weather Satellite Mode + * NOAA APT and Meteor LRPT decoder interface + */ + +const WeatherSat = (function() { + // State + let isRunning = false; + let eventSource = null; + let images = []; + let passes = []; + let currentSatellite = null; + + /** + * Initialize the Weather Satellite mode + */ + function init() { + checkStatus(); + loadImages(); + loadLocationInputs(); + loadPasses(); + } + + /** + * Load observer location into input fields + */ + function loadLocationInputs() { + const latInput = document.getElementById('wxsatObsLat'); + const lonInput = document.getElementById('wxsatObsLon'); + + let storedLat = localStorage.getItem('observerLat'); + let storedLon = localStorage.getItem('observerLon'); + if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) { + const shared = ObserverLocation.getShared(); + storedLat = shared.lat.toString(); + storedLon = shared.lon.toString(); + } + + if (latInput && storedLat) latInput.value = storedLat; + if (lonInput && storedLon) lonInput.value = storedLon; + + if (latInput) latInput.addEventListener('change', saveLocationFromInputs); + if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs); + } + + /** + * Save location from inputs and refresh passes + */ + function saveLocationFromInputs() { + const latInput = document.getElementById('wxsatObsLat'); + const lonInput = document.getElementById('wxsatObsLon'); + + const lat = parseFloat(latInput?.value); + const lon = parseFloat(lonInput?.value); + + if (!isNaN(lat) && lat >= -90 && lat <= 90 && + !isNaN(lon) && lon >= -180 && lon <= 180) { + if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) { + ObserverLocation.setShared({ lat, lon }); + } else { + localStorage.setItem('observerLat', lat.toString()); + localStorage.setItem('observerLon', lon.toString()); + } + loadPasses(); + } + } + + /** + * Use GPS for location + */ + function useGPS(btn) { + if (!navigator.geolocation) { + showNotification('Weather Sat', 'GPS not available in this browser'); + return; + } + + const originalText = btn.innerHTML; + btn.innerHTML = '...'; + btn.disabled = true; + + navigator.geolocation.getCurrentPosition( + (pos) => { + const latInput = document.getElementById('wxsatObsLat'); + const lonInput = document.getElementById('wxsatObsLon'); + + const lat = pos.coords.latitude.toFixed(4); + const lon = pos.coords.longitude.toFixed(4); + + if (latInput) latInput.value = lat; + if (lonInput) lonInput.value = lon; + + if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) { + ObserverLocation.setShared({ lat: parseFloat(lat), lon: parseFloat(lon) }); + } else { + localStorage.setItem('observerLat', lat); + localStorage.setItem('observerLon', lon); + } + + btn.innerHTML = originalText; + btn.disabled = false; + showNotification('Weather Sat', 'Location updated'); + loadPasses(); + }, + (err) => { + btn.innerHTML = originalText; + btn.disabled = false; + showNotification('Weather Sat', 'Failed to get location'); + }, + { enableHighAccuracy: true, timeout: 10000 } + ); + } + + /** + * Check decoder status + */ + async function checkStatus() { + try { + const response = await fetch('/weather-sat/status'); + const data = await response.json(); + + if (!data.available) { + updateStatusUI('unavailable', 'SatDump not installed'); + return; + } + + if (data.running) { + isRunning = true; + currentSatellite = data.satellite; + updateStatusUI('capturing', `Capturing ${data.satellite}...`); + startStream(); + } else { + updateStatusUI('idle', 'Idle'); + } + } catch (err) { + console.error('Failed to check weather sat status:', err); + } + } + + /** + * Start capture + */ + async function start() { + const satSelect = document.getElementById('weatherSatSelect'); + const gainInput = document.getElementById('weatherSatGain'); + const biasTInput = document.getElementById('weatherSatBiasT'); + const deviceSelect = document.getElementById('deviceSelect'); + + const satellite = satSelect?.value || 'NOAA-18'; + const gain = parseFloat(gainInput?.value || '40'); + const biasT = biasTInput?.checked || false; + const device = parseInt(deviceSelect?.value || '0', 10); + + updateStatusUI('connecting', 'Starting...'); + + try { + const response = await fetch('/weather-sat/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + satellite, + device, + gain, + bias_t: biasT, + }) + }); + + const data = await response.json(); + + if (data.status === 'started' || data.status === 'already_running') { + isRunning = true; + currentSatellite = data.satellite || satellite; + updateStatusUI('capturing', `${data.satellite} ${data.frequency} MHz`); + updateFreqDisplay(data.frequency, data.mode); + startStream(); + showNotification('Weather Sat', `Capturing ${data.satellite} on ${data.frequency} MHz`); + } else { + updateStatusUI('idle', 'Start failed'); + showNotification('Weather Sat', data.message || 'Failed to start'); + } + } catch (err) { + console.error('Failed to start weather sat:', err); + updateStatusUI('idle', 'Error'); + showNotification('Weather Sat', 'Connection error'); + } + } + + /** + * Start capture for a specific pass + */ + function startPass(satellite) { + const satSelect = document.getElementById('weatherSatSelect'); + if (satSelect) { + satSelect.value = satellite; + } + start(); + } + + /** + * Stop capture + */ + async function stop() { + try { + await fetch('/weather-sat/stop', { method: 'POST' }); + isRunning = false; + stopStream(); + updateStatusUI('idle', 'Stopped'); + showNotification('Weather Sat', 'Capture stopped'); + } catch (err) { + console.error('Failed to stop weather sat:', err); + } + } + + /** + * Update status UI + */ + function updateStatusUI(status, text) { + const dot = document.getElementById('wxsatStripDot'); + const statusText = document.getElementById('wxsatStripStatus'); + const startBtn = document.getElementById('wxsatStartBtn'); + const stopBtn = document.getElementById('wxsatStopBtn'); + + if (dot) { + dot.className = 'wxsat-strip-dot'; + if (status === 'capturing') dot.classList.add('capturing'); + else if (status === 'decoding') dot.classList.add('decoding'); + } + + if (statusText) statusText.textContent = text || status; + + if (startBtn && stopBtn) { + if (status === 'capturing' || status === 'decoding') { + startBtn.style.display = 'none'; + stopBtn.style.display = 'inline-block'; + } else { + startBtn.style.display = 'inline-block'; + stopBtn.style.display = 'none'; + } + } + } + + /** + * Update frequency display in strip + */ + function updateFreqDisplay(freq, mode) { + const freqEl = document.getElementById('wxsatStripFreq'); + const modeEl = document.getElementById('wxsatStripMode'); + if (freqEl) freqEl.textContent = freq || '--'; + if (modeEl) modeEl.textContent = mode || '--'; + } + + /** + * Start SSE stream + */ + function startStream() { + if (eventSource) eventSource.close(); + + eventSource = new EventSource('/weather-sat/stream'); + + eventSource.onmessage = (e) => { + try { + const data = JSON.parse(e.data); + if (data.type === 'weather_sat_progress') { + handleProgress(data); + } + } catch (err) { + console.error('Failed to parse SSE:', err); + } + }; + + eventSource.onerror = () => { + setTimeout(() => { + if (isRunning) startStream(); + }, 3000); + }; + } + + /** + * Stop SSE stream + */ + function stopStream() { + if (eventSource) { + eventSource.close(); + eventSource = null; + } + } + + /** + * Handle progress update + */ + function handleProgress(data) { + const captureStatus = document.getElementById('wxsatCaptureStatus'); + const captureMsg = document.getElementById('wxsatCaptureMsg'); + const captureElapsed = document.getElementById('wxsatCaptureElapsed'); + const progressBar = document.getElementById('wxsatProgressFill'); + + if (data.status === 'capturing' || data.status === 'decoding') { + updateStatusUI(data.status, `${data.status === 'decoding' ? 'Decoding' : 'Capturing'} ${data.satellite}...`); + + if (captureStatus) captureStatus.classList.add('active'); + if (captureMsg) captureMsg.textContent = data.message || ''; + if (captureElapsed) captureElapsed.textContent = formatElapsed(data.elapsed_seconds || 0); + if (progressBar) progressBar.style.width = (data.progress || 0) + '%'; + + } else if (data.status === 'complete') { + if (data.image) { + images.unshift(data.image); + updateImageCount(images.length); + renderGallery(); + showNotification('Weather Sat', `New image: ${data.image.product || data.image.satellite}`); + } + + if (!data.image) { + // Capture ended + isRunning = false; + stopStream(); + updateStatusUI('idle', 'Capture complete'); + if (captureStatus) captureStatus.classList.remove('active'); + } + + } else if (data.status === 'error') { + updateStatusUI('idle', 'Error'); + showNotification('Weather Sat', data.message || 'Capture error'); + if (captureStatus) captureStatus.classList.remove('active'); + } + } + + /** + * Format elapsed seconds + */ + function formatElapsed(seconds) { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${s.toString().padStart(2, '0')}`; + } + + /** + * Load pass predictions + */ + async function loadPasses() { + const storedLat = localStorage.getItem('observerLat'); + const storedLon = localStorage.getItem('observerLon'); + + if (!storedLat || !storedLon) { + renderPasses([]); + return; + } + + try { + const url = `/weather-sat/passes?latitude=${storedLat}&longitude=${storedLon}&hours=24&min_elevation=15`; + const response = await fetch(url); + const data = await response.json(); + + if (data.status === 'ok') { + passes = data.passes || []; + renderPasses(passes); + } + } catch (err) { + console.error('Failed to load passes:', err); + } + } + + /** + * Render pass predictions list + */ + function renderPasses(passList) { + const container = document.getElementById('wxsatPassesList'); + const countEl = document.getElementById('wxsatPassesCount'); + + if (countEl) countEl.textContent = passList.length; + + if (!container) return; + + if (passList.length === 0) { + const hasLocation = localStorage.getItem('observerLat') !== null; + container.innerHTML = ` + + `; + return; + } + + container.innerHTML = passList.map((pass, idx) => { + const modeClass = pass.mode === 'APT' ? 'apt' : 'lrpt'; + const timeStr = pass.startTime || '--'; + const now = new Date(); + const passStart = new Date(pass.startTimeISO); + const diffMs = passStart - now; + const diffMins = Math.floor(diffMs / 60000); + + let countdown = ''; + if (diffMs < 0) { + countdown = 'NOW'; + } else if (diffMins < 60) { + countdown = `in ${diffMins}m`; + } else { + const hrs = Math.floor(diffMins / 60); + const mins = diffMins % 60; + countdown = `in ${hrs}h${mins}m`; + } + + return ` +
+
+ ${escapeHtml(pass.name)} + ${escapeHtml(pass.mode)} +
+
+ Time + ${escapeHtml(timeStr)} + Max El + ${pass.maxEl}° + Duration + ${pass.duration} min + Freq + ${pass.frequency} MHz +
+
+ ${pass.quality} + ${countdown} +
+
+ `; + }).join(''); + } + + /** + * Load decoded images + */ + async function loadImages() { + try { + const response = await fetch('/weather-sat/images'); + const data = await response.json(); + + if (data.status === 'ok') { + images = data.images || []; + updateImageCount(images.length); + renderGallery(); + } + } catch (err) { + console.error('Failed to load weather sat images:', err); + } + } + + /** + * Update image count + */ + function updateImageCount(count) { + const countEl = document.getElementById('wxsatImageCount'); + const stripCount = document.getElementById('wxsatStripImageCount'); + if (countEl) countEl.textContent = count; + if (stripCount) stripCount.textContent = count; + } + + /** + * Render image gallery + */ + function renderGallery() { + const gallery = document.getElementById('wxsatGallery'); + if (!gallery) return; + + if (images.length === 0) { + gallery.innerHTML = ` + + `; + return; + } + + gallery.innerHTML = images.map(img => ` +
+ ${escapeHtml(img.satellite)} ${escapeHtml(img.product)} +
+
${escapeHtml(img.satellite)}
+
${escapeHtml(img.product || img.mode)}
+
${formatTimestamp(img.timestamp)}
+
+
+ `).join(''); + } + + /** + * Show full-size image + */ + function showImage(url, satellite, product) { + let modal = document.getElementById('wxsatImageModal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'wxsatImageModal'; + modal.className = 'wxsat-image-modal'; + modal.innerHTML = ` + + Weather Satellite Image +
+ `; + modal.addEventListener('click', (e) => { + if (e.target === modal) closeImage(); + }); + document.body.appendChild(modal); + } + + modal.querySelector('img').src = url; + const info = modal.querySelector('.wxsat-modal-info'); + if (info) { + info.textContent = `${satellite || ''} ${product ? '// ' + product : ''}`; + } + modal.classList.add('show'); + } + + /** + * Close image modal + */ + function closeImage() { + const modal = document.getElementById('wxsatImageModal'); + if (modal) modal.classList.remove('show'); + } + + /** + * Format timestamp + */ + function formatTimestamp(isoString) { + if (!isoString) return '--'; + try { + return new Date(isoString).toLocaleString(); + } catch { + return isoString; + } + } + + /** + * Escape HTML + */ + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + // Public API + return { + init, + start, + stop, + startPass, + loadImages, + loadPasses, + showImage, + closeImage, + useGPS, + }; +})(); + +document.addEventListener('DOMContentLoaded', function() { + // Initialization happens via selectMode when weather-satellite mode is activated +}); diff --git a/templates/index.html b/templates/index.html index f2490ff..cbfb1b9 100644 --- a/templates/index.html +++ b/templates/index.html @@ -57,6 +57,7 @@ + @@ -224,6 +225,10 @@ ISS SSTV + @@ -506,6 +511,8 @@ {% include 'partials/modes/sstv.html' %} + {% include 'partials/modes/weather-satellite.html' %} + {% include 'partials/modes/listening-post.html' %} {% include 'partials/modes/tscm.html' %} @@ -1880,6 +1887,93 @@ + + + + +
+
+
+ DECODER CONSOLE +
+ TUNING + + LISTENING + + SIGNAL + + DECODING + + COMPLETE +
+
+ +
+
+
+
Waiting for capture...
+
+
+
+
diff --git a/utils/weather_sat.py b/utils/weather_sat.py index dc465ae..818a2a3 100644 --- a/utils/weather_sat.py +++ b/utils/weather_sat.py @@ -110,6 +110,8 @@ class CaptureProgress: progress_percent: int = 0 elapsed_seconds: int = 0 image: WeatherSatImage | None = None + log_type: str = '' # 'info', 'debug', 'progress', 'error', 'signal', 'save', 'warning' + capture_phase: str = '' # 'tuning', 'listening', 'signal_detected', 'decoding', 'complete', 'error' def to_dict(self) -> dict: result = { @@ -121,6 +123,8 @@ class CaptureProgress: 'message': self.message, 'progress': self.progress_percent, 'elapsed_seconds': self.elapsed_seconds, + 'log_type': self.log_type, + 'capture_phase': self.capture_phase, } if self.image: result['image'] = self.image.to_dict() @@ -150,6 +154,7 @@ class WeatherSatDecoder: self._device_index: int = 0 self._capture_output_dir: Path | None = None self._on_complete_callback: Callable[[], None] | None = None + self._capture_phase: str = 'idle' # Ensure output directory exists self._output_dir.mkdir(parents=True, exist_ok=True) @@ -240,6 +245,7 @@ class WeatherSatDecoder: self._current_mode = sat_info['mode'] self._device_index = device_index self._capture_start_time = time.time() + self._capture_phase = 'tuning' try: self._start_satdump(sat_info, device_index, gain, sample_rate, bias_t) @@ -254,7 +260,9 @@ class WeatherSatDecoder: satellite=satellite, frequency=sat_info['frequency'], mode=sat_info['mode'], - message=f"Capturing {sat_info['name']} on {sat_info['frequency']} MHz ({sat_info['mode']})..." + message=f"Capturing {sat_info['name']} on {sat_info['frequency']} MHz ({sat_info['mode']})...", + log_type='info', + capture_phase=self._capture_phase, )) return True @@ -345,6 +353,24 @@ class WeatherSatDecoder: ) self._watcher_thread.start() + @staticmethod + def _classify_log_type(line: str) -> str: + """Classify a SatDump output line into a log type.""" + lower = line.lower() + if '(e)' in lower or 'error' in lower or 'fail' in lower: + return 'error' + if 'progress' in lower and '%' in line: + return 'progress' + if 'saved' in lower or 'writing' in lower: + return 'save' + if 'detected' in lower or 'lock' in lower or 'sync' in lower: + return 'signal' + if '(w)' in lower: + return 'warning' + if '(d)' in lower: + return 'debug' + return 'info' + @staticmethod def _resolve_device_id(device_index: int) -> str: """Resolve RTL-SDR device index to serial number string for SatDump v1.2+. @@ -400,9 +426,22 @@ class WeatherSatDecoder: elapsed = int(time.time() - self._capture_start_time) now = time.time() + log_type = self._classify_log_type(line) + + # Track phase transitions + lower = line.lower() + if log_type == 'signal': + self._capture_phase = 'signal_detected' + elif log_type == 'progress': + self._capture_phase = 'decoding' + elif self._capture_phase == 'tuning' and ( + 'freq' in lower or 'processing' in lower + or 'starting' in lower or 'source' in lower + ): + self._capture_phase = 'listening' # Parse progress from SatDump output - if 'Progress' in line or 'progress' in line: + if log_type == 'progress': match = re.search(r'(\d+(?:\.\d+)?)\s*%', line) pct = int(float(match.group(1))) if match else 0 self._emit_progress(CaptureProgress( @@ -413,9 +452,11 @@ class WeatherSatDecoder: message=line, progress_percent=pct, elapsed_seconds=elapsed, + log_type=log_type, + capture_phase=self._capture_phase, )) last_emit_time = now - elif 'Saved' in line or 'saved' in line or 'Writing' in line: + elif log_type == 'save': self._emit_progress(CaptureProgress( status='decoding', satellite=self._current_satellite, @@ -423,9 +464,11 @@ class WeatherSatDecoder: mode=self._current_mode, message=line, elapsed_seconds=elapsed, + log_type=log_type, + capture_phase=self._capture_phase, )) last_emit_time = now - elif 'error' in line.lower() or 'fail' in line.lower(): + elif log_type == 'error': self._emit_progress(CaptureProgress( status='capturing', satellite=self._current_satellite, @@ -433,11 +476,25 @@ class WeatherSatDecoder: mode=self._current_mode, message=line, elapsed_seconds=elapsed, + log_type=log_type, + capture_phase=self._capture_phase, + )) + last_emit_time = now + elif log_type == 'signal': + self._emit_progress(CaptureProgress( + status='capturing', + satellite=self._current_satellite, + frequency=self._current_frequency, + mode=self._current_mode, + message=line, + elapsed_seconds=elapsed, + log_type=log_type, + capture_phase=self._capture_phase, )) last_emit_time = now else: - # Emit all output lines, throttled to every 2 seconds - if now - last_emit_time >= 2.0: + # Emit other lines, throttled to every 0.5 seconds + if now - last_emit_time >= 0.5: self._emit_progress(CaptureProgress( status='capturing', satellite=self._current_satellite, @@ -445,6 +502,8 @@ class WeatherSatDecoder: mode=self._current_mode, message=line, elapsed_seconds=elapsed, + log_type=log_type, + capture_phase=self._capture_phase, )) last_emit_time = now @@ -457,6 +516,7 @@ class WeatherSatDecoder: elapsed = int(time.time() - self._capture_start_time) if self._capture_start_time else 0 if was_running: + self._capture_phase = 'complete' self._emit_progress(CaptureProgress( status='complete', satellite=self._current_satellite, @@ -464,6 +524,8 @@ class WeatherSatDecoder: mode=self._current_mode, message=f"Capture complete ({elapsed}s)", elapsed_seconds=elapsed, + log_type='info', + capture_phase='complete', )) # Notify route layer to release SDR device From 4d24e648ab0f2aedbe1e6964b743edb7c7eeebb4 Mon Sep 17 00:00:00 2001 From: Mitch Ross Date: Sat, 7 Feb 2026 15:04:53 -0500 Subject: [PATCH 13/28] Update weather_sat.py --- utils/weather_sat.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/utils/weather_sat.py b/utils/weather_sat.py index 818a2a3..cd97f41 100644 --- a/utils/weather_sat.py +++ b/utils/weather_sat.py @@ -318,6 +318,8 @@ class WeatherSatDecoder: stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + bufsize=1, # line-buffered + env={**os.environ, 'PYTHONUNBUFFERED': '1'}, ) # Check for early exit (SatDump errors out immediately) @@ -406,6 +408,30 @@ class WeatherSatDecoder: # Fall back to string index return str(device_index) + @staticmethod + def _read_lines(stream): + """Read lines from stream, splitting on both \\n and \\r. + + SatDump uses \\r carriage returns for progress updates that overwrite + the same terminal line. Python's readline() only splits on \\n, so + those updates never arrive. This reads char-by-char and yields + complete lines on either delimiter. + """ + buf = [] + while True: + ch = stream.read(1) + if not ch: + # EOF + if buf: + yield ''.join(buf) + return + if ch in ('\n', '\r'): + if buf: + yield ''.join(buf) + buf = [] + else: + buf.append(ch) + def _read_satdump_output(self) -> None: """Read SatDump stdout/stderr for progress updates.""" if not self._process or not self._process.stdout: @@ -414,7 +440,7 @@ class WeatherSatDecoder: last_emit_time = 0.0 try: - for line in iter(self._process.stdout.readline, ''): + for line in self._read_lines(self._process.stdout): if not self._running: break From b87623cf6601848ff726e23bb2b6bf4e2b50b9a1 Mon Sep 17 00:00:00 2001 From: Mitch Ross Date: Sat, 7 Feb 2026 15:06:58 -0500 Subject: [PATCH 14/28] Update weather_sat.py --- utils/weather_sat.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/utils/weather_sat.py b/utils/weather_sat.py index cd97f41..2d5a7f4 100644 --- a/utils/weather_sat.py +++ b/utils/weather_sat.py @@ -297,7 +297,7 @@ class WeatherSatDecoder: # Auto-detect serial by querying rtl_eeprom, fall back to string index. source_id = self._resolve_device_id(device_index) - cmd = [ + satdump_cmd = [ 'satdump', 'live', sat_info['pipeline'], str(self._capture_output_dir), @@ -308,6 +308,14 @@ class WeatherSatDecoder: '--source_id', source_id, ] + # Wrap with stdbuf to disable output buffering. + # SatDump (C++) fully buffers stdout when writing to a pipe, + # which prevents our reader from seeing any output until exit. + if shutil.which('stdbuf'): + cmd = ['stdbuf', '-o0', '-e0'] + satdump_cmd + else: + cmd = satdump_cmd + if bias_t: cmd.append('--bias') From f9786aa75a754df8338b34f18da2a016efb000ab Mon Sep 17 00:00:00 2001 From: Mitch Ross Date: Sat, 7 Feb 2026 15:17:03 -0500 Subject: [PATCH 15/28] Use PTY for SatDump output capture instead of pipe SatDump writes to stderr via fwrite() with its custom logger. When stderr is redirected to a pipe, C runtime fully buffers it. Neither stdbuf nor bufsize settings help since SatDump doesn't use stdio for output. PTY (pseudo-terminal) makes SatDump think it's writing to a real terminal, which disables buffering. Also strips ANSI escape codes from the output and properly handles \r progress lines. Co-Authored-By: Claude Opus 4.6 --- utils/weather_sat.py | 144 ++++++++++++++++++++++++++++++------------- 1 file changed, 100 insertions(+), 44 deletions(-) diff --git a/utils/weather_sat.py b/utils/weather_sat.py index 2d5a7f4..34df3b1 100644 --- a/utils/weather_sat.py +++ b/utils/weather_sat.py @@ -14,8 +14,11 @@ rtl_fm capture for manual decoding when SatDump is unavailable. from __future__ import annotations +import io import os +import pty import re +import select import shutil import subprocess import threading @@ -147,6 +150,7 @@ class WeatherSatDecoder: self._images: list[WeatherSatImage] = [] self._reader_thread: threading.Thread | None = None self._watcher_thread: threading.Thread | None = None + self._pty_master_fd: int | None = None self._current_satellite: str = '' self._current_frequency: float = 0.0 self._current_mode: str = '' @@ -297,7 +301,7 @@ class WeatherSatDecoder: # Auto-detect serial by querying rtl_eeprom, fall back to string index. source_id = self._resolve_device_id(device_index) - satdump_cmd = [ + cmd = [ 'satdump', 'live', sat_info['pipeline'], str(self._capture_output_dir), @@ -308,43 +312,52 @@ class WeatherSatDecoder: '--source_id', source_id, ] - # Wrap with stdbuf to disable output buffering. - # SatDump (C++) fully buffers stdout when writing to a pipe, - # which prevents our reader from seeing any output until exit. - if shutil.which('stdbuf'): - cmd = ['stdbuf', '-o0', '-e0'] + satdump_cmd - else: - cmd = satdump_cmd - if bias_t: cmd.append('--bias') logger.info(f"Starting SatDump: {' '.join(cmd)}") + # Use a pseudo-terminal so SatDump thinks it's writing to a real + # terminal. C/C++ runtimes disable buffering on TTYs, which lets + # us see output (including \r progress lines) in real time. + master_fd, slave_fd = pty.openpty() + self._pty_master_fd = master_fd + self._process = subprocess.Popen( cmd, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, # line-buffered - env={**os.environ, 'PYTHONUNBUFFERED': '1'}, + stdout=slave_fd, + stderr=slave_fd, + stdin=subprocess.DEVNULL, + close_fds=True, ) + os.close(slave_fd) # parent doesn't need the slave side # Check for early exit (SatDump errors out immediately) try: retcode = self._process.wait(timeout=3) # Process already died — read whatever output it produced - output = '' - if self._process.stdout: - output = self._process.stdout.read() + output = b'' + try: + while True: + r, _, _ = select.select([master_fd], [], [], 0.1) + if not r: + break + chunk = os.read(master_fd, 4096) + if not chunk: + break + output += chunk + except OSError: + pass + os.close(master_fd) + self._pty_master_fd = None + output_str = output.decode('utf-8', errors='replace') error_msg = f"SatDump exited immediately (code {retcode})" - if output: - # Extract the most useful error line - for line in output.strip().splitlines(): + if output_str: + for line in output_str.strip().splitlines(): if 'error' in line.lower() or 'could not' in line.lower() or 'cannot' in line.lower(): error_msg = line.strip() break - logger.error(f"SatDump output:\n{output}") + logger.error(f"SatDump output:\n{output_str}") self._process = None raise RuntimeError(error_msg) except subprocess.TimeoutExpired: @@ -416,39 +429,67 @@ class WeatherSatDecoder: # Fall back to string index return str(device_index) - @staticmethod - def _read_lines(stream): - """Read lines from stream, splitting on both \\n and \\r. + def _read_pty_lines(self): + """Read lines from the PTY master fd, splitting on \\n and \\r. - SatDump uses \\r carriage returns for progress updates that overwrite - the same terminal line. Python's readline() only splits on \\n, so - those updates never arrive. This reads char-by-char and yields - complete lines on either delimiter. + SatDump uses \\r carriage returns for progress updates. A PTY gives + us unbuffered output. We use select() to detect data availability + and os.read() for raw bytes, then split on line boundaries. """ - buf = [] - while True: - ch = stream.read(1) - if not ch: - # EOF - if buf: - yield ''.join(buf) - return - if ch in ('\n', '\r'): - if buf: - yield ''.join(buf) - buf = [] - else: - buf.append(ch) + master_fd = self._pty_master_fd + if master_fd is None: + return + + buf = b'' + while self._running: + try: + r, _, _ = select.select([master_fd], [], [], 1.0) + if not r: + # Timeout — check if process is still alive + if self._process and self._process.poll() is not None: + break + continue + chunk = os.read(master_fd, 4096) + if not chunk: + break + buf += chunk + # Split on \r and \n + while b'\n' in buf or b'\r' in buf: + # Find earliest delimiter + idx_n = buf.find(b'\n') + idx_r = buf.find(b'\r') + if idx_n == -1: + idx = idx_r + elif idx_r == -1: + idx = idx_n + else: + idx = min(idx_n, idx_r) + line = buf[:idx] + buf = buf[idx + 1:] + # Skip empty lines + text = line.decode('utf-8', errors='replace').strip() + # Strip ANSI escape codes that terminals produce + text = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', text) + if text: + yield text + except OSError: + break + # Drain remaining buffer + text = buf.decode('utf-8', errors='replace').strip() + if text: + text = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', text) + if text: + yield text def _read_satdump_output(self) -> None: """Read SatDump stdout/stderr for progress updates.""" - if not self._process or not self._process.stdout: + if not self._process or self._pty_master_fd is None: return last_emit_time = 0.0 try: - for line in self._read_lines(self._process.stdout): + for line in self._read_pty_lines(): if not self._running: break @@ -544,6 +585,14 @@ class WeatherSatDecoder: except Exception as e: logger.error(f"Error reading SatDump output: {e}") finally: + # Close PTY master fd + if self._pty_master_fd is not None: + try: + os.close(self._pty_master_fd) + except OSError: + pass + self._pty_master_fd = None + # Process ended — release resources was_running = self._running self._running = False @@ -670,6 +719,13 @@ class WeatherSatDecoder: with self._lock: self._running = False + if self._pty_master_fd is not None: + try: + os.close(self._pty_master_fd) + except OSError: + pass + self._pty_master_fd = None + if self._process: try: self._process.terminate() From 03c5d33eb763d38d3e12dad1d4995d151588cf49 Mon Sep 17 00:00:00 2001 From: Mitch Ross Date: Sat, 7 Feb 2026 15:28:07 -0500 Subject: [PATCH 16/28] Fix race condition: set _running before starting reader thread The reader thread loop checks self._running but it was being set to True after _start_satdump() returned, which is after the thread already started. The thread would see _running=False and exit immediately without reading any SatDump output. Co-Authored-By: Claude Opus 4.6 --- utils/weather_sat.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/utils/weather_sat.py b/utils/weather_sat.py index 34df3b1..0137597 100644 --- a/utils/weather_sat.py +++ b/utils/weather_sat.py @@ -252,8 +252,8 @@ class WeatherSatDecoder: self._capture_phase = 'tuning' try: - self._start_satdump(sat_info, device_index, gain, sample_rate, bias_t) self._running = True + self._start_satdump(sat_info, device_index, gain, sample_rate, bias_t) logger.info( f"Weather satellite capture started: {satellite} " @@ -272,6 +272,7 @@ class WeatherSatDecoder: return True except Exception as e: + self._running = False logger.error(f"Failed to start weather satellite capture: {e}") self._emit_progress(CaptureProgress( status='error', From 556a4ffcc27ada21b9e00e1e4ef91ef94367ea57 Mon Sep 17 00:00:00 2001 From: Mitch Ross Date: Sat, 7 Feb 2026 15:52:52 -0500 Subject: [PATCH 17/28] tweaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. utils/weather_sat.py — Added delete_all_images() method that globs for *.png, *.jpg, *.jpeg in the output dir, unlinks each, clears _images list, and returns the count. 2. routes/weather_sat.py — Added DELETE /weather-sat/images route that calls decoder.delete_all_images() and returns {'status': 'ok', 'deleted': count}. 3. static/js/modes/weather-satellite.js: - Added currentModalFilename state variable - renderGallery() now sorts images by timestamp descending, groups by date using toLocaleDateString(), renders date headers spanning the grid, and adds a delete overlay button on each card - showImage() accepts a filename param, stores it in currentModalFilename, and creates a modal toolbar with a delete button - Added deleteImage(filename) — confirm dialog → DELETE /weather-sat/images/{filename} → filter from array → re-render + close modal - Added deleteAllImages() — confirm dialog → DELETE /weather-sat/images → clear array → re-render - Exposed deleteImage, deleteAllImages, and _getModalFilename in public API 4. static/css/modes/weather-satellite.css: - Added position: relative to .wxsat-image-card - .wxsat-image-actions — absolute top-right overlay, hidden by default, appears on card hover - .wxsat-image-actions button — dark background, turns red on hover - .wxsat-date-header — full-grid-width date separator with dimmed uppercase text - .wxsat-modal-toolbar — absolute top-left in modal for the delete button - .wxsat-modal-btn.delete — turns red on hover - .wxsat-gallery-clear-btn — subtle icon button, pushed right via margin-left: auto, turns red on hover - Updated .wxsat-gallery-header from justify-content: space-between to gap: 8px for proper 3-child layout 5. templates/index.html — Added clear-all trash button with SVG icon in the gallery header, wired to WeatherSat.deleteAllImages(). --- routes/weather_sat.py | 12 +++ static/css/modes/weather-satellite.css | 107 +++++++++++++++++++++- static/js/modes/weather-satellite.js | 122 ++++++++++++++++++++++--- templates/index.html | 5 + utils/weather_sat.py | 13 +++ 5 files changed, 246 insertions(+), 13 deletions(-) diff --git a/routes/weather_sat.py b/routes/weather_sat.py index 7170155..649b7ac 100644 --- a/routes/weather_sat.py +++ b/routes/weather_sat.py @@ -301,6 +301,18 @@ def delete_image(filename: str): 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. diff --git a/static/css/modes/weather-satellite.css b/static/css/modes/weather-satellite.css index d2589b5..ea7e961 100644 --- a/static/css/modes/weather-satellite.css +++ b/static/css/modes/weather-satellite.css @@ -530,7 +530,7 @@ .wxsat-gallery-header { display: flex; align-items: center; - justify-content: space-between; + gap: 8px; padding: 10px 14px; background: var(--bg-tertiary, #1a1f2e); border-bottom: 1px solid var(--border-color, #2a3040); @@ -561,6 +561,7 @@ } .wxsat-image-card { + position: relative; background: var(--bg-primary, #0d1117); border: 1px solid var(--border-color, #2a3040); border-radius: 6px; @@ -575,6 +576,43 @@ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); } +.wxsat-image-clickable { + display: block; +} + +.wxsat-image-actions { + position: absolute; + top: 6px; + right: 6px; + opacity: 0; + transition: opacity 0.2s; + z-index: 2; +} + +.wxsat-image-card:hover .wxsat-image-actions { + opacity: 1; +} + +.wxsat-image-actions button { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 4px; + background: rgba(0, 0, 0, 0.7); + color: var(--text-secondary, #999); + cursor: pointer; + transition: all 0.2s; +} + +.wxsat-image-actions button:hover { + background: rgba(255, 68, 68, 0.9); + color: #fff; +} + .wxsat-image-preview { width: 100%; aspect-ratio: 4/3; @@ -607,6 +645,23 @@ margin-top: 2px; } +/* Date group headers */ +.wxsat-date-header { + grid-column: 1 / -1; + font-size: 11px; + font-family: 'JetBrains Mono', monospace; + color: var(--text-dim, #666); + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 8px 0 4px; + border-bottom: 1px solid var(--border-color, #2a3040); + margin-bottom: 4px; +} + +.wxsat-date-header:first-child { + padding-top: 0; +} + /* Empty state */ .wxsat-gallery-empty { display: flex; @@ -734,6 +789,56 @@ text-align: center; } +.wxsat-modal-toolbar { + position: absolute; + top: 16px; + left: 24px; + z-index: 10001; + display: flex; + gap: 8px; +} + +.wxsat-modal-btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + background: rgba(0, 0, 0, 0.6); + color: var(--text-secondary, #999); + cursor: pointer; + transition: all 0.2s; +} + +.wxsat-modal-btn.delete:hover { + background: rgba(255, 68, 68, 0.9); + border-color: #ff4444; + color: #fff; +} + +/* Gallery clear-all button */ +.wxsat-gallery-clear-btn { + display: flex; + align-items: center; + justify-content: center; + margin-left: auto; + padding: 4px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-dim, #666); + cursor: pointer; + transition: all 0.2s; +} + +.wxsat-gallery-clear-btn:hover { + color: #ff4444; + background: rgba(255, 68, 68, 0.1); +} + /* ===== Responsive ===== */ @media (max-width: 1100px) { .wxsat-content { diff --git a/static/js/modes/weather-satellite.js b/static/js/modes/weather-satellite.js index a4a6327..50e79ef 100644 --- a/static/js/modes/weather-satellite.js +++ b/static/js/modes/weather-satellite.js @@ -21,6 +21,7 @@ const WeatherSat = (function() { let consoleCollapsed = false; let currentPhase = 'idle'; let consoleAutoHideTimer = null; + let currentModalFilename = null; /** * Initialize the Weather Satellite mode @@ -1005,7 +1006,7 @@ const WeatherSat = (function() { } /** - * Render image gallery + * Render image gallery grouped by date */ function renderGallery() { const gallery = document.getElementById('wxsatGallery'); @@ -1026,28 +1027,69 @@ const WeatherSat = (function() { return; } - gallery.innerHTML = images.map(img => ` -
- ${escapeHtml(img.satellite)} ${escapeHtml(img.product)} -
-
${escapeHtml(img.satellite)}
-
${escapeHtml(img.product || img.mode)}
-
${formatTimestamp(img.timestamp)}
-
-
- `).join(''); + // Sort by timestamp descending + const sorted = [...images].sort((a, b) => { + return new Date(b.timestamp || 0) - new Date(a.timestamp || 0); + }); + + // Group by date + const groups = {}; + sorted.forEach(img => { + const dateKey = img.timestamp + ? new Date(img.timestamp).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) + : 'Unknown Date'; + if (!groups[dateKey]) groups[dateKey] = []; + groups[dateKey].push(img); + }); + + let html = ''; + for (const [date, imgs] of Object.entries(groups)) { + html += `
${escapeHtml(date)}
`; + html += imgs.map(img => { + const fn = escapeHtml(img.filename || img.url.split('/').pop()); + return ` +
+
+ ${escapeHtml(img.satellite)} ${escapeHtml(img.product)} +
+
${escapeHtml(img.satellite)}
+
${escapeHtml(img.product || img.mode)}
+
${formatTimestamp(img.timestamp)}
+
+
+
+ +
+
`; + }).join(''); + } + + gallery.innerHTML = html; } /** * Show full-size image */ - function showImage(url, satellite, product) { + function showImage(url, satellite, product, filename) { + currentModalFilename = filename || null; + let modal = document.getElementById('wxsatImageModal'); if (!modal) { modal = document.createElement('div'); modal.id = 'wxsatImageModal'; modal.className = 'wxsat-image-modal'; modal.innerHTML = ` +
+ +
Weather Satellite Image
@@ -1074,6 +1116,59 @@ const WeatherSat = (function() { if (modal) modal.classList.remove('show'); } + /** + * Delete a single image + */ + async function deleteImage(filename) { + if (!filename) return; + if (!confirm(`Delete this image?`)) return; + + try { + const response = await fetch(`/weather-sat/images/${encodeURIComponent(filename)}`, { method: 'DELETE' }); + const data = await response.json(); + + if (data.status === 'deleted') { + images = images.filter(img => { + const imgFn = img.filename || img.url.split('/').pop(); + return imgFn !== filename; + }); + updateImageCount(images.length); + renderGallery(); + closeImage(); + } else { + showNotification('Weather Sat', data.message || 'Failed to delete image'); + } + } catch (err) { + console.error('Failed to delete image:', err); + showNotification('Weather Sat', 'Failed to delete image'); + } + } + + /** + * Delete all images + */ + async function deleteAllImages() { + if (images.length === 0) return; + if (!confirm(`Delete all ${images.length} decoded images?`)) return; + + try { + const response = await fetch('/weather-sat/images', { method: 'DELETE' }); + const data = await response.json(); + + if (data.status === 'ok') { + images = []; + updateImageCount(0); + renderGallery(); + showNotification('Weather Sat', `Deleted ${data.deleted} images`); + } else { + showNotification('Weather Sat', 'Failed to delete images'); + } + } catch (err) { + console.error('Failed to delete all images:', err); + showNotification('Weather Sat', 'Failed to delete images'); + } + } + /** * Format timestamp */ @@ -1218,10 +1313,13 @@ const WeatherSat = (function() { loadPasses, showImage, closeImage, + deleteImage, + deleteAllImages, useGPS, toggleScheduler, invalidateMap, toggleConsole, + _getModalFilename: () => currentModalFilename, }; })(); diff --git a/templates/index.html b/templates/index.html index 49f306e..b5773a9 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2212,6 +2212,11 @@ + + + {% include 'partials/modes/pager.html' %} {% include 'partials/modes/sensor.html' %} @@ -552,10 +580,10 @@
-
-
-

Pager Decoder

-
+
+
+

Pager Decoder

+
0
0
@@ -577,11 +605,21 @@ +
-
- - - -
-

Waterfall

-
- - -
-
- - -
-
- - -
-
- - -
- - -
- - -
-

Antenna Guide

-
-

- Wideband listening — antenna choice depends on your target frequency -

- -
- Stock Telescopic Antenna -
    -
  • Best range: 800 MHz–1.2 GHz (tuned near 1 GHz)
  • -
  • Usable for: Airband, trunked radio, cell bands
  • -
  • Poor below: ~400 MHz (too short for VHF/lower UHF)
  • -
-
- -
- Discone (Best All-rounder — ~$30-50) -
    -
  • Coverage: ~25 MHz to 1.3 GHz continuous
  • -
  • Gain: ~2 dBi (modest but consistent across bands)
  • -
  • Polarization: Vertical omnidirectional
  • -
  • Placement: Outdoors, as high as possible
  • -
-

- Ideal for scanning — covers VHF, UHF, airband, marine, public safety, and more in one antenna. -

-
- -
- Tuned Antenna (Best for One Band) -
    -
  • Formula: Element length (cm) = 7500 / frequency (MHz)
  • -
  • Example: 155 MHz police → 48.4 cm per element dipole
  • -
  • Example: 460 MHz UHF → 16.3 cm per element dipole
  • -
  • Higher gain: ~3–5 dBi better than discone on target freq
  • -
-
- -
- Quick Reference - - - - - - - - - - - - - - - - - - - - - - - - - -
RTL-SDR range24–1766 MHz
VHF low band30–88 MHz
VHF high band136–174 MHz
UHF band400–520 MHz
Airband (AM)118–137 MHz
λ/4 formula7500 / MHz cm
-
-
-
- -
From 13be4302c304656e04a5e163ea43bd7ce591d6c7 Mon Sep 17 00:00:00 2001 From: Mitch Ross Date: Sat, 7 Feb 2026 16:07:12 -0500 Subject: [PATCH 19/28] Update index.html --- templates/index.html | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/templates/index.html b/templates/index.html index 6de1e67..fa82842 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3243,17 +3243,6 @@ waterfallPanel.style.display = (waterfallSupported && running) ? 'block' : 'none'; } - // Show shared waterfall controls for supported modes - const waterfallControlsSection = document.getElementById('waterfallControlsSection'); - const waterfallPanel = document.getElementById('waterfallPanel'); - const waterfallModes = ['pager', 'sensor', 'rtlamr', 'dmr', 'sstv', 'sstv_general', 'listening']; - const waterfallSupported = waterfallModes.includes(mode); - if (waterfallControlsSection) waterfallControlsSection.style.display = waterfallSupported ? 'block' : 'none'; - if (waterfallPanel) { - const running = (typeof isWaterfallRunning !== 'undefined' && isWaterfallRunning); - waterfallPanel.style.display = (waterfallSupported && running) ? 'block' : 'none'; - } - // Toggle mode-specific tool status displays const toolStatusPager = document.getElementById('toolStatusPager'); const toolStatusSensor = document.getElementById('toolStatusSensor'); From fd0953bfb5fc0ecfc02576e61014a1e189db7dac Mon Sep 17 00:00:00 2001 From: Mitch Ross Date: Sat, 7 Feb 2026 17:56:45 -0500 Subject: [PATCH 20/28] up --- routes/satellite.py | 14 ++++++++++++++ utils/weather_sat_predict.py | 11 ++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/routes/satellite.py b/routes/satellite.py index 8fb9054..8a4e3c8 100644 --- a/routes/satellite.py +++ b/routes/satellite.py @@ -30,6 +30,20 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www # Local TLE cache (can be updated via API) _tle_cache = dict(TLE_SATELLITES) +# Auto-refresh TLEs from CelesTrak on startup (non-blocking) +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}") + +# Delay import — refresh_tle_data is defined later in this module +threading.Timer(2.0, _auto_refresh_tle).start() + def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Optional[float] = None) -> Optional[dict]: """ diff --git a/utils/weather_sat_predict.py b/utils/weather_sat_predict.py index b5d6f2d..8d6432f 100644 --- a/utils/weather_sat_predict.py +++ b/utils/weather_sat_predict.py @@ -42,6 +42,15 @@ def predict_passes( 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() @@ -53,7 +62,7 @@ def predict_passes( if not sat_info['active']: continue - tle_data = TLE_SATELLITES.get(sat_info['tle_key']) + tle_data = tle_source.get(sat_info['tle_key']) if not tle_data: continue From ca15e227cd40accedb0b7f68884c7515dd5bf878 Mon Sep 17 00:00:00 2001 From: Mitch Ross Date: Sun, 8 Feb 2026 14:45:12 -0500 Subject: [PATCH 21/28] add test harness --- .gitignore | 3 + download-weather-sat-samples.sh | 30 ++++ routes/weather_sat.py | 120 ++++++++++++++ static/css/modes/weather-satellite.css | 23 +++ static/js/modes/weather-satellite.js | 56 +++++++ .../partials/modes/weather-satellite.html | 39 +++++ utils/weather_sat.py | 149 ++++++++++++++++++ 7 files changed, 420 insertions(+) create mode 100755 download-weather-sat-samples.sh diff --git a/.gitignore b/.gitignore index 18ae397..bc822b4 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,9 @@ intercept_agent_*.cfg /tmp/ *.tmp +# Weather satellite runtime data (decoded images, samples, SatDump output) +data/weather_sat/ + # Env files .env .env.* diff --git a/download-weather-sat-samples.sh b/download-weather-sat-samples.sh new file mode 100755 index 0000000..ce13900 --- /dev/null +++ b/download-weather-sat-samples.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Download sample NOAA APT recordings for testing the weather satellite +# test-decode feature. These are FM-demodulated audio WAV files. +# +# Usage: +# ./download-weather-sat-samples.sh +# docker exec intercept /app/download-weather-sat-samples.sh + +set -euo pipefail + +SAMPLE_DIR="$(dirname "$0")/data/weather_sat/samples" +mkdir -p "$SAMPLE_DIR" + +echo "Downloading NOAA APT sample files to $SAMPLE_DIR ..." + +# Full satellite pass recorded over Argentina (NOAA, 11025 Hz mono WAV) +# Source: https://github.com/martinber/noaa-apt +if [ ! -f "$SAMPLE_DIR/noaa_apt_argentina.wav" ]; then + echo " -> noaa_apt_argentina.wav (18 MB) ..." + curl -fSL -o "$SAMPLE_DIR/noaa_apt_argentina.wav" \ + "https://noaa-apt.mbernardi.com.ar/examples/argentina.wav" +else + echo " -> noaa_apt_argentina.wav (already exists)" +fi + +echo "" +echo "Done. Test decode with:" +echo " Satellite: NOAA-18" +echo " File path: data/weather_sat/samples/noaa_apt_argentina.wav" +echo " Sample rate: 11025 Hz" diff --git a/routes/weather_sat.py b/routes/weather_sat.py index 649b7ac..dd0d5b3 100644 --- a/routes/weather_sat.py +++ b/routes/weather_sat.py @@ -199,6 +199,126 @@ def 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 + allowed_base = Path('data').resolve() + try: + resolved = input_path.resolve() + if not str(resolved).startswith(str(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(): + return jsonify({ + 'status': 'error', + 'message': f'File not found: {input_file}' + }), 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. diff --git a/static/css/modes/weather-satellite.css b/static/css/modes/weather-satellite.css index ea7e961..940b5f9 100644 --- a/static/css/modes/weather-satellite.css +++ b/static/css/modes/weather-satellite.css @@ -1058,3 +1058,26 @@ border-left-color: transparent; color: var(--text-dim, #555); } + +/* Test Decode collapsible section */ +.wxsat-test-decode-body { + transition: max-height 0.3s ease, opacity 0.2s ease, margin 0.3s ease; + max-height: 400px; + opacity: 1; + margin-top: 8px; +} + +.wxsat-test-decode-body.collapsed { + max-height: 0; + opacity: 0; + margin-top: 0; + overflow: hidden; +} + +.wxsat-collapse-icon { + transition: transform 0.2s ease; +} + +.wxsat-collapse-icon.collapsed { + transform: rotate(-90deg); +} diff --git a/static/js/modes/weather-satellite.js b/static/js/modes/weather-satellite.js index 50e79ef..c53a2cd 100644 --- a/static/js/modes/weather-satellite.js +++ b/static/js/modes/weather-satellite.js @@ -229,6 +229,61 @@ const WeatherSat = (function() { } } + /** + * Start test decode from a pre-recorded file + */ + async function testDecode() { + const satSelect = document.getElementById('wxsatTestSatSelect'); + const fileInput = document.getElementById('wxsatTestFilePath'); + const rateSelect = document.getElementById('wxsatTestSampleRate'); + + const satellite = satSelect?.value || 'NOAA-18'; + const inputFile = (fileInput?.value || '').trim(); + const sampleRate = parseInt(rateSelect?.value || '1000000', 10); + + if (!inputFile) { + showNotification('Weather Sat', 'Enter a file path'); + return; + } + + clearConsole(); + showConsole(true); + updatePhaseIndicator('decoding'); + addConsoleEntry(`Test decode: ${inputFile}`, 'info'); + updateStatusUI('connecting', 'Starting file decode...'); + + try { + const response = await fetch('/weather-sat/test-decode', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + satellite, + input_file: inputFile, + sample_rate: sampleRate, + }) + }); + + const data = await response.json(); + + if (data.status === 'started' || data.status === 'already_running') { + isRunning = true; + currentSatellite = data.satellite || satellite; + updateStatusUI('decoding', `Decoding ${data.satellite} from file`); + updateFreqDisplay(data.frequency, data.mode); + startStream(); + showNotification('Weather Sat', `Decoding ${data.satellite} from file`); + } else { + updateStatusUI('idle', 'Decode failed'); + showNotification('Weather Sat', data.message || 'Failed to start decode'); + addConsoleEntry(data.message || 'Failed to start decode', 'error'); + } + } catch (err) { + console.error('Failed to start test decode:', err); + updateStatusUI('idle', 'Error'); + showNotification('Weather Sat', 'Connection error'); + } + } + /** * Update status UI */ @@ -1309,6 +1364,7 @@ const WeatherSat = (function() { stop, startPass, selectPass, + testDecode, loadImages, loadPasses, showImage, diff --git a/templates/partials/modes/weather-satellite.html b/templates/partials/modes/weather-satellite.html index 039eba4..18aeee1 100644 --- a/templates/partials/modes/weather-satellite.html +++ b/templates/partials/modes/weather-satellite.html @@ -174,6 +174,45 @@
+
+

+ Test Decode (File) + +

+ +
+

Auto-Scheduler

diff --git a/utils/weather_sat.py b/utils/weather_sat.py index 2072374..a62b92e 100644 --- a/utils/weather_sat.py +++ b/utils/weather_sat.py @@ -203,6 +203,92 @@ class WeatherSatDecoder: """Set callback invoked when capture process ends (for SDR release).""" self._on_complete_callback = callback + def start_from_file( + self, + satellite: str, + input_file: str | Path, + sample_rate: int = DEFAULT_SAMPLE_RATE, + ) -> bool: + """Start weather satellite decode from a pre-recorded IQ/WAV file. + + No SDR hardware is required — SatDump runs in offline mode. + + Args: + satellite: Satellite key (e.g. 'NOAA-18', 'METEOR-M2-3') + input_file: Path to IQ baseband or WAV audio file + sample_rate: Sample rate of the recording in Hz + + Returns: + True if started successfully + """ + with self._lock: + if self._running: + return True + + if not self._decoder: + logger.error("No weather satellite decoder available") + self._emit_progress(CaptureProgress( + status='error', + message='SatDump not installed. Build from source or install via package manager.' + )) + return False + + sat_info = WEATHER_SATELLITES.get(satellite) + if not sat_info: + logger.error(f"Unknown satellite: {satellite}") + self._emit_progress(CaptureProgress( + status='error', + message=f'Unknown satellite: {satellite}' + )) + return False + + input_path = Path(input_file) + if not input_path.is_file(): + logger.error(f"Input file not found: {input_file}") + self._emit_progress(CaptureProgress( + status='error', + message=f'Input file not found: {input_file}' + )) + return False + + self._current_satellite = satellite + self._current_frequency = sat_info['frequency'] + self._current_mode = sat_info['mode'] + self._capture_start_time = time.time() + self._capture_phase = 'decoding' + + try: + self._running = True + self._start_satdump_offline( + sat_info, input_path, sample_rate, + ) + + logger.info( + f"Weather satellite file decode started: {satellite} " + f"({sat_info['mode']}) from {input_file}" + ) + self._emit_progress(CaptureProgress( + status='decoding', + satellite=satellite, + frequency=sat_info['frequency'], + mode=sat_info['mode'], + message=f"Decoding {sat_info['name']} from file ({sat_info['mode']})...", + log_type='info', + capture_phase='decoding', + )) + + return True + + except Exception as e: + self._running = False + logger.error(f"Failed to start file decode: {e}") + self._emit_progress(CaptureProgress( + status='error', + satellite=satellite, + message=str(e) + )) + return False + def start( self, satellite: str, @@ -377,6 +463,69 @@ class WeatherSatDecoder: ) self._watcher_thread.start() + def _start_satdump_offline( + self, + sat_info: dict, + input_file: Path, + sample_rate: int, + ) -> None: + """Start SatDump offline decode from a recorded file.""" + # Create timestamped output directory for this decode + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + sat_name = sat_info['tle_key'].replace(' ', '_') + self._capture_output_dir = self._output_dir / f"{sat_name}_{timestamp}" + self._capture_output_dir.mkdir(parents=True, exist_ok=True) + + # Determine input level from file extension. + # WAV audio files (FM-demodulated) use 'audio_wav' level. + # Raw IQ baseband files use 'baseband' level. + suffix = input_file.suffix.lower() + if suffix in ('.wav', '.wave'): + input_level = 'audio_wav' + else: + input_level = 'baseband' + + cmd = [ + 'satdump', + sat_info['pipeline'], + input_level, + str(input_file), + str(self._capture_output_dir), + '--samplerate', str(sample_rate), + ] + + logger.info(f"Starting SatDump offline: {' '.join(cmd)}") + + # Use a pseudo-terminal so SatDump thinks it's writing to a real + # terminal — same approach as live mode for unbuffered output. + master_fd, slave_fd = pty.openpty() + self._pty_master_fd = master_fd + + self._process = subprocess.Popen( + cmd, + stdout=slave_fd, + stderr=slave_fd, + stdin=subprocess.DEVNULL, + close_fds=True, + ) + os.close(slave_fd) # parent doesn't need the slave side + + # For offline mode, don't check for early exit — file decoding + # may complete very quickly and exit code 0 is normal success. + # The reader thread will handle output and detect errors. + + # Start reader thread to monitor output + self._reader_thread = threading.Thread( + target=self._read_satdump_output, daemon=True + ) + self._reader_thread.start() + + # Start image watcher thread + self._watcher_thread = threading.Thread( + target=self._watch_images, daemon=True + ) + self._watcher_thread.start() + @staticmethod def _classify_log_type(line: str) -> str: """Classify a SatDump output line into a log type.""" From 54c849ab60bd5cdc77ac1a43f232192722e0483c Mon Sep 17 00:00:00 2001 From: Mitch Ross Date: Sun, 8 Feb 2026 21:29:45 -0500 Subject: [PATCH 22/28] Fix weather satellite decoder security, architecture, and race conditions Security: replace path traversal-vulnerable str().startswith() with is_relative_to(), anchor path checks to app root, strip filesystem paths from error responses, add decoder-level path validation. Architecture: use safe_terminate/register_process for subprocess lifecycle, replace custom SSE generator with sse_stream(), use centralized validate_* functions, remove unused app.py declarations. Bugs: add thread-safe singleton locks, protect _images list across threads, move blocking process.wait() to async daemon thread, fix timezone handling for tz-aware datetimes, use full path for image deduplication, guard TLE auto-refresh during tests, validate scheduler parameters to avoid 500 errors. Docker: pin SatDump to v1.2.2 and slowrx to ca6d7012, document INTERCEPT_IMAGE fallback pattern. Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 9 +-- app.py | 4 -- docker-compose.yml | 3 + routes/satellite.py | 5 +- routes/weather_sat.py | 103 ++++++++++++--------------------- utils/weather_sat.py | 95 ++++++++++++++++++++---------- utils/weather_sat_scheduler.py | 15 ++++- 7 files changed, 124 insertions(+), 110 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5e1a7fa..41b0f7e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -136,16 +136,17 @@ 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) + # Build slowrx (SSTV decoder) — pinned to known-good commit && cd /tmp \ - && git clone --depth 1 https://github.com/windytan/slowrx.git \ + && 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) + # Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) — pinned to v1.2.2 && cd /tmp \ - && git clone --depth 1 https://github.com/SatDump/SatDump.git \ + && 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 .. \ diff --git a/app.py b/app.py index 8d4cbc4..df2b1a3 100644 --- a/app.py +++ b/app.py @@ -182,10 +182,6 @@ dmr_lock = threading.Lock() tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) tscm_lock = threading.Lock() -# Weather Satellite (NOAA/Meteor) -weather_sat_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) -weather_sat_lock = threading.Lock() - # Deauth Attack Detection deauth_detector = None deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) diff --git a/docker-compose.yml b/docker-compose.yml index 19b998d..0211fb4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,8 @@ services: intercept: + # When INTERCEPT_IMAGE is set, use that pre-built image; when empty/unset, + # the empty string causes Docker Compose to fall through to the build: directive. image: ${INTERCEPT_IMAGE:-} build: . container_name: intercept @@ -61,6 +63,7 @@ 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:-} build: . container_name: intercept-history diff --git a/routes/satellite.py b/routes/satellite.py index 8a4e3c8..3a8f078 100644 --- a/routes/satellite.py +++ b/routes/satellite.py @@ -31,6 +31,7 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www _tle_cache = dict(TLE_SATELLITES) # Auto-refresh TLEs from CelesTrak on startup (non-blocking) +import os import threading def _auto_refresh_tle(): @@ -42,7 +43,9 @@ def _auto_refresh_tle(): logger.warning(f"Auto TLE refresh failed: {e}") # Delay import — refresh_tle_data is defined later in this module -threading.Timer(2.0, _auto_refresh_tle).start() +# Guard to avoid firing during tests +if not os.environ.get('TESTING'): + threading.Timer(2.0, _auto_refresh_tle).start() def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Optional[float] = None) -> Optional[dict]: diff --git a/routes/weather_sat.py b/routes/weather_sat.py index dd0d5b3..728d7be 100644 --- a/routes/weather_sat.py +++ b/routes/weather_sat.py @@ -7,13 +7,12 @@ from NOAA (APT) and Meteor (LRPT) satellites using SatDump. from __future__ import annotations import queue -import time -from typing import Generator from flask import Blueprint, jsonify, request, Response, send_file from utils.logging import get_logger -from utils.sse import format_sse +from utils.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, @@ -116,28 +115,14 @@ def start_capture(): 'message': f'Invalid satellite. Must be one of: {", ".join(WEATHER_SATELLITES.keys())}' }), 400 - # Validate device index - device_index = data.get('device', 0) + # Validate device index and gain try: - device_index = int(device_index) - if not (0 <= device_index <= 255): - raise ValueError - except (TypeError, ValueError): + device_index = validate_device_index(data.get('device', 0)) + gain = validate_gain(data.get('gain', 40.0)) + except ValueError as e: return jsonify({ 'status': 'error', - 'message': 'Invalid device index (0-255)' - }), 400 - - # Validate gain - gain = data.get('gain', 40.0) - try: - gain = float(gain) - if not (0 <= gain <= 50): - raise ValueError - except (TypeError, ValueError): - return jsonify({ - 'status': 'error', - 'message': 'Invalid gain (0-50 dB)' + 'message': str(e) }), 400 bias_t = bool(data.get('bias_t', False)) @@ -252,11 +237,11 @@ def test_decode(): from pathlib import Path input_path = Path(input_file) - # Security: restrict to data directory - allowed_base = Path('data').resolve() + # 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 str(resolved).startswith(str(allowed_base)): + if not resolved.is_relative_to(allowed_base): return jsonify({ 'status': 'error', 'message': 'input_file must be under the data/ directory' @@ -268,9 +253,10 @@ def test_decode(): }), 400 if not input_path.is_file(): + logger.warning(f"Test-decode file not found: {input_file}") return jsonify({ 'status': 'error', - 'message': f'File not found: {input_file}' + 'message': 'File not found' }), 404 # Validate sample rate @@ -440,22 +426,7 @@ def stream_progress(): Returns: SSE stream (text/event-stream) """ - def generate() -> Generator[str, None, None]: - last_keepalive = time.time() - keepalive_interval = 30.0 - - while True: - try: - progress = _weather_sat_queue.get(timeout=1) - last_keepalive = time.time() - yield format_sse(progress) - except queue.Empty: - now = time.time() - if now - last_keepalive >= keepalive_interval: - yield format_sse({'type': 'keepalive'}) - last_keepalive = now - - response = Response(generate(), mimetype='text/event-stream') + response = 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' @@ -477,26 +448,26 @@ def get_passes(): Returns: JSON with upcoming passes for all weather satellites. """ - lat = request.args.get('latitude', type=float) - lon = request.args.get('longitude', type=float) - hours = request.args.get('hours', 24, type=int) - min_elevation = request.args.get('min_elevation', 15, type=float) include_trajectory = request.args.get('trajectory', 'false').lower() in ('true', '1') include_ground_track = request.args.get('ground_track', 'false').lower() in ('true', '1') - if lat is None or lon is None: + 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 - if not (-90 <= lat <= 90): - return jsonify({'status': 'error', 'message': 'Invalid latitude'}), 400 - if not (-180 <= lon <= 180): - return jsonify({'status': 'error', 'message': 'Invalid longitude'}), 400 + try: + lat = validate_latitude(raw_lat) + lon = validate_longitude(raw_lon) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 - hours = max(1, min(hours, 72)) - min_elevation = max(0, min(min_elevation, 90)) + 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 @@ -529,7 +500,7 @@ def get_passes(): logger.error(f"Error predicting passes: {e}") return jsonify({ 'status': 'error', - 'message': str(e) + 'message': 'Pass prediction failed' }), 500 @@ -571,24 +542,22 @@ def enable_schedule(): data = request.get_json(silent=True) or {} - lat = data.get('latitude') - lon = data.get('longitude') - - if lat is None or lon is None: + if data.get('latitude') is None or data.get('longitude') is None: return jsonify({ 'status': 'error', 'message': 'latitude and longitude required' }), 400 try: - lat = float(lat) - lon = float(lon) - if not (-90 <= lat <= 90) or not (-180 <= lon <= 180): - raise ValueError - except (TypeError, ValueError): + 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: return jsonify({ 'status': 'error', - 'message': 'Invalid coordinates' + 'message': str(e) }), 400 scheduler = get_weather_sat_scheduler() @@ -597,9 +566,9 @@ def enable_schedule(): result = scheduler.enable( lat=lat, lon=lon, - min_elevation=float(data.get('min_elevation', 15)), - device=int(data.get('device', 0)), - gain=float(data.get('gain', 40.0)), + min_elevation=min_elev, + device=device, + gain=gain_val, bias_t=bool(data.get('bias_t', False)), ) diff --git a/utils/weather_sat.py b/utils/weather_sat.py index a62b92e..e91442a 100644 --- a/utils/weather_sat.py +++ b/utils/weather_sat.py @@ -29,6 +29,7 @@ from pathlib import Path from typing import Callable from utils.logging import get_logger +from utils.process import register_process, safe_terminate logger = get_logger('intercept.weather_sat') @@ -145,6 +146,7 @@ class WeatherSatDecoder: self._process: subprocess.Popen | None = None self._running = False self._lock = threading.Lock() + self._images_lock = threading.Lock() self._callback: Callable[[CaptureProgress], None] | None = None self._output_dir = Path(output_dir) if output_dir else Path('data/weather_sat') self._images: list[WeatherSatImage] = [] @@ -243,11 +245,30 @@ class WeatherSatDecoder: return False input_path = Path(input_file) + + # Security: restrict to data directory + allowed_base = Path(__file__).resolve().parent.parent / 'data' + try: + resolved = input_path.resolve() + if not resolved.is_relative_to(allowed_base): + logger.warning(f"Path traversal blocked in start_from_file: {input_file}") + self._emit_progress(CaptureProgress( + status='error', + message='Input file must be under the data/ directory' + )) + return False + except (OSError, ValueError): + self._emit_progress(CaptureProgress( + status='error', + message='Invalid file path' + )) + return False + if not input_path.is_file(): logger.error(f"Input file not found: {input_file}") self._emit_progress(CaptureProgress( status='error', - message=f'Input file not found: {input_file}' + message='Input file not found' )) return False @@ -417,12 +438,17 @@ class WeatherSatDecoder: stdin=subprocess.DEVNULL, close_fds=True, ) + register_process(self._process) os.close(slave_fd) # parent doesn't need the slave side - # Check for early exit (SatDump errors out immediately) - try: - retcode = self._process.wait(timeout=3) - # Process already died — read whatever output it produced + # Check for early exit asynchronously (avoid blocking /start for 3s) + def _check_early_exit(): + """Poll once after 3s; if SatDump died, emit an error event.""" + time.sleep(3) + process = self._process + if process is None or process.poll() is None: + return # still running or already cleaned up + retcode = process.returncode output = b'' try: while True: @@ -435,8 +461,6 @@ class WeatherSatDecoder: output += chunk except OSError: pass - os.close(master_fd) - self._pty_master_fd = None output_str = output.decode('utf-8', errors='replace') error_msg = f"SatDump exited immediately (code {retcode})" if output_str: @@ -445,11 +469,17 @@ class WeatherSatDecoder: error_msg = line.strip() break logger.error(f"SatDump output:\n{output_str}") - self._process = None - raise RuntimeError(error_msg) - except subprocess.TimeoutExpired: - # Good — process is still running after 3 seconds - pass + self._emit_progress(CaptureProgress( + status='error', + satellite=self._current_satellite, + frequency=self._current_frequency, + mode=self._current_mode, + message=error_msg, + log_type='error', + capture_phase='error', + )) + + threading.Thread(target=_check_early_exit, daemon=True).start() # Start reader thread to monitor output self._reader_thread = threading.Thread( @@ -508,6 +538,7 @@ class WeatherSatDecoder: stdin=subprocess.DEVNULL, close_fds=True, ) + register_process(self._process) os.close(slave_fd) # parent doesn't need the slave side # For offline mode, don't check for early exit — file decoding @@ -782,7 +813,8 @@ class WeatherSatDecoder: # Recursively scan for image files for ext in ('*.png', '*.jpg', '*.jpeg'): for filepath in self._capture_output_dir.rglob(ext): - if filepath.name in known_files: + file_key = str(filepath) + if file_key in known_files: continue # Skip tiny files (likely incomplete) @@ -793,7 +825,7 @@ class WeatherSatDecoder: except OSError: continue - known_files.add(filepath.name) + known_files.add(file_key) # Determine product type from filename/path product = self._parse_product_name(filepath) @@ -817,7 +849,8 @@ class WeatherSatDecoder: size_bytes=stat.st_size, product=product, ) - self._images.append(image) + with self._images_lock: + self._images.append(image) logger.info(f"New weather satellite image: {serve_name} ({product})") self._emit_progress(CaptureProgress( @@ -877,16 +910,7 @@ class WeatherSatDecoder: self._pty_master_fd = None if self._process: - try: - self._process.terminate() - self._process.wait(timeout=5) - except subprocess.TimeoutExpired: - self._process.kill() - except Exception: - try: - self._process.kill() - except Exception: - pass + safe_terminate(self._process) self._process = None elapsed = int(time.time() - self._capture_start_time) if self._capture_start_time else 0 @@ -894,11 +918,15 @@ class WeatherSatDecoder: def get_images(self) -> list[WeatherSatImage]: """Get list of decoded images.""" - self._scan_images() - return list(self._images) + with self._images_lock: + self._scan_images() + return list(self._images) def _scan_images(self) -> None: - """Scan output directory for images not yet tracked.""" + """Scan output directory for images not yet tracked. + + Must be called with self._images_lock held. + """ known_filenames = {img.filename for img in self._images} for ext in ('*.png', '*.jpg', '*.jpeg'): @@ -940,7 +968,8 @@ class WeatherSatDecoder: if filepath.exists(): try: filepath.unlink() - self._images = [img for img in self._images if img.filename != filename] + with self._images_lock: + self._images = [img for img in self._images if img.filename != filename] return True except OSError as e: logger.error(f"Failed to delete image {filename}: {e}") @@ -956,7 +985,8 @@ class WeatherSatDecoder: count += 1 except OSError: pass - self._images.clear() + with self._images_lock: + self._images.clear() return count def _emit_progress(self, progress: CaptureProgress) -> None: @@ -987,13 +1017,16 @@ class WeatherSatDecoder: # Global decoder instance _decoder: WeatherSatDecoder | None = None +_decoder_lock = threading.Lock() def get_weather_sat_decoder() -> WeatherSatDecoder: """Get or create the global weather satellite decoder instance.""" global _decoder if _decoder is None: - _decoder = WeatherSatDecoder() + with _decoder_lock: + if _decoder is None: + _decoder = WeatherSatDecoder() return _decoder diff --git a/utils/weather_sat_scheduler.py b/utils/weather_sat_scheduler.py index f08ec6b..48aea6f 100644 --- a/utils/weather_sat_scheduler.py +++ b/utils/weather_sat_scheduler.py @@ -49,11 +49,17 @@ class ScheduledPass: @property def start_dt(self) -> datetime: - return datetime.fromisoformat(self.start_time).replace(tzinfo=timezone.utc) + dt = datetime.fromisoformat(self.start_time) + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) @property def end_dt(self) -> datetime: - return datetime.fromisoformat(self.end_time).replace(tzinfo=timezone.utc) + dt = datetime.fromisoformat(self.end_time) + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) def to_dict(self) -> dict[str, Any]: return { @@ -375,11 +381,14 @@ class WeatherSatScheduler: # 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: - _scheduler = WeatherSatScheduler() + with _scheduler_lock: + if _scheduler is None: + _scheduler = WeatherSatScheduler() return _scheduler From 35cf01c11e88b3fb4bbc9f24de12bdfac238e4ea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:52:52 +0000 Subject: [PATCH 23/28] Initial plan From d41ba61aee7a7ea0dd1bafe110ede4edaab12b7c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:58:26 +0000 Subject: [PATCH 24/28] Fix security issues, breaking changes, and code cleanup for weather satellite PR Co-authored-by: mitchross <6330506+mitchross@users.noreply.github.com> --- app.py | 12 ++++++++-- config.py | 2 +- docker-compose.yml | 9 +++----- routes/satellite.py | 34 +++++++++++++++------------- routes/weather_sat.py | 4 ++-- static/js/modes/weather-satellite.js | 13 +++++++++-- utils/weather_sat.py | 5 ++++ utils/weather_sat_scheduler.py | 6 +++-- 8 files changed, 54 insertions(+), 31 deletions(-) diff --git a/app.py b/app.py index df2b1a3..7a82bd5 100644 --- a/app.py +++ b/app.py @@ -692,8 +692,7 @@ def kill_all() -> Response: 'airodump-ng', 'aireplay-ng', 'airmon-ng', 'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher', 'hcitool', 'bluetoothctl', 'satdump', 'dsd', - 'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg', - 'grgsm_scanner', 'grgsm_livemon', 'tshark' + 'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg' ] for proc in processes_to_kill: @@ -870,6 +869,15 @@ def main() -> None: from routes import register_blueprints register_blueprints(app) + # Initialize TLE auto-refresh (must be after blueprint registration) + try: + from routes.satellite import init_tle_auto_refresh + import os + if not os.environ.get('TESTING'): + init_tle_auto_refresh() + except Exception as e: + logger.warning(f"Failed to initialize TLE auto-refresh: {e}") + # Update TLE data in background thread (non-blocking) def update_tle_background(): try: diff --git a/config.py b/config.py index 67e7d3e..b7758cb 100644 --- a/config.py +++ b/config.py @@ -152,7 +152,7 @@ def _get_env_bool(key: str, default: bool) -> bool: # Logging configuration -_log_level_str = _get_env('LOG_LEVEL', 'INFO').upper() +_log_level_str = _get_env('LOG_LEVEL', 'WARNING').upper() LOG_LEVEL = getattr(logging, _log_level_str, logging.WARNING) LOG_FORMAT = _get_env('LOG_FORMAT', '%(asctime)s - %(levelname)s - %(message)s') diff --git a/docker-compose.yml b/docker-compose.yml index 0211fb4..b6318ba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,13 +12,10 @@ services: intercept: - # When INTERCEPT_IMAGE is set, use that pre-built image; when empty/unset, - # the empty string causes Docker Compose to fall through to the build: directive. - image: ${INTERCEPT_IMAGE:-} + # When INTERCEPT_IMAGE is set, use that pre-built image; otherwise build locally + image: ${INTERCEPT_IMAGE:-intercept:latest} build: . container_name: intercept - profiles: - - basic ports: - "5050:5050" # Privileged mode required for USB SDR device access @@ -64,7 +61,7 @@ services: # Enable with: docker compose --profile history up -d intercept-history: # Same image/build fallback pattern as above - image: ${INTERCEPT_IMAGE:-} + image: ${INTERCEPT_IMAGE:-intercept:latest} build: . container_name: intercept-history profiles: diff --git a/routes/satellite.py b/routes/satellite.py index 3a8f078..e3dbefa 100644 --- a/routes/satellite.py +++ b/routes/satellite.py @@ -30,22 +30,22 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www # Local TLE cache (can be updated via API) _tle_cache = dict(TLE_SATELLITES) -# Auto-refresh TLEs from CelesTrak on startup (non-blocking) -import os -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}") - -# Delay import — refresh_tle_data is defined later in this module -# Guard to avoid firing during tests -if not os.environ.get('TESTING'): +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]: @@ -498,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/') @@ -552,4 +553,5 @@ def fetch_celestrak(category): }) except Exception as e: - return jsonify({'status': 'error', 'message': str(e)}) + logger.error(f"Error fetching CelesTrak data: {e}") + return jsonify({'status': 'error', 'message': 'Failed to fetch satellite data'}) diff --git a/routes/weather_sat.py b/routes/weather_sat.py index 728d7be..0caa5ad 100644 --- a/routes/weather_sat.py +++ b/routes/weather_sat.py @@ -253,7 +253,7 @@ def test_decode(): }), 400 if not input_path.is_file(): - logger.warning(f"Test-decode file not found: {input_file}") + logger.warning("Test-decode file not found") return jsonify({ 'status': 'error', 'message': 'File not found' @@ -313,7 +313,7 @@ def stop_capture(): JSON confirmation. """ decoder = get_weather_sat_decoder() - device_index = decoder._device_index + device_index = decoder.device_index decoder.stop() diff --git a/static/js/modes/weather-satellite.js b/static/js/modes/weather-satellite.js index c53a2cd..51ecaa9 100644 --- a/static/js/modes/weather-satellite.js +++ b/static/js/modes/weather-satellite.js @@ -446,8 +446,17 @@ const WeatherSat = (function() { * Load pass predictions (with trajectory + ground track) */ async function loadPasses() { - const storedLat = localStorage.getItem('observerLat'); - const storedLon = localStorage.getItem('observerLon'); + let storedLat, storedLon; + + // Use ObserverLocation if available, otherwise fall back to localStorage + if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) { + const shared = ObserverLocation.getShared(); + storedLat = shared?.lat?.toString(); + storedLon = shared?.lon?.toString(); + } else { + storedLat = localStorage.getItem('observerLat'); + storedLon = localStorage.getItem('observerLon'); + } if (!storedLat || !storedLon) { renderPasses([]); diff --git a/utils/weather_sat.py b/utils/weather_sat.py index e91442a..347c0a9 100644 --- a/utils/weather_sat.py +++ b/utils/weather_sat.py @@ -185,6 +185,11 @@ class WeatherSatDecoder: def current_frequency(self) -> float: return self._current_frequency + @property + def device_index(self) -> int: + """Return current device index.""" + return self._device_index + def _detect_decoder(self) -> str | None: """Detect which weather satellite decoder is available.""" if shutil.which('satdump'): diff --git a/utils/weather_sat_scheduler.py b/utils/weather_sat_scheduler.py index 48aea6f..6f16a54 100644 --- a/utils/weather_sat_scheduler.py +++ b/utils/weather_sat_scheduler.py @@ -51,14 +51,16 @@ class ScheduledPass: def start_dt(self) -> datetime: dt = datetime.fromisoformat(self.start_time) if dt.tzinfo is None: - return dt.replace(tzinfo=timezone.utc) + # 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: - return dt.replace(tzinfo=timezone.utc) + # Naive datetime - assume UTC + dt = dt.replace(tzinfo=timezone.utc) return dt.astimezone(timezone.utc) def to_dict(self) -> dict[str, Any]: From f217230ef44a2b5a08f9c695aa568087922e7cec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:41:46 +0000 Subject: [PATCH 25/28] Initial plan From 4a6dddbb487ce9eeab18be7627be930e3741410e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:50:22 +0000 Subject: [PATCH 26/28] Add comprehensive test coverage for weather satellite modules - Created test_weather_sat_routes.py with 42 tests for all endpoints - Created test_weather_sat_decoder.py with 47 tests for WeatherSatDecoder class - Created test_weather_sat_predict.py with 14 tests for pass prediction - Created test_weather_sat_scheduler.py with 31 tests for auto-scheduler - Total: 134 test functions across 14 test classes - All tests follow existing patterns (mocking, fixtures, docstrings) - Tests cover happy paths, error handling, and edge cases - Mock all external subprocess calls and HTTP requests Co-authored-by: mitchross <6330506+mitchross@users.noreply.github.com> --- tests/test_weather_sat_decoder.py | 643 ++++++++++++++++++++++ tests/test_weather_sat_predict.py | 675 +++++++++++++++++++++++ tests/test_weather_sat_routes.py | 801 ++++++++++++++++++++++++++++ tests/test_weather_sat_scheduler.py | 779 +++++++++++++++++++++++++++ 4 files changed, 2898 insertions(+) create mode 100644 tests/test_weather_sat_decoder.py create mode 100644 tests/test_weather_sat_predict.py create mode 100644 tests/test_weather_sat_routes.py create mode 100644 tests/test_weather_sat_scheduler.py diff --git a/tests/test_weather_sat_decoder.py b/tests/test_weather_sat_decoder.py new file mode 100644 index 0000000..1f48642 --- /dev/null +++ b/tests/test_weather_sat_decoder.py @@ -0,0 +1,643 @@ +"""Tests for WeatherSatDecoder class. + +Covers WeatherSatDecoder methods, subprocess management, progress callbacks, +and image handling. +""" + +from __future__ import annotations + +import os +import tempfile +import threading +import time +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import patch, MagicMock, call, mock_open +import pytest + +from utils.weather_sat import ( + WeatherSatDecoder, + WeatherSatImage, + CaptureProgress, + WEATHER_SATELLITES, + get_weather_sat_decoder, + is_weather_sat_available, +) + + +class TestWeatherSatDecoder: + """Tests for WeatherSatDecoder class.""" + + def test_decoder_initialization(self): + """Decoder should initialize with default output directory.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + assert decoder.is_running is False + assert decoder.decoder_available == 'satdump' + assert decoder.current_satellite == '' + assert decoder.current_frequency == 0.0 + + def test_decoder_initialization_no_satdump(self): + """Decoder should detect when SatDump is unavailable.""" + with patch('shutil.which', return_value=None): + decoder = WeatherSatDecoder() + assert decoder.decoder_available is None + + def test_decoder_custom_output_dir(self): + """Decoder should accept custom output directory.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + custom_dir = '/tmp/custom_output' + decoder = WeatherSatDecoder(output_dir=custom_dir) + assert decoder._output_dir == Path(custom_dir) + + def test_set_callback(self): + """Decoder should accept progress callback.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + callback = MagicMock() + decoder.set_callback(callback) + assert decoder._callback == callback + + def test_set_on_complete(self): + """Decoder should accept on_complete callback.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + callback = MagicMock() + decoder.set_on_complete(callback) + assert decoder._on_complete_callback == callback + + def test_start_no_decoder(self): + """start() should fail when no decoder available.""" + with patch('shutil.which', return_value=None): + decoder = WeatherSatDecoder() + callback = MagicMock() + decoder.set_callback(callback) + + success = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0) + + assert success is False + callback.assert_called() + progress = callback.call_args[0][0] + assert progress.status == 'error' + assert 'SatDump' in progress.message + + def test_start_invalid_satellite(self): + """start() should fail with invalid satellite.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + callback = MagicMock() + decoder.set_callback(callback) + + success = decoder.start(satellite='FAKE-SAT', device_index=0, gain=40.0) + + assert success is False + callback.assert_called() + progress = callback.call_args[0][0] + assert progress.status == 'error' + assert 'Unknown satellite' in progress.message + + @patch('subprocess.Popen') + @patch('pty.openpty') + @patch('utils.weather_sat.register_process') + def test_start_success(self, mock_register, mock_pty, mock_popen): + """start() should successfully start SatDump.""" + with patch('shutil.which', return_value='/usr/bin/satdump'), \ + patch('utils.weather_sat.WeatherSatDecoder._resolve_device_id', return_value='0'): + + mock_pty.return_value = (10, 11) + mock_process = MagicMock() + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + decoder = WeatherSatDecoder() + callback = MagicMock() + decoder.set_callback(callback) + + success = decoder.start( + satellite='NOAA-18', + device_index=0, + gain=40.0, + bias_t=True, + ) + + assert success is True + assert decoder.is_running is True + assert decoder.current_satellite == 'NOAA-18' + assert decoder.current_frequency == 137.9125 + assert decoder.current_mode == 'APT' + assert decoder.device_index == 0 + + mock_popen.assert_called_once() + cmd = mock_popen.call_args[0][0] + assert cmd[0] == 'satdump' + assert 'live' in cmd + assert 'noaa_apt' in cmd + assert '--bias' in cmd + + @patch('subprocess.Popen') + @patch('pty.openpty') + def test_start_already_running(self, mock_pty, mock_popen): + """start() should return True when already running.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + decoder._running = True + + success = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0) + + assert success is True + mock_popen.assert_not_called() + + @patch('subprocess.Popen') + @patch('pty.openpty') + def test_start_exception_handling(self, mock_pty, mock_popen): + """start() should handle exceptions gracefully.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + mock_pty.return_value = (10, 11) + mock_popen.side_effect = OSError('Device not found') + + decoder = WeatherSatDecoder() + callback = MagicMock() + decoder.set_callback(callback) + + success = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0) + + assert success is False + assert decoder.is_running is False + callback.assert_called() + progress = callback.call_args[0][0] + assert progress.status == 'error' + + def test_start_from_file_no_decoder(self): + """start_from_file() should fail when no decoder available.""" + with patch('shutil.which', return_value=None): + decoder = WeatherSatDecoder() + callback = MagicMock() + decoder.set_callback(callback) + + success = decoder.start_from_file( + satellite='NOAA-18', + input_file='data/test.wav', + ) + + assert success is False + callback.assert_called() + + @patch('subprocess.Popen') + @patch('pty.openpty') + @patch('pathlib.Path.is_file', return_value=True) + @patch('pathlib.Path.resolve') + def test_start_from_file_success(self, mock_resolve, mock_is_file, mock_pty, mock_popen): + """start_from_file() should successfully decode from file.""" + with patch('shutil.which', return_value='/usr/bin/satdump'), \ + patch('utils.weather_sat.register_process'): + + # Mock path resolution + mock_path = MagicMock() + mock_path.is_relative_to.return_value = True + mock_path.suffix = '.wav' + mock_resolve.return_value = mock_path + + mock_pty.return_value = (10, 11) + mock_process = MagicMock() + mock_popen.return_value = mock_process + + decoder = WeatherSatDecoder() + callback = MagicMock() + decoder.set_callback(callback) + + success = decoder.start_from_file( + satellite='NOAA-18', + input_file='data/test.wav', + sample_rate=1000000, + ) + + assert success is True + assert decoder.is_running is True + assert decoder.current_satellite == 'NOAA-18' + + mock_popen.assert_called_once() + cmd = mock_popen.call_args[0][0] + assert cmd[0] == 'satdump' + assert 'noaa_apt' in cmd + assert 'audio_wav' in cmd + assert '--samplerate' in cmd + + @patch('pathlib.Path.resolve') + def test_start_from_file_path_traversal(self, mock_resolve): + """start_from_file() should block path traversal.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + # Mock path outside allowed directory + mock_path = MagicMock() + mock_path.is_relative_to.return_value = False + mock_resolve.return_value = mock_path + + decoder = WeatherSatDecoder() + callback = MagicMock() + decoder.set_callback(callback) + + success = decoder.start_from_file( + satellite='NOAA-18', + input_file='/etc/passwd', + ) + + assert success is False + callback.assert_called() + progress = callback.call_args[0][0] + assert 'data/ directory' in progress.message + + @patch('pathlib.Path.is_file', return_value=False) + @patch('pathlib.Path.resolve') + def test_start_from_file_not_found(self, mock_resolve, mock_is_file): + """start_from_file() should fail when file not found.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + mock_path = MagicMock() + mock_path.is_relative_to.return_value = True + mock_resolve.return_value = mock_path + + decoder = WeatherSatDecoder() + callback = MagicMock() + decoder.set_callback(callback) + + success = decoder.start_from_file( + satellite='NOAA-18', + input_file='data/missing.wav', + ) + + assert success is False + callback.assert_called() + progress = callback.call_args[0][0] + assert 'not found' in progress.message.lower() + + def test_stop_not_running(self): + """stop() should be safe when not running.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + decoder.stop() # Should not raise + + @patch('utils.weather_sat.safe_terminate') + def test_stop_running(self, mock_terminate): + """stop() should terminate process.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + mock_process = MagicMock() + decoder._process = mock_process + decoder._running = True + decoder._pty_master_fd = 10 + + with patch('os.close') as mock_close: + decoder.stop() + + assert decoder._running is False + mock_terminate.assert_called_once_with(mock_process) + mock_close.assert_called_once_with(10) + + def test_get_images_empty(self): + """get_images() should return empty list initially.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + images = decoder.get_images() + assert images == [] + + @patch('pathlib.Path.glob') + @patch('pathlib.Path.stat') + def test_get_images_scans_directory(self, mock_stat, mock_glob): + """get_images() should scan output directory.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + + # Mock image files + mock_file = MagicMock() + mock_file.name = 'NOAA-18_test.png' + mock_file.stat.return_value.st_size = 10000 + mock_file.stat.return_value.st_mtime = time.time() + mock_glob.return_value = [mock_file] + + images = decoder.get_images() + + assert len(images) == 1 + assert images[0].filename == 'NOAA-18_test.png' + assert images[0].satellite == 'NOAA-18' + + def test_delete_image_success(self): + """delete_image() should delete file.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + + with patch('pathlib.Path.exists', return_value=True), \ + patch('pathlib.Path.unlink') as mock_unlink: + + result = decoder.delete_image('test.png') + + assert result is True + mock_unlink.assert_called_once() + + def test_delete_image_not_found(self): + """delete_image() should return False for non-existent file.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + + with patch('pathlib.Path.exists', return_value=False): + result = decoder.delete_image('missing.png') + + assert result is False + + def test_delete_all_images(self): + """delete_all_images() should delete all images.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + + mock_files = [MagicMock() for _ in range(3)] + with patch('pathlib.Path.glob', return_value=mock_files): + count = decoder.delete_all_images() + + assert count == 3 + for f in mock_files: + f.unlink.assert_called_once() + + def test_get_status_idle(self): + """get_status() should return idle status.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + status = decoder.get_status() + + assert status['available'] is True + assert status['decoder'] == 'satdump' + assert status['running'] is False + assert status['satellite'] == '' + + def test_get_status_running(self): + """get_status() should return running status.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + decoder._running = True + decoder._current_satellite = 'NOAA-18' + decoder._current_frequency = 137.9125 + decoder._current_mode = 'APT' + decoder._capture_start_time = time.time() - 60 + + status = decoder.get_status() + + assert status['running'] is True + assert status['satellite'] == 'NOAA-18' + assert status['frequency'] == 137.9125 + assert status['mode'] == 'APT' + assert status['elapsed_seconds'] >= 60 + + def test_classify_log_type_error(self): + """_classify_log_type() should detect errors.""" + assert WeatherSatDecoder._classify_log_type('(E) Error occurred') == 'error' + assert WeatherSatDecoder._classify_log_type('Failed to open device') == 'error' + + def test_classify_log_type_progress(self): + """_classify_log_type() should detect progress.""" + assert WeatherSatDecoder._classify_log_type('Progress: 50%') == 'progress' + + def test_classify_log_type_save(self): + """_classify_log_type() should detect save events.""" + assert WeatherSatDecoder._classify_log_type('Saved image: test.png') == 'save' + assert WeatherSatDecoder._classify_log_type('Writing output file') == 'save' + + def test_classify_log_type_signal(self): + """_classify_log_type() should detect signal events.""" + assert WeatherSatDecoder._classify_log_type('Signal detected') == 'signal' + assert WeatherSatDecoder._classify_log_type('Lock acquired') == 'signal' + + def test_classify_log_type_warning(self): + """_classify_log_type() should detect warnings.""" + assert WeatherSatDecoder._classify_log_type('(W) Low signal quality') == 'warning' + + def test_classify_log_type_debug(self): + """_classify_log_type() should detect debug messages.""" + assert WeatherSatDecoder._classify_log_type('(D) Debug info') == 'debug' + + @patch('subprocess.run') + def test_resolve_device_id_success(self, mock_run): + """_resolve_device_id() should extract serial from rtl_test.""" + mock_result = MagicMock() + mock_result.stdout = 'Found 1 device(s):\n 0: RTLSDRBlog, SN: 00004000' + mock_result.stderr = '' + mock_run.return_value = mock_result + + serial = WeatherSatDecoder._resolve_device_id(0) + + assert serial == '00004000' + mock_run.assert_called_once() + + @patch('subprocess.run') + def test_resolve_device_id_fallback(self, mock_run): + """_resolve_device_id() should fall back to index string.""" + mock_run.side_effect = FileNotFoundError + + serial = WeatherSatDecoder._resolve_device_id(0) + + assert serial == '0' + + def test_parse_product_name_rgb(self): + """_parse_product_name() should identify RGB composite.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + product = decoder._parse_product_name(Path('/tmp/output/rgb_composite.png')) + assert product == 'RGB Composite' + + def test_parse_product_name_thermal(self): + """_parse_product_name() should identify thermal imagery.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + product = decoder._parse_product_name(Path('/tmp/output/thermal_image.png')) + assert product == 'Thermal' + + def test_parse_product_name_channel(self): + """_parse_product_name() should identify channel images.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + product = decoder._parse_product_name(Path('/tmp/output/channel_3.png')) + assert product == 'Channel 3' + + def test_parse_product_name_unknown(self): + """_parse_product_name() should return stem for unknown products.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + product = decoder._parse_product_name(Path('/tmp/output/unknown_image.png')) + assert product == 'unknown_image' + + def test_emit_progress(self): + """_emit_progress() should call callback.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + callback = MagicMock() + decoder.set_callback(callback) + + progress = CaptureProgress(status='capturing', message='Test') + decoder._emit_progress(progress) + + callback.assert_called_once_with(progress) + + def test_emit_progress_no_callback(self): + """_emit_progress() should handle missing callback.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + progress = CaptureProgress(status='capturing', message='Test') + decoder._emit_progress(progress) # Should not raise + + def test_emit_progress_callback_exception(self): + """_emit_progress() should handle callback exceptions.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + decoder = WeatherSatDecoder() + callback = MagicMock(side_effect=Exception('Callback error')) + decoder.set_callback(callback) + + progress = CaptureProgress(status='capturing', message='Test') + decoder._emit_progress(progress) # Should not raise + + +class TestWeatherSatImage: + """Tests for WeatherSatImage dataclass.""" + + def test_to_dict(self): + """WeatherSatImage.to_dict() should serialize correctly.""" + image = WeatherSatImage( + filename='test.png', + path=Path('/tmp/test.png'), + satellite='NOAA-18', + mode='APT', + timestamp=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + frequency=137.9125, + size_bytes=12345, + product='RGB Composite', + ) + + data = image.to_dict() + + assert data['filename'] == 'test.png' + assert data['satellite'] == 'NOAA-18' + assert data['mode'] == 'APT' + assert data['timestamp'] == '2024-01-01T12:00:00+00:00' + assert data['frequency'] == 137.9125 + assert data['size_bytes'] == 12345 + assert data['product'] == 'RGB Composite' + assert data['url'] == '/weather-sat/images/test.png' + + +class TestCaptureProgress: + """Tests for CaptureProgress dataclass.""" + + def test_to_dict_minimal(self): + """CaptureProgress.to_dict() with minimal fields.""" + progress = CaptureProgress(status='idle') + data = progress.to_dict() + + assert data['type'] == 'weather_sat_progress' + assert data['status'] == 'idle' + assert data['satellite'] == '' + assert data['message'] == '' + assert data['progress'] == 0 + + def test_to_dict_complete(self): + """CaptureProgress.to_dict() with all fields.""" + image = WeatherSatImage( + filename='test.png', + path=Path('/tmp/test.png'), + satellite='NOAA-18', + mode='APT', + timestamp=datetime.now(timezone.utc), + frequency=137.9125, + ) + + progress = CaptureProgress( + status='complete', + satellite='NOAA-18', + frequency=137.9125, + mode='APT', + message='Capture complete', + progress_percent=100, + elapsed_seconds=600, + image=image, + log_type='info', + capture_phase='complete', + ) + + data = progress.to_dict() + + assert data['status'] == 'complete' + assert data['satellite'] == 'NOAA-18' + assert data['frequency'] == 137.9125 + assert data['mode'] == 'APT' + assert data['message'] == 'Capture complete' + assert data['progress'] == 100 + assert data['elapsed_seconds'] == 600 + assert 'image' in data + assert data['log_type'] == 'info' + assert data['capture_phase'] == 'complete' + + +class TestGlobalFunctions: + """Tests for global utility functions.""" + + def test_get_weather_sat_decoder_singleton(self): + """get_weather_sat_decoder() should return singleton.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + import utils.weather_sat as mod + old = mod._decoder + mod._decoder = None + + try: + decoder1 = get_weather_sat_decoder() + decoder2 = get_weather_sat_decoder() + + assert decoder1 is decoder2 + finally: + mod._decoder = old + + def test_is_weather_sat_available_true(self): + """is_weather_sat_available() should return True when available.""" + with patch('shutil.which', return_value='/usr/bin/satdump'): + import utils.weather_sat as mod + old = mod._decoder + mod._decoder = None + + try: + assert is_weather_sat_available() is True + finally: + mod._decoder = old + + def test_is_weather_sat_available_false(self): + """is_weather_sat_available() should return False when unavailable.""" + with patch('shutil.which', return_value=None): + import utils.weather_sat as mod + old = mod._decoder + mod._decoder = None + + try: + assert is_weather_sat_available() is False + finally: + mod._decoder = old + + +class TestWeatherSatellitesConstant: + """Tests for WEATHER_SATELLITES constant.""" + + def test_weather_satellites_structure(self): + """WEATHER_SATELLITES should have correct structure.""" + assert 'NOAA-18' in WEATHER_SATELLITES + sat = WEATHER_SATELLITES['NOAA-18'] + + assert 'name' in sat + assert 'frequency' in sat + assert 'mode' in sat + assert 'pipeline' in sat + assert 'tle_key' in sat + assert 'description' in sat + assert 'active' in sat + + def test_noaa_satellites(self): + """NOAA satellites should have correct frequencies.""" + assert WEATHER_SATELLITES['NOAA-15']['frequency'] == 137.620 + assert WEATHER_SATELLITES['NOAA-18']['frequency'] == 137.9125 + assert WEATHER_SATELLITES['NOAA-19']['frequency'] == 137.100 + + def test_meteor_satellite(self): + """Meteor satellite should use LRPT mode.""" + meteor = WEATHER_SATELLITES['METEOR-M2-3'] + assert meteor['mode'] == 'LRPT' + assert meteor['frequency'] == 137.900 + assert meteor['pipeline'] == 'meteor_m2-x_lrpt' diff --git a/tests/test_weather_sat_predict.py b/tests/test_weather_sat_predict.py new file mode 100644 index 0000000..97c2e29 --- /dev/null +++ b/tests/test_weather_sat_predict.py @@ -0,0 +1,675 @@ +"""Tests for weather satellite pass prediction. + +Covers predict_passes() function, TLE handling, trajectory computation, +and ground track generation. +""" + +from __future__ import annotations + +from datetime import datetime, timezone, timedelta +from unittest.mock import patch, MagicMock +import pytest + +from utils.weather_sat_predict import predict_passes + + +class TestPredictPasses: + """Tests for predict_passes() function.""" + + @patch('utils.weather_sat_predict.load') + @patch('utils.weather_sat_predict.TLE_SATELLITES') + def test_predict_passes_no_tle_data(self, mock_tle, mock_load): + """predict_passes() should handle missing TLE data.""" + mock_tle.get.return_value = None + mock_ts = MagicMock() + mock_ts.now.return_value = MagicMock() + mock_ts.utc.return_value = MagicMock() + mock_load.timescale.return_value = mock_ts + + passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15) + + assert passes == [] + + @patch('utils.weather_sat_predict.load') + @patch('utils.weather_sat_predict.TLE_SATELLITES') + @patch('utils.weather_sat_predict.wgs84') + @patch('utils.weather_sat_predict.EarthSatellite') + @patch('utils.weather_sat_predict.find_discrete') + def test_predict_passes_basic(self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load): + """predict_passes() should predict basic passes.""" + # Mock timescale + mock_ts = MagicMock() + now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_now = MagicMock() + mock_now.utc_datetime.return_value = now + mock_ts.now.return_value = mock_now + mock_ts.utc.side_effect = lambda dt: self._mock_time(dt) + mock_load.timescale.return_value = mock_ts + + # Mock TLE data + mock_tle.get.return_value = ( + 'NOAA-18', + '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999', + '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000' + ) + + # Mock observer + mock_observer = MagicMock() + mock_wgs84.latlon.return_value = mock_observer + + # Mock satellite + mock_satellite_obj = MagicMock() + mock_sat.return_value = mock_satellite_obj + + # Mock pass detection - one pass + rise_time = MagicMock() + rise_time.utc_datetime.return_value = now + timedelta(hours=2) + set_time = MagicMock() + set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15) + + mock_find.return_value = ([rise_time, set_time], [True, False]) + + # Mock topocentric calculations + def mock_topocentric(t): + topo = MagicMock() + alt = MagicMock() + alt.degrees = 45.0 + az = MagicMock() + az.degrees = 180.0 + topo.altaz.return_value = (alt, az, MagicMock()) + return topo + + mock_diff = MagicMock() + mock_diff.at.side_effect = mock_topocentric + mock_satellite_obj.__sub__.return_value = mock_diff + + passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15) + + assert len(passes) == 1 + pass_data = passes[0] + assert pass_data['satellite'] == 'NOAA-18' + assert pass_data['name'] == 'NOAA 18' + assert pass_data['frequency'] == 137.9125 + assert pass_data['mode'] == 'APT' + assert 'maxEl' in pass_data + assert 'duration' in pass_data + assert 'quality' in pass_data + + @patch('utils.weather_sat_predict.load') + @patch('utils.weather_sat_predict.TLE_SATELLITES') + @patch('utils.weather_sat_predict.wgs84') + @patch('utils.weather_sat_predict.EarthSatellite') + @patch('utils.weather_sat_predict.find_discrete') + def test_predict_passes_below_min_elevation( + self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load + ): + """predict_passes() should filter passes below min elevation.""" + mock_ts = MagicMock() + now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_now = MagicMock() + mock_now.utc_datetime.return_value = now + mock_ts.now.return_value = mock_now + mock_ts.utc.side_effect = lambda dt: self._mock_time(dt) + mock_load.timescale.return_value = mock_ts + + mock_tle.get.return_value = ( + 'NOAA-18', + '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999', + '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000' + ) + + mock_observer = MagicMock() + mock_wgs84.latlon.return_value = mock_observer + + mock_satellite_obj = MagicMock() + mock_sat.return_value = mock_satellite_obj + + rise_time = MagicMock() + rise_time.utc_datetime.return_value = now + timedelta(hours=2) + set_time = MagicMock() + set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15) + + mock_find.return_value = ([rise_time, set_time], [True, False]) + + # Mock low elevation pass + def mock_topocentric(t): + topo = MagicMock() + alt = MagicMock() + alt.degrees = 10.0 # Below min_elevation of 15 + az = MagicMock() + az.degrees = 180.0 + topo.altaz.return_value = (alt, az, MagicMock()) + return topo + + mock_diff = MagicMock() + mock_diff.at.side_effect = mock_topocentric + mock_satellite_obj.__sub__.return_value = mock_diff + + passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15) + + assert len(passes) == 0 + + @patch('utils.weather_sat_predict.load') + @patch('utils.weather_sat_predict.TLE_SATELLITES') + @patch('utils.weather_sat_predict.wgs84') + @patch('utils.weather_sat_predict.EarthSatellite') + @patch('utils.weather_sat_predict.find_discrete') + def test_predict_passes_with_trajectory( + self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load + ): + """predict_passes() should include trajectory when requested.""" + mock_ts = MagicMock() + now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_now = MagicMock() + mock_now.utc_datetime.return_value = now + mock_ts.now.return_value = mock_now + mock_ts.utc.side_effect = lambda dt: self._mock_time(dt) + mock_load.timescale.return_value = mock_ts + + mock_tle.get.return_value = ( + 'NOAA-18', + '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999', + '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000' + ) + + mock_observer = MagicMock() + mock_wgs84.latlon.return_value = mock_observer + + mock_satellite_obj = MagicMock() + mock_sat.return_value = mock_satellite_obj + + rise_time = MagicMock() + rise_time.utc_datetime.return_value = now + timedelta(hours=2) + set_time = MagicMock() + set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15) + + mock_find.return_value = ([rise_time, set_time], [True, False]) + + def mock_topocentric(t): + topo = MagicMock() + alt = MagicMock() + alt.degrees = 45.0 + az = MagicMock() + az.degrees = 180.0 + topo.altaz.return_value = (alt, az, MagicMock()) + return topo + + mock_diff = MagicMock() + mock_diff.at.side_effect = mock_topocentric + mock_satellite_obj.__sub__.return_value = mock_diff + + passes = predict_passes( + lat=51.5, lon=-0.1, hours=24, min_elevation=15, include_trajectory=True + ) + + assert len(passes) == 1 + assert 'trajectory' in passes[0] + assert len(passes[0]['trajectory']) == 30 + + @patch('utils.weather_sat_predict.load') + @patch('utils.weather_sat_predict.TLE_SATELLITES') + @patch('utils.weather_sat_predict.wgs84') + @patch('utils.weather_sat_predict.EarthSatellite') + @patch('utils.weather_sat_predict.find_discrete') + def test_predict_passes_with_ground_track( + self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load + ): + """predict_passes() should include ground track when requested.""" + mock_ts = MagicMock() + now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_now = MagicMock() + mock_now.utc_datetime.return_value = now + mock_ts.now.return_value = mock_now + mock_ts.utc.side_effect = lambda dt: self._mock_time(dt) + mock_load.timescale.return_value = mock_ts + + mock_tle.get.return_value = ( + 'NOAA-18', + '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999', + '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000' + ) + + mock_observer = MagicMock() + mock_wgs84.latlon.return_value = mock_observer + + mock_satellite_obj = MagicMock() + mock_sat.return_value = mock_satellite_obj + + rise_time = MagicMock() + rise_time.utc_datetime.return_value = now + timedelta(hours=2) + set_time = MagicMock() + set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15) + + mock_find.return_value = ([rise_time, set_time], [True, False]) + + def mock_topocentric(t): + topo = MagicMock() + alt = MagicMock() + alt.degrees = 45.0 + az = MagicMock() + az.degrees = 180.0 + topo.altaz.return_value = (alt, az, MagicMock()) + return topo + + mock_diff = MagicMock() + mock_diff.at.side_effect = mock_topocentric + mock_satellite_obj.__sub__.return_value = mock_diff + + # Mock geocentric position + def mock_at(t): + geocentric = MagicMock() + return geocentric + + mock_satellite_obj.at.side_effect = mock_at + + # Mock subpoint + mock_subpoint = MagicMock() + mock_lat = MagicMock() + mock_lat.degrees = 51.5 + mock_lon = MagicMock() + mock_lon.degrees = -0.1 + mock_subpoint.latitude = mock_lat + mock_subpoint.longitude = mock_lon + mock_wgs84.subpoint.return_value = mock_subpoint + + passes = predict_passes( + lat=51.5, lon=-0.1, hours=24, min_elevation=15, include_ground_track=True + ) + + assert len(passes) == 1 + assert 'groundTrack' in passes[0] + assert len(passes[0]['groundTrack']) == 60 + + @patch('utils.weather_sat_predict.load') + @patch('utils.weather_sat_predict.TLE_SATELLITES') + @patch('utils.weather_sat_predict.wgs84') + @patch('utils.weather_sat_predict.EarthSatellite') + @patch('utils.weather_sat_predict.find_discrete') + def test_predict_passes_quality_excellent( + self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load + ): + """predict_passes() should mark high elevation passes as excellent.""" + mock_ts = MagicMock() + now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_now = MagicMock() + mock_now.utc_datetime.return_value = now + mock_ts.now.return_value = mock_now + mock_ts.utc.side_effect = lambda dt: self._mock_time(dt) + mock_load.timescale.return_value = mock_ts + + mock_tle.get.return_value = ( + 'NOAA-18', + '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999', + '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000' + ) + + mock_observer = MagicMock() + mock_wgs84.latlon.return_value = mock_observer + + mock_satellite_obj = MagicMock() + mock_sat.return_value = mock_satellite_obj + + rise_time = MagicMock() + rise_time.utc_datetime.return_value = now + timedelta(hours=2) + set_time = MagicMock() + set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15) + + mock_find.return_value = ([rise_time, set_time], [True, False]) + + def mock_topocentric(t): + topo = MagicMock() + alt = MagicMock() + alt.degrees = 75.0 # Excellent pass + az = MagicMock() + az.degrees = 180.0 + topo.altaz.return_value = (alt, az, MagicMock()) + return topo + + mock_diff = MagicMock() + mock_diff.at.side_effect = mock_topocentric + mock_satellite_obj.__sub__.return_value = mock_diff + + passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15) + + assert len(passes) == 1 + assert passes[0]['quality'] == 'excellent' + assert passes[0]['maxEl'] >= 60 + + @patch('utils.weather_sat_predict.load') + @patch('utils.weather_sat_predict.TLE_SATELLITES') + @patch('utils.weather_sat_predict.wgs84') + @patch('utils.weather_sat_predict.EarthSatellite') + @patch('utils.weather_sat_predict.find_discrete') + def test_predict_passes_quality_good( + self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load + ): + """predict_passes() should mark medium elevation passes as good.""" + mock_ts = MagicMock() + now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_now = MagicMock() + mock_now.utc_datetime.return_value = now + mock_ts.now.return_value = mock_now + mock_ts.utc.side_effect = lambda dt: self._mock_time(dt) + mock_load.timescale.return_value = mock_ts + + mock_tle.get.return_value = ( + 'NOAA-18', + '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999', + '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000' + ) + + mock_observer = MagicMock() + mock_wgs84.latlon.return_value = mock_observer + + mock_satellite_obj = MagicMock() + mock_sat.return_value = mock_satellite_obj + + rise_time = MagicMock() + rise_time.utc_datetime.return_value = now + timedelta(hours=2) + set_time = MagicMock() + set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15) + + mock_find.return_value = ([rise_time, set_time], [True, False]) + + def mock_topocentric(t): + topo = MagicMock() + alt = MagicMock() + alt.degrees = 45.0 # Good pass + az = MagicMock() + az.degrees = 180.0 + topo.altaz.return_value = (alt, az, MagicMock()) + return topo + + mock_diff = MagicMock() + mock_diff.at.side_effect = mock_topocentric + mock_satellite_obj.__sub__.return_value = mock_diff + + passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15) + + assert len(passes) == 1 + assert passes[0]['quality'] == 'good' + assert 30 <= passes[0]['maxEl'] < 60 + + @patch('utils.weather_sat_predict.load') + @patch('utils.weather_sat_predict.TLE_SATELLITES') + @patch('utils.weather_sat_predict.wgs84') + @patch('utils.weather_sat_predict.EarthSatellite') + @patch('utils.weather_sat_predict.find_discrete') + def test_predict_passes_quality_fair( + self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load + ): + """predict_passes() should mark low elevation passes as fair.""" + mock_ts = MagicMock() + now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_now = MagicMock() + mock_now.utc_datetime.return_value = now + mock_ts.now.return_value = mock_now + mock_ts.utc.side_effect = lambda dt: self._mock_time(dt) + mock_load.timescale.return_value = mock_ts + + mock_tle.get.return_value = ( + 'NOAA-18', + '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999', + '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000' + ) + + mock_observer = MagicMock() + mock_wgs84.latlon.return_value = mock_observer + + mock_satellite_obj = MagicMock() + mock_sat.return_value = mock_satellite_obj + + rise_time = MagicMock() + rise_time.utc_datetime.return_value = now + timedelta(hours=2) + set_time = MagicMock() + set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15) + + mock_find.return_value = ([rise_time, set_time], [True, False]) + + def mock_topocentric(t): + topo = MagicMock() + alt = MagicMock() + alt.degrees = 20.0 # Fair pass + az = MagicMock() + az.degrees = 180.0 + topo.altaz.return_value = (alt, az, MagicMock()) + return topo + + mock_diff = MagicMock() + mock_diff.at.side_effect = mock_topocentric + mock_satellite_obj.__sub__.return_value = mock_diff + + passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15) + + assert len(passes) == 1 + assert passes[0]['quality'] == 'fair' + assert passes[0]['maxEl'] < 30 + + @patch('utils.weather_sat_predict.load') + @patch('utils.weather_sat_predict.TLE_SATELLITES') + @patch('utils.weather_sat_predict.wgs84') + @patch('utils.weather_sat_predict.EarthSatellite') + @patch('utils.weather_sat_predict.find_discrete') + def test_predict_passes_inactive_satellite( + self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load + ): + """predict_passes() should skip inactive satellites.""" + mock_ts = MagicMock() + now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_now = MagicMock() + mock_now.utc_datetime.return_value = now + mock_ts.now.return_value = mock_now + mock_load.timescale.return_value = mock_ts + + # Temporarily mark satellite as inactive + from utils.weather_sat import WEATHER_SATELLITES + original_active = WEATHER_SATELLITES['NOAA-18']['active'] + WEATHER_SATELLITES['NOAA-18']['active'] = False + + try: + passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15) + # Should not include NOAA-18 + noaa_18_passes = [p for p in passes if p['satellite'] == 'NOAA-18'] + assert len(noaa_18_passes) == 0 + finally: + WEATHER_SATELLITES['NOAA-18']['active'] = original_active + + @patch('utils.weather_sat_predict.load') + @patch('utils.weather_sat_predict.TLE_SATELLITES') + @patch('utils.weather_sat_predict.wgs84') + @patch('utils.weather_sat_predict.EarthSatellite') + @patch('utils.weather_sat_predict.find_discrete') + def test_predict_passes_exception_handling( + self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load + ): + """predict_passes() should handle exceptions gracefully.""" + mock_ts = MagicMock() + now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_now = MagicMock() + mock_now.utc_datetime.return_value = now + mock_ts.now.return_value = mock_now + mock_ts.utc.side_effect = lambda dt: self._mock_time(dt) + mock_load.timescale.return_value = mock_ts + + mock_tle.get.return_value = ( + 'NOAA-18', + '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999', + '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000' + ) + + mock_observer = MagicMock() + mock_wgs84.latlon.return_value = mock_observer + + mock_satellite_obj = MagicMock() + mock_sat.return_value = mock_satellite_obj + + # Make find_discrete raise exception + mock_find.side_effect = Exception('Computation error') + + # Should not raise, just skip this satellite + passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15) + # May include passes from other satellites or be empty + assert isinstance(passes, list) + + @patch('utils.weather_sat_predict.load') + @patch('utils.weather_sat_predict.TLE_SATELLITES') + def test_predict_passes_uses_tle_cache(self, mock_tle, mock_load): + """predict_passes() should use live TLE cache if available.""" + with patch('utils.weather_sat_predict._tle_cache', {'NOAA-18': ('NOAA-18', 'line1', 'line2')}): + mock_ts = MagicMock() + mock_ts.now.return_value = MagicMock() + mock_ts.utc.return_value = MagicMock() + mock_load.timescale.return_value = mock_ts + + # Even though TLE_SATELLITES is mocked, should use _tle_cache + with patch('utils.weather_sat_predict.wgs84'), \ + patch('utils.weather_sat_predict.EarthSatellite'), \ + patch('utils.weather_sat_predict.find_discrete', return_value=([], [])): + + passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15) + # Should not raise + + @patch('utils.weather_sat_predict.load') + @patch('utils.weather_sat_predict.TLE_SATELLITES') + @patch('utils.weather_sat_predict.wgs84') + @patch('utils.weather_sat_predict.EarthSatellite') + @patch('utils.weather_sat_predict.find_discrete') + def test_predict_passes_sorted_by_time( + self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load + ): + """predict_passes() should return passes sorted by start time.""" + mock_ts = MagicMock() + now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_now = MagicMock() + mock_now.utc_datetime.return_value = now + mock_ts.now.return_value = mock_now + mock_ts.utc.side_effect = lambda dt: self._mock_time(dt) + mock_load.timescale.return_value = mock_ts + + mock_tle.get.return_value = ( + 'NOAA-18', + '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999', + '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000' + ) + + mock_observer = MagicMock() + mock_wgs84.latlon.return_value = mock_observer + + mock_satellite_obj = MagicMock() + mock_sat.return_value = mock_satellite_obj + + # Two passes + rise1 = MagicMock() + rise1.utc_datetime.return_value = now + timedelta(hours=4) + set1 = MagicMock() + set1.utc_datetime.return_value = now + timedelta(hours=4, minutes=15) + rise2 = MagicMock() + rise2.utc_datetime.return_value = now + timedelta(hours=2) + set2 = MagicMock() + set2.utc_datetime.return_value = now + timedelta(hours=2, minutes=15) + + # Return in non-chronological order + mock_find.return_value = ([rise1, set1, rise2, set2], [True, False, True, False]) + + def mock_topocentric(t): + topo = MagicMock() + alt = MagicMock() + alt.degrees = 45.0 + az = MagicMock() + az.degrees = 180.0 + topo.altaz.return_value = (alt, az, MagicMock()) + return topo + + mock_diff = MagicMock() + mock_diff.at.side_effect = mock_topocentric + mock_satellite_obj.__sub__.return_value = mock_diff + + passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15) + + # Should be sorted with earliest pass first + if len(passes) >= 2: + assert passes[0]['startTimeISO'] < passes[1]['startTimeISO'] + + @staticmethod + def _mock_time(dt): + """Helper to create mock time object.""" + mock_t = MagicMock() + if isinstance(dt, datetime): + mock_t.utc_datetime.return_value = dt + else: + mock_t.utc_datetime.return_value = datetime.now(timezone.utc) + return mock_t + + +class TestPassDataStructure: + """Tests for pass data structure.""" + + @patch('utils.weather_sat_predict.load') + @patch('utils.weather_sat_predict.TLE_SATELLITES') + @patch('utils.weather_sat_predict.wgs84') + @patch('utils.weather_sat_predict.EarthSatellite') + @patch('utils.weather_sat_predict.find_discrete') + def test_pass_data_fields( + self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load + ): + """Pass data should contain all required fields.""" + mock_ts = MagicMock() + now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_now = MagicMock() + mock_now.utc_datetime.return_value = now + mock_ts.now.return_value = mock_now + mock_ts.utc.side_effect = lambda dt: TestPredictPasses._mock_time(dt) + mock_load.timescale.return_value = mock_ts + + mock_tle.get.return_value = ( + 'NOAA-18', + '1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999', + '2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000' + ) + + mock_observer = MagicMock() + mock_wgs84.latlon.return_value = mock_observer + + mock_satellite_obj = MagicMock() + mock_sat.return_value = mock_satellite_obj + + rise_time = MagicMock() + rise_time.utc_datetime.return_value = now + timedelta(hours=2) + set_time = MagicMock() + set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15) + + mock_find.return_value = ([rise_time, set_time], [True, False]) + + def mock_topocentric(t): + topo = MagicMock() + alt = MagicMock() + alt.degrees = 45.0 + az = MagicMock() + az.degrees = 180.0 + topo.altaz.return_value = (alt, az, MagicMock()) + return topo + + mock_diff = MagicMock() + mock_diff.at.side_effect = mock_topocentric + mock_satellite_obj.__sub__.return_value = mock_diff + + passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15) + + assert len(passes) == 1 + pass_data = passes[0] + + # Check all required fields + required_fields = [ + 'id', 'satellite', 'name', 'frequency', 'mode', + 'startTime', 'startTimeISO', 'endTimeISO', + 'maxEl', 'maxElAz', 'riseAz', 'setAz', + 'duration', 'quality' + ] + for field in required_fields: + assert field in pass_data, f"Missing required field: {field}" + + def test_import_error_propagates(self): + """predict_passes() should raise ImportError if skyfield unavailable.""" + with patch.dict('sys.modules', {'skyfield': None, 'skyfield.api': None}): + with pytest.raises((ImportError, AttributeError)): + predict_passes(lat=51.5, lon=-0.1) diff --git a/tests/test_weather_sat_routes.py b/tests/test_weather_sat_routes.py new file mode 100644 index 0000000..7f13aca --- /dev/null +++ b/tests/test_weather_sat_routes.py @@ -0,0 +1,801 @@ +"""Tests for weather satellite routes. + +Covers all weather_sat endpoints: /status, /satellites, /start, /test-decode, +/stop, /images, /passes, and scheduler endpoints. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch, MagicMock, mock_open +import pytest + +from utils.weather_sat import WeatherSatImage, WEATHER_SATELLITES +from datetime import datetime, timezone + + +class TestWeatherSatRoutes: + """Tests for weather satellite routes.""" + + def test_get_status(self, client): + """GET /weather-sat/status returns decoder status.""" + with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + mock_decoder = MagicMock() + mock_decoder.get_status.return_value = { + 'available': True, + 'decoder': 'satdump', + 'running': False, + 'satellite': '', + 'frequency': 0.0, + 'mode': '', + 'elapsed_seconds': 0, + 'image_count': 0, + } + mock_get.return_value = mock_decoder + + response = client.get('/weather-sat/status') + assert response.status_code == 200 + data = response.get_json() + assert data['available'] is True + assert data['decoder'] == 'satdump' + assert data['running'] is False + + def test_list_satellites(self, client): + """GET /weather-sat/satellites returns satellite list.""" + response = client.get('/weather-sat/satellites') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'ok' + assert 'satellites' in data + assert len(data['satellites']) > 0 + + # Check structure + sat = data['satellites'][0] + assert 'key' in sat + assert 'name' in sat + assert 'frequency' in sat + assert 'mode' in sat + assert 'description' in sat + assert 'active' in sat + + # Verify NOAA-18 is in list + noaa_18 = next((s for s in data['satellites'] if s['key'] == 'NOAA-18'), None) + assert noaa_18 is not None + assert noaa_18['frequency'] == 137.9125 + assert noaa_18['mode'] == 'APT' + + def test_start_capture_success(self, client): + """POST /weather-sat/start successfully starts capture.""" + with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ + patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \ + patch('routes.weather_sat.queue.Queue') as mock_queue: + + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_decoder.start.return_value = True + mock_get.return_value = mock_decoder + + payload = { + 'satellite': 'NOAA-18', + 'device': 0, + 'gain': 40.0, + 'bias_t': False, + } + + response = client.post( + '/weather-sat/start', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'started' + assert data['satellite'] == 'NOAA-18' + assert data['frequency'] == 137.9125 + assert data['mode'] == 'APT' + assert data['device'] == 0 + + mock_decoder.start.assert_called_once_with( + satellite='NOAA-18', + device_index=0, + gain=40.0, + bias_t=False, + ) + + def test_start_capture_no_satdump(self, client): + """POST /weather-sat/start returns error when SatDump unavailable.""" + with patch('routes.weather_sat.is_weather_sat_available', return_value=False): + payload = {'satellite': 'NOAA-18'} + response = client.post( + '/weather-sat/start', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + assert 'SatDump not installed' in data['message'] + + def test_start_capture_already_running(self, client): + """POST /weather-sat/start when already running.""" + with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ + patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + + mock_decoder = MagicMock() + mock_decoder.is_running = True + mock_decoder.current_satellite = 'NOAA-19' + mock_decoder.current_frequency = 137.100 + mock_get.return_value = mock_decoder + + payload = {'satellite': 'NOAA-18'} + response = client.post( + '/weather-sat/start', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'already_running' + assert data['satellite'] == 'NOAA-19' + + def test_start_capture_invalid_satellite(self, client): + """POST /weather-sat/start with invalid satellite.""" + with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ + patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_get.return_value = mock_decoder + + payload = {'satellite': 'FAKE-SAT-99'} + response = client.post( + '/weather-sat/start', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + assert 'Invalid satellite' in data['message'] + + def test_start_capture_invalid_device(self, client): + """POST /weather-sat/start with invalid device index.""" + with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ + patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_get.return_value = mock_decoder + + payload = {'satellite': 'NOAA-18', 'device': -1} + response = client.post( + '/weather-sat/start', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + + def test_start_capture_invalid_gain(self, client): + """POST /weather-sat/start with invalid gain.""" + with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ + patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_get.return_value = mock_decoder + + payload = {'satellite': 'NOAA-18', 'gain': 999} + response = client.post( + '/weather-sat/start', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + + def test_start_capture_device_busy(self, client): + """POST /weather-sat/start when SDR device is busy.""" + with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ + patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \ + patch('app.claim_sdr_device', return_value='Device busy with pager') as mock_claim: + + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_get.return_value = mock_decoder + + payload = {'satellite': 'NOAA-18'} + response = client.post( + '/weather-sat/start', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 409 + data = response.get_json() + assert data['status'] == 'error' + assert data['error_type'] == 'DEVICE_BUSY' + assert 'Device busy' in data['message'] + + def test_start_capture_start_failure(self, client): + """POST /weather-sat/start when decoder.start() fails.""" + with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ + patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_decoder.start.return_value = False + mock_get.return_value = mock_decoder + + payload = {'satellite': 'NOAA-18'} + response = client.post( + '/weather-sat/start', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 500 + data = response.get_json() + assert data['status'] == 'error' + assert 'Failed to start capture' in data['message'] + + def test_test_decode_success(self, client): + """POST /weather-sat/test-decode successfully starts file decode.""" + with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ + patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \ + patch('pathlib.Path.is_file', return_value=True), \ + patch('pathlib.Path.resolve') as mock_resolve: + + # Mock path resolution to be under data/ + mock_path = MagicMock() + mock_path.is_relative_to.return_value = True + mock_resolve.return_value = mock_path + + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_decoder.start_from_file.return_value = True + mock_get.return_value = mock_decoder + + payload = { + 'satellite': 'NOAA-18', + 'input_file': 'data/weather_sat/test.wav', + 'sample_rate': 1000000, + } + + response = client.post( + '/weather-sat/test-decode', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'started' + assert data['satellite'] == 'NOAA-18' + assert data['source'] == 'file' + + def test_test_decode_invalid_path(self, client): + """POST /weather-sat/test-decode with path outside data/.""" + with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ + patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \ + patch('pathlib.Path.resolve') as mock_resolve: + + # Mock path outside allowed directory + mock_path = MagicMock() + mock_path.is_relative_to.return_value = False + mock_resolve.return_value = mock_path + + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_get.return_value = mock_decoder + + payload = { + 'satellite': 'NOAA-18', + 'input_file': '/etc/passwd', + } + + response = client.post( + '/weather-sat/test-decode', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 403 + data = response.get_json() + assert data['status'] == 'error' + assert 'data/ directory' in data['message'] + + def test_test_decode_file_not_found(self, client): + """POST /weather-sat/test-decode with non-existent file.""" + with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ + patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \ + patch('pathlib.Path.is_file', return_value=False), \ + patch('pathlib.Path.resolve') as mock_resolve: + + mock_path = MagicMock() + mock_path.is_relative_to.return_value = True + mock_resolve.return_value = mock_path + + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_get.return_value = mock_decoder + + payload = { + 'satellite': 'NOAA-18', + 'input_file': 'data/missing.wav', + } + + response = client.post( + '/weather-sat/test-decode', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 404 + data = response.get_json() + assert data['status'] == 'error' + assert 'not found' in data['message'].lower() + + def test_test_decode_invalid_sample_rate(self, client): + """POST /weather-sat/test-decode with invalid sample rate.""" + with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ + patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_get.return_value = mock_decoder + + payload = { + 'satellite': 'NOAA-18', + 'input_file': 'data/test.wav', + 'sample_rate': 100, # Too low + } + + response = client.post( + '/weather-sat/test-decode', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + assert 'sample_rate' in data['message'] + + def test_stop_capture(self, client): + """POST /weather-sat/stop stops capture.""" + with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + mock_decoder = MagicMock() + mock_decoder.device_index = 0 + mock_get.return_value = mock_decoder + + response = client.post('/weather-sat/stop') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'stopped' + mock_decoder.stop.assert_called_once() + + def test_list_images_empty(self, client): + """GET /weather-sat/images with no images.""" + with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + mock_decoder = MagicMock() + mock_decoder.get_images.return_value = [] + mock_get.return_value = mock_decoder + + response = client.get('/weather-sat/images') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'ok' + assert data['images'] == [] + assert data['count'] == 0 + + def test_list_images_with_data(self, client): + """GET /weather-sat/images with images.""" + with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + mock_decoder = MagicMock() + image = WeatherSatImage( + filename='NOAA-18_test.png', + path=Path('/tmp/test.png'), + satellite='NOAA-18', + mode='APT', + timestamp=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + frequency=137.9125, + size_bytes=12345, + product='RGB Composite', + ) + mock_decoder.get_images.return_value = [image] + mock_get.return_value = mock_decoder + + response = client.get('/weather-sat/images') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'ok' + assert data['count'] == 1 + assert data['images'][0]['filename'] == 'NOAA-18_test.png' + assert data['images'][0]['satellite'] == 'NOAA-18' + + def test_list_images_with_filter(self, client): + """GET /weather-sat/images with satellite filter.""" + with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + mock_decoder = MagicMock() + image1 = WeatherSatImage( + filename='NOAA-18_test.png', + path=Path('/tmp/test1.png'), + satellite='NOAA-18', + mode='APT', + timestamp=datetime.now(timezone.utc), + frequency=137.9125, + ) + image2 = WeatherSatImage( + filename='NOAA-19_test.png', + path=Path('/tmp/test2.png'), + satellite='NOAA-19', + mode='APT', + timestamp=datetime.now(timezone.utc), + frequency=137.100, + ) + mock_decoder.get_images.return_value = [image1, image2] + mock_get.return_value = mock_decoder + + response = client.get('/weather-sat/images?satellite=NOAA-18') + assert response.status_code == 200 + data = response.get_json() + assert data['count'] == 1 + assert data['images'][0]['satellite'] == 'NOAA-18' + + def test_list_images_with_limit(self, client): + """GET /weather-sat/images with limit.""" + with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + mock_decoder = MagicMock() + images = [ + WeatherSatImage( + filename=f'test{i}.png', + path=Path(f'/tmp/test{i}.png'), + satellite='NOAA-18', + mode='APT', + timestamp=datetime.now(timezone.utc), + frequency=137.9125, + ) + for i in range(10) + ] + mock_decoder.get_images.return_value = images + mock_get.return_value = mock_decoder + + response = client.get('/weather-sat/images?limit=5') + assert response.status_code == 200 + data = response.get_json() + assert data['count'] == 5 + + def test_get_image_success(self, client): + """GET /weather-sat/images/ serves image.""" + with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \ + patch('routes.weather_sat.send_file') as mock_send, \ + patch('pathlib.Path.exists', return_value=True): + + mock_decoder = MagicMock() + mock_decoder._output_dir = Path('/tmp') + mock_get.return_value = mock_decoder + mock_send.return_value = MagicMock() + + response = client.get('/weather-sat/images/test_image.png') + mock_send.assert_called_once() + call_args = mock_send.call_args + assert call_args[1]['mimetype'] == 'image/png' + + def test_get_image_invalid_filename(self, client): + """GET /weather-sat/images/ with invalid filename.""" + with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + mock_decoder = MagicMock() + mock_get.return_value = mock_decoder + + response = client.get('/weather-sat/images/../../../etc/passwd') + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + assert 'Invalid filename' in data['message'] + + def test_get_image_wrong_extension(self, client): + """GET /weather-sat/images/ with wrong extension.""" + with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + mock_decoder = MagicMock() + mock_get.return_value = mock_decoder + + response = client.get('/weather-sat/images/test.txt') + assert response.status_code == 400 + data = response.get_json() + assert 'PNG/JPG' in data['message'] + + def test_get_image_not_found(self, client): + """GET /weather-sat/images/ for non-existent image.""" + with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \ + patch('pathlib.Path.exists', return_value=False): + + mock_decoder = MagicMock() + mock_decoder._output_dir = Path('/tmp') + mock_get.return_value = mock_decoder + + response = client.get('/weather-sat/images/missing.png') + assert response.status_code == 404 + + def test_delete_image_success(self, client): + """DELETE /weather-sat/images/ deletes image.""" + with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + mock_decoder = MagicMock() + mock_decoder.delete_image.return_value = True + mock_get.return_value = mock_decoder + + response = client.delete('/weather-sat/images/test.png') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'deleted' + assert data['filename'] == 'test.png' + + def test_delete_image_not_found(self, client): + """DELETE /weather-sat/images/ for non-existent image.""" + with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + mock_decoder = MagicMock() + mock_decoder.delete_image.return_value = False + mock_get.return_value = mock_decoder + + response = client.delete('/weather-sat/images/missing.png') + assert response.status_code == 404 + + def test_delete_all_images(self, client): + """DELETE /weather-sat/images deletes all images.""" + with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + mock_decoder = MagicMock() + mock_decoder.delete_all_images.return_value = 5 + mock_get.return_value = mock_decoder + + response = client.delete('/weather-sat/images') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'ok' + assert data['deleted'] == 5 + + def test_stream_progress(self, client): + """GET /weather-sat/stream returns SSE stream.""" + response = client.get('/weather-sat/stream') + assert response.status_code == 200 + assert response.mimetype == 'text/event-stream' + assert response.headers['Cache-Control'] == 'no-cache' + + def test_get_passes_missing_params(self, client): + """GET /weather-sat/passes without required params.""" + response = client.get('/weather-sat/passes') + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + assert 'latitude and longitude' in data['message'] + + def test_get_passes_invalid_coords(self, client): + """GET /weather-sat/passes with invalid coordinates.""" + response = client.get('/weather-sat/passes?latitude=999&longitude=0') + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + + def test_get_passes_success(self, client): + """GET /weather-sat/passes successfully predicts passes.""" + with patch('routes.weather_sat.predict_passes') as mock_predict: + mock_predict.return_value = [ + { + 'id': 'NOAA-18_202401011200', + 'satellite': 'NOAA-18', + 'name': 'NOAA 18', + 'frequency': 137.9125, + 'mode': 'APT', + 'startTime': '2024-01-01 12:00 UTC', + 'startTimeISO': '2024-01-01T12:00:00+00:00', + 'endTimeISO': '2024-01-01T12:15:00+00:00', + 'maxEl': 45.0, + 'maxElAz': 180.0, + 'riseAz': 160.0, + 'setAz': 200.0, + 'duration': 15.0, + 'quality': 'good', + } + ] + + response = client.get('/weather-sat/passes?latitude=51.5&longitude=-0.1') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'ok' + assert data['count'] == 1 + assert data['passes'][0]['satellite'] == 'NOAA-18' + + def test_get_passes_with_options(self, client): + """GET /weather-sat/passes with trajectory and ground track.""" + with patch('routes.weather_sat.predict_passes') as mock_predict: + mock_predict.return_value = [] + + response = client.get( + '/weather-sat/passes?latitude=51.5&longitude=-0.1&' + 'hours=48&min_elevation=20&trajectory=true&ground_track=true' + ) + assert response.status_code == 200 + + mock_predict.assert_called_once() + call_kwargs = mock_predict.call_args[1] + assert call_kwargs['lat'] == 51.5 + assert call_kwargs['lon'] == -0.1 + assert call_kwargs['hours'] == 48 + assert call_kwargs['min_elevation'] == 20.0 + assert call_kwargs['include_trajectory'] is True + assert call_kwargs['include_ground_track'] is True + + def test_get_passes_import_error(self, client): + """GET /weather-sat/passes when skyfield not installed.""" + with patch('routes.weather_sat.predict_passes', side_effect=ImportError): + response = client.get('/weather-sat/passes?latitude=51.5&longitude=-0.1') + assert response.status_code == 503 + data = response.get_json() + assert data['status'] == 'error' + assert 'skyfield' in data['message'] + + def test_get_passes_prediction_error(self, client): + """GET /weather-sat/passes when prediction fails.""" + with patch('routes.weather_sat.predict_passes', side_effect=Exception('TLE error')): + response = client.get('/weather-sat/passes?latitude=51.5&longitude=-0.1') + assert response.status_code == 500 + data = response.get_json() + assert data['status'] == 'error' + + +class TestWeatherSatScheduler: + """Tests for weather satellite scheduler endpoints.""" + + def test_enable_schedule_success(self, client): + """POST /weather-sat/schedule/enable enables scheduler.""" + with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get: + mock_scheduler = MagicMock() + mock_scheduler.enable.return_value = { + 'enabled': True, + 'observer': {'latitude': 51.5, 'longitude': -0.1}, + 'device': 0, + 'gain': 40.0, + 'bias_t': False, + 'min_elevation': 15.0, + 'scheduled_count': 3, + 'total_passes': 3, + } + mock_get.return_value = mock_scheduler + + payload = { + 'latitude': 51.5, + 'longitude': -0.1, + 'min_elevation': 15, + 'device': 0, + 'gain': 40.0, + 'bias_t': False, + } + + response = client.post( + '/weather-sat/schedule/enable', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'ok' + assert data['enabled'] is True + + def test_enable_schedule_missing_coords(self, client): + """POST /weather-sat/schedule/enable without coordinates.""" + payload = {'device': 0} + response = client.post( + '/weather-sat/schedule/enable', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + assert 'latitude and longitude' in data['message'] + + def test_enable_schedule_invalid_coords(self, client): + """POST /weather-sat/schedule/enable with invalid coordinates.""" + payload = {'latitude': 999, 'longitude': 0} + response = client.post( + '/weather-sat/schedule/enable', + data=json.dumps(payload), + content_type='application/json' + ) + + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + + def test_disable_schedule(self, client): + """POST /weather-sat/schedule/disable disables scheduler.""" + with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get: + mock_scheduler = MagicMock() + mock_scheduler.disable.return_value = {'status': 'disabled'} + mock_get.return_value = mock_scheduler + + response = client.post('/weather-sat/schedule/disable') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'disabled' + + def test_schedule_status(self, client): + """GET /weather-sat/schedule/status returns scheduler status.""" + with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get: + mock_scheduler = MagicMock() + mock_scheduler.get_status.return_value = { + 'enabled': False, + 'observer': {'latitude': 0, 'longitude': 0}, + 'device': 0, + 'gain': 40.0, + 'bias_t': False, + 'min_elevation': 15.0, + 'scheduled_count': 0, + 'total_passes': 0, + } + mock_get.return_value = mock_scheduler + + response = client.get('/weather-sat/schedule/status') + assert response.status_code == 200 + data = response.get_json() + assert 'enabled' in data + + def test_schedule_passes(self, client): + """GET /weather-sat/schedule/passes lists scheduled passes.""" + with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get: + mock_scheduler = MagicMock() + mock_scheduler.get_passes.return_value = [ + { + 'id': 'NOAA-18_202401011200', + 'satellite': 'NOAA-18', + 'status': 'scheduled', + } + ] + mock_get.return_value = mock_scheduler + + response = client.get('/weather-sat/schedule/passes') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'ok' + assert data['count'] == 1 + + def test_skip_pass_success(self, client): + """POST /weather-sat/schedule/skip/ skips a pass.""" + with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get: + mock_scheduler = MagicMock() + mock_scheduler.skip_pass.return_value = True + mock_get.return_value = mock_scheduler + + response = client.post('/weather-sat/schedule/skip/NOAA-18_202401011200') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'skipped' + assert data['pass_id'] == 'NOAA-18_202401011200' + + def test_skip_pass_not_found(self, client): + """POST /weather-sat/schedule/skip/ for non-existent pass.""" + with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get: + mock_scheduler = MagicMock() + mock_scheduler.skip_pass.return_value = False + mock_get.return_value = mock_scheduler + + response = client.post('/weather-sat/schedule/skip/nonexistent') + assert response.status_code == 404 + + def test_skip_pass_invalid_id(self, client): + """POST /weather-sat/schedule/skip/ with invalid ID.""" + response = client.post('/weather-sat/schedule/skip/../../../etc/passwd') + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + assert 'Invalid pass ID' in data['message'] diff --git a/tests/test_weather_sat_scheduler.py b/tests/test_weather_sat_scheduler.py new file mode 100644 index 0000000..6f079b3 --- /dev/null +++ b/tests/test_weather_sat_scheduler.py @@ -0,0 +1,779 @@ +"""Tests for weather satellite auto-scheduler. + +Covers WeatherSatScheduler class, pass scheduling, timer management, +and automatic capture execution. +""" + +from __future__ import annotations + +import threading +import time +from datetime import datetime, timezone, timedelta +from unittest.mock import patch, MagicMock, call +import pytest + +from utils.weather_sat_scheduler import ( + WeatherSatScheduler, + ScheduledPass, + get_weather_sat_scheduler, +) + + +class TestScheduledPass: + """Tests for ScheduledPass class.""" + + def test_scheduled_pass_initialization(self): + """ScheduledPass should initialize from pass data.""" + pass_data = { + 'id': 'NOAA-18_202401011200', + 'satellite': 'NOAA-18', + 'name': 'NOAA 18', + 'frequency': 137.9125, + 'mode': 'APT', + 'startTimeISO': '2024-01-01T12:00:00+00:00', + 'endTimeISO': '2024-01-01T12:15:00+00:00', + 'maxEl': 45.0, + 'duration': 15.0, + 'quality': 'good', + } + + sp = ScheduledPass(pass_data) + + assert sp.id == 'NOAA-18_202401011200' + assert sp.satellite == 'NOAA-18' + assert sp.name == 'NOAA 18' + assert sp.frequency == 137.9125 + assert sp.mode == 'APT' + assert sp.max_el == 45.0 + assert sp.duration == 15.0 + assert sp.quality == 'good' + assert sp.status == 'scheduled' + assert sp.skipped is False + + def test_scheduled_pass_start_dt(self): + """ScheduledPass.start_dt should parse ISO datetime.""" + pass_data = { + 'satellite': 'NOAA-18', + 'name': 'NOAA 18', + 'frequency': 137.9125, + 'mode': 'APT', + 'startTimeISO': '2024-01-01T12:00:00+00:00', + 'endTimeISO': '2024-01-01T12:15:00+00:00', + 'maxEl': 45.0, + 'duration': 15.0, + 'quality': 'good', + } + + sp = ScheduledPass(pass_data) + + assert sp.start_dt.year == 2024 + assert sp.start_dt.month == 1 + assert sp.start_dt.day == 1 + assert sp.start_dt.hour == 12 + assert sp.start_dt.tzinfo == timezone.utc + + def test_scheduled_pass_end_dt(self): + """ScheduledPass.end_dt should parse ISO datetime.""" + pass_data = { + 'satellite': 'NOAA-18', + 'name': 'NOAA 18', + 'frequency': 137.9125, + 'mode': 'APT', + 'startTimeISO': '2024-01-01T12:00:00+00:00', + 'endTimeISO': '2024-01-01T12:15:00+00:00', + 'maxEl': 45.0, + 'duration': 15.0, + 'quality': 'good', + } + + sp = ScheduledPass(pass_data) + + assert sp.end_dt.year == 2024 + assert sp.end_dt.minute == 15 + + def test_scheduled_pass_to_dict(self): + """ScheduledPass.to_dict() should serialize correctly.""" + pass_data = { + 'id': 'NOAA-18_202401011200', + 'satellite': 'NOAA-18', + 'name': 'NOAA 18', + 'frequency': 137.9125, + 'mode': 'APT', + 'startTimeISO': '2024-01-01T12:00:00+00:00', + 'endTimeISO': '2024-01-01T12:15:00+00:00', + 'maxEl': 45.0, + 'duration': 15.0, + 'quality': 'good', + } + + sp = ScheduledPass(pass_data) + sp.status = 'complete' + + data = sp.to_dict() + + assert data['id'] == 'NOAA-18_202401011200' + assert data['satellite'] == 'NOAA-18' + assert data['status'] == 'complete' + assert data['skipped'] is False + + +class TestWeatherSatScheduler: + """Tests for WeatherSatScheduler class.""" + + def test_scheduler_initialization(self): + """Scheduler should initialize with defaults.""" + scheduler = WeatherSatScheduler() + + assert scheduler.enabled is False + assert scheduler._lat == 0.0 + assert scheduler._lon == 0.0 + assert scheduler._min_elevation == 15.0 + assert scheduler._device == 0 + assert scheduler._gain == 40.0 + assert scheduler._bias_t is False + assert scheduler._passes == [] + + def test_set_callbacks(self): + """Scheduler should accept callbacks.""" + scheduler = WeatherSatScheduler() + progress_cb = MagicMock() + event_cb = MagicMock() + + scheduler.set_callbacks(progress_cb, event_cb) + + assert scheduler._progress_callback == progress_cb + assert scheduler._event_callback == event_cb + + @patch('utils.weather_sat_scheduler.WeatherSatScheduler._refresh_passes') + def test_enable(self, mock_refresh): + """enable() should start scheduler.""" + scheduler = WeatherSatScheduler() + + result = scheduler.enable( + lat=51.5, + lon=-0.1, + min_elevation=20.0, + device=1, + gain=35.0, + bias_t=True, + ) + + assert scheduler._enabled is True + assert scheduler._lat == 51.5 + assert scheduler._lon == -0.1 + assert scheduler._min_elevation == 20.0 + assert scheduler._device == 1 + assert scheduler._gain == 35.0 + assert scheduler._bias_t is True + mock_refresh.assert_called_once() + assert 'enabled' in result + + def test_disable(self): + """disable() should stop scheduler and cancel timers.""" + scheduler = WeatherSatScheduler() + scheduler._enabled = True + + # Add mock timer + mock_timer = MagicMock() + scheduler._refresh_timer = mock_timer + + # Add pass with timer + pass_data = { + 'satellite': 'NOAA-18', + 'name': 'NOAA 18', + 'frequency': 137.9125, + 'mode': 'APT', + 'startTimeISO': '2024-01-01T12:00:00+00:00', + 'endTimeISO': '2024-01-01T12:15:00+00:00', + 'maxEl': 45.0, + 'duration': 15.0, + 'quality': 'good', + } + sp = ScheduledPass(pass_data) + sp._timer = MagicMock() + sp._stop_timer = MagicMock() + scheduler._passes = [sp] + + result = scheduler.disable() + + assert scheduler._enabled is False + assert scheduler._passes == [] + mock_timer.cancel.assert_called_once() + sp._timer.cancel.assert_called_once() + sp._stop_timer.cancel.assert_called_once() + assert result['status'] == 'disabled' + + def test_skip_pass_success(self): + """skip_pass() should skip a scheduled pass.""" + scheduler = WeatherSatScheduler() + event_cb = MagicMock() + scheduler.set_callbacks(MagicMock(), event_cb) + + pass_data = { + 'id': 'NOAA-18_202401011200', + 'satellite': 'NOAA-18', + 'name': 'NOAA 18', + 'frequency': 137.9125, + 'mode': 'APT', + 'startTimeISO': '2024-01-01T12:00:00+00:00', + 'endTimeISO': '2024-01-01T12:15:00+00:00', + 'maxEl': 45.0, + 'duration': 15.0, + 'quality': 'good', + } + sp = ScheduledPass(pass_data) + sp._timer = MagicMock() + scheduler._passes = [sp] + + result = scheduler.skip_pass('NOAA-18_202401011200') + + assert result is True + assert sp.status == 'skipped' + assert sp.skipped is True + sp._timer.cancel.assert_called_once() + event_cb.assert_called_once() + + def test_skip_pass_not_found(self): + """skip_pass() should return False for non-existent pass.""" + scheduler = WeatherSatScheduler() + + result = scheduler.skip_pass('NONEXISTENT') + + assert result is False + + def test_skip_pass_already_complete(self): + """skip_pass() should not skip already complete passes.""" + scheduler = WeatherSatScheduler() + + pass_data = { + 'id': 'NOAA-18_202401011200', + 'satellite': 'NOAA-18', + 'name': 'NOAA 18', + 'frequency': 137.9125, + 'mode': 'APT', + 'startTimeISO': '2024-01-01T12:00:00+00:00', + 'endTimeISO': '2024-01-01T12:15:00+00:00', + 'maxEl': 45.0, + 'duration': 15.0, + 'quality': 'good', + } + sp = ScheduledPass(pass_data) + sp.status = 'complete' + scheduler._passes = [sp] + + result = scheduler.skip_pass('NOAA-18_202401011200') + + assert result is False + assert sp.status == 'complete' + + def test_get_status(self): + """get_status() should return scheduler state.""" + scheduler = WeatherSatScheduler() + scheduler._enabled = True + scheduler._lat = 51.5 + scheduler._lon = -0.1 + scheduler._device = 0 + scheduler._gain = 40.0 + scheduler._bias_t = False + scheduler._min_elevation = 15.0 + + pass_data = { + 'satellite': 'NOAA-18', + 'name': 'NOAA 18', + 'frequency': 137.9125, + 'mode': 'APT', + 'startTimeISO': '2024-01-01T12:00:00+00:00', + 'endTimeISO': '2024-01-01T12:15:00+00:00', + 'maxEl': 45.0, + 'duration': 15.0, + 'quality': 'good', + } + sp = ScheduledPass(pass_data) + scheduler._passes = [sp] + + status = scheduler.get_status() + + assert status['enabled'] is True + assert status['observer']['latitude'] == 51.5 + assert status['observer']['longitude'] == -0.1 + assert status['device'] == 0 + assert status['gain'] == 40.0 + assert status['bias_t'] is False + assert status['min_elevation'] == 15.0 + assert status['scheduled_count'] == 1 + assert status['total_passes'] == 1 + + def test_get_passes(self): + """get_passes() should return list of scheduled passes.""" + scheduler = WeatherSatScheduler() + + pass_data = { + 'id': 'NOAA-18_202401011200', + 'satellite': 'NOAA-18', + 'name': 'NOAA 18', + 'frequency': 137.9125, + 'mode': 'APT', + 'startTimeISO': '2024-01-01T12:00:00+00:00', + 'endTimeISO': '2024-01-01T12:15:00+00:00', + 'maxEl': 45.0, + 'duration': 15.0, + 'quality': 'good', + } + sp = ScheduledPass(pass_data) + scheduler._passes = [sp] + + passes = scheduler.get_passes() + + assert len(passes) == 1 + assert passes[0]['id'] == 'NOAA-18_202401011200' + + @patch('utils.weather_sat_scheduler.predict_passes') + @patch('threading.Timer') + def test_refresh_passes(self, mock_timer, mock_predict): + """_refresh_passes() should schedule future passes.""" + now = datetime.now(timezone.utc) + future_pass = { + 'id': 'NOAA-18_202401011200', + 'satellite': 'NOAA-18', + 'name': 'NOAA 18', + 'frequency': 137.9125, + 'mode': 'APT', + 'startTimeISO': (now + timedelta(hours=2)).isoformat(), + 'endTimeISO': (now + timedelta(hours=2, minutes=15)).isoformat(), + 'maxEl': 45.0, + 'duration': 15.0, + 'quality': 'good', + } + mock_predict.return_value = [future_pass] + + mock_timer_instance = MagicMock() + mock_timer.return_value = mock_timer_instance + + scheduler = WeatherSatScheduler() + scheduler._enabled = True + scheduler._lat = 51.5 + scheduler._lon = -0.1 + + scheduler._refresh_passes() + + mock_predict.assert_called_once() + assert len(scheduler._passes) == 1 + assert scheduler._passes[0].satellite == 'NOAA-18' + mock_timer_instance.start.assert_called() + + @patch('utils.weather_sat_scheduler.predict_passes') + def test_refresh_passes_skip_past(self, mock_predict): + """_refresh_passes() should skip passes that already started.""" + now = datetime.now(timezone.utc) + past_pass = { + 'id': 'NOAA-18_202401011200', + 'satellite': 'NOAA-18', + 'name': 'NOAA 18', + 'frequency': 137.9125, + 'mode': 'APT', + 'startTimeISO': (now - timedelta(hours=1)).isoformat(), + 'endTimeISO': (now - timedelta(hours=1) + timedelta(minutes=15)).isoformat(), + 'maxEl': 45.0, + 'duration': 15.0, + 'quality': 'good', + } + mock_predict.return_value = [past_pass] + + scheduler = WeatherSatScheduler() + scheduler._enabled = True + scheduler._lat = 51.5 + scheduler._lon = -0.1 + + scheduler._refresh_passes() + + # Should not schedule past passes + assert len(scheduler._passes) == 0 + + @patch('utils.weather_sat_scheduler.predict_passes') + def test_refresh_passes_disabled(self, mock_predict): + """_refresh_passes() should do nothing when disabled.""" + scheduler = WeatherSatScheduler() + scheduler._enabled = False + + scheduler._refresh_passes() + + mock_predict.assert_not_called() + + @patch('utils.weather_sat_scheduler.predict_passes') + def test_refresh_passes_error_handling(self, mock_predict): + """_refresh_passes() should handle prediction errors.""" + mock_predict.side_effect = Exception('TLE error') + + scheduler = WeatherSatScheduler() + scheduler._enabled = True + scheduler._lat = 51.5 + scheduler._lon = -0.1 + + # Should not raise + scheduler._refresh_passes() + + assert len(scheduler._passes) == 0 + + @patch('utils.weather_sat_scheduler.get_weather_sat_decoder') + def test_execute_capture_disabled(self, mock_get): + """_execute_capture() should do nothing when disabled.""" + scheduler = WeatherSatScheduler() + scheduler._enabled = False + + pass_data = { + 'satellite': 'NOAA-18', + 'name': 'NOAA 18', + 'frequency': 137.9125, + 'mode': 'APT', + 'startTimeISO': '2024-01-01T12:00:00+00:00', + 'endTimeISO': '2024-01-01T12:15:00+00:00', + 'maxEl': 45.0, + 'duration': 15.0, + 'quality': 'good', + } + sp = ScheduledPass(pass_data) + + scheduler._execute_capture(sp) + + mock_get.assert_not_called() + + @patch('utils.weather_sat_scheduler.get_weather_sat_decoder') + def test_execute_capture_skipped(self, mock_get): + """_execute_capture() should do nothing for skipped passes.""" + scheduler = WeatherSatScheduler() + scheduler._enabled = True + + pass_data = { + 'satellite': 'NOAA-18', + 'name': 'NOAA 18', + 'frequency': 137.9125, + 'mode': 'APT', + 'startTimeISO': '2024-01-01T12:00:00+00:00', + 'endTimeISO': '2024-01-01T12:15:00+00:00', + 'maxEl': 45.0, + 'duration': 15.0, + 'quality': 'good', + } + sp = ScheduledPass(pass_data) + sp.skipped = True + + scheduler._execute_capture(sp) + + mock_get.assert_not_called() + + @patch('utils.weather_sat_scheduler.get_weather_sat_decoder') + def test_execute_capture_decoder_busy(self, mock_get): + """_execute_capture() should skip when decoder is busy.""" + scheduler = WeatherSatScheduler() + scheduler._enabled = True + event_cb = MagicMock() + scheduler.set_callbacks(MagicMock(), event_cb) + + mock_decoder = MagicMock() + mock_decoder.is_running = True + mock_get.return_value = mock_decoder + + pass_data = { + 'satellite': 'NOAA-18', + 'name': 'NOAA 18', + 'frequency': 137.9125, + 'mode': 'APT', + 'startTimeISO': '2024-01-01T12:00:00+00:00', + 'endTimeISO': '2024-01-01T12:15:00+00:00', + 'maxEl': 45.0, + 'duration': 15.0, + 'quality': 'good', + } + sp = ScheduledPass(pass_data) + + scheduler._execute_capture(sp) + + assert sp.status == 'skipped' + assert sp.skipped is True + event_cb.assert_called_once() + event_data = event_cb.call_args[0][0] + assert event_data['type'] == 'schedule_capture_skipped' + assert event_data['reason'] == 'sdr_busy' + + @patch('utils.weather_sat_scheduler.get_weather_sat_decoder') + @patch('threading.Timer') + def test_execute_capture_success(self, mock_timer, mock_get): + """_execute_capture() should start capture.""" + scheduler = WeatherSatScheduler() + scheduler._enabled = True + scheduler._device = 0 + scheduler._gain = 40.0 + scheduler._bias_t = False + progress_cb = MagicMock() + event_cb = MagicMock() + scheduler.set_callbacks(progress_cb, event_cb) + + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_decoder.start.return_value = True + mock_get.return_value = mock_decoder + + mock_timer_instance = MagicMock() + mock_timer.return_value = mock_timer_instance + + now = datetime.now(timezone.utc) + pass_data = { + 'satellite': 'NOAA-18', + 'name': 'NOAA 18', + 'frequency': 137.9125, + 'mode': 'APT', + 'startTimeISO': (now + timedelta(seconds=10)).isoformat(), + 'endTimeISO': (now + timedelta(minutes=15)).isoformat(), + 'maxEl': 45.0, + 'duration': 15.0, + 'quality': 'good', + } + sp = ScheduledPass(pass_data) + + scheduler._execute_capture(sp) + + assert sp.status == 'capturing' + mock_decoder.set_callback.assert_called_once_with(progress_cb) + mock_decoder.start.assert_called_once_with( + satellite='NOAA-18', + device_index=0, + gain=40.0, + bias_t=False, + ) + event_cb.assert_called_once() + event_data = event_cb.call_args[0][0] + assert event_data['type'] == 'schedule_capture_start' + + @patch('utils.weather_sat_scheduler.get_weather_sat_decoder') + def test_execute_capture_start_failed(self, mock_get): + """_execute_capture() should handle start failure.""" + scheduler = WeatherSatScheduler() + scheduler._enabled = True + event_cb = MagicMock() + scheduler.set_callbacks(MagicMock(), event_cb) + + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_decoder.start.return_value = False + mock_get.return_value = mock_decoder + + pass_data = { + 'satellite': 'NOAA-18', + 'name': 'NOAA 18', + 'frequency': 137.9125, + 'mode': 'APT', + 'startTimeISO': '2024-01-01T12:00:00+00:00', + 'endTimeISO': '2024-01-01T12:15:00+00:00', + 'maxEl': 45.0, + 'duration': 15.0, + 'quality': 'good', + } + sp = ScheduledPass(pass_data) + + scheduler._execute_capture(sp) + + assert sp.status == 'skipped' + event_cb.assert_called_once() + event_data = event_cb.call_args[0][0] + assert event_data['reason'] == 'start_failed' + + @patch('utils.weather_sat_scheduler.get_weather_sat_decoder') + def test_stop_capture(self, mock_get): + """_stop_capture() should stop decoder.""" + scheduler = WeatherSatScheduler() + + mock_decoder = MagicMock() + mock_decoder.is_running = True + mock_get.return_value = mock_decoder + + pass_data = { + 'satellite': 'NOAA-18', + 'name': 'NOAA 18', + 'frequency': 137.9125, + 'mode': 'APT', + 'startTimeISO': '2024-01-01T12:00:00+00:00', + 'endTimeISO': '2024-01-01T12:15:00+00:00', + 'maxEl': 45.0, + 'duration': 15.0, + 'quality': 'good', + } + sp = ScheduledPass(pass_data) + + scheduler._stop_capture(sp) + + mock_decoder.stop.assert_called_once() + + def test_on_capture_complete(self): + """_on_capture_complete() should mark pass complete and emit event.""" + scheduler = WeatherSatScheduler() + event_cb = MagicMock() + scheduler.set_callbacks(MagicMock(), event_cb) + release_fn = MagicMock() + + pass_data = { + 'satellite': 'NOAA-18', + 'name': 'NOAA 18', + 'frequency': 137.9125, + 'mode': 'APT', + 'startTimeISO': '2024-01-01T12:00:00+00:00', + 'endTimeISO': '2024-01-01T12:15:00+00:00', + 'maxEl': 45.0, + 'duration': 15.0, + 'quality': 'good', + } + sp = ScheduledPass(pass_data) + + scheduler._on_capture_complete(sp, release_fn) + + assert sp.status == 'complete' + release_fn.assert_called_once() + event_cb.assert_called_once() + event_data = event_cb.call_args[0][0] + assert event_data['type'] == 'schedule_capture_complete' + + def test_emit_event(self): + """_emit_event() should call event callback.""" + scheduler = WeatherSatScheduler() + event_cb = MagicMock() + scheduler.set_callbacks(MagicMock(), event_cb) + + event = {'type': 'test_event', 'data': 'test'} + scheduler._emit_event(event) + + event_cb.assert_called_once_with(event) + + def test_emit_event_no_callback(self): + """_emit_event() should handle missing callback.""" + scheduler = WeatherSatScheduler() + + event = {'type': 'test_event'} + scheduler._emit_event(event) # Should not raise + + def test_emit_event_callback_exception(self): + """_emit_event() should handle callback exceptions.""" + scheduler = WeatherSatScheduler() + event_cb = MagicMock(side_effect=Exception('Callback error')) + scheduler.set_callbacks(MagicMock(), event_cb) + + event = {'type': 'test_event'} + scheduler._emit_event(event) # Should not raise + + +class TestGlobalScheduler: + """Tests for global scheduler singleton.""" + + def test_get_weather_sat_scheduler_singleton(self): + """get_weather_sat_scheduler() should return singleton.""" + import utils.weather_sat_scheduler as mod + old = mod._scheduler + mod._scheduler = None + + try: + scheduler1 = get_weather_sat_scheduler() + scheduler2 = get_weather_sat_scheduler() + + assert scheduler1 is scheduler2 + finally: + mod._scheduler = old + + def test_get_weather_sat_scheduler_thread_safe(self): + """get_weather_sat_scheduler() should be thread-safe.""" + import utils.weather_sat_scheduler as mod + old = mod._scheduler + mod._scheduler = None + + schedulers = [] + + def create_scheduler(): + schedulers.append(get_weather_sat_scheduler()) + + try: + threads = [threading.Thread(target=create_scheduler) for _ in range(10)] + for t in threads: + t.start() + for t in threads: + t.join() + + # All should be the same instance + assert all(s is schedulers[0] for s in schedulers) + finally: + mod._scheduler = old + + +class TestSchedulerConfiguration: + """Tests for scheduler configuration constants.""" + + def test_config_constants(self): + """Scheduler should have configuration constants.""" + from utils.weather_sat_scheduler import ( + WEATHER_SAT_SCHEDULE_REFRESH_MINUTES, + WEATHER_SAT_CAPTURE_BUFFER_SECONDS, + ) + + assert isinstance(WEATHER_SAT_SCHEDULE_REFRESH_MINUTES, int) + assert isinstance(WEATHER_SAT_CAPTURE_BUFFER_SECONDS, int) + assert WEATHER_SAT_SCHEDULE_REFRESH_MINUTES > 0 + assert WEATHER_SAT_CAPTURE_BUFFER_SECONDS >= 0 + + +class TestSchedulerIntegration: + """Integration tests for scheduler.""" + + @patch('utils.weather_sat_scheduler.predict_passes') + @patch('utils.weather_sat_scheduler.get_weather_sat_decoder') + @patch('threading.Timer') + def test_full_scheduling_cycle(self, mock_timer, mock_get_decoder, mock_predict): + """Test complete scheduling cycle from enable to execute.""" + now = datetime.now(timezone.utc) + future_pass = { + 'id': 'NOAA-18_202401011200', + 'satellite': 'NOAA-18', + 'name': 'NOAA 18', + 'frequency': 137.9125, + 'mode': 'APT', + 'startTimeISO': (now + timedelta(hours=2)).isoformat(), + 'endTimeISO': (now + timedelta(hours=2, minutes=15)).isoformat(), + 'maxEl': 45.0, + 'duration': 15.0, + 'quality': 'good', + } + mock_predict.return_value = [future_pass] + + mock_timer_instance = MagicMock() + mock_timer.return_value = mock_timer_instance + + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_decoder.start.return_value = True + mock_get_decoder.return_value = mock_decoder + + scheduler = WeatherSatScheduler() + progress_cb = MagicMock() + event_cb = MagicMock() + scheduler.set_callbacks(progress_cb, event_cb) + + # Enable scheduler + result = scheduler.enable(lat=51.5, lon=-0.1) + + assert result['enabled'] is True + assert len(scheduler._passes) == 1 + assert scheduler._passes[0].satellite == 'NOAA-18' + + # Simulate timer firing (capture start) + scheduler._execute_capture(scheduler._passes[0]) + + assert scheduler._passes[0].status == 'capturing' + mock_decoder.start.assert_called_once() + + # Simulate completion + release_fn = MagicMock() + scheduler._on_capture_complete(scheduler._passes[0], release_fn) + + assert scheduler._passes[0].status == 'complete' + release_fn.assert_called_once() + + # Disable scheduler + scheduler.disable() + + assert scheduler.enabled is False + assert len(scheduler._passes) == 0 From aa963519e9a7c29d693b17e4b15c81e6790dbd6c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:55:30 +0000 Subject: [PATCH 27/28] Fix str(e) in error responses, remove location modal, document GTK dependency Co-authored-by: mitchross <6330506+mitchross@users.noreply.github.com> --- Dockerfile | 8 ++ routes/weather_sat.py | 9 ++- templates/index.html | 166 ------------------------------------------ 3 files changed, 14 insertions(+), 169 deletions(-) diff --git a/Dockerfile b/Dockerfile index 41b0f7e..afd8894 100644 --- a/Dockerfile +++ b/Dockerfile @@ -70,6 +70,10 @@ 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 \ @@ -195,6 +199,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && ldconfig \ && rm -rf /tmp/dsd-fme \ # Cleanup build tools to reduce image size + # Note: libgtk-3-dev is removed here but runtime GTK libs (from first stage) + # remain for slowrx. This adds ~10MB to the image but is required for slowrx + # to function. Consider removing slowrx build entirely if moving fully to + # the pure Python SSTV decoder. && apt-get remove -y \ build-essential \ git \ diff --git a/routes/weather_sat.py b/routes/weather_sat.py index 0caa5ad..f7ae788 100644 --- a/routes/weather_sat.py +++ b/routes/weather_sat.py @@ -120,9 +120,10 @@ def start_capture(): 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': str(e) + 'message': 'Invalid parameter value' }), 400 bias_t = bool(data.get('bias_t', False)) @@ -464,7 +465,8 @@ def get_passes(): lat = validate_latitude(raw_lat) lon = validate_longitude(raw_lon) except ValueError as e: - return jsonify({'status': 'error', 'message': str(e)}), 400 + 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)) @@ -555,9 +557,10 @@ def enable_schedule(): 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': str(e) + 'message': 'Invalid parameter value' }), 400 scheduler = get_weather_sat_scheduler() diff --git a/templates/index.html b/templates/index.html index 8c6361d..dfeccee 100644 --- a/templates/index.html +++ b/templates/index.html @@ -15270,172 +15270,6 @@ {% include 'partials/settings-modal.html' %} - -

- -
From 311d268b1027721ba1a52e1ef2e37bf36ce7f161 Mon Sep 17 00:00:00 2001 From: Mitch Ross Date: Mon, 9 Feb 2026 19:09:50 -0500 Subject: [PATCH 28/28] Explicitly remove libgtk-3-dev in Dockerfile cleanup step Adds libgtk-3-dev to the apt-get remove list so it doesn't remain in the final image. Runtime GTK libs stay for slowrx. Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index afd8894..7068f91 100644 --- a/Dockerfile +++ b/Dockerfile @@ -199,10 +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 - # Note: libgtk-3-dev is removed here but runtime GTK libs (from first stage) - # remain for slowrx. This adds ~10MB to the image but is required for slowrx - # to function. Consider removing slowrx build entirely if moving fully to - # the pure Python SSTV decoder. + # libgtk-3-dev is explicitly removed; runtime GTK libs remain for slowrx && apt-get remove -y \ build-essential \ git \ @@ -210,6 +207,7 @@ 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 \