mirror of
https://github.com/smittix/intercept.git
synced 2026-04-23 22:30:00 -07:00
fix(sstv): fix inaccurate ISS orbit tracking — three root causes
1. iss_schedule() was importing TLE_SATELLITES directly from data/satellites.py (hardcoded, 446 days stale) instead of the live _tle_cache kept fresh by the 24h auto-refresh. Add get_cached_tle() to satellite.py and use it. 2. Ground track was a fake sine wave (inclination * sin(phase)) that mapped longitude offset directly to orbital phase, ignoring Earth's rotation under the satellite (~23° westward shift per orbit). Replace with a /sstv/iss-track endpoint that propagates the orbit via skyfield SGP4 over ±90 minutes, and update the frontend to call it. Past/future track rendered with separate polylines (dim solid vs bright dashed). 3. refresh_tle_data() updated _tle_cache in memory but never persisted back to data/satellites.py, so every restart reloaded the stale hardcoded TLE. Add _persist_tle_cache() called after each successful refresh. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
466
routes/sstv.py
466
routes/sstv.py
@@ -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
|
||||
|
||||
@@ -13,12 +13,14 @@ const SSTV = (function() {
|
||||
let issMap = null;
|
||||
let issMarker = null;
|
||||
let issTrackLine = null;
|
||||
let issTrackPast = null;
|
||||
let issPosition = null;
|
||||
let issUpdateInterval = null;
|
||||
let countdownInterval = null;
|
||||
let nextPassData = null;
|
||||
let pendingMapInvalidate = false;
|
||||
let locationListenersAttached = false;
|
||||
let issUpdateInterval = null;
|
||||
let issTrackInterval = null;
|
||||
let countdownInterval = null;
|
||||
let nextPassData = null;
|
||||
let pendingMapInvalidate = false;
|
||||
let locationListenersAttached = false;
|
||||
|
||||
// ISS frequency
|
||||
const ISS_FREQ = 145.800;
|
||||
@@ -93,12 +95,12 @@ const SSTV = (function() {
|
||||
if (latInput && storedLat) latInput.value = storedLat;
|
||||
if (lonInput && storedLon) lonInput.value = storedLon;
|
||||
|
||||
if (!locationListenersAttached) {
|
||||
if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
|
||||
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs);
|
||||
locationListenersAttached = true;
|
||||
}
|
||||
}
|
||||
if (!locationListenersAttached) {
|
||||
if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
|
||||
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs);
|
||||
locationListenersAttached = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save location from input fields
|
||||
@@ -250,12 +252,19 @@ const SSTV = (function() {
|
||||
// Create ISS marker (will be positioned when we get data)
|
||||
issMarker = L.marker([0, 0], { icon: issIcon }).addTo(issMap);
|
||||
|
||||
// Create ground track line
|
||||
// Past track (dimmer, solid)
|
||||
issTrackPast = L.polyline([], {
|
||||
color: '#00d4ff',
|
||||
weight: 1.5,
|
||||
opacity: 0.3,
|
||||
}).addTo(issMap);
|
||||
|
||||
// Future track (brighter, dashed)
|
||||
issTrackLine = L.polyline([], {
|
||||
color: '#00d4ff',
|
||||
weight: 2,
|
||||
opacity: 0.6,
|
||||
dashArray: '5, 5'
|
||||
opacity: 0.7,
|
||||
dashArray: '6, 4'
|
||||
}).addTo(issMap);
|
||||
|
||||
issMap.on('resize moveend zoomend', () => {
|
||||
@@ -272,9 +281,12 @@ const SSTV = (function() {
|
||||
*/
|
||||
function startIssTracking() {
|
||||
updateIssPosition();
|
||||
// Update every 5 seconds
|
||||
updateIssTrack();
|
||||
if (issUpdateInterval) clearInterval(issUpdateInterval);
|
||||
issUpdateInterval = setInterval(updateIssPosition, 5000);
|
||||
// Track refreshes every 5 minutes — one orbit is ~93 min so this keeps it current
|
||||
if (issTrackInterval) clearInterval(issTrackInterval);
|
||||
issTrackInterval = setInterval(updateIssTrack, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -285,6 +297,52 @@ const SSTV = (function() {
|
||||
clearInterval(issUpdateInterval);
|
||||
issUpdateInterval = null;
|
||||
}
|
||||
if (issTrackInterval) {
|
||||
clearInterval(issTrackInterval);
|
||||
issTrackInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and render the ISS ground track from the backend (TLE-propagated).
|
||||
*/
|
||||
async function updateIssTrack() {
|
||||
try {
|
||||
const response = await fetch('/sstv/iss-track');
|
||||
const data = await response.json();
|
||||
if (data.status !== 'ok' || !issTrackLine || !issTrackPast) return;
|
||||
|
||||
const pastPts = [], futurePts = [];
|
||||
for (const pt of data.track) {
|
||||
(pt.past ? pastPts : futurePts).push([pt.lat, pt.lon]);
|
||||
}
|
||||
|
||||
// Split future track at antimeridian crossings to avoid long horizontal lines
|
||||
const futureSegments = _splitAtAntimeridian(futurePts);
|
||||
const pastSegments = _splitAtAntimeridian(pastPts);
|
||||
|
||||
issTrackLine.setLatLngs(futureSegments);
|
||||
issTrackPast.setLatLngs(pastSegments);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch ISS track:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an array of [lat, lon] points into segments at antimeridian crossings.
|
||||
*/
|
||||
function _splitAtAntimeridian(points) {
|
||||
const segments = [];
|
||||
let current = [];
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
if (i > 0 && Math.abs(points[i][1] - points[i - 1][1]) > 180) {
|
||||
if (current.length > 1) segments.push(current);
|
||||
current = [];
|
||||
}
|
||||
current.push(points[i]);
|
||||
}
|
||||
if (current.length > 1) segments.push(current);
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -486,55 +544,7 @@ const SSTV = (function() {
|
||||
issMarker.setLatLng([lat, lon]);
|
||||
}
|
||||
|
||||
// Calculate and draw ground track
|
||||
if (issTrackLine) {
|
||||
const trackPoints = [];
|
||||
const inclination = 51.6; // ISS orbital inclination in degrees
|
||||
|
||||
// Generate orbit track points
|
||||
for (let offset = -180; offset <= 180; offset += 3) {
|
||||
let trackLon = lon + offset;
|
||||
|
||||
// Normalize longitude
|
||||
while (trackLon > 180) trackLon -= 360;
|
||||
while (trackLon < -180) trackLon += 360;
|
||||
|
||||
// Calculate latitude based on orbital inclination
|
||||
const phase = (offset / 360) * 2 * Math.PI;
|
||||
const currentPhase = Math.asin(Math.max(-1, Math.min(1, lat / inclination)));
|
||||
let trackLat = inclination * Math.sin(phase + currentPhase);
|
||||
|
||||
// Clamp to valid range
|
||||
trackLat = Math.max(-inclination, Math.min(inclination, trackLat));
|
||||
|
||||
trackPoints.push([trackLat, trackLon]);
|
||||
}
|
||||
|
||||
// Split track at antimeridian to avoid line across map
|
||||
const segments = [];
|
||||
let currentSegment = [];
|
||||
|
||||
for (let i = 0; i < trackPoints.length; i++) {
|
||||
if (i > 0) {
|
||||
const prevLon = trackPoints[i - 1][1];
|
||||
const currLon = trackPoints[i][1];
|
||||
if (Math.abs(currLon - prevLon) > 180) {
|
||||
// Crossed antimeridian
|
||||
if (currentSegment.length > 0) {
|
||||
segments.push(currentSegment);
|
||||
}
|
||||
currentSegment = [];
|
||||
}
|
||||
}
|
||||
currentSegment.push(trackPoints[i]);
|
||||
}
|
||||
if (currentSegment.length > 0) {
|
||||
segments.push(currentSegment);
|
||||
}
|
||||
|
||||
// Use only the longest segment or combine if needed
|
||||
issTrackLine.setLatLngs(segments.length > 0 ? segments : []);
|
||||
}
|
||||
// Track is fetched separately by updateIssTrack() via /sstv/iss-track
|
||||
|
||||
// Pan map to follow ISS only when the map pane is currently renderable.
|
||||
if (isMapContainerVisible()) {
|
||||
|
||||
Reference in New Issue
Block a user