From 4cf394f92e8243c0cb3625331e01f6abd92642d2 Mon Sep 17 00:00:00 2001 From: James Smith Date: Wed, 18 Mar 2026 22:20:24 +0000 Subject: [PATCH] Persist Meteor decode job state --- routes/ground_station.py | 46 +++ routes/weather_sat.py | 214 +++++++++---- static/js/modes/weather-satellite.js | 55 +++- .../partials/modes/weather-satellite.html | 106 +++---- templates/satellite_dashboard.html | 36 ++- utils/database.py | 24 ++ utils/ground_station/meteor_backend.py | 292 +++++++++++++++++- utils/weather_sat.py | 98 +++--- 8 files changed, 699 insertions(+), 172 deletions(-) diff --git a/routes/ground_station.py b/routes/ground_station.py index 6b6b4dd..8144931 100644 --- a/routes/ground_station.py +++ b/routes/ground_station.py @@ -379,6 +379,52 @@ def download_output(output_id: int): return jsonify({'error': str(e)}), 500 +@ground_station_bp.route('/decode-jobs', methods=['GET']) +def list_decode_jobs(): + try: + query = ''' + SELECT * FROM ground_station_decode_jobs + WHERE (? IS NULL OR norad_id = ?) + AND (? IS NULL OR observation_id = ?) + AND (? IS NULL OR backend = ?) + ORDER BY created_at DESC + LIMIT ? + ''' + norad_id = request.args.get('norad_id', type=int) + observation_id = request.args.get('observation_id', type=int) + backend = request.args.get('backend') + limit = min(request.args.get('limit', 20, type=int) or 20, 200) + + from utils.database import get_db + with get_db() as conn: + rows = conn.execute( + query, + ( + norad_id, norad_id, + observation_id, observation_id, + backend, backend, + limit, + ), + ).fetchall() + + results = [] + for row in rows: + item = dict(row) + details_raw = item.get('details_json') + if details_raw: + try: + item['details'] = json.loads(details_raw) + except json.JSONDecodeError: + item['details'] = {} + else: + item['details'] = {} + item.pop('details_json', None) + results.append(item) + return jsonify(results) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + # --------------------------------------------------------------------------- # Phase 5 — Live waterfall WebSocket # --------------------------------------------------------------------------- diff --git a/routes/weather_sat.py b/routes/weather_sat.py index 290b883..fa9445b 100644 --- a/routes/weather_sat.py +++ b/routes/weather_sat.py @@ -1,14 +1,17 @@ -"""Weather Satellite decoder routes. +"""Weather Satellite decoder routes. + +Provides endpoints for capturing and decoding Meteor LRPT weather +imagery, including shared results produced by the ground-station +observation pipeline. +""" -Provides endpoints for capturing and decoding weather satellite images -from NOAA (APT) and Meteor (LRPT) satellites using SatDump. -""" - -from __future__ import annotations - -import queue - -from flask import Blueprint, Response, jsonify, request, send_file +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 @@ -30,12 +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) +_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: @@ -120,7 +132,7 @@ def start_capture(): JSON body: { - "satellite": "NOAA-18", // 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) "bias_t": false // Enable bias-T for LNA (default: false) @@ -248,7 +260,7 @@ def test_decode(): JSON body: { - "satellite": "NOAA-18", // 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) } @@ -292,15 +304,14 @@ def test_decode(): from pathlib import Path input_path = Path(input_file) - # Security: restrict to data directory (anchored to app root, not CWD) - allowed_base = Path(__file__).resolve().parent.parent / 'data' - try: - resolved = input_path.resolve() - if not resolved.is_relative_to(allowed_base): - return jsonify({ - 'status': 'error', - 'message': 'input_file must be under the data/ directory' - }), 403 + # 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', @@ -377,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: @@ -388,28 +399,41 @@ def list_images(): 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), - }) + 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: @@ -432,8 +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) + 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']) @@ -458,15 +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}) + 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') diff --git a/static/js/modes/weather-satellite.js b/static/js/modes/weather-satellite.js index c12eb90..e563f05 100644 --- a/static/js/modes/weather-satellite.js +++ b/static/js/modes/weather-satellite.js @@ -1,6 +1,6 @@ /** * Weather Satellite Mode - * NOAA APT and Meteor LRPT decoder interface with auto-scheduler, + * Meteor LRPT decoder interface with auto-scheduler, * polar plot, styled real-world map, countdown, and timeline. */ @@ -28,6 +28,7 @@ const WeatherSat = (function() { let currentModalFilename = null; let locationListenersAttached = false; let initialized = false; + let imageRefreshInterval = null; /** * Initialize the Weather Satellite mode @@ -52,6 +53,7 @@ const WeatherSat = (function() { startCountdownTimer(); checkSchedulerStatus(); initGroundMap(); + ensureImageRefresh(); } /** @@ -137,7 +139,12 @@ const WeatherSat = (function() { if (latInput) latInput.addEventListener('change', saveLocationFromInputs); if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs); const satSelect = document.getElementById('weatherSatSelect'); - if (satSelect) satSelect.addEventListener('change', applyPassFilter); + if (satSelect) { + satSelect.addEventListener('change', () => { + applyPassFilter(); + loadImages(); + }); + } locationListenersAttached = true; } } @@ -536,6 +543,7 @@ const WeatherSat = (function() { updatePhaseIndicator('error'); if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer); consoleAutoHideTimer = setTimeout(() => showConsole(false), 15000); + loadImages(); } } @@ -1549,7 +1557,12 @@ const WeatherSat = (function() { */ async function loadImages() { try { - const response = await fetch('/weather-sat/images'); + const satSelect = document.getElementById('weatherSatSelect'); + const selectedSatellite = satSelect?.value || ''; + const url = selectedSatellite + ? `/weather-sat/images?satellite=${encodeURIComponent(selectedSatellite)}` + : '/weather-sat/images'; + const response = await fetch(url); const data = await response.json(); if (data.status === 'ok') { @@ -1614,6 +1627,14 @@ const WeatherSat = (function() { html += `
${escapeHtml(date)}
`; html += imgs.map(img => { const fn = escapeHtml(img.filename || img.url.split('/').pop()); + const deleteButton = img.deletable === false ? '' : ` +
+ +
`; return `
@@ -1624,13 +1645,7 @@ const WeatherSat = (function() {
${formatTimestamp(img.timestamp)}
-
- -
+ ${deleteButton} `; }).join(''); } @@ -1722,9 +1737,14 @@ const WeatherSat = (function() { */ async function deleteAllImages() { if (images.length === 0) return; + const deletableCount = images.filter(img => img.deletable !== false).length; + if (deletableCount === 0) { + showNotification('Weather Sat', 'Only shared ground-station imagery is available here'); + return; + } const confirmed = await AppFeedback.confirmAction({ title: 'Delete All Images', - message: `Delete all ${images.length} decoded images? This cannot be undone.`, + message: `Delete all ${deletableCount} local decoded images? Shared ground-station outputs will be kept.`, confirmLabel: 'Delete All', confirmClass: 'btn-danger' }); @@ -1735,8 +1755,8 @@ const WeatherSat = (function() { const data = await response.json(); if (data.status === 'ok') { - images = []; - updateImageCount(0); + images = images.filter(img => img.deletable === false); + updateImageCount(images.length); renderGallery(); showNotification('Weather Sat', `Deleted ${data.deleted} images`); } else { @@ -1760,6 +1780,15 @@ const WeatherSat = (function() { } } + function ensureImageRefresh() { + if (imageRefreshInterval) return; + imageRefreshInterval = setInterval(() => { + const mode = document.getElementById('weatherSatMode'); + if (!mode || !mode.classList.contains('active')) return; + loadImages(); + }, 30000); + } + /** * Escape HTML */ diff --git a/templates/partials/modes/weather-satellite.html b/templates/partials/modes/weather-satellite.html index c43f671..d6471fd 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 weather images from NOAA and Meteor satellites. - Uses SatDump for live SDR capture and image processing. -

+
+ 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,8 @@ -
+ +
@@ -72,7 +69,7 @@
  • Connection: Solder elements to coax center + shield, connect to SDR via SMA
  • - Best starter antenna. Good enough for clear NOAA images with a direct overhead pass. + Best starter antenna. Good enough for a clean Meteor LRPT pass when the satellite gets high overhead.

    @@ -135,8 +132,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: Nooelec SAWbird+ NOAA (137 MHz filtered LNA, ~$30)
  • +
  • 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
  • @@ -165,13 +162,9 @@ Polarization RHCP - - NOAA (APT) bandwidth - ~40 kHz - - - Meteor (LRPT) bandwidth - ~140 kHz + + Meteor (LRPT) bandwidth + ~140 kHz @@ -184,34 +177,29 @@ + + SatDump Documentation + + + Meteor Reception Guide + + + + diff --git a/templates/satellite_dashboard.html b/templates/satellite_dashboard.html index 0ec6f7f..0b64d3a 100644 --- a/templates/satellite_dashboard.html +++ b/templates/satellite_dashboard.html @@ -1962,12 +1962,15 @@ const list = document.getElementById('gsOutputsList'); const status = document.getElementById('gsDecodeStatus'); if (!panel || !list || !norad) return; + gsLoadDecodeJobs(norad); fetch(`/ground_station/outputs?norad_id=${encodeURIComponent(norad)}&type=image`) .then(r => r.json()) .then(outputs => { if (!Array.isArray(outputs) || !outputs.length) { - panel.style.display = 'none'; - if (status) status.style.display = 'none'; + if (!status || !status.textContent) { + panel.style.display = 'none'; + if (status) status.style.display = 'none'; + } return; } panel.style.display = ''; @@ -1984,6 +1987,35 @@ .catch(() => {}); } + function gsLoadDecodeJobs(norad) { + const panel = document.getElementById('gsOutputsPanel'); + const status = document.getElementById('gsDecodeStatus'); + if (!panel || !status || !norad) return; + fetch(`/ground_station/decode-jobs?norad_id=${encodeURIComponent(norad)}&backend=meteor_lrpt&limit=1`) + .then(r => r.json()) + .then(jobs => { + if (!Array.isArray(jobs) || !jobs.length) return; + const job = jobs[0]; + const details = job.details || {}; + let message = ''; + if (job.status === 'queued') { + message = 'Decode queued'; + } else if (job.status === 'decoding') { + message = 'Decode in progress'; + } else if (job.status === 'failed') { + message = job.error_message || details.message || 'Decode failed'; + } else if (job.status === 'complete') { + const count = details.output_count; + message = count ? `Decode complete (${count} image${count === 1 ? '' : 's'})` : 'Decode complete'; + } + if (!message) return; + status.textContent = message; + status.style.display = ''; + panel.style.display = ''; + }) + .catch(() => {}); + } + function _updateDecodeStatus(data) { const panel = document.getElementById('gsOutputsPanel'); const status = document.getElementById('gsDecodeStatus'); diff --git a/utils/database.py b/utils/database.py index 43b445e..89fb19d 100644 --- a/utils/database.py +++ b/utils/database.py @@ -717,6 +717,25 @@ def init_db() -> None: FOREIGN KEY (observation_id) REFERENCES ground_station_observations(id) ON DELETE CASCADE ) ''') + + conn.execute(''' + CREATE TABLE IF NOT EXISTS ground_station_decode_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + observation_id INTEGER, + norad_id INTEGER, + backend TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + input_path TEXT, + output_dir TEXT, + error_message TEXT, + details_json TEXT, + started_at TIMESTAMP, + completed_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (observation_id) REFERENCES ground_station_observations(id) ON DELETE CASCADE + ) + ''') conn.execute(''' CREATE INDEX IF NOT EXISTS idx_gs_observations_norad @@ -733,6 +752,11 @@ def init_db() -> None: ON ground_station_outputs(observation_id, created_at) ''') + conn.execute(''' + CREATE INDEX IF NOT EXISTS idx_gs_decode_jobs_observation + ON ground_station_decode_jobs(observation_id, created_at) + ''') + # Lightweight schema migrations for existing installs. profile_cols = { row['name'] for row in conn.execute('PRAGMA table_info(observation_profiles)') diff --git a/utils/ground_station/meteor_backend.py b/utils/ground_station/meteor_backend.py index 685b05b..1d303ab 100644 --- a/utils/ground_station/meteor_backend.py +++ b/utils/ground_station/meteor_backend.py @@ -2,8 +2,10 @@ from __future__ import annotations +import json import threading import time +from datetime import datetime, timezone from pathlib import Path from utils.logging import get_logger @@ -43,9 +45,24 @@ def launch_meteor_decode( register_output, ) -> None: """Run Meteor LRPT offline decode in a background thread.""" + decode_job_id = _create_decode_job( + observation_id=obs_db_id, + norad_id=norad_id, + backend='meteor_lrpt', + input_path=data_path, + ) + emit_event({ + 'type': 'weather_decode_queued', + 'decode_job_id': decode_job_id, + 'norad_id': norad_id, + 'satellite': satellite_name, + 'backend': 'meteor_lrpt', + 'input_path': str(data_path), + }) t = threading.Thread( target=_run_decode, kwargs={ + 'decode_job_id': decode_job_id, 'obs_db_id': obs_db_id, 'norad_id': norad_id, 'satellite_name': satellite_name, @@ -62,6 +79,7 @@ def launch_meteor_decode( def _run_decode( *, + decode_job_id: int | None, obs_db_id: int | None, norad_id: int, satellite_name: str, @@ -70,10 +88,23 @@ def _run_decode( emit_event, register_output, ) -> None: + latest_status: dict[str, str | int | None] = { + 'message': None, + 'status': None, + 'phase': None, + } sat_key = resolve_meteor_satellite_key(norad_id, satellite_name) if not sat_key: + _update_decode_job( + decode_job_id, + status='failed', + error_message='No Meteor satellite mapping is available for this observation.', + details={'reason': 'unknown_satellite_mapping'}, + completed=True, + ) emit_event({ 'type': 'weather_decode_failed', + 'decode_job_id': decode_job_id, 'norad_id': norad_id, 'satellite': satellite_name, 'backend': 'meteor_lrpt', @@ -84,8 +115,16 @@ def _run_decode( output_dir = OUTPUT_ROOT / f'{norad_id}_{int(time.time())}' decoder = WeatherSatDecoder(output_dir=output_dir) if decoder.decoder_available is None: + _update_decode_job( + decode_job_id, + status='failed', + error_message='SatDump backend is not available for Meteor LRPT decode.', + details={'reason': 'backend_unavailable', 'output_dir': str(output_dir)}, + completed=True, + ) emit_event({ 'type': 'weather_decode_failed', + 'decode_job_id': decode_job_id, 'norad_id': norad_id, 'satellite': satellite_name, 'backend': 'meteor_lrpt', @@ -94,10 +133,14 @@ def _run_decode( return def _progress_cb(progress): + latest_status['message'] = progress.message or latest_status.get('message') + latest_status['status'] = progress.status + latest_status['phase'] = progress.capture_phase or latest_status.get('phase') progress_event = progress.to_dict() progress_event.pop('type', None) emit_event({ 'type': 'weather_decode_progress', + 'decode_job_id': decode_job_id, 'norad_id': norad_id, 'satellite': satellite_name, 'backend': 'meteor_lrpt', @@ -105,8 +148,20 @@ def _run_decode( }) decoder.set_callback(_progress_cb) + _update_decode_job( + decode_job_id, + status='decoding', + output_dir=output_dir, + details={ + 'sample_rate': sample_rate, + 'input_path': str(data_path), + 'satellite': satellite_name, + }, + started=True, + ) emit_event({ 'type': 'weather_decode_started', + 'decode_job_id': decode_job_id, 'norad_id': norad_id, 'satellite': satellite_name, 'backend': 'meteor_lrpt', @@ -119,13 +174,29 @@ def _run_decode( sample_rate=sample_rate, ) if not ok: + details = _build_failure_details( + data_path=data_path, + sample_rate=sample_rate, + decoder=decoder, + latest_status=latest_status, + ) emit_event({ 'type': 'weather_decode_failed', + 'decode_job_id': decode_job_id, 'norad_id': norad_id, 'satellite': satellite_name, 'backend': 'meteor_lrpt', - 'message': error or 'Meteor decode failed to start.', + 'message': error or details['message'], + 'failure_reason': details['reason'], + 'details': details, }) + _update_decode_job( + decode_job_id, + status='failed', + error_message=error or details['message'], + details=details, + completed=True, + ) return started = time.time() @@ -134,24 +205,58 @@ def _run_decode( if decoder.is_running: decoder.stop() + details = _build_failure_details( + data_path=data_path, + sample_rate=sample_rate, + decoder=decoder, + latest_status=latest_status, + override_reason='timeout', + override_message='Meteor decode timed out.', + ) emit_event({ 'type': 'weather_decode_failed', + 'decode_job_id': decode_job_id, 'norad_id': norad_id, 'satellite': satellite_name, 'backend': 'meteor_lrpt', - 'message': 'Meteor decode timed out.', + 'message': details['message'], + 'failure_reason': details['reason'], + 'details': details, }) + _update_decode_job( + decode_job_id, + status='failed', + error_message=details['message'], + details=details, + completed=True, + ) return images = decoder.get_images() if not images: + details = _build_failure_details( + data_path=data_path, + sample_rate=sample_rate, + decoder=decoder, + latest_status=latest_status, + ) emit_event({ 'type': 'weather_decode_failed', + 'decode_job_id': decode_job_id, 'norad_id': norad_id, 'satellite': satellite_name, 'backend': 'meteor_lrpt', - 'message': 'Decode completed but no image outputs were produced.', + 'message': details['message'], + 'failure_reason': details['reason'], + 'details': details, }) + _update_decode_job( + decode_job_id, + status='failed', + error_message=details['message'], + details=details, + completed=True, + ) return outputs = [] @@ -180,10 +285,191 @@ def _run_decode( 'product': image.product, }) + completion_details = { + 'sample_rate': sample_rate, + 'input_path': str(data_path), + 'output_dir': str(output_dir), + 'output_count': len(outputs), + } + _update_decode_job( + decode_job_id, + status='complete', + details=completion_details, + completed=True, + ) + emit_event({ 'type': 'weather_decode_complete', + 'decode_job_id': decode_job_id, 'norad_id': norad_id, 'satellite': satellite_name, 'backend': 'meteor_lrpt', 'outputs': outputs, }) + + +def _build_failure_details( + *, + data_path: Path, + sample_rate: int, + decoder: WeatherSatDecoder, + latest_status: dict[str, str | int | None], + override_reason: str | None = None, + override_message: str | None = None, +) -> dict[str, str | int | None]: + file_size = data_path.stat().st_size if data_path.exists() else 0 + status = decoder.get_status() + last_error = str(status.get('last_error') or latest_status.get('message') or '').strip() + return_code = status.get('last_returncode') + + if override_reason: + reason = override_reason + elif sample_rate < 200_000: + reason = 'sample_rate_too_low' + elif not data_path.exists(): + reason = 'missing_recording' + elif file_size < 5_000_000: + reason = 'recording_too_small' + elif return_code not in (None, 0): + reason = 'satdump_failed' + elif 'samplerate' in last_error.lower() or 'sample rate' in last_error.lower(): + reason = 'invalid_sample_rate' + elif 'not found' in last_error.lower(): + reason = 'input_missing' + elif 'permission' in last_error.lower(): + reason = 'permission_error' + else: + reason = 'no_imagery_produced' + + if override_message: + message = override_message + elif reason == 'sample_rate_too_low': + message = f'Sample rate {sample_rate} Hz is too low for Meteor LRPT decoding.' + elif reason == 'missing_recording': + message = 'The recording file was not found when decode started.' + elif reason == 'recording_too_small': + message = ( + f'Recording is very small ({_format_bytes(file_size)}); this usually means the pass ' + 'ended early or little usable IQ was captured.' + ) + elif reason == 'satdump_failed': + message = last_error or f'SatDump exited with code {return_code}.' + elif reason == 'invalid_sample_rate': + message = last_error or 'SatDump rejected the recording sample rate.' + elif reason == 'input_missing': + message = last_error or 'Input recording was not accessible to the decoder.' + elif reason == 'permission_error': + message = last_error or 'Decoder could not access the recording or output path.' + else: + message = ( + last_error or + 'Decode completed without any image outputs. This usually indicates weak signal, ' + 'incorrect sample rate, or a SatDump pipeline mismatch.' + ) + + return { + 'reason': reason, + 'message': message, + 'sample_rate': sample_rate, + 'file_size_bytes': file_size, + 'file_size_human': _format_bytes(file_size), + 'last_error': last_error or None, + 'last_returncode': return_code, + 'capture_phase': status.get('capture_phase') or latest_status.get('phase'), + 'input_path': str(data_path), + } + + +def _format_bytes(size_bytes: int) -> str: + if size_bytes < 1024: + return f'{size_bytes} B' + if size_bytes < 1024 * 1024: + return f'{size_bytes / 1024:.1f} KB' + if size_bytes < 1024 * 1024 * 1024: + return f'{size_bytes / (1024 * 1024):.1f} MB' + return f'{size_bytes / (1024 * 1024 * 1024):.2f} GB' + + +def _create_decode_job( + *, + observation_id: int | None, + norad_id: int, + backend: str, + input_path: Path, +) -> int | None: + try: + from utils.database import get_db + + with get_db() as conn: + cur = conn.execute( + ''' + INSERT INTO ground_station_decode_jobs + (observation_id, norad_id, backend, status, input_path, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ''', + ( + observation_id, + norad_id, + backend, + 'queued', + str(input_path), + _utcnow_iso(), + _utcnow_iso(), + ), + ) + return cur.lastrowid + except Exception as e: + logger.warning("Failed to create decode job: %s", e) + return None + + +def _update_decode_job( + decode_job_id: int | None, + *, + status: str, + output_dir: Path | None = None, + error_message: str | None = None, + details: dict | None = None, + started: bool = False, + completed: bool = False, +) -> None: + if decode_job_id is None: + return + try: + from utils.database import get_db + + fields = ['status = ?', 'updated_at = ?'] + values: list[object] = [status, _utcnow_iso()] + + if output_dir is not None: + fields.append('output_dir = ?') + values.append(str(output_dir)) + if error_message is not None: + fields.append('error_message = ?') + values.append(error_message) + if details is not None: + fields.append('details_json = ?') + values.append(json.dumps(details)) + if started: + fields.append('started_at = ?') + values.append(_utcnow_iso()) + if completed: + fields.append('completed_at = ?') + values.append(_utcnow_iso()) + + values.append(decode_job_id) + with get_db() as conn: + conn.execute( + f''' + UPDATE ground_station_decode_jobs + SET {", ".join(fields)} + WHERE id = ? + ''', + values, + ) + except Exception as e: + logger.warning("Failed to update decode job %s: %s", decode_job_id, e) + + +def _utcnow_iso() -> str: + return datetime.now(timezone.utc).isoformat() diff --git a/utils/weather_sat.py b/utils/weather_sat.py index 78dd4c0..ee58b72 100644 --- a/utils/weather_sat.py +++ b/utils/weather_sat.py @@ -177,12 +177,14 @@ class WeatherSatDecoder: self._pty_master_fd: int | None = None self._current_satellite: str = '' self._current_frequency: float = 0.0 - self._current_mode: str = '' - self._capture_start_time: float = 0 - self._device_index: int = -1 - self._capture_output_dir: Path | None = None - self._on_complete_callback: Callable[[], None] | None = None - self._capture_phase: str = 'idle' + self._current_mode: str = '' + self._capture_start_time: float = 0 + self._device_index: int = -1 + self._capture_output_dir: Path | None = None + self._on_complete_callback: Callable[[], None] | None = None + self._capture_phase: str = 'idle' + self._last_error_message: str = '' + self._last_process_returncode: int | None = None # Ensure output directory exists self._output_dir.mkdir(parents=True, exist_ok=True) @@ -314,11 +316,13 @@ class WeatherSatDecoder: self._current_satellite = satellite self._current_frequency = sat_info['frequency'] - self._current_mode = sat_info['mode'] - self._device_index = -1 # Offline decode does not claim an SDR device - self._capture_start_time = time.time() - self._capture_phase = 'decoding' - self._stop_event.clear() + self._current_mode = sat_info['mode'] + self._device_index = -1 # Offline decode does not claim an SDR device + self._capture_start_time = time.time() + self._capture_phase = 'decoding' + self._last_error_message = '' + self._last_process_returncode = None + self._stop_event.clear() try: self._running = True @@ -408,11 +412,13 @@ class WeatherSatDecoder: self._current_satellite = satellite self._current_frequency = sat_info['frequency'] - self._current_mode = sat_info['mode'] - self._device_index = device_index - self._capture_start_time = time.time() - self._capture_phase = 'tuning' - self._stop_event.clear() + self._current_mode = sat_info['mode'] + self._device_index = device_index + self._capture_start_time = time.time() + self._capture_phase = 'tuning' + self._last_error_message = '' + self._last_process_returncode = None + self._stop_event.clear() try: self._running = True @@ -886,16 +892,17 @@ class WeatherSatDecoder: if was_running: # Collect exit status (returncode is only set after poll/wait) - if process and process.returncode is None: - try: - process.wait(timeout=5) - except subprocess.TimeoutExpired: - process.kill() - process.wait() - retcode = process.returncode if process else None - if retcode and retcode != 0: - self._capture_phase = 'error' - self._emit_progress(CaptureProgress( + if process and process.returncode is None: + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + retcode = process.returncode if process else None + self._last_process_returncode = retcode + if retcode and retcode != 0: + self._capture_phase = 'error' + self._emit_progress(CaptureProgress( status='error', satellite=self._current_satellite, frequency=self._current_frequency, @@ -1138,13 +1145,15 @@ class WeatherSatDecoder: self._images.clear() return count - def _emit_progress(self, progress: CaptureProgress) -> None: - """Emit progress update to callback.""" - if self._callback: - try: - self._callback(progress) - except Exception as e: - logger.error(f"Error in progress callback: {e}") + def _emit_progress(self, progress: CaptureProgress) -> None: + """Emit progress update to callback.""" + if progress.status == 'error' and progress.message: + self._last_error_message = str(progress.message) + if self._callback: + try: + self._callback(progress) + except Exception as e: + logger.error(f"Error in progress callback: {e}") def get_status(self) -> dict: """Get current decoder status.""" @@ -1152,16 +1161,19 @@ class WeatherSatDecoder: if self._running and self._capture_start_time: elapsed = int(time.time() - self._capture_start_time) - return { - 'available': self._decoder is not None, - 'decoder': self._decoder, - 'running': self._running, - 'satellite': self._current_satellite, - 'frequency': self._current_frequency, - 'mode': self._current_mode, - 'elapsed_seconds': elapsed, - 'image_count': len(self._images), - } + return { + 'available': self._decoder is not None, + 'decoder': self._decoder, + 'running': self._running, + 'satellite': self._current_satellite, + 'frequency': self._current_frequency, + 'mode': self._current_mode, + 'capture_phase': self._capture_phase, + 'elapsed_seconds': elapsed, + 'image_count': len(self._images), + 'last_error': self._last_error_message, + 'last_returncode': self._last_process_returncode, + } # Global decoder instance