From 7b68c19dc533c2319f9e716508cf9d4a0e36eabe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Feb 2026 21:45:33 +0000 Subject: [PATCH] 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 @@ + + +