diff --git a/config.py b/config.py index c56c1f5..efd1307 100644 --- a/config.py +++ b/config.py @@ -459,7 +459,7 @@ 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_DEFAULT_GAIN = _get_env_float('WEATHER_SAT_GAIN', 30.0) WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 2400000) 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) diff --git a/routes/weather_sat.py b/routes/weather_sat.py index fa9445b..ffa923a 100644 --- a/routes/weather_sat.py +++ b/routes/weather_sat.py @@ -1,17 +1,17 @@ -"""Weather Satellite decoder routes. - -Provides endpoints for capturing and decoding Meteor LRPT weather -imagery, including shared results produced by the ground-station -observation pipeline. -""" +"""Weather Satellite decoder routes. -from __future__ import annotations - -import json -import queue -from pathlib import Path - -from flask import Blueprint, Response, jsonify, request, send_file +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 @@ -33,21 +33,21 @@ from utils.weather_sat import ( is_weather_sat_available, ) -logger = get_logger('intercept.weather_sat') - -weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat') +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', -) +_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: @@ -132,9 +132,9 @@ def start_capture(): JSON body: { - "satellite": "METEOR-M2-3", // Required: satellite key + "satellite": "METEOR-M2-3", // Required: satellite key "device": 0, // RTL-SDR device index (default: 0) - "gain": 40.0, // SDR gain in dB (default: 40) + "gain": 30.0, // SDR gain in dB (default: 30) "bias_t": false // Enable bias-T for LNA (default: false) } @@ -176,7 +176,7 @@ def start_capture(): # Validate device index and gain try: device_index = validate_device_index(data.get('device', 0)) - gain = validate_gain(data.get('gain', 40.0)) + gain = validate_gain(data.get('gain', 30.0)) except ValueError as e: logger.warning('Invalid parameter in start_capture: %s', e) return jsonify({ @@ -260,7 +260,7 @@ def test_decode(): JSON body: { - "satellite": "METEOR-M2-3", // Required: satellite key + "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) } @@ -304,14 +304,14 @@ def test_decode(): 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 + # 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', @@ -388,8 +388,8 @@ def stop_capture(): return jsonify({'status': 'stopped'}) -@weather_sat_bp.route('/images') -def list_images(): +@weather_sat_bp.route('/images') +def list_images(): """Get list of decoded weather satellite images. Query parameters: @@ -399,41 +399,41 @@ def list_images(): 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), - }) + 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): +@weather_sat_bp.route('/images/') +def get_image(filename: str): """Serve a decoded weather satellite image file. Args: @@ -456,38 +456,38 @@ def get_image(filename: str): 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) + 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']) @@ -512,71 +512,71 @@ def delete_image(filename: str): @weather_sat_bp.route('/images', methods=['DELETE']) -def delete_all_images(): +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' + 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') @@ -689,7 +689,7 @@ def enable_schedule(): "longitude": -0.1, // Required "min_elevation": 15, // Minimum pass elevation (default: 15) "device": 0, // RTL-SDR device index (default: 0) - "gain": 40.0, // SDR gain (default: 40) + "gain": 30.0, // SDR gain (default: 30) "bias_t": false // Enable bias-T (default: false) } diff --git a/templates/partials/modes/weather-satellite.html b/templates/partials/modes/weather-satellite.html index d6471fd..5fdb2c0 100644 --- a/templates/partials/modes/weather-satellite.html +++ b/templates/partials/modes/weather-satellite.html @@ -2,13 +2,13 @@

Weather Satellite Decoder

-
- ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions. -
-

- Receive and decode Meteor LRPT weather imagery. - Uses SatDump for live SDR capture and image processing, and also shows Meteor imagery produced by the ground-station scheduler. -

+
+ ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions. +
+

+ Receive and decode Meteor LRPT weather imagery. + Uses SatDump for live SDR capture and image processing, and also shows Meteor imagery produced by the ground-station scheduler. +

@@ -18,11 +18,12 @@ -
+ +
- + +

Reduce if decoding fails on strong passes (ADC saturation).

@@ -132,8 +133,8 @@
  • Antenna up: Point the antenna straight UP (zenith) for best overhead coverage
  • Avoid: Metal roofs, power lines, buildings blocking the sky
  • Coax length: Keep short (<10m). Signal loss at 137 MHz is ~3 dB per 10m of RG-58
  • -
  • LNA: Mount at the antenna feed point, NOT at the SDR end. - Recommended: a low-noise 137 MHz filtered LNA near the antenna feed point
  • +
  • LNA: Mount at the antenna feed point, NOT at the SDR end. + Recommended: a low-noise 137 MHz filtered LNA near the antenna feed point
  • Bias-T: Enable the Bias-T checkbox above if your LNA is powered via the coax from the SDR
  • @@ -162,9 +163,9 @@ Polarization RHCP - - Meteor (LRPT) bandwidth - ~140 kHz + + Meteor (LRPT) bandwidth + ~140 kHz @@ -177,29 +178,29 @@ + + SatDump Documentation + + + Meteor Reception Guide + + + +