"""Weather Satellite decoder routes. Provides endpoints for capturing and decoding Meteor LRPT weather imagery, including shared results produced by the ground-station observation pipeline. """ from __future__ import annotations import json import queue from pathlib import Path from flask import Blueprint, Response, jsonify, request, send_file from utils.logging import get_logger from utils.responses import api_error from utils.sse import sse_stream from utils.validation import ( validate_device_index, validate_elevation, validate_gain, validate_latitude, validate_longitude, validate_rtl_tcp_host, validate_rtl_tcp_port, ) from utils.weather_sat import ( DEFAULT_SAMPLE_RATE, WEATHER_SATELLITES, CaptureProgress, get_weather_sat_decoder, is_weather_sat_available, ) 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) METEOR_NORAD_IDS = { "METEOR-M2-3": 57166, "METEOR-M2-4": 59051, } ALLOWED_TEST_DECODE_DIRS = ( Path(__file__).resolve().parent.parent / "data", Path(__file__).resolve().parent.parent / "instance" / "ground_station" / "recordings", ) 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 def _release_weather_sat_device(device_index: int) -> None: """Release an SDR device only if weather-sat currently owns it.""" if device_index < 0: return try: import app as app_module except ImportError: return owner = None get_status = getattr(app_module, "get_sdr_device_status", None) if callable(get_status): try: owner = get_status().get(device_index) except Exception: owner = None if owner and owner != "weather_sat": logger.debug( "Skipping SDR release for device %s owned by %s", device_index, owner, ) return app_module.release_sdr_device(device_index) @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": "METEOR-M2-3", // Required: satellite key "device": 0, // RTL-SDR device index (default: 0) "gain": 30.0, // SDR gain in dB (default: 30) "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 {} sdr_type_str = data.get("sdr_type", "rtlsdr") if sdr_type_str != "rtlsdr": return jsonify( { "status": "error", "message": f"{sdr_type_str.replace('_', ' ').title()} is not yet supported for this mode. Please use an RTL-SDR device.", } ), 400 # Validate satellite satellite = data.get("satellite") if not satellite or satellite not in WEATHER_SATELLITES: return jsonify( {"status": "error", "message": f"Invalid satellite. Must be one of: {', '.join(WEATHER_SATELLITES.keys())}"} ), 400 # Validate device index and gain try: device_index = validate_device_index(data.get("device", 0)) gain = validate_gain(data.get("gain", 30.0)) except ValueError as e: logger.warning("Invalid parameter in start_capture: %s", e) return jsonify({"status": "error", "message": "Invalid parameter value"}), 400 bias_t = bool(data.get("bias_t", False)) # Check for rtl_tcp (remote SDR) connection rtl_tcp_host = data.get("rtl_tcp_host") rtl_tcp_port = data.get("rtl_tcp_port", 1234) if rtl_tcp_host: try: rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host) rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port) except ValueError as e: return api_error(str(e), 400) # Claim SDR device (skip for remote rtl_tcp) if not rtl_tcp_host: try: import app as app_module error = app_module.claim_sdr_device(device_index, "weather_sat", sdr_type_str) if error: return api_error(error, 409, error_type="DEVICE_BUSY") except ImportError: pass # Clear queue while not _weather_sat_queue.empty(): try: _weather_sat_queue.get_nowait() except queue.Empty: break # Set callback and on-complete handler for SDR release decoder.set_callback(_progress_callback) def _release_device(): if not rtl_tcp_host: _release_weather_sat_device(device_index) decoder.set_on_complete(_release_device) success, error_msg = decoder.start( satellite=satellite, device_index=device_index, gain=gain, sample_rate=DEFAULT_SAMPLE_RATE, bias_t=bias_t, rtl_tcp_host=rtl_tcp_host, rtl_tcp_port=rtl_tcp_port, ) if success: sat_info = WEATHER_SATELLITES[satellite] return jsonify( { "status": "started", "satellite": satellite, "frequency": sat_info["frequency"], "mode": sat_info["mode"], "device": device_index, } ) else: # Release device on failure _release_device() return jsonify({"status": "error", "message": error_msg or "Failed to start capture"}), 500 @weather_sat_bp.route("/test-decode", methods=["POST"]) def test_decode(): """Start weather satellite decode from a pre-recorded file. No SDR hardware is required — decodes an IQ baseband or WAV file using SatDump offline mode. JSON body: { "satellite": "METEOR-M2-3", // 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) # Restrict test-decode to application-owned sample and recording paths. try: resolved = input_path.resolve() if not any(resolved.is_relative_to(base) for base in ALLOWED_TEST_DECODE_DIRS): return jsonify( {"status": "error", "message": "input_file must be under INTERCEPT data or ground-station recordings"} ), 403 except (OSError, ValueError): return jsonify({"status": "error", "message": "Invalid file path"}), 400 if not input_path.is_file(): logger.warning("Test-decode file not found") return jsonify({"status": "error", "message": "File not found"}), 404 # Validate sample rate sample_rate = data.get("sample_rate", 1000000) try: sample_rate = int(sample_rate) if sample_rate < 1000 or sample_rate > 20000000: raise ValueError except (TypeError, ValueError): return jsonify({"status": "error", "message": "Invalid sample_rate (1000-20000000)"}), 400 # Clear queue while not _weather_sat_queue.empty(): try: _weather_sat_queue.get_nowait() except queue.Empty: break # Set callback — no on_complete needed (no SDR to release) decoder.set_callback(_progress_callback) decoder.set_on_complete(None) success, error_msg = 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": error_msg or "Failed to start file decode"}), 500 @weather_sat_bp.route("/stop", methods=["POST"]) def stop_capture(): """Stop weather satellite capture. Returns: JSON confirmation. """ decoder = get_weather_sat_decoder() device_index = decoder.device_index decoder.stop() _release_weather_sat_device(device_index) 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 = [ { **img.to_dict(), "source": "weather_sat", "deletable": True, } for img in decoder.get_images() ] images.extend(_get_ground_station_images()) # Filter by satellite if specified satellite_filter = request.args.get("satellite") if satellite_filter: images = [img for img in images if str(img.get("satellite", "")).upper() == satellite_filter.upper()] images.sort(key=lambda img: img.get("timestamp") or "", reverse=True) # Apply limit limit = request.args.get("limit", type=int) if limit and limit > 0: images = images[:limit] return jsonify( { "status": "ok", "images": 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 api_error("Invalid filename", 400) if not (filename.endswith(".png") or filename.endswith(".jpg") or filename.endswith(".jpeg")): return api_error("Only PNG/JPG files supported", 400) image_path = decoder._output_dir / filename if not image_path.exists(): return api_error("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/shared/") def get_shared_image(output_id: int): """Serve a Meteor image stored in ground-station outputs.""" try: from utils.database import get_db with get_db() as conn: row = conn.execute( """ SELECT file_path FROM ground_station_outputs WHERE id=? AND output_type='image' """, (output_id,), ).fetchone() except Exception as e: logger.warning("Failed to load shared weather image %s: %s", output_id, e) return api_error("Image not found", 404) if not row: return api_error("Image not found", 404) image_path = Path(row["file_path"]) if not image_path.exists(): return api_error("Image not found", 404) suffix = image_path.suffix.lower() mimetype = "image/png" if suffix == ".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 api_error("Invalid filename", 400) if decoder.delete_image(filename): return jsonify({"status": "deleted", "filename": filename}) else: return api_error("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}) def _get_ground_station_images() -> list[dict]: try: from utils.database import get_db with get_db() as conn: rows = conn.execute( """ SELECT id, norad_id, file_path, metadata_json, created_at FROM ground_station_outputs WHERE output_type='image' AND backend='meteor_lrpt' ORDER BY created_at DESC LIMIT 200 """ ).fetchall() except Exception as e: logger.debug("Failed to fetch ground-station weather outputs: %s", e) return [] images: list[dict] = [] for row in rows: file_path = Path(row["file_path"]) if not file_path.exists(): continue metadata = {} raw_metadata = row["metadata_json"] if raw_metadata: try: metadata = json.loads(raw_metadata) except json.JSONDecodeError: metadata = {} satellite = metadata.get("satellite") or _satellite_from_norad(row["norad_id"]) images.append( { "filename": file_path.name, "satellite": satellite, "mode": metadata.get("mode", "LRPT"), "timestamp": metadata.get("timestamp") or row["created_at"], "frequency": metadata.get("frequency", 137.9), "size_bytes": metadata.get("size_bytes") or file_path.stat().st_size, "product": metadata.get("product", ""), "url": f"/weather-sat/images/shared/{row['id']}", "source": "ground_station", "deletable": False, "output_id": row["id"], } ) return images def _satellite_from_norad(norad_id: int | None) -> str: for satellite, known_norad in METEOR_NORAD_IDS.items(): if known_norad == norad_id: return satellite return "METEOR" @weather_sat_bp.route("/stream") def stream_progress(): """SSE stream of capture/decode progress. Returns: SSE stream (text/event-stream) """ response = Response(sse_stream(_weather_sat_queue), mimetype="text/event-stream") response.headers["Cache-Control"] = "no-cache" response.headers["X-Accel-Buffering"] = "no" response.headers["Connection"] = "keep-alive" return response @weather_sat_bp.route("/passes") def get_passes(): """Get upcoming weather satellite passes for observer location. Query parameters: latitude: Observer latitude (required) longitude: Observer longitude (required) hours: Hours to predict ahead (default: 24, max: 72) min_elevation: Minimum elevation in degrees (default: 15) trajectory: Include az/el trajectory points (default: false) ground_track: Include lat/lon ground track points (default: false) Returns: JSON with upcoming passes for all weather satellites. """ include_trajectory = request.args.get("trajectory", "false").lower() in ("true", "1") include_ground_track = request.args.get("ground_track", "false").lower() in ("true", "1") raw_lat = request.args.get("latitude") raw_lon = request.args.get("longitude") if raw_lat is None or raw_lon is None: return api_error("latitude and longitude parameters required", 400) try: lat = validate_latitude(raw_lat) lon = validate_longitude(raw_lon) except ValueError as e: logger.warning("Invalid coordinates in get_passes: %s", e) return api_error("Invalid coordinates", 400) hours = max(1, min(request.args.get("hours", 24, type=int), 72)) min_elevation = max(0, min(request.args.get("min_elevation", 15, type=float), 90)) try: from utils.weather_sat_predict import predict_passes all_passes = predict_passes( lat=lat, lon=lon, hours=hours, min_elevation=min_elevation, include_trajectory=include_trajectory, include_ground_track=include_ground_track, ) return jsonify( { "status": "ok", "passes": all_passes, "count": len(all_passes), "observer": {"latitude": lat, "longitude": lon}, "prediction_hours": hours, "min_elevation": min_elevation, } ) except ImportError: return jsonify({"status": "error", "message": "skyfield library not installed"}), 503 except Exception as e: logger.error(f"Error predicting passes: {e}") return jsonify({"status": "error", "message": "Pass prediction failed"}), 500 # ======================== # Auto-Scheduler Endpoints # ======================== def _scheduler_event_callback(event: dict) -> None: """Forward scheduler events to the SSE queue.""" try: _weather_sat_queue.put_nowait(event) except queue.Full: try: _weather_sat_queue.get_nowait() _weather_sat_queue.put_nowait(event) except queue.Empty: pass @weather_sat_bp.route("/schedule/enable", methods=["POST"]) def enable_schedule(): """Enable auto-scheduling of weather satellite captures. JSON body: { "latitude": 51.5, // Required "longitude": -0.1, // Required "min_elevation": 15, // Minimum pass elevation (default: 15) "device": 0, // RTL-SDR device index (default: 0) "gain": 30.0, // SDR gain (default: 30) "bias_t": false // Enable bias-T (default: false) } Returns: JSON with scheduler status. """ from utils.weather_sat_scheduler import get_weather_sat_scheduler data = request.get_json(silent=True) or {} if data.get("latitude") is None or data.get("longitude") is None: return jsonify({"status": "error", "message": "latitude and longitude required"}), 400 try: lat = validate_latitude(data.get("latitude")) lon = validate_longitude(data.get("longitude")) min_elev = validate_elevation(data.get("min_elevation", 15)) device = validate_device_index(data.get("device", 0)) gain_val = validate_gain(data.get("gain", 40.0)) except ValueError as e: logger.warning("Invalid parameter in enable_schedule: %s", e) return jsonify({"status": "error", "message": "Invalid parameter value"}), 400 scheduler = get_weather_sat_scheduler() scheduler.set_callbacks(_progress_callback, _scheduler_event_callback) try: result = scheduler.enable( lat=lat, lon=lon, min_elevation=min_elev, device=device, gain=gain_val, bias_t=bool(data.get("bias_t", False)), ) except Exception: logger.exception("Failed to enable weather sat scheduler") return jsonify({"status": "error", "message": "Failed to enable scheduler"}), 500 return jsonify({"status": "ok", **result}) @weather_sat_bp.route("/schedule/disable", methods=["POST"]) def disable_schedule(): """Disable auto-scheduling.""" from utils.weather_sat_scheduler import get_weather_sat_scheduler scheduler = get_weather_sat_scheduler() result = scheduler.disable() return jsonify(result) @weather_sat_bp.route("/schedule/status") def schedule_status(): """Get current scheduler state.""" from utils.weather_sat_scheduler import get_weather_sat_scheduler scheduler = get_weather_sat_scheduler() return jsonify(scheduler.get_status()) @weather_sat_bp.route("/schedule/passes") def schedule_passes(): """List scheduled passes.""" from utils.weather_sat_scheduler import get_weather_sat_scheduler scheduler = get_weather_sat_scheduler() passes = scheduler.get_passes() return jsonify( { "status": "ok", "passes": passes, "count": len(passes), } ) @weather_sat_bp.route("/schedule/skip/", methods=["POST"]) def skip_pass(pass_id: str): """Skip a scheduled pass.""" from utils.weather_sat_scheduler import get_weather_sat_scheduler if not pass_id.replace("_", "").replace("-", "").isalnum(): return api_error("Invalid pass ID", 400) scheduler = get_weather_sat_scheduler() if scheduler.skip_pass(pass_id): return jsonify({"status": "skipped", "pass_id": pass_id}) else: return api_error("Pass not found or already processed", 404)