Merge main into misc-fixes and address PR #202 review

Sync with upstream main and fix required items from review:

- updateTimelineLabels() now uses InterceptTime API (getTimezone/getIANA)
  instead of the stale selectedTimezone/TZ_MAP globals that were removed
  during the earlier InterceptTime refactor — fixes ReferenceError on TZ
  change and pass refresh.

- Remove profiles: [basic] from the intercept service in
  docker-compose.yml so bare `docker compose up -d` still starts the
  main service. Profile-gated services (intercept-history, adsb_db)
  stay as-is.
This commit is contained in:
Mitch Ross
2026-04-24 16:34:09 -04:00
60 changed files with 6969 additions and 5301 deletions
+46 -2
View File
@@ -79,6 +79,7 @@ adsb_bytes_received = 0
adsb_lines_received = 0
adsb_active_device = None # Track which device index is being used
adsb_active_sdr_type: str | None = None
adsb_bias_t_active = False # Track if bias-t was enabled at start (for cleanup on stop)
_sbs_error_logged = False # Suppress repeated connection error logs
# Track ICAOs already looked up in aircraft database (avoid repeated lookups)
@@ -803,6 +804,41 @@ def adsb_status():
})
@adsb_bp.route('/aircraft')
def adsb_aircraft_export():
"""Export current ADS-B aircraft data as JSON.
Returns a snapshot of all tracked aircraft suitable for integration
with external tools. For SBS (BaseStation) format, connect directly
to port 30003 which dump1090 exposes natively.
Query parameters:
icao: Filter to a specific ICAO hex code (optional)
military: 'true' to return only military aircraft (optional)
Returns:
JSON with aircraft list and metadata.
"""
aircraft = dict(app_module.adsb_aircraft)
icao_filter = request.args.get('icao', '').upper()
if icao_filter:
aircraft = {k: v for k, v in aircraft.items() if k.upper() == icao_filter}
if request.args.get('military') == 'true':
try:
from utils.military_icao import is_military_icao
aircraft = {k: v for k, v in aircraft.items() if is_military_icao(k)}
except ImportError:
pass
return jsonify({
'count': len(aircraft),
'aircraft': list(aircraft.values()),
'sbs_port': 30003, # dump1090 SBS stream for tools like Virtual Radar Server
})
@adsb_bp.route('/session')
def adsb_session():
"""Get ADS-B session status and uptime."""
@@ -824,7 +860,7 @@ def adsb_session():
@adsb_bp.route('/start', methods=['POST'])
def start_adsb():
"""Start ADS-B tracking."""
global adsb_using_service, adsb_active_device, adsb_active_sdr_type
global adsb_using_service, adsb_active_device, adsb_active_sdr_type, adsb_bias_t_active
with app_module.adsb_lock:
if adsb_using_service:
@@ -956,6 +992,7 @@ def start_adsb():
# Build ADS-B decoder command
bias_t = data.get('bias_t', False)
adsb_bias_t_active = bias_t
cmd = builder.build_adsb_command(
device=sdr_device,
gain=float(gain),
@@ -1104,7 +1141,7 @@ def start_adsb():
@adsb_bp.route('/stop', methods=['POST'])
def stop_adsb():
"""Stop ADS-B tracking."""
global adsb_using_service, adsb_active_device, adsb_active_sdr_type
global adsb_using_service, adsb_active_device, adsb_active_sdr_type, adsb_bias_t_active
data = request.get_json(silent=True) or {}
stop_source = data.get('source')
stopped_by = request.remote_addr
@@ -1127,6 +1164,13 @@ def stop_adsb():
clear_dump1090_pid()
logger.info("ADS-B process stopped")
# Turn off bias-T if it was enabled at start — the hardware register
# persists after the device is closed, so we must explicitly disable it.
if adsb_bias_t_active and (adsb_active_sdr_type or 'rtlsdr') == 'rtlsdr':
from utils.sdr.rtlsdr import disable_bias_t_via_rtl_biast
disable_bias_t_via_rtl_biast(adsb_active_device or 0)
adsb_bias_t_active = False
# Release device from registry
if adsb_active_device is not None:
app_module.release_sdr_device(adsb_active_device, adsb_active_sdr_type or 'rtlsdr')
+39 -1
View File
@@ -408,11 +408,24 @@ def start_ais():
bias_t = data.get('bias_t', False)
tcp_port = AIS_TCP_PORT
# Optional UDP NMEA forwarding (e.g. for OpenCPN on port 10110)
udp_host = data.get('udp_host') or None
udp_port = None
if udp_host:
try:
udp_port = int(data.get('udp_port', 10110))
if not 1 <= udp_port <= 65535:
raise ValueError
except (TypeError, ValueError):
return api_error('Invalid udp_port (1-65535)', 400)
cmd = builder.build_ais_command(
device=sdr_device,
gain=float(gain),
bias_t=bias_t,
tcp_port=tcp_port
tcp_port=tcp_port,
udp_host=udp_host,
udp_port=udp_port,
)
# Use the found AIS-catcher path
@@ -535,6 +548,31 @@ def get_vessel_dsc(mmsi: str):
return api_success(data={'mmsi': mmsi, 'dsc_messages': matches})
@ais_bp.route('/vessels')
def ais_vessels():
"""Export current AIS vessel data as JSON.
Returns a snapshot of all tracked vessels suitable for integration
with external tools (OpenCPN, ship tracking apps, etc.).
Query parameters:
mmsi: Filter to a specific MMSI (optional)
Returns:
JSON with vessel list and metadata.
"""
vessels = dict(app_module.ais_vessels)
mmsi_filter = request.args.get('mmsi')
if mmsi_filter:
vessels = {k: v for k, v in vessels.items() if str(k) == str(mmsi_filter)}
return jsonify({
'count': len(vessels),
'vessels': list(vessels.values()),
})
@ais_bp.route('/dashboard')
def ais_dashboard():
"""Popout AIS dashboard."""
+53 -60
View File
@@ -9,49 +9,43 @@ from flask import Blueprint, request
from utils.database import get_setting, set_setting
from utils.responses import api_error, api_success
offline_bp = Blueprint('offline', __name__, url_prefix='/offline')
offline_bp = Blueprint("offline", __name__, url_prefix="/offline")
# Default offline settings
OFFLINE_DEFAULTS = {
'offline.enabled': False,
"offline.enabled": False,
# Default to bundled assets/fonts to avoid third-party CDN privacy blocks.
'offline.assets_source': 'local',
'offline.fonts_source': 'local',
'offline.tile_provider': 'cartodb_dark_cyan',
'offline.tile_server_url': ''
"offline.assets_source": "local",
"offline.fonts_source": "local",
"offline.tile_provider": "cartodb_dark_cyan",
"offline.tile_server_url": "",
"offline.stadia_key": "",
}
# Asset paths to check
ASSET_PATHS = {
'leaflet': [
'static/vendor/leaflet/leaflet.js',
'static/vendor/leaflet/leaflet.css'
"leaflet": ["static/vendor/leaflet/leaflet.js", "static/vendor/leaflet/leaflet.css"],
"chartjs": ["static/vendor/chartjs/chart.umd.min.js"],
"inter": [
"static/vendor/fonts/Inter-Regular.woff2",
"static/vendor/fonts/Inter-Medium.woff2",
"static/vendor/fonts/Inter-SemiBold.woff2",
"static/vendor/fonts/Inter-Bold.woff2",
],
'chartjs': [
'static/vendor/chartjs/chart.umd.min.js'
"jetbrains": [
"static/vendor/fonts/JetBrainsMono-Regular.woff2",
"static/vendor/fonts/JetBrainsMono-Medium.woff2",
"static/vendor/fonts/JetBrainsMono-SemiBold.woff2",
"static/vendor/fonts/JetBrainsMono-Bold.woff2",
],
'inter': [
'static/vendor/fonts/Inter-Regular.woff2',
'static/vendor/fonts/Inter-Medium.woff2',
'static/vendor/fonts/Inter-SemiBold.woff2',
'static/vendor/fonts/Inter-Bold.woff2'
"leaflet_images": [
"static/vendor/leaflet/images/marker-icon.png",
"static/vendor/leaflet/images/marker-icon-2x.png",
"static/vendor/leaflet/images/marker-shadow.png",
"static/vendor/leaflet/images/layers.png",
"static/vendor/leaflet/images/layers-2x.png",
],
'jetbrains': [
'static/vendor/fonts/JetBrainsMono-Regular.woff2',
'static/vendor/fonts/JetBrainsMono-Medium.woff2',
'static/vendor/fonts/JetBrainsMono-SemiBold.woff2',
'static/vendor/fonts/JetBrainsMono-Bold.woff2'
],
'leaflet_images': [
'static/vendor/leaflet/images/marker-icon.png',
'static/vendor/leaflet/images/marker-icon-2x.png',
'static/vendor/leaflet/images/marker-shadow.png',
'static/vendor/leaflet/images/layers.png',
'static/vendor/leaflet/images/layers-2x.png'
],
'leaflet_heat': [
'static/vendor/leaflet-heat/leaflet-heat.js'
]
"leaflet_heat": ["static/vendor/leaflet-heat/leaflet-heat.js"],
}
@@ -63,26 +57,26 @@ def get_offline_settings():
return settings
@offline_bp.route('/settings', methods=['GET'])
@offline_bp.route("/settings", methods=["GET"])
def get_settings():
"""Get current offline settings."""
settings = get_offline_settings()
return api_success(data={'settings': settings})
return api_success(data={"settings": settings})
@offline_bp.route('/settings', methods=['POST'])
@offline_bp.route("/settings", methods=["POST"])
def save_setting():
"""Save an offline setting."""
data = request.get_json()
if not data or 'key' not in data or 'value' not in data:
return api_error('Missing key or value', 400)
if not data or "key" not in data or "value" not in data:
return api_error("Missing key or value", 400)
key = data['key']
value = data['value']
key = data["key"]
value = data["value"]
# Validate key is an allowed setting
if key not in OFFLINE_DEFAULTS:
return api_error(f'Unknown setting: {key}', 400)
return api_error(f"Unknown setting: {key}", 400)
# Validate value type matches default
default_type = type(OFFLINE_DEFAULTS[key])
@@ -90,18 +84,18 @@ def save_setting():
# Try to convert
try:
if default_type == bool:
value = str(value).lower() in ('true', '1', 'yes')
value = str(value).lower() in ("true", "1", "yes")
else:
value = default_type(value)
except (ValueError, TypeError):
return api_error(f'Invalid value type for {key}', 400)
return api_error(f"Invalid value type for {key}", 400)
set_setting(key, value)
return api_success(data={'key': key, 'value': value})
return api_success(data={"key": key, "value": value})
@offline_bp.route('/status', methods=['GET'])
@offline_bp.route("/status", methods=["GET"])
def get_status():
"""Check status of local assets."""
# Get the app root directory
@@ -119,37 +113,36 @@ def get_status():
available = False
missing.append(path)
results[asset_name] = {
'available': available,
'missing': missing if not available else []
}
results[asset_name] = {"available": available, "missing": missing if not available else []}
if not available:
all_available = False
return api_success(data={
'all_available': all_available,
'assets': results,
'offline_enabled': get_setting('offline.enabled', False)
})
return api_success(
data={
"all_available": all_available,
"assets": results,
"offline_enabled": get_setting("offline.enabled", False),
}
)
@offline_bp.route('/check-asset', methods=['GET'])
@offline_bp.route("/check-asset", methods=["GET"])
def check_asset():
"""Check if a specific asset file exists."""
path = request.args.get('path', '')
path = request.args.get("path", "")
if not path:
return api_error('Missing path parameter', 400)
return api_error("Missing path parameter", 400)
# Security: only allow checking within static/vendor
if not path.startswith('/static/vendor/'):
return api_error('Invalid path', 400)
if not path.startswith("/static/vendor/"):
return api_error("Invalid path", 400)
# Remove leading slash and construct full path
relative_path = path.lstrip('/')
relative_path = path.lstrip("/")
app_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
full_path = os.path.join(app_root, relative_path)
exists = os.path.exists(full_path)
return api_success(data={'path': path, 'exists': exists})
return api_success(data={"path": path, "exists": exists})
+337 -262
View File
File diff suppressed because it is too large Load Diff
+235 -231
View File
@@ -16,6 +16,7 @@ from typing import Any
from flask import Blueprint, Response, jsonify, request, send_file
import app as app_module
from routes.satellite import get_cached_tle
from utils.event_pipeline import process_event
from utils.logging import get_logger
from utils.responses import api_error
@@ -26,13 +27,13 @@ from utils.sstv import (
is_sstv_available,
)
logger = get_logger('intercept.sstv')
logger = get_logger("intercept.sstv")
sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv')
sstv_bp = Blueprint("sstv", __name__, url_prefix="/sstv")
# ISS SSTV runs on a fixed downlink; allow a small entry tolerance so users
# can type nearby values and still land on the canonical center frequency.
ISS_SSTV_MODULATION = 'fm'
ISS_SSTV_MODULATION = "fm"
ISS_SSTV_FREQUENCIES = (ISS_SSTV_FREQ,)
ISS_SSTV_FREQ_TOLERANCE_MHZ = 0.05
@@ -59,7 +60,7 @@ _timescale_lock = threading.Lock()
# Track which device is being used
sstv_active_device: int | None = None
sstv_active_sdr_type: str = 'rtlsdr'
sstv_active_sdr_type: str = "rtlsdr"
def _progress_callback(data: dict) -> None:
@@ -82,7 +83,7 @@ def _normalize_iss_frequency(frequency_mhz: float) -> float | None:
return None
@sstv_bp.route('/status')
@sstv_bp.route("/status")
def get_status():
"""
Get SSTV decoder status.
@@ -94,24 +95,24 @@ def get_status():
decoder = get_sstv_decoder()
result = {
'available': available,
'decoder': decoder.decoder_available,
'running': decoder.is_running,
'iss_frequency': ISS_SSTV_FREQ,
'modulation': ISS_SSTV_MODULATION,
'image_count': len(decoder.get_images()),
'doppler_enabled': decoder.doppler_enabled,
"available": available,
"decoder": decoder.decoder_available,
"running": decoder.is_running,
"iss_frequency": ISS_SSTV_FREQ,
"modulation": ISS_SSTV_MODULATION,
"image_count": len(decoder.get_images()),
"doppler_enabled": decoder.doppler_enabled,
}
# Include Doppler info if available
doppler_info = decoder.last_doppler_info
if doppler_info:
result['doppler'] = doppler_info.to_dict()
result["doppler"] = doppler_info.to_dict()
return jsonify(result)
@sstv_bp.route('/start', methods=['POST'])
@sstv_bp.route("/start", methods=["POST"])
def start_decoder():
"""
Start SSTV decoder.
@@ -133,20 +134,24 @@ def start_decoder():
JSON with start status.
"""
if not is_sstv_available():
return jsonify({
'status': 'error',
'message': 'SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow'
}), 400
return jsonify(
{
"status": "error",
"message": "SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow",
}
), 400
decoder = get_sstv_decoder()
if decoder.is_running:
return jsonify({
'status': 'already_running',
'frequency': ISS_SSTV_FREQ,
'modulation': ISS_SSTV_MODULATION,
'doppler_enabled': decoder.doppler_enabled
})
return jsonify(
{
"status": "already_running",
"frequency": ISS_SSTV_FREQ,
"modulation": ISS_SSTV_MODULATION,
"doppler_enabled": decoder.doppler_enabled,
}
)
# Clear queue
while not _sstv_queue.empty():
@@ -157,43 +162,38 @@ def start_decoder():
# Get parameters
data = request.get_json(silent=True) or {}
sdr_type_str = data.get('sdr_type', 'rtlsdr')
sdr_type_str = data.get("sdr_type", "rtlsdr")
if sdr_type_str != 'rtlsdr':
return jsonify({
'status': 'error',
'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.'
}), 400
if sdr_type_str != "rtlsdr":
return jsonify(
{
"status": "error",
"message": f"{sdr_type_str.replace('_', ' ').title()} is not yet supported for this mode. Please use an RTL-SDR device.",
}
), 400
frequency = data.get('frequency', ISS_SSTV_FREQ)
modulation = str(data.get('modulation', ISS_SSTV_MODULATION)).strip().lower()
device_index = data.get('device', 0)
latitude = data.get('latitude')
longitude = data.get('longitude')
frequency = data.get("frequency", ISS_SSTV_FREQ)
modulation = str(data.get("modulation", ISS_SSTV_MODULATION)).strip().lower()
device_index = data.get("device", 0)
latitude = data.get("latitude")
longitude = data.get("longitude")
# Validate modulation (ISS mode is FM-only)
if modulation != ISS_SSTV_MODULATION:
return jsonify({
'status': 'error',
'message': f'Modulation must be {ISS_SSTV_MODULATION} for ISS SSTV mode'
}), 400
return jsonify(
{"status": "error", "message": f"Modulation must be {ISS_SSTV_MODULATION} for ISS SSTV mode"}
), 400
# Validate frequency
try:
frequency = float(frequency)
normalized_frequency = _normalize_iss_frequency(frequency)
if normalized_frequency is None:
supported = ', '.join(f'{freq:.3f}' for freq in ISS_SSTV_FREQUENCIES)
return jsonify({
'status': 'error',
'message': f'Supported ISS SSTV frequency: {supported} MHz FM'
}), 400
supported = ", ".join(f"{freq:.3f}" for freq in ISS_SSTV_FREQUENCIES)
return jsonify({"status": "error", "message": f"Supported ISS SSTV frequency: {supported} MHz FM"}), 400
frequency = normalized_frequency
except (TypeError, ValueError):
return jsonify({
'status': 'error',
'message': 'Invalid frequency'
}), 400
return jsonify({"status": "error", "message": "Invalid frequency"}), 400
# Validate location if provided
if latitude is not None and longitude is not None:
@@ -201,20 +201,11 @@ def start_decoder():
latitude = float(latitude)
longitude = float(longitude)
if not (-90 <= latitude <= 90):
return jsonify({
'status': 'error',
'message': 'Latitude must be between -90 and 90'
}), 400
return jsonify({"status": "error", "message": "Latitude must be between -90 and 90"}), 400
if not (-180 <= longitude <= 180):
return jsonify({
'status': 'error',
'message': 'Longitude must be between -180 and 180'
}), 400
return jsonify({"status": "error", "message": "Longitude must be between -180 and 180"}), 400
except (TypeError, ValueError):
return jsonify({
'status': 'error',
'message': 'Invalid latitude or longitude'
}), 400
return jsonify({"status": "error", "message": "Invalid latitude or longitude"}), 400
else:
latitude = None
longitude = None
@@ -222,13 +213,9 @@ def start_decoder():
# Claim SDR device
global sstv_active_device, sstv_active_sdr_type
device_int = int(device_index)
error = app_module.claim_sdr_device(device_int, 'sstv', sdr_type_str)
error = app_module.claim_sdr_device(device_int, "sstv", sdr_type_str)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
return jsonify({"status": "error", "error_type": "DEVICE_BUSY", "message": error}), 409
# Set callback and start
decoder.set_callback(_progress_callback)
@@ -245,28 +232,25 @@ def start_decoder():
sstv_active_sdr_type = sdr_type_str
result = {
'status': 'started',
'frequency': frequency,
'modulation': ISS_SSTV_MODULATION,
'device': device_index,
'doppler_enabled': decoder.doppler_enabled
"status": "started",
"frequency": frequency,
"modulation": ISS_SSTV_MODULATION,
"device": device_index,
"doppler_enabled": decoder.doppler_enabled,
}
# Include initial Doppler info if available
if decoder.doppler_enabled and decoder.last_doppler_info:
result['doppler'] = decoder.last_doppler_info.to_dict()
result["doppler"] = decoder.last_doppler_info.to_dict()
return jsonify(result)
else:
# Release device on failure
app_module.release_sdr_device(device_int, sdr_type_str)
return jsonify({
'status': 'error',
'message': 'Failed to start decoder'
}), 500
return jsonify({"status": "error", "message": "Failed to start decoder"}), 500
@sstv_bp.route('/stop', methods=['POST'])
@sstv_bp.route("/stop", methods=["POST"])
def stop_decoder():
"""
Stop SSTV decoder.
@@ -283,10 +267,10 @@ def stop_decoder():
app_module.release_sdr_device(sstv_active_device, sstv_active_sdr_type)
sstv_active_device = None
return jsonify({'status': 'stopped'})
return jsonify({"status": "stopped"})
@sstv_bp.route('/doppler')
@sstv_bp.route("/doppler")
def get_doppler():
"""
Get current Doppler shift information.
@@ -299,27 +283,28 @@ def get_doppler():
decoder = get_sstv_decoder()
if not decoder.doppler_enabled:
return jsonify({
'status': 'disabled',
'message': 'Doppler tracking not enabled. Provide latitude/longitude when starting decoder.'
})
return jsonify(
{
"status": "disabled",
"message": "Doppler tracking not enabled. Provide latitude/longitude when starting decoder.",
}
)
doppler_info = decoder.last_doppler_info
if not doppler_info:
return jsonify({
'status': 'unavailable',
'message': 'Doppler data not yet available'
})
return jsonify({"status": "unavailable", "message": "Doppler data not yet available"})
return jsonify({
'status': 'ok',
'doppler': doppler_info.to_dict(),
'nominal_frequency_mhz': ISS_SSTV_FREQ,
'corrected_frequency_mhz': doppler_info.frequency_hz / 1_000_000
})
return jsonify(
{
"status": "ok",
"doppler": doppler_info.to_dict(),
"nominal_frequency_mhz": ISS_SSTV_FREQ,
"corrected_frequency_mhz": doppler_info.frequency_hz / 1_000_000,
}
)
@sstv_bp.route('/images')
@sstv_bp.route("/images")
def list_images():
"""
Get list of decoded SSTV images.
@@ -333,18 +318,14 @@ def list_images():
decoder = get_sstv_decoder()
images = decoder.get_images()
limit = request.args.get('limit', type=int)
limit = request.args.get("limit", type=int)
if limit and limit > 0:
images = images[-limit:]
return jsonify({
'status': 'ok',
'images': [img.to_dict() for img in images],
'count': len(images)
})
return jsonify({"status": "ok", "images": [img.to_dict() for img in images], "count": len(images)})
@sstv_bp.route('/images/<filename>')
@sstv_bp.route("/images/<filename>")
def get_image(filename: str):
"""
Get a decoded SSTV image file.
@@ -358,22 +339,22 @@ def get_image(filename: str):
decoder = get_sstv_decoder()
# Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return api_error('Invalid filename', 400)
if not filename.replace("_", "").replace("-", "").replace(".", "").isalnum():
return api_error("Invalid filename", 400)
if not filename.endswith('.png'):
return api_error('Only PNG files supported', 400)
if not filename.endswith(".png"):
return api_error("Only PNG files supported", 400)
# Find image in decoder's output directory
image_path = decoder._output_dir / filename
if not image_path.exists():
return api_error('Image not found', 404)
return api_error("Image not found", 404)
return send_file(image_path, mimetype='image/png')
return send_file(image_path, mimetype="image/png")
@sstv_bp.route('/images/<filename>/download')
@sstv_bp.route("/images/<filename>/download")
def download_image(filename: str):
"""
Download a decoded SSTV image file.
@@ -387,21 +368,21 @@ def download_image(filename: str):
decoder = get_sstv_decoder()
# Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return api_error('Invalid filename', 400)
if not filename.replace("_", "").replace("-", "").replace(".", "").isalnum():
return api_error("Invalid filename", 400)
if not filename.endswith('.png'):
return api_error('Only PNG files supported', 400)
if not filename.endswith(".png"):
return api_error("Only PNG files supported", 400)
image_path = decoder._output_dir / filename
if not image_path.exists():
return api_error('Image not found', 404)
return api_error("Image not found", 404)
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
return send_file(image_path, mimetype="image/png", as_attachment=True, download_name=filename)
@sstv_bp.route('/images/<filename>', methods=['DELETE'])
@sstv_bp.route("/images/<filename>", methods=["DELETE"])
def delete_image(filename: str):
"""
Delete a decoded SSTV image.
@@ -415,19 +396,19 @@ def delete_image(filename: str):
decoder = get_sstv_decoder()
# Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return api_error('Invalid filename', 400)
if not filename.replace("_", "").replace("-", "").replace(".", "").isalnum():
return api_error("Invalid filename", 400)
if not filename.endswith('.png'):
return api_error('Only PNG files supported', 400)
if not filename.endswith(".png"):
return api_error("Only PNG files supported", 400)
if decoder.delete_image(filename):
return jsonify({'status': 'ok'})
return jsonify({"status": "ok"})
else:
return api_error('Image not found', 404)
return api_error("Image not found", 404)
@sstv_bp.route('/images', methods=['DELETE'])
@sstv_bp.route("/images", methods=["DELETE"])
def delete_all_images():
"""
Delete all decoded SSTV images.
@@ -437,10 +418,10 @@ def delete_all_images():
"""
decoder = get_sstv_decoder()
count = decoder.delete_all_images()
return jsonify({'status': 'ok', 'deleted': count})
return jsonify({"status": "ok", "deleted": count})
@sstv_bp.route('/stream')
@sstv_bp.route("/stream")
def stream_progress():
"""
SSE stream of SSTV decode progress.
@@ -453,36 +434,38 @@ def stream_progress():
Returns:
SSE stream (text/event-stream)
"""
def _on_msg(msg: dict[str, Any]) -> None:
process_event('sstv', msg, msg.get('type'))
process_event("sstv", msg, msg.get("type"))
response = Response(
sse_stream_fanout(
source_queue=_sstv_queue,
channel_key='sstv',
channel_key="sstv",
timeout=1.0,
keepalive_interval=30.0,
on_message=_on_msg,
),
mimetype='text/event-stream',
mimetype="text/event-stream",
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
response.headers["Cache-Control"] = "no-cache"
response.headers["X-Accel-Buffering"] = "no"
response.headers["Connection"] = "keep-alive"
return response
def _get_timescale():
"""Return a cached skyfield timescale (expensive to create)."""
global _timescale
with _timescale_lock:
if _timescale is None:
from skyfield.api import load
_timescale = load.timescale(builtin=True)
return _timescale
def _get_timescale():
"""Return a cached skyfield timescale (expensive to create)."""
global _timescale
with _timescale_lock:
if _timescale is None:
from skyfield.api import load
_timescale = load.timescale(builtin=True)
return _timescale
@sstv_bp.route('/iss-schedule')
@sstv_bp.route("/iss-schedule")
def iss_schedule():
"""
Get ISS pass schedule for SSTV reception.
@@ -500,24 +483,23 @@ def iss_schedule():
"""
global _iss_schedule_cache, _iss_schedule_cache_time, _iss_schedule_cache_key
lat = request.args.get('latitude', type=float)
lon = request.args.get('longitude', type=float)
hours = request.args.get('hours', 48, type=int)
lat = request.args.get("latitude", type=float)
lon = request.args.get("longitude", type=float)
hours = request.args.get("hours", 48, type=int)
if lat is None or lon is None:
return jsonify({
'status': 'error',
'message': 'latitude and longitude parameters required'
}), 400
return jsonify({"status": "error", "message": "latitude and longitude parameters required"}), 400
# Cache key: rounded lat/lon (1 decimal place) so nearby locations share cache
cache_key = f"{round(lat, 1)}:{round(lon, 1)}:{hours}"
with _iss_schedule_lock:
now = time.time()
if (_iss_schedule_cache is not None
and cache_key == _iss_schedule_cache_key
and (now - _iss_schedule_cache_time) < ISS_SCHEDULE_CACHE_TTL):
if (
_iss_schedule_cache is not None
and cache_key == _iss_schedule_cache_key
and (now - _iss_schedule_cache_time) < ISS_SCHEDULE_CACHE_TTL
):
return jsonify(_iss_schedule_cache)
try:
@@ -526,15 +508,10 @@ def iss_schedule():
from skyfield.almanac import find_discrete
from skyfield.api import EarthSatellite, wgs84
from data.satellites import TLE_SATELLITES
# Get ISS TLE
iss_tle = TLE_SATELLITES.get('ISS')
# Get ISS TLE from live cache (kept fresh by auto-refresh)
iss_tle = get_cached_tle("ISS")
if not iss_tle:
return jsonify({
'status': 'error',
'message': 'ISS TLE data not available'
}), 500
return jsonify({"status": "error", "message": "ISS TLE data not available"}), 500
ts = _get_timescale()
satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts)
@@ -549,7 +526,7 @@ def iss_schedule():
alt, _, _ = topocentric.altaz()
return alt.degrees > 0
above_horizon.step_days = 1/720
above_horizon.step_days = 1 / 720
times, events = find_discrete(t0, t1, above_horizon)
@@ -588,23 +565,25 @@ def iss_schedule():
max_el = alt.degrees
if max_el >= 10: # Min elevation filter
passes.append({
'satellite': 'ISS',
'startTime': rise_time.utc_datetime().strftime('%Y-%m-%d %H:%M UTC'),
'startTimeISO': rise_time.utc_datetime().isoformat(),
'maxEl': round(max_el, 1),
'duration': duration_minutes,
'color': '#00ffff'
})
passes.append(
{
"satellite": "ISS",
"startTime": rise_time.utc_datetime().strftime("%Y-%m-%d %H:%M UTC"),
"startTimeISO": rise_time.utc_datetime().isoformat(),
"maxEl": round(max_el, 1),
"duration": duration_minutes,
"color": "#00ffff",
}
)
i += 1
result = {
'status': 'ok',
'passes': passes,
'count': len(passes),
'sstv_frequency': ISS_SSTV_FREQ,
'note': 'ISS SSTV events are not continuous. Check ARISS.org for scheduled events.'
"status": "ok",
"passes": passes,
"count": len(passes),
"sstv_frequency": ISS_SSTV_FREQ,
"note": "ISS SSTV events are not continuous. Check ARISS.org for scheduled events.",
}
# Update cache
@@ -616,17 +595,11 @@ def iss_schedule():
return jsonify(result)
except ImportError:
return jsonify({
'status': 'error',
'message': 'skyfield library not installed'
}), 503
return jsonify({"status": "error", "message": "skyfield library not installed"}), 503
except Exception as e:
logger.error(f"Error getting ISS schedule: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
return jsonify({"status": "error", "message": str(e)}), 500
def _fetch_iss_position() -> dict | None:
@@ -644,14 +617,14 @@ def _fetch_iss_position() -> dict | None:
# Try primary API: Where The ISS At
try:
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=3)
response = requests.get("https://api.wheretheiss.at/v1/satellites/25544", timeout=3)
if response.status_code == 200:
data = response.json()
cached = {
'lat': float(data['latitude']),
'lon': float(data['longitude']),
'altitude': float(data.get('altitude', 420)),
'source': 'wheretheiss',
"lat": float(data["latitude"]),
"lon": float(data["longitude"]),
"altitude": float(data.get("altitude", 420)),
"source": "wheretheiss",
}
except Exception as e:
logger.warning(f"Where The ISS At API failed: {e}")
@@ -659,15 +632,15 @@ def _fetch_iss_position() -> dict | None:
# Try fallback API: Open Notify
if cached is None:
try:
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=3)
response = requests.get("http://api.open-notify.org/iss-now.json", timeout=3)
if response.status_code == 200:
data = response.json()
if data.get('message') == 'success':
if data.get("message") == "success":
cached = {
'lat': float(data['iss_position']['latitude']),
'lon': float(data['iss_position']['longitude']),
'altitude': 420,
'source': 'open-notify',
"lat": float(data["iss_position"]["latitude"]),
"lon": float(data["iss_position"]["longitude"]),
"altitude": 420,
"source": "open-notify",
}
except Exception as e:
logger.warning(f"Open Notify API failed: {e}")
@@ -680,7 +653,7 @@ def _fetch_iss_position() -> dict | None:
return cached
@sstv_bp.route('/iss-position')
@sstv_bp.route("/iss-position")
def iss_position():
"""
Get current ISS position from real-time API.
@@ -698,28 +671,25 @@ def iss_position():
"""
from datetime import datetime
observer_lat = request.args.get('latitude', type=float)
observer_lon = request.args.get('longitude', type=float)
observer_lat = request.args.get("latitude", type=float)
observer_lon = request.args.get("longitude", type=float)
pos = _fetch_iss_position()
if pos is None:
return jsonify({
'status': 'error',
'message': 'Unable to fetch ISS position from real-time APIs'
}), 503
return jsonify({"status": "error", "message": "Unable to fetch ISS position from real-time APIs"}), 503
result = {
'status': 'ok',
'lat': pos['lat'],
'lon': pos['lon'],
'altitude': pos['altitude'],
'timestamp': datetime.utcnow().isoformat(),
'source': pos['source'],
"status": "ok",
"lat": pos["lat"],
"lon": pos["lon"],
"altitude": pos["altitude"],
"timestamp": datetime.utcnow().isoformat(),
"source": pos["source"],
}
# Calculate observer-relative data if location provided
if observer_lat is not None and observer_lon is not None:
result.update(_calculate_observer_data(pos['lat'], pos['lon'], observer_lat, observer_lon))
result.update(_calculate_observer_data(pos["lat"], pos["lon"], observer_lat, observer_lon))
return jsonify(result)
@@ -743,7 +713,7 @@ def _calculate_observer_data(iss_lat: float, iss_lon: float, obs_lat: float, obs
# Haversine for ground distance
dlat = lat2 - lat1
dlon = lon2 - lon1
a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
c = 2 * math.asin(math.sqrt(a))
ground_distance = earth_radius * c
@@ -763,14 +733,60 @@ def _calculate_observer_data(iss_lat: float, iss_lon: float, obs_lat: float, obs
azimuth = math.degrees(math.atan2(y, x))
azimuth = (azimuth + 360) % 360
return {
'elevation': round(elevation, 1),
'azimuth': round(azimuth, 1),
'distance': round(slant_range, 1)
}
return {"elevation": round(elevation, 1), "azimuth": round(azimuth, 1), "distance": round(slant_range, 1)}
@sstv_bp.route('/decode-file', methods=['POST'])
@sstv_bp.route("/iss-track")
def iss_track():
"""
Return ISS ground track points propagated from TLE data.
Uses skyfield SGP4 propagation over ±90 minutes (roughly one full orbit)
to produce an accurate track that accounts for Earth's rotation.
Returns:
JSON with list of {lat, lon, past} points.
"""
try:
from datetime import timedelta
from skyfield.api import EarthSatellite, wgs84
iss_tle = get_cached_tle("ISS")
if not iss_tle:
return jsonify({"status": "error", "message": "ISS TLE not available"}), 500
ts = _get_timescale()
satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts)
now = ts.now()
now_dt = now.utc_datetime()
track = []
for minutes_offset in range(-90, 91, 1):
t_point = ts.utc(now_dt + timedelta(minutes=minutes_offset))
try:
geo = satellite.at(t_point)
sp = wgs84.subpoint(geo)
track.append(
{
"lat": round(float(sp.latitude.degrees), 4),
"lon": round(float(sp.longitude.degrees), 4),
"past": minutes_offset < 0,
}
)
except Exception:
continue
return jsonify({"status": "ok", "track": track})
except ImportError:
return jsonify({"status": "error", "message": "skyfield not installed"}), 503
except Exception as e:
logger.error(f"Error computing ISS track: {e}")
return jsonify({"status": "error", "message": str(e)}), 500
@sstv_bp.route("/decode-file", methods=["POST"])
def decode_file():
"""
Decode SSTV from an uploaded audio file.
@@ -780,23 +796,18 @@ def decode_file():
Returns:
JSON with decoded images.
"""
if 'audio' not in request.files:
return jsonify({
'status': 'error',
'message': 'No audio file provided'
}), 400
if "audio" not in request.files:
return jsonify({"status": "error", "message": "No audio file provided"}), 400
audio_file = request.files['audio']
audio_file = request.files["audio"]
if not audio_file.filename:
return jsonify({
'status': 'error',
'message': 'No file selected'
}), 400
return jsonify({"status": "error", "message": "No file selected"}), 400
# Save to temp file
import tempfile
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
audio_file.save(tmp.name)
tmp_path = tmp.name
@@ -804,18 +815,11 @@ def decode_file():
decoder = get_sstv_decoder()
images = decoder.decode_file(tmp_path)
return jsonify({
'status': 'ok',
'images': [img.to_dict() for img in images],
'count': len(images)
})
return jsonify({"status": "ok", "images": [img.to_dict() for img in images], "count": len(images)})
except Exception as e:
logger.error(f"Error decoding file: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
return jsonify({"status": "error", "message": str(e)}), 500
finally:
# Clean up temp file
+5 -3
View File
@@ -490,6 +490,7 @@ def _start_sweep_internal(
bt_interface: str = '',
sdr_device: int | None = None,
verbose_results: bool = False,
custom_ranges: list[dict] | None = None,
) -> dict:
"""Start a TSCM sweep without request context."""
global _sweep_running, _sweep_thread, _current_sweep_id
@@ -532,7 +533,7 @@ def _start_sweep_internal(
_sweep_thread = threading.Thread(
target=_run_sweep,
args=(sweep_type, baseline_id, wifi_enabled, bt_enabled, rf_enabled,
wifi_interface, bt_interface, sdr_device, verbose_results),
wifi_interface, bt_interface, sdr_device, verbose_results, custom_ranges),
daemon=True
)
_sweep_thread.start()
@@ -1127,7 +1128,8 @@ def _run_sweep(
wifi_interface: str = '',
bt_interface: str = '',
sdr_device: int | None = None,
verbose_results: bool = False
verbose_results: bool = False,
custom_ranges: list[dict] | None = None,
) -> None:
"""
Run the TSCM sweep in a background thread.
@@ -1504,7 +1506,7 @@ def _run_sweep(
'rf_count': len(all_rf),
})
# Try RF scan even if sdr_device is None (will use device 0)
rf_signals = _scan_rf_signals(sdr_device, sweep_ranges=preset.get('ranges'))
rf_signals = _scan_rf_signals(sdr_device, sweep_ranges=custom_ranges or preset.get('ranges'))
# If no signals and this is first RF scan, send info event
if not rf_signals and last_rf_scan == 0:
+20 -3
View File
@@ -19,12 +19,9 @@ from flask import Response, jsonify, request
from data.tscm_frequencies import get_all_sweep_presets, get_sweep_preset
from routes.tscm import (
_baseline_recorder,
_current_sweep_id,
_emit_event,
_start_sweep_internal,
_sweep_running,
tscm_bp,
tscm_queue,
)
from utils.database import get_tscm_sweep, update_tscm_sweep
from utils.event_pipeline import process_event
@@ -58,6 +55,25 @@ def start_sweep():
bt_interface = data.get('bt_interface', '')
sdr_device = data.get('sdr_device')
# Validate custom frequency ranges if provided
custom_ranges = None
if sweep_type == 'custom':
raw_ranges = data.get('custom_ranges') or []
validated = []
for rng in raw_ranges:
try:
start = float(rng.get('start', 0))
end = float(rng.get('end', 0))
step = float(rng.get('step', 0.1))
if 0 < start < end <= 6000:
validated.append({'start': start, 'end': end, 'step': step,
'name': rng.get('name') or f'{start:.0f}{end:.0f} MHz'})
except (TypeError, ValueError):
pass
if not validated:
return jsonify({'status': 'error', 'message': 'custom sweep requires valid start/end MHz'}), 400
custom_ranges = validated
result = _start_sweep_internal(
sweep_type=sweep_type,
baseline_id=baseline_id,
@@ -68,6 +84,7 @@ def start_sweep():
bt_interface=bt_interface,
sdr_device=sdr_device,
verbose_results=verbose_results,
custom_ranges=custom_ranges,
)
http_status = result.pop('http_status', 200)
return jsonify(result), http_status
+165 -165
View File
@@ -1,17 +1,17 @@
"""Weather Satellite decoder routes.
Provides endpoints for capturing and decoding Meteor LRPT weather
imagery, including shared results produced by the ground-station
observation pipeline.
"""
"""Weather Satellite decoder routes.
from __future__ import annotations
import json
import queue
from pathlib import Path
from flask import Blueprint, Response, jsonify, request, send_file
Provides endpoints for capturing and decoding Meteor LRPT weather
imagery, including shared results produced by the ground-station
observation pipeline.
"""
from __future__ import annotations
import json
import queue
from pathlib import Path
from flask import Blueprint, Response, jsonify, request, send_file
from utils.logging import get_logger
from utils.responses import api_error
@@ -33,21 +33,21 @@ from utils.weather_sat import (
is_weather_sat_available,
)
logger = get_logger('intercept.weather_sat')
weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat')
logger = get_logger('intercept.weather_sat')
weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat')
# Queue for SSE progress streaming
_weather_sat_queue: queue.Queue = queue.Queue(maxsize=100)
METEOR_NORAD_IDS = {
'METEOR-M2-3': 57166,
'METEOR-M2-4': 59051,
}
ALLOWED_TEST_DECODE_DIRS = (
Path(__file__).resolve().parent.parent / 'data',
Path(__file__).resolve().parent.parent / 'instance' / 'ground_station' / 'recordings',
)
_weather_sat_queue: queue.Queue = queue.Queue(maxsize=100)
METEOR_NORAD_IDS = {
'METEOR-M2-3': 57166,
'METEOR-M2-4': 59051,
}
ALLOWED_TEST_DECODE_DIRS = (
Path(__file__).resolve().parent.parent / 'data',
Path(__file__).resolve().parent.parent / 'instance' / 'ground_station' / 'recordings',
)
def _progress_callback(progress: CaptureProgress) -> None:
@@ -132,9 +132,9 @@ def start_capture():
JSON body:
{
"satellite": "METEOR-M2-3", // Required: satellite key
"satellite": "METEOR-M2-3", // Required: satellite key
"device": 0, // RTL-SDR device index (default: 0)
"gain": 40.0, // SDR gain in dB (default: 40)
"gain": 30.0, // SDR gain in dB (default: 30)
"bias_t": false // Enable bias-T for LNA (default: false)
}
@@ -176,7 +176,7 @@ def start_capture():
# Validate device index and gain
try:
device_index = validate_device_index(data.get('device', 0))
gain = validate_gain(data.get('gain', 40.0))
gain = validate_gain(data.get('gain', 30.0))
except ValueError as e:
logger.warning('Invalid parameter in start_capture: %s', e)
return jsonify({
@@ -260,7 +260,7 @@ def test_decode():
JSON body:
{
"satellite": "METEOR-M2-3", // Required: satellite key
"satellite": "METEOR-M2-3", // Required: satellite key
"input_file": "/path/to/file", // Required: server-side file path
"sample_rate": 1000000 // Sample rate in Hz (default: 1000000)
}
@@ -304,14 +304,14 @@ def test_decode():
from pathlib import Path
input_path = Path(input_file)
# Restrict test-decode to application-owned sample and recording paths.
try:
resolved = input_path.resolve()
if not any(resolved.is_relative_to(base) for base in ALLOWED_TEST_DECODE_DIRS):
return jsonify({
'status': 'error',
'message': 'input_file must be under INTERCEPT data or ground-station recordings'
}), 403
# Restrict test-decode to application-owned sample and recording paths.
try:
resolved = input_path.resolve()
if not any(resolved.is_relative_to(base) for base in ALLOWED_TEST_DECODE_DIRS):
return jsonify({
'status': 'error',
'message': 'input_file must be under INTERCEPT data or ground-station recordings'
}), 403
except (OSError, ValueError):
return jsonify({
'status': 'error',
@@ -388,8 +388,8 @@ def stop_capture():
return jsonify({'status': 'stopped'})
@weather_sat_bp.route('/images')
def list_images():
@weather_sat_bp.route('/images')
def list_images():
"""Get list of decoded weather satellite images.
Query parameters:
@@ -399,41 +399,41 @@ def list_images():
Returns:
JSON with list of decoded images.
"""
decoder = get_weather_sat_decoder()
images = [
{
**img.to_dict(),
'source': 'weather_sat',
'deletable': True,
}
for img in decoder.get_images()
]
images.extend(_get_ground_station_images())
# Filter by satellite if specified
satellite_filter = request.args.get('satellite')
if satellite_filter:
images = [
img for img in images
if str(img.get('satellite', '')).upper() == satellite_filter.upper()
]
images.sort(key=lambda img: img.get('timestamp') or '', reverse=True)
# Apply limit
limit = request.args.get('limit', type=int)
if limit and limit > 0:
images = images[:limit]
return jsonify({
'status': 'ok',
'images': images,
'count': len(images),
})
decoder = get_weather_sat_decoder()
images = [
{
**img.to_dict(),
'source': 'weather_sat',
'deletable': True,
}
for img in decoder.get_images()
]
images.extend(_get_ground_station_images())
# Filter by satellite if specified
satellite_filter = request.args.get('satellite')
if satellite_filter:
images = [
img for img in images
if str(img.get('satellite', '')).upper() == satellite_filter.upper()
]
images.sort(key=lambda img: img.get('timestamp') or '', reverse=True)
# Apply limit
limit = request.args.get('limit', type=int)
if limit and limit > 0:
images = images[:limit]
return jsonify({
'status': 'ok',
'images': images,
'count': len(images),
})
@weather_sat_bp.route('/images/<filename>')
def get_image(filename: str):
@weather_sat_bp.route('/images/<filename>')
def get_image(filename: str):
"""Serve a decoded weather satellite image file.
Args:
@@ -456,38 +456,38 @@ def get_image(filename: str):
if not image_path.exists():
return api_error('Image not found', 404)
mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg'
return send_file(image_path, mimetype=mimetype)
@weather_sat_bp.route('/images/shared/<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)
mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg'
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'])
@@ -512,71 +512,71 @@ def delete_image(filename: str):
@weather_sat_bp.route('/images', methods=['DELETE'])
def delete_all_images():
def delete_all_images():
"""Delete all decoded weather satellite images.
Returns:
JSON with count of deleted images.
"""
decoder = get_weather_sat_decoder()
count = decoder.delete_all_images()
return jsonify({'status': 'ok', 'deleted': count})
def _get_ground_station_images() -> list[dict]:
try:
from utils.database import get_db
with get_db() as conn:
rows = conn.execute(
'''
SELECT id, norad_id, file_path, metadata_json, created_at
FROM ground_station_outputs
WHERE output_type='image' AND backend='meteor_lrpt'
ORDER BY created_at DESC
LIMIT 200
'''
).fetchall()
except Exception as e:
logger.debug("Failed to fetch ground-station weather outputs: %s", e)
return []
images: list[dict] = []
for row in rows:
file_path = Path(row['file_path'])
if not file_path.exists():
continue
metadata = {}
raw_metadata = row['metadata_json']
if raw_metadata:
try:
metadata = json.loads(raw_metadata)
except json.JSONDecodeError:
metadata = {}
satellite = metadata.get('satellite') or _satellite_from_norad(row['norad_id'])
images.append({
'filename': file_path.name,
'satellite': satellite,
'mode': metadata.get('mode', 'LRPT'),
'timestamp': metadata.get('timestamp') or row['created_at'],
'frequency': metadata.get('frequency', 137.9),
'size_bytes': metadata.get('size_bytes') or file_path.stat().st_size,
'product': metadata.get('product', ''),
'url': f"/weather-sat/images/shared/{row['id']}",
'source': 'ground_station',
'deletable': False,
'output_id': row['id'],
})
return images
def _satellite_from_norad(norad_id: int | None) -> str:
for satellite, known_norad in METEOR_NORAD_IDS.items():
if known_norad == norad_id:
return satellite
return 'METEOR'
count = decoder.delete_all_images()
return jsonify({'status': 'ok', 'deleted': count})
def _get_ground_station_images() -> list[dict]:
try:
from utils.database import get_db
with get_db() as conn:
rows = conn.execute(
'''
SELECT id, norad_id, file_path, metadata_json, created_at
FROM ground_station_outputs
WHERE output_type='image' AND backend='meteor_lrpt'
ORDER BY created_at DESC
LIMIT 200
'''
).fetchall()
except Exception as e:
logger.debug("Failed to fetch ground-station weather outputs: %s", e)
return []
images: list[dict] = []
for row in rows:
file_path = Path(row['file_path'])
if not file_path.exists():
continue
metadata = {}
raw_metadata = row['metadata_json']
if raw_metadata:
try:
metadata = json.loads(raw_metadata)
except json.JSONDecodeError:
metadata = {}
satellite = metadata.get('satellite') or _satellite_from_norad(row['norad_id'])
images.append({
'filename': file_path.name,
'satellite': satellite,
'mode': metadata.get('mode', 'LRPT'),
'timestamp': metadata.get('timestamp') or row['created_at'],
'frequency': metadata.get('frequency', 137.9),
'size_bytes': metadata.get('size_bytes') or file_path.stat().st_size,
'product': metadata.get('product', ''),
'url': f"/weather-sat/images/shared/{row['id']}",
'source': 'ground_station',
'deletable': False,
'output_id': row['id'],
})
return images
def _satellite_from_norad(norad_id: int | None) -> str:
for satellite, known_norad in METEOR_NORAD_IDS.items():
if known_norad == norad_id:
return satellite
return 'METEOR'
@weather_sat_bp.route('/stream')
@@ -689,7 +689,7 @@ def enable_schedule():
"longitude": -0.1, // Required
"min_elevation": 15, // Minimum pass elevation (default: 15)
"device": 0, // RTL-SDR device index (default: 0)
"gain": 40.0, // SDR gain (default: 40)
"gain": 30.0, // SDR gain (default: 30)
"bias_t": false // Enable bias-T (default: false)
}