mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Persist Meteor decode job state
This commit is contained in:
@@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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 (<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 (<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;">▼</span>
|
<span class="wxsat-collapse-icon collapsed" style="font-size: 10px; transition: transform 0.2s; display: inline-block;">▼</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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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)')
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user