Persist Meteor decode job state

This commit is contained in:
James Smith
2026-03-18 22:20:24 +00:00
parent e388baa464
commit 4cf394f92e
8 changed files with 699 additions and 172 deletions

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -1,12 +1,15 @@
"""Weather Satellite decoder routes.
Provides endpoints for capturing and decoding weather satellite images
from NOAA (APT) and Meteor (LRPT) satellites using SatDump.
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
@@ -37,6 +40,15 @@ 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."""
@@ -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,14 +304,13 @@ 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'
# Restrict test-decode to application-owned sample and recording paths.
try:
resolved = input_path.resolve()
if not resolved.is_relative_to(allowed_base):
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 the data/ directory'
'message': 'input_file must be under INTERCEPT data or ground-station recordings'
}), 403
except (OSError, ValueError):
return jsonify({
@@ -389,21 +400,34 @@ def list_images():
JSON with list of decoded images.
"""
decoder = get_weather_sat_decoder()
images = decoder.get_images()
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 img.satellite == 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:]
images = images[:limit]
return jsonify({
'status': 'ok',
'images': [img.to_dict() for img in images],
'images': images,
'count': len(images),
})
@@ -436,6 +460,36 @@ def get_image(filename: str):
return send_file(image_path, mimetype=mimetype)
@weather_sat_bp.route('/images/shared/<int:output_id>')
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/<filename>', methods=['DELETE'])
def delete_image(filename: str):
"""Delete a decoded image.
@@ -469,6 +523,62 @@ def 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.

View File

@@ -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 += `<div class="wxsat-date-header">${escapeHtml(date)}</div>`;
html += imgs.map(img => {
const fn = escapeHtml(img.filename || img.url.split('/').pop());
const deleteButton = img.deletable === false ? '' : `
<div class="wxsat-image-actions">
<button onclick="event.stopPropagation(); WeatherSat.deleteImage('${fn}')" title="Delete image">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>`;
return `
<div class="wxsat-image-card">
<div class="wxsat-image-clickable" onclick="WeatherSat.showImage('${escapeHtml(img.url)}', '${escapeHtml(img.satellite)}', '${escapeHtml(img.product)}', '${fn}')">
@@ -1624,13 +1645,7 @@ const WeatherSat = (function() {
<div class="wxsat-image-timestamp">${formatTimestamp(img.timestamp)}</div>
</div>
</div>
<div class="wxsat-image-actions">
<button onclick="event.stopPropagation(); WeatherSat.deleteImage('${fn}')" title="Delete image">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
${deleteButton}
</div>`;
}).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
*/

View File

@@ -6,8 +6,8 @@
ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions.
</div>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
Receive and decode weather images from NOAA and Meteor satellites.
Uses SatDump for live SDR capture and image processing.
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.
</p>
</div>
@@ -18,9 +18,6 @@
<select id="weatherSatSelect" class="mode-select">
<option value="METEOR-M2-3" selected>Meteor-M2-3 (137.900 MHz LRPT)</option>
<option value="METEOR-M2-4">Meteor-M2-4 (137.900 MHz LRPT)</option>
<option value="NOAA-15" disabled>NOAA-15 (137.620 MHz APT) [DEFUNCT]</option>
<option value="NOAA-18" disabled>NOAA-18 (137.9125 MHz APT) [DEFUNCT]</option>
<option value="NOAA-19" disabled>NOAA-19 (137.100 MHz APT) [DEFUNCT]</option>
</select>
</div>
<div class="form-group">
@@ -72,7 +69,7 @@
<li><strong style="color: var(--text-primary);">Connection:</strong> Solder elements to coax center + shield, connect to SDR via SMA</li>
</ul>
<p style="margin-top: 6px; color: var(--text-dim); font-style: italic;">
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.
</p>
</div>
@@ -136,7 +133,7 @@
<li><strong style="color: var(--text-primary);">Avoid:</strong> Metal roofs, power lines, buildings blocking the sky</li>
<li><strong style="color: var(--text-primary);">Coax length:</strong> Keep short (&lt;10m). Signal loss at 137 MHz is ~3 dB per 10m of RG-58</li>
<li><strong style="color: var(--text-primary);">LNA:</strong> Mount at the antenna feed point, NOT at the SDR end.
Recommended: Nooelec SAWbird+ NOAA (137 MHz filtered LNA, ~$30)</li>
Recommended: a low-noise 137 MHz filtered LNA near the antenna feed point</li>
<li><strong style="color: var(--text-primary);">Bias-T:</strong> Enable the Bias-T checkbox above if your LNA is powered via the coax from the SDR</li>
</ul>
</div>
@@ -165,10 +162,6 @@
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">RHCP</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">NOAA (APT) bandwidth</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~40 kHz</td>
</tr>
<tr>
<td style="padding: 3px 4px; color: var(--text-dim);">Meteor (LRPT) bandwidth</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~140 kHz</td>
@@ -185,31 +178,26 @@
</h3>
<div class="wxsat-test-decode-body collapsed" style="overflow: hidden;">
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">
Decode a pre-recorded IQ or WAV file without SDR hardware.
Run <code style="font-size: 10px;">./download-weather-sat-samples.sh</code> to fetch sample files.
Decode a pre-recorded Meteor IQ file without SDR hardware.
Shared ground-station recordings are also accepted by the backend.
</p>
<div class="form-group">
<label>Satellite</label>
<select id="wxsatTestSatSelect" class="mode-select">
<option value="METEOR-M2-3" selected>Meteor-M2-3 (LRPT)</option>
<option value="METEOR-M2-4">Meteor-M2-4 (LRPT)</option>
<option value="NOAA-15">NOAA-15 (APT)</option>
<option value="NOAA-18">NOAA-18 (APT)</option>
<option value="NOAA-19">NOAA-19 (APT)</option>
</select>
</div>
<div class="form-group">
<label>File Path (server-side)</label>
<input type="text" id="wxsatTestFilePath" value="data/weather_sat/samples/noaa_apt_argentina.wav" style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; font-size: 11px;">
<input type="text" id="wxsatTestFilePath" value="data/weather_sat/samples/meteor_lrpt.sigmf-data" style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; font-size: 11px;">
</div>
<div class="form-group">
<label>Sample Rate</label>
<select id="wxsatTestSampleRate" class="mode-select">
<option value="11025">11025 Hz (WAV audio APT)</option>
<option value="48000">48000 Hz (WAV audio APT)</option>
<option value="500000">500 kHz (IQ LRPT)</option>
<option value="1000000" selected>1 MHz (IQ default)</option>
<option value="2000000">2 MHz (IQ wideband)</option>
<option value="1000000">1 MHz (IQ narrow)</option>
<option value="2400000" selected>2.4 MHz (INTERCEPT default)</option>
</select>
</div>
<button class="mode-btn" onclick="WeatherSat.testDecode()" style="width: 100%; margin-top: 4px;">
@@ -241,8 +229,8 @@
<a href="https://github.com/SatDump/SatDump" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
SatDump Documentation
</a>
<a href="https://www.rtl-sdr.com/rtl-sdr-tutorial-receiving-noaa-weather-satellite-images/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
NOAA Reception Guide
<a href="https://www.rtl-sdr.com/rtl-sdr-tutorial-receiving-meteor-m2-weather-satellite-images/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
Meteor Reception Guide
</a>
</div>
</div>

View File

@@ -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) {
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');

View File

@@ -718,6 +718,25 @@ def init_db() -> None:
)
''')
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
ON ground_station_observations(norad_id, created_at)
@@ -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)')

View File

@@ -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()

View File

@@ -183,6 +183,8 @@ class WeatherSatDecoder:
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)
@@ -318,6 +320,8 @@ class WeatherSatDecoder:
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:
@@ -412,6 +416,8 @@ class WeatherSatDecoder:
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:
@@ -893,6 +899,7 @@ class WeatherSatDecoder:
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(
@@ -1140,6 +1147,8 @@ class WeatherSatDecoder:
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)
@@ -1159,8 +1168,11 @@ class WeatherSatDecoder:
'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,
}