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 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 # Phase 5 — Live waterfall WebSocket
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -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 __future__ import annotations
from NOAA (APT) and Meteor (LRPT) satellites using SatDump.
""" import json
import queue
from __future__ import annotations from pathlib import Path
import queue from flask import Blueprint, Response, jsonify, request, send_file
from flask import Blueprint, Response, jsonify, request, send_file
from utils.logging import get_logger from utils.logging import get_logger
from utils.responses import api_error from utils.responses import api_error
@@ -30,12 +33,21 @@ from utils.weather_sat import (
is_weather_sat_available, is_weather_sat_available,
) )
logger = get_logger('intercept.weather_sat') logger = get_logger('intercept.weather_sat')
weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat') weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat')
# Queue for SSE progress streaming # 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: def _progress_callback(progress: CaptureProgress) -> None:
@@ -120,7 +132,7 @@ def start_capture():
JSON body: JSON body:
{ {
"satellite": "NOAA-18", // Required: satellite key "satellite": "METEOR-M2-3", // Required: satellite key
"device": 0, // RTL-SDR device index (default: 0) "device": 0, // RTL-SDR device index (default: 0)
"gain": 40.0, // SDR gain in dB (default: 40) "gain": 40.0, // SDR gain in dB (default: 40)
"bias_t": false // Enable bias-T for LNA (default: false) "bias_t": false // Enable bias-T for LNA (default: false)
@@ -248,7 +260,7 @@ def test_decode():
JSON body: 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 "input_file": "/path/to/file", // Required: server-side file path
"sample_rate": 1000000 // Sample rate in Hz (default: 1000000) "sample_rate": 1000000 // Sample rate in Hz (default: 1000000)
} }
@@ -292,15 +304,14 @@ def test_decode():
from pathlib import Path from pathlib import Path
input_path = Path(input_file) input_path = Path(input_file)
# Security: restrict to data directory (anchored to app root, not CWD) # Restrict test-decode to application-owned sample and recording paths.
allowed_base = Path(__file__).resolve().parent.parent / 'data' try:
try: resolved = input_path.resolve()
resolved = input_path.resolve() if not any(resolved.is_relative_to(base) for base in ALLOWED_TEST_DECODE_DIRS):
if not resolved.is_relative_to(allowed_base): return jsonify({
return jsonify({ 'status': 'error',
'status': 'error', 'message': 'input_file must be under INTERCEPT data or ground-station recordings'
'message': 'input_file must be under the data/ directory' }), 403
}), 403
except (OSError, ValueError): except (OSError, ValueError):
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -377,8 +388,8 @@ def stop_capture():
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@weather_sat_bp.route('/images') @weather_sat_bp.route('/images')
def list_images(): def list_images():
"""Get list of decoded weather satellite images. """Get list of decoded weather satellite images.
Query parameters: Query parameters:
@@ -388,28 +399,41 @@ def list_images():
Returns: Returns:
JSON with list of decoded images. JSON with list of decoded images.
""" """
decoder = get_weather_sat_decoder() decoder = get_weather_sat_decoder()
images = decoder.get_images() images = [
{
# Filter by satellite if specified **img.to_dict(),
satellite_filter = request.args.get('satellite') 'source': 'weather_sat',
if satellite_filter: 'deletable': True,
images = [img for img in images if img.satellite == satellite_filter] }
for img in decoder.get_images()
# Apply limit ]
limit = request.args.get('limit', type=int) images.extend(_get_ground_station_images())
if limit and limit > 0:
images = images[-limit:] # Filter by satellite if specified
satellite_filter = request.args.get('satellite')
return jsonify({ if satellite_filter:
'status': 'ok', images = [
'images': [img.to_dict() for img in images], img for img in images
'count': len(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/<filename>') @weather_sat_bp.route('/images/<filename>')
def get_image(filename: str): def get_image(filename: str):
"""Serve a decoded weather satellite image file. """Serve a decoded weather satellite image file.
Args: Args:
@@ -432,8 +456,38 @@ def get_image(filename: str):
if not image_path.exists(): if not image_path.exists():
return api_error('Image not found', 404) return api_error('Image not found', 404)
mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg' mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg'
return send_file(image_path, mimetype=mimetype) 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']) @weather_sat_bp.route('/images/<filename>', methods=['DELETE'])
@@ -458,15 +512,71 @@ def delete_image(filename: str):
@weather_sat_bp.route('/images', methods=['DELETE']) @weather_sat_bp.route('/images', methods=['DELETE'])
def delete_all_images(): def delete_all_images():
"""Delete all decoded weather satellite images. """Delete all decoded weather satellite images.
Returns: Returns:
JSON with count of deleted images. JSON with count of deleted images.
""" """
decoder = get_weather_sat_decoder() decoder = get_weather_sat_decoder()
count = decoder.delete_all_images() count = decoder.delete_all_images()
return jsonify({'status': 'ok', 'deleted': count}) 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') @weather_sat_bp.route('/stream')

View File

@@ -1,6 +1,6 @@
/** /**
* Weather Satellite Mode * 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. * polar plot, styled real-world map, countdown, and timeline.
*/ */
@@ -28,6 +28,7 @@ const WeatherSat = (function() {
let currentModalFilename = null; let currentModalFilename = null;
let locationListenersAttached = false; let locationListenersAttached = false;
let initialized = false; let initialized = false;
let imageRefreshInterval = null;
/** /**
* Initialize the Weather Satellite mode * Initialize the Weather Satellite mode
@@ -52,6 +53,7 @@ const WeatherSat = (function() {
startCountdownTimer(); startCountdownTimer();
checkSchedulerStatus(); checkSchedulerStatus();
initGroundMap(); initGroundMap();
ensureImageRefresh();
} }
/** /**
@@ -137,7 +139,12 @@ const WeatherSat = (function() {
if (latInput) latInput.addEventListener('change', saveLocationFromInputs); if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs); if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs);
const satSelect = document.getElementById('weatherSatSelect'); const satSelect = document.getElementById('weatherSatSelect');
if (satSelect) satSelect.addEventListener('change', applyPassFilter); if (satSelect) {
satSelect.addEventListener('change', () => {
applyPassFilter();
loadImages();
});
}
locationListenersAttached = true; locationListenersAttached = true;
} }
} }
@@ -536,6 +543,7 @@ const WeatherSat = (function() {
updatePhaseIndicator('error'); updatePhaseIndicator('error');
if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer); if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer);
consoleAutoHideTimer = setTimeout(() => showConsole(false), 15000); consoleAutoHideTimer = setTimeout(() => showConsole(false), 15000);
loadImages();
} }
} }
@@ -1549,7 +1557,12 @@ const WeatherSat = (function() {
*/ */
async function loadImages() { async function loadImages() {
try { 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(); const data = await response.json();
if (data.status === 'ok') { if (data.status === 'ok') {
@@ -1614,6 +1627,14 @@ const WeatherSat = (function() {
html += `<div class="wxsat-date-header">${escapeHtml(date)}</div>`; html += `<div class="wxsat-date-header">${escapeHtml(date)}</div>`;
html += imgs.map(img => { html += imgs.map(img => {
const fn = escapeHtml(img.filename || img.url.split('/').pop()); 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 ` return `
<div class="wxsat-image-card"> <div class="wxsat-image-card">
<div class="wxsat-image-clickable" onclick="WeatherSat.showImage('${escapeHtml(img.url)}', '${escapeHtml(img.satellite)}', '${escapeHtml(img.product)}', '${fn}')"> <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 class="wxsat-image-timestamp">${formatTimestamp(img.timestamp)}</div>
</div> </div>
</div> </div>
<div class="wxsat-image-actions"> ${deleteButton}
<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>
</div>`; </div>`;
}).join(''); }).join('');
} }
@@ -1722,9 +1737,14 @@ const WeatherSat = (function() {
*/ */
async function deleteAllImages() { async function deleteAllImages() {
if (images.length === 0) return; 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({ const confirmed = await AppFeedback.confirmAction({
title: 'Delete All Images', 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', confirmLabel: 'Delete All',
confirmClass: 'btn-danger' confirmClass: 'btn-danger'
}); });
@@ -1735,8 +1755,8 @@ const WeatherSat = (function() {
const data = await response.json(); const data = await response.json();
if (data.status === 'ok') { if (data.status === 'ok') {
images = []; images = images.filter(img => img.deletable === false);
updateImageCount(0); updateImageCount(images.length);
renderGallery(); renderGallery();
showNotification('Weather Sat', `Deleted ${data.deleted} images`); showNotification('Weather Sat', `Deleted ${data.deleted} images`);
} else { } 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 * Escape HTML
*/ */

View File

@@ -2,13 +2,13 @@
<div id="weatherSatMode" class="mode-content"> <div id="weatherSatMode" class="mode-content">
<div class="section"> <div class="section">
<h3>Weather Satellite Decoder</h3> <h3>Weather Satellite Decoder</h3>
<div class="alpha-mode-notice"> <div class="alpha-mode-notice">
ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions. ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions.
</div> </div>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;"> <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. Receive and decode Meteor LRPT weather imagery.
Uses SatDump for live SDR capture and image processing. Uses SatDump for live SDR capture and image processing, and also shows Meteor imagery produced by the ground-station scheduler.
</p> </p>
</div> </div>
<div class="section"> <div class="section">
@@ -18,11 +18,8 @@
<select id="weatherSatSelect" class="mode-select"> <select id="weatherSatSelect" class="mode-select">
<option value="METEOR-M2-3" selected>Meteor-M2-3 (137.900 MHz LRPT)</option> <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="METEOR-M2-4">Meteor-M2-4 (137.900 MHz LRPT)</option>
<option value="NOAA-15" disabled>NOAA-15 (137.620 MHz APT) [DEFUNCT]</option> </select>
<option value="NOAA-18" disabled>NOAA-18 (137.9125 MHz APT) [DEFUNCT]</option> </div>
<option value="NOAA-19" disabled>NOAA-19 (137.100 MHz APT) [DEFUNCT]</option>
</select>
</div>
<div class="form-group"> <div class="form-group">
<label>Gain (dB)</label> <label>Gain (dB)</label>
<input type="number" id="weatherSatGain" value="40" step="0.1" min="0" max="50"> <input type="number" id="weatherSatGain" value="40" step="0.1" min="0" max="50">
@@ -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> <li><strong style="color: var(--text-primary);">Connection:</strong> Solder elements to coax center + shield, connect to SDR via SMA</li>
</ul> </ul>
<p style="margin-top: 6px; color: var(--text-dim); font-style: italic;"> <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> </p>
</div> </div>
@@ -135,8 +132,8 @@
<li><strong style="color: var(--text-primary);">Antenna up:</strong> Point the antenna straight UP (zenith) for best overhead coverage</li> <li><strong style="color: var(--text-primary);">Antenna up:</strong> Point the antenna straight UP (zenith) for best overhead coverage</li>
<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);">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);">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. <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> <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> </ul>
</div> </div>
@@ -165,13 +162,9 @@
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td> <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> <td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">RHCP</td>
</tr> </tr>
<tr style="border-bottom: 1px solid var(--border-color);"> <tr>
<td style="padding: 3px 4px; color: var(--text-dim);">NOAA (APT) bandwidth</td> <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;">~40 kHz</td> <td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~140 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>
</tr> </tr>
</table> </table>
</div> </div>
@@ -184,34 +177,29 @@
<span class="wxsat-collapse-icon collapsed" style="font-size: 10px; transition: transform 0.2s; display: inline-block;">&#9660;</span> <span class="wxsat-collapse-icon collapsed" style="font-size: 10px; transition: transform 0.2s; display: inline-block;">&#9660;</span>
</h3> </h3>
<div class="wxsat-test-decode-body collapsed" style="overflow: hidden;"> <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;"> <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. Decode a pre-recorded Meteor IQ file without SDR hardware.
Run <code style="font-size: 10px;">./download-weather-sat-samples.sh</code> to fetch sample files. Shared ground-station recordings are also accepted by the backend.
</p> </p>
<div class="form-group"> <div class="form-group">
<label>Satellite</label> <label>Satellite</label>
<select id="wxsatTestSatSelect" class="mode-select"> <select id="wxsatTestSatSelect" class="mode-select">
<option value="METEOR-M2-3" selected>Meteor-M2-3 (LRPT)</option> <option value="METEOR-M2-3" selected>Meteor-M2-3 (LRPT)</option>
<option value="METEOR-M2-4">Meteor-M2-4 (LRPT)</option> <option value="METEOR-M2-4">Meteor-M2-4 (LRPT)</option>
<option value="NOAA-15">NOAA-15 (APT)</option> </select>
<option value="NOAA-18">NOAA-18 (APT)</option> </div>
<option value="NOAA-19">NOAA-19 (APT)</option> <div class="form-group">
</select> <label>File Path (server-side)</label>
</div> <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 class="form-group"> </div>
<label>File Path (server-side)</label> <div class="form-group">
<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;"> <label>Sample Rate</label>
</div> <select id="wxsatTestSampleRate" class="mode-select">
<div class="form-group"> <option value="500000">500 kHz (IQ LRPT)</option>
<label>Sample Rate</label> <option value="1000000">1 MHz (IQ narrow)</option>
<select id="wxsatTestSampleRate" class="mode-select"> <option value="2400000" selected>2.4 MHz (INTERCEPT default)</option>
<option value="11025">11025 Hz (WAV audio APT)</option> </select>
<option value="48000">48000 Hz (WAV audio APT)</option> </div>
<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>
</select>
</div>
<button class="mode-btn" onclick="WeatherSat.testDecode()" style="width: 100%; margin-top: 4px;"> <button class="mode-btn" onclick="WeatherSat.testDecode()" style="width: 100%; margin-top: 4px;">
Test Decode Test Decode
</button> </button>
@@ -238,12 +226,12 @@
<div class="section"> <div class="section">
<h3>Resources</h3> <h3>Resources</h3>
<div style="display: flex; flex-direction: column; gap: 6px;"> <div style="display: flex; flex-direction: column; gap: 6px;">
<a href="https://github.com/SatDump/SatDump" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;"> <a href="https://github.com/SatDump/SatDump" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
SatDump Documentation SatDump Documentation
</a> </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;"> <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;">
NOAA Reception Guide Meteor Reception Guide
</a> </a>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1962,12 +1962,15 @@
const list = document.getElementById('gsOutputsList'); const list = document.getElementById('gsOutputsList');
const status = document.getElementById('gsDecodeStatus'); const status = document.getElementById('gsDecodeStatus');
if (!panel || !list || !norad) return; if (!panel || !list || !norad) return;
gsLoadDecodeJobs(norad);
fetch(`/ground_station/outputs?norad_id=${encodeURIComponent(norad)}&type=image`) fetch(`/ground_station/outputs?norad_id=${encodeURIComponent(norad)}&type=image`)
.then(r => r.json()) .then(r => r.json())
.then(outputs => { .then(outputs => {
if (!Array.isArray(outputs) || !outputs.length) { if (!Array.isArray(outputs) || !outputs.length) {
panel.style.display = 'none'; if (!status || !status.textContent) {
if (status) status.style.display = 'none'; panel.style.display = 'none';
if (status) status.style.display = 'none';
}
return; return;
} }
panel.style.display = ''; panel.style.display = '';
@@ -1984,6 +1987,35 @@
.catch(() => {}); .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) { function _updateDecodeStatus(data) {
const panel = document.getElementById('gsOutputsPanel'); const panel = document.getElementById('gsOutputsPanel');
const status = document.getElementById('gsDecodeStatus'); const status = document.getElementById('gsDecodeStatus');

View File

@@ -717,6 +717,25 @@ def init_db() -> None:
FOREIGN KEY (observation_id) REFERENCES ground_station_observations(id) ON DELETE CASCADE 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(''' conn.execute('''
CREATE INDEX IF NOT EXISTS idx_gs_observations_norad 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) 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. # Lightweight schema migrations for existing installs.
profile_cols = { profile_cols = {
row['name'] for row in conn.execute('PRAGMA table_info(observation_profiles)') row['name'] for row in conn.execute('PRAGMA table_info(observation_profiles)')

View File

@@ -2,8 +2,10 @@
from __future__ import annotations from __future__ import annotations
import json
import threading import threading
import time import time
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from utils.logging import get_logger from utils.logging import get_logger
@@ -43,9 +45,24 @@ def launch_meteor_decode(
register_output, register_output,
) -> None: ) -> None:
"""Run Meteor LRPT offline decode in a background thread.""" """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( t = threading.Thread(
target=_run_decode, target=_run_decode,
kwargs={ kwargs={
'decode_job_id': decode_job_id,
'obs_db_id': obs_db_id, 'obs_db_id': obs_db_id,
'norad_id': norad_id, 'norad_id': norad_id,
'satellite_name': satellite_name, 'satellite_name': satellite_name,
@@ -62,6 +79,7 @@ def launch_meteor_decode(
def _run_decode( def _run_decode(
*, *,
decode_job_id: int | None,
obs_db_id: int | None, obs_db_id: int | None,
norad_id: int, norad_id: int,
satellite_name: str, satellite_name: str,
@@ -70,10 +88,23 @@ def _run_decode(
emit_event, emit_event,
register_output, register_output,
) -> None: ) -> None:
latest_status: dict[str, str | int | None] = {
'message': None,
'status': None,
'phase': None,
}
sat_key = resolve_meteor_satellite_key(norad_id, satellite_name) sat_key = resolve_meteor_satellite_key(norad_id, satellite_name)
if not sat_key: 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({ emit_event({
'type': 'weather_decode_failed', 'type': 'weather_decode_failed',
'decode_job_id': decode_job_id,
'norad_id': norad_id, 'norad_id': norad_id,
'satellite': satellite_name, 'satellite': satellite_name,
'backend': 'meteor_lrpt', 'backend': 'meteor_lrpt',
@@ -84,8 +115,16 @@ def _run_decode(
output_dir = OUTPUT_ROOT / f'{norad_id}_{int(time.time())}' output_dir = OUTPUT_ROOT / f'{norad_id}_{int(time.time())}'
decoder = WeatherSatDecoder(output_dir=output_dir) decoder = WeatherSatDecoder(output_dir=output_dir)
if decoder.decoder_available is None: 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({ emit_event({
'type': 'weather_decode_failed', 'type': 'weather_decode_failed',
'decode_job_id': decode_job_id,
'norad_id': norad_id, 'norad_id': norad_id,
'satellite': satellite_name, 'satellite': satellite_name,
'backend': 'meteor_lrpt', 'backend': 'meteor_lrpt',
@@ -94,10 +133,14 @@ def _run_decode(
return return
def _progress_cb(progress): 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 = progress.to_dict()
progress_event.pop('type', None) progress_event.pop('type', None)
emit_event({ emit_event({
'type': 'weather_decode_progress', 'type': 'weather_decode_progress',
'decode_job_id': decode_job_id,
'norad_id': norad_id, 'norad_id': norad_id,
'satellite': satellite_name, 'satellite': satellite_name,
'backend': 'meteor_lrpt', 'backend': 'meteor_lrpt',
@@ -105,8 +148,20 @@ def _run_decode(
}) })
decoder.set_callback(_progress_cb) 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({ emit_event({
'type': 'weather_decode_started', 'type': 'weather_decode_started',
'decode_job_id': decode_job_id,
'norad_id': norad_id, 'norad_id': norad_id,
'satellite': satellite_name, 'satellite': satellite_name,
'backend': 'meteor_lrpt', 'backend': 'meteor_lrpt',
@@ -119,13 +174,29 @@ def _run_decode(
sample_rate=sample_rate, sample_rate=sample_rate,
) )
if not ok: if not ok:
details = _build_failure_details(
data_path=data_path,
sample_rate=sample_rate,
decoder=decoder,
latest_status=latest_status,
)
emit_event({ emit_event({
'type': 'weather_decode_failed', 'type': 'weather_decode_failed',
'decode_job_id': decode_job_id,
'norad_id': norad_id, 'norad_id': norad_id,
'satellite': satellite_name, 'satellite': satellite_name,
'backend': 'meteor_lrpt', '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 return
started = time.time() started = time.time()
@@ -134,24 +205,58 @@ def _run_decode(
if decoder.is_running: if decoder.is_running:
decoder.stop() 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({ emit_event({
'type': 'weather_decode_failed', 'type': 'weather_decode_failed',
'decode_job_id': decode_job_id,
'norad_id': norad_id, 'norad_id': norad_id,
'satellite': satellite_name, 'satellite': satellite_name,
'backend': 'meteor_lrpt', '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 return
images = decoder.get_images() images = decoder.get_images()
if not images: if not images:
details = _build_failure_details(
data_path=data_path,
sample_rate=sample_rate,
decoder=decoder,
latest_status=latest_status,
)
emit_event({ emit_event({
'type': 'weather_decode_failed', 'type': 'weather_decode_failed',
'decode_job_id': decode_job_id,
'norad_id': norad_id, 'norad_id': norad_id,
'satellite': satellite_name, 'satellite': satellite_name,
'backend': 'meteor_lrpt', '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 return
outputs = [] outputs = []
@@ -180,10 +285,191 @@ def _run_decode(
'product': image.product, '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({ emit_event({
'type': 'weather_decode_complete', 'type': 'weather_decode_complete',
'decode_job_id': decode_job_id,
'norad_id': norad_id, 'norad_id': norad_id,
'satellite': satellite_name, 'satellite': satellite_name,
'backend': 'meteor_lrpt', 'backend': 'meteor_lrpt',
'outputs': outputs, '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

@@ -177,12 +177,14 @@ class WeatherSatDecoder:
self._pty_master_fd: int | None = None self._pty_master_fd: int | None = None
self._current_satellite: str = '' self._current_satellite: str = ''
self._current_frequency: float = 0.0 self._current_frequency: float = 0.0
self._current_mode: str = '' self._current_mode: str = ''
self._capture_start_time: float = 0 self._capture_start_time: float = 0
self._device_index: int = -1 self._device_index: int = -1
self._capture_output_dir: Path | None = None self._capture_output_dir: Path | None = None
self._on_complete_callback: Callable[[], None] | None = None self._on_complete_callback: Callable[[], None] | None = None
self._capture_phase: str = 'idle' self._capture_phase: str = 'idle'
self._last_error_message: str = ''
self._last_process_returncode: int | None = None
# Ensure output directory exists # Ensure output directory exists
self._output_dir.mkdir(parents=True, exist_ok=True) self._output_dir.mkdir(parents=True, exist_ok=True)
@@ -314,11 +316,13 @@ class WeatherSatDecoder:
self._current_satellite = satellite self._current_satellite = satellite
self._current_frequency = sat_info['frequency'] self._current_frequency = sat_info['frequency']
self._current_mode = sat_info['mode'] self._current_mode = sat_info['mode']
self._device_index = -1 # Offline decode does not claim an SDR device self._device_index = -1 # Offline decode does not claim an SDR device
self._capture_start_time = time.time() self._capture_start_time = time.time()
self._capture_phase = 'decoding' self._capture_phase = 'decoding'
self._stop_event.clear() self._last_error_message = ''
self._last_process_returncode = None
self._stop_event.clear()
try: try:
self._running = True self._running = True
@@ -408,11 +412,13 @@ class WeatherSatDecoder:
self._current_satellite = satellite self._current_satellite = satellite
self._current_frequency = sat_info['frequency'] self._current_frequency = sat_info['frequency']
self._current_mode = sat_info['mode'] self._current_mode = sat_info['mode']
self._device_index = device_index self._device_index = device_index
self._capture_start_time = time.time() self._capture_start_time = time.time()
self._capture_phase = 'tuning' self._capture_phase = 'tuning'
self._stop_event.clear() self._last_error_message = ''
self._last_process_returncode = None
self._stop_event.clear()
try: try:
self._running = True self._running = True
@@ -886,16 +892,17 @@ class WeatherSatDecoder:
if was_running: if was_running:
# Collect exit status (returncode is only set after poll/wait) # Collect exit status (returncode is only set after poll/wait)
if process and process.returncode is None: if process and process.returncode is None:
try: try:
process.wait(timeout=5) process.wait(timeout=5)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
process.kill() process.kill()
process.wait() process.wait()
retcode = process.returncode if process else None retcode = process.returncode if process else None
if retcode and retcode != 0: self._last_process_returncode = retcode
self._capture_phase = 'error' if retcode and retcode != 0:
self._emit_progress(CaptureProgress( self._capture_phase = 'error'
self._emit_progress(CaptureProgress(
status='error', status='error',
satellite=self._current_satellite, satellite=self._current_satellite,
frequency=self._current_frequency, frequency=self._current_frequency,
@@ -1138,13 +1145,15 @@ class WeatherSatDecoder:
self._images.clear() self._images.clear()
return count return count
def _emit_progress(self, progress: CaptureProgress) -> None: def _emit_progress(self, progress: CaptureProgress) -> None:
"""Emit progress update to callback.""" """Emit progress update to callback."""
if self._callback: if progress.status == 'error' and progress.message:
try: self._last_error_message = str(progress.message)
self._callback(progress) if self._callback:
except Exception as e: try:
logger.error(f"Error in progress callback: {e}") self._callback(progress)
except Exception as e:
logger.error(f"Error in progress callback: {e}")
def get_status(self) -> dict: def get_status(self) -> dict:
"""Get current decoder status.""" """Get current decoder status."""
@@ -1152,16 +1161,19 @@ class WeatherSatDecoder:
if self._running and self._capture_start_time: if self._running and self._capture_start_time:
elapsed = int(time.time() - self._capture_start_time) elapsed = int(time.time() - self._capture_start_time)
return { return {
'available': self._decoder is not None, 'available': self._decoder is not None,
'decoder': self._decoder, 'decoder': self._decoder,
'running': self._running, 'running': self._running,
'satellite': self._current_satellite, 'satellite': self._current_satellite,
'frequency': self._current_frequency, 'frequency': self._current_frequency,
'mode': self._current_mode, 'mode': self._current_mode,
'elapsed_seconds': elapsed, 'capture_phase': self._capture_phase,
'image_count': len(self._images), 'elapsed_seconds': elapsed,
} 'image_count': len(self._images),
'last_error': self._last_error_message,
'last_returncode': self._last_process_returncode,
}
# Global decoder instance # Global decoder instance