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:
James Smith
2026-04-20 15:37:02 +01:00
parent 1dc45a285d
commit 7cf94cce14
3 changed files with 646 additions and 557 deletions
+337 -262
View File
File diff suppressed because it is too large Load Diff
+227 -223
View File
@@ -16,6 +16,7 @@ from typing import Any
from flask import Blueprint, Response, jsonify, request, send_file from flask import Blueprint, Response, jsonify, request, send_file
import app as app_module import app as app_module
from routes.satellite import get_cached_tle
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.logging import get_logger from utils.logging import get_logger
from utils.responses import api_error from utils.responses import api_error
@@ -26,13 +27,13 @@ from utils.sstv import (
is_sstv_available, 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 # 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. # 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_FREQUENCIES = (ISS_SSTV_FREQ,)
ISS_SSTV_FREQ_TOLERANCE_MHZ = 0.05 ISS_SSTV_FREQ_TOLERANCE_MHZ = 0.05
@@ -59,7 +60,7 @@ _timescale_lock = threading.Lock()
# Track which device is being used # Track which device is being used
sstv_active_device: int | None = None sstv_active_device: int | None = None
sstv_active_sdr_type: str = 'rtlsdr' sstv_active_sdr_type: str = "rtlsdr"
def _progress_callback(data: dict) -> None: def _progress_callback(data: dict) -> None:
@@ -82,7 +83,7 @@ def _normalize_iss_frequency(frequency_mhz: float) -> float | None:
return None return None
@sstv_bp.route('/status') @sstv_bp.route("/status")
def get_status(): def get_status():
""" """
Get SSTV decoder status. Get SSTV decoder status.
@@ -94,24 +95,24 @@ def get_status():
decoder = get_sstv_decoder() decoder = get_sstv_decoder()
result = { result = {
'available': available, "available": available,
'decoder': decoder.decoder_available, "decoder": decoder.decoder_available,
'running': decoder.is_running, "running": decoder.is_running,
'iss_frequency': ISS_SSTV_FREQ, "iss_frequency": ISS_SSTV_FREQ,
'modulation': ISS_SSTV_MODULATION, "modulation": ISS_SSTV_MODULATION,
'image_count': len(decoder.get_images()), "image_count": len(decoder.get_images()),
'doppler_enabled': decoder.doppler_enabled, "doppler_enabled": decoder.doppler_enabled,
} }
# Include Doppler info if available # Include Doppler info if available
doppler_info = decoder.last_doppler_info doppler_info = decoder.last_doppler_info
if doppler_info: if doppler_info:
result['doppler'] = doppler_info.to_dict() result["doppler"] = doppler_info.to_dict()
return jsonify(result) return jsonify(result)
@sstv_bp.route('/start', methods=['POST']) @sstv_bp.route("/start", methods=["POST"])
def start_decoder(): def start_decoder():
""" """
Start SSTV decoder. Start SSTV decoder.
@@ -133,20 +134,24 @@ def start_decoder():
JSON with start status. JSON with start status.
""" """
if not is_sstv_available(): if not is_sstv_available():
return jsonify({ return jsonify(
'status': 'error', {
'message': 'SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow' "status": "error",
}), 400 "message": "SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow",
}
), 400
decoder = get_sstv_decoder() decoder = get_sstv_decoder()
if decoder.is_running: if decoder.is_running:
return jsonify({ return jsonify(
'status': 'already_running', {
'frequency': ISS_SSTV_FREQ, "status": "already_running",
'modulation': ISS_SSTV_MODULATION, "frequency": ISS_SSTV_FREQ,
'doppler_enabled': decoder.doppler_enabled "modulation": ISS_SSTV_MODULATION,
}) "doppler_enabled": decoder.doppler_enabled,
}
)
# Clear queue # Clear queue
while not _sstv_queue.empty(): while not _sstv_queue.empty():
@@ -157,43 +162,38 @@ def start_decoder():
# Get parameters # Get parameters
data = request.get_json(silent=True) or {} 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': if sdr_type_str != "rtlsdr":
return jsonify({ return jsonify(
'status': 'error', {
'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.' "status": "error",
}), 400 "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) frequency = data.get("frequency", ISS_SSTV_FREQ)
modulation = str(data.get('modulation', ISS_SSTV_MODULATION)).strip().lower() modulation = str(data.get("modulation", ISS_SSTV_MODULATION)).strip().lower()
device_index = data.get('device', 0) device_index = data.get("device", 0)
latitude = data.get('latitude') latitude = data.get("latitude")
longitude = data.get('longitude') longitude = data.get("longitude")
# Validate modulation (ISS mode is FM-only) # Validate modulation (ISS mode is FM-only)
if modulation != ISS_SSTV_MODULATION: if modulation != ISS_SSTV_MODULATION:
return jsonify({ return jsonify(
'status': 'error', {"status": "error", "message": f"Modulation must be {ISS_SSTV_MODULATION} for ISS SSTV mode"}
'message': f'Modulation must be {ISS_SSTV_MODULATION} for ISS SSTV mode' ), 400
}), 400
# Validate frequency # Validate frequency
try: try:
frequency = float(frequency) frequency = float(frequency)
normalized_frequency = _normalize_iss_frequency(frequency) normalized_frequency = _normalize_iss_frequency(frequency)
if normalized_frequency is None: if normalized_frequency is None:
supported = ', '.join(f'{freq:.3f}' for freq in ISS_SSTV_FREQUENCIES) supported = ", ".join(f"{freq:.3f}" for freq in ISS_SSTV_FREQUENCIES)
return jsonify({ return jsonify({"status": "error", "message": f"Supported ISS SSTV frequency: {supported} MHz FM"}), 400
'status': 'error',
'message': f'Supported ISS SSTV frequency: {supported} MHz FM'
}), 400
frequency = normalized_frequency frequency = normalized_frequency
except (TypeError, ValueError): except (TypeError, ValueError):
return jsonify({ return jsonify({"status": "error", "message": "Invalid frequency"}), 400
'status': 'error',
'message': 'Invalid frequency'
}), 400
# Validate location if provided # Validate location if provided
if latitude is not None and longitude is not None: if latitude is not None and longitude is not None:
@@ -201,20 +201,11 @@ def start_decoder():
latitude = float(latitude) latitude = float(latitude)
longitude = float(longitude) longitude = float(longitude)
if not (-90 <= latitude <= 90): if not (-90 <= latitude <= 90):
return jsonify({ return jsonify({"status": "error", "message": "Latitude must be between -90 and 90"}), 400
'status': 'error',
'message': 'Latitude must be between -90 and 90'
}), 400
if not (-180 <= longitude <= 180): if not (-180 <= longitude <= 180):
return jsonify({ return jsonify({"status": "error", "message": "Longitude must be between -180 and 180"}), 400
'status': 'error',
'message': 'Longitude must be between -180 and 180'
}), 400
except (TypeError, ValueError): except (TypeError, ValueError):
return jsonify({ return jsonify({"status": "error", "message": "Invalid latitude or longitude"}), 400
'status': 'error',
'message': 'Invalid latitude or longitude'
}), 400
else: else:
latitude = None latitude = None
longitude = None longitude = None
@@ -222,13 +213,9 @@ def start_decoder():
# Claim SDR device # Claim SDR device
global sstv_active_device, sstv_active_sdr_type global sstv_active_device, sstv_active_sdr_type
device_int = int(device_index) 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: if error:
return jsonify({ return jsonify({"status": "error", "error_type": "DEVICE_BUSY", "message": error}), 409
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
# Set callback and start # Set callback and start
decoder.set_callback(_progress_callback) decoder.set_callback(_progress_callback)
@@ -245,28 +232,25 @@ def start_decoder():
sstv_active_sdr_type = sdr_type_str sstv_active_sdr_type = sdr_type_str
result = { result = {
'status': 'started', "status": "started",
'frequency': frequency, "frequency": frequency,
'modulation': ISS_SSTV_MODULATION, "modulation": ISS_SSTV_MODULATION,
'device': device_index, "device": device_index,
'doppler_enabled': decoder.doppler_enabled "doppler_enabled": decoder.doppler_enabled,
} }
# Include initial Doppler info if available # Include initial Doppler info if available
if decoder.doppler_enabled and decoder.last_doppler_info: 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) return jsonify(result)
else: else:
# Release device on failure # Release device on failure
app_module.release_sdr_device(device_int, sdr_type_str) app_module.release_sdr_device(device_int, sdr_type_str)
return jsonify({ return jsonify({"status": "error", "message": "Failed to start decoder"}), 500
'status': 'error',
'message': 'Failed to start decoder'
}), 500
@sstv_bp.route('/stop', methods=['POST']) @sstv_bp.route("/stop", methods=["POST"])
def stop_decoder(): def stop_decoder():
""" """
Stop SSTV decoder. Stop SSTV decoder.
@@ -283,10 +267,10 @@ def stop_decoder():
app_module.release_sdr_device(sstv_active_device, sstv_active_sdr_type) app_module.release_sdr_device(sstv_active_device, sstv_active_sdr_type)
sstv_active_device = None sstv_active_device = None
return jsonify({'status': 'stopped'}) return jsonify({"status": "stopped"})
@sstv_bp.route('/doppler') @sstv_bp.route("/doppler")
def get_doppler(): def get_doppler():
""" """
Get current Doppler shift information. Get current Doppler shift information.
@@ -299,27 +283,28 @@ def get_doppler():
decoder = get_sstv_decoder() decoder = get_sstv_decoder()
if not decoder.doppler_enabled: if not decoder.doppler_enabled:
return jsonify({ return jsonify(
'status': 'disabled', {
'message': 'Doppler tracking not enabled. Provide latitude/longitude when starting decoder.' "status": "disabled",
}) "message": "Doppler tracking not enabled. Provide latitude/longitude when starting decoder.",
}
)
doppler_info = decoder.last_doppler_info doppler_info = decoder.last_doppler_info
if not doppler_info: if not doppler_info:
return jsonify({ return jsonify({"status": "unavailable", "message": "Doppler data not yet available"})
'status': 'unavailable',
'message': 'Doppler data not yet available'
})
return jsonify({ return jsonify(
'status': 'ok', {
'doppler': doppler_info.to_dict(), "status": "ok",
'nominal_frequency_mhz': ISS_SSTV_FREQ, "doppler": doppler_info.to_dict(),
'corrected_frequency_mhz': doppler_info.frequency_hz / 1_000_000 "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(): def list_images():
""" """
Get list of decoded SSTV images. Get list of decoded SSTV images.
@@ -333,18 +318,14 @@ def list_images():
decoder = get_sstv_decoder() decoder = get_sstv_decoder()
images = decoder.get_images() images = decoder.get_images()
limit = request.args.get('limit', type=int) limit = request.args.get("limit", type=int)
if limit and limit > 0: if limit and limit > 0:
images = images[-limit:] images = images[-limit:]
return jsonify({ return jsonify({"status": "ok", "images": [img.to_dict() for img in images], "count": len(images)})
'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): def get_image(filename: str):
""" """
Get a decoded SSTV image file. Get a decoded SSTV image file.
@@ -358,22 +339,22 @@ def get_image(filename: str):
decoder = get_sstv_decoder() decoder = get_sstv_decoder()
# Security: only allow alphanumeric filenames with .png extension # Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): if not filename.replace("_", "").replace("-", "").replace(".", "").isalnum():
return api_error('Invalid filename', 400) return api_error("Invalid filename", 400)
if not filename.endswith('.png'): if not filename.endswith(".png"):
return api_error('Only PNG files supported', 400) return api_error("Only PNG files supported", 400)
# Find image in decoder's output directory # Find image in decoder's output directory
image_path = decoder._output_dir / filename image_path = decoder._output_dir / filename
if not image_path.exists(): 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): def download_image(filename: str):
""" """
Download a decoded SSTV image file. Download a decoded SSTV image file.
@@ -387,21 +368,21 @@ def download_image(filename: str):
decoder = get_sstv_decoder() decoder = get_sstv_decoder()
# Security: only allow alphanumeric filenames with .png extension # Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): if not filename.replace("_", "").replace("-", "").replace(".", "").isalnum():
return api_error('Invalid filename', 400) return api_error("Invalid filename", 400)
if not filename.endswith('.png'): if not filename.endswith(".png"):
return api_error('Only PNG files supported', 400) return api_error("Only PNG files supported", 400)
image_path = decoder._output_dir / filename image_path = decoder._output_dir / filename
if not image_path.exists(): 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): def delete_image(filename: str):
""" """
Delete a decoded SSTV image. Delete a decoded SSTV image.
@@ -415,19 +396,19 @@ def delete_image(filename: str):
decoder = get_sstv_decoder() decoder = get_sstv_decoder()
# Security: only allow alphanumeric filenames with .png extension # Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): if not filename.replace("_", "").replace("-", "").replace(".", "").isalnum():
return api_error('Invalid filename', 400) return api_error("Invalid filename", 400)
if not filename.endswith('.png'): if not filename.endswith(".png"):
return api_error('Only PNG files supported', 400) return api_error("Only PNG files supported", 400)
if decoder.delete_image(filename): if decoder.delete_image(filename):
return jsonify({'status': 'ok'}) return jsonify({"status": "ok"})
else: 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(): def delete_all_images():
""" """
Delete all decoded SSTV images. Delete all decoded SSTV images.
@@ -437,10 +418,10 @@ def delete_all_images():
""" """
decoder = get_sstv_decoder() decoder = get_sstv_decoder()
count = decoder.delete_all_images() 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(): def stream_progress():
""" """
SSE stream of SSTV decode progress. SSE stream of SSTV decode progress.
@@ -453,22 +434,23 @@ def stream_progress():
Returns: Returns:
SSE stream (text/event-stream) SSE stream (text/event-stream)
""" """
def _on_msg(msg: dict[str, Any]) -> None: def _on_msg(msg: dict[str, Any]) -> None:
process_event('sstv', msg, msg.get('type')) process_event("sstv", msg, msg.get("type"))
response = Response( response = Response(
sse_stream_fanout( sse_stream_fanout(
source_queue=_sstv_queue, source_queue=_sstv_queue,
channel_key='sstv', channel_key="sstv",
timeout=1.0, timeout=1.0,
keepalive_interval=30.0, keepalive_interval=30.0,
on_message=_on_msg, on_message=_on_msg,
), ),
mimetype='text/event-stream', mimetype="text/event-stream",
) )
response.headers['Cache-Control'] = 'no-cache' response.headers["Cache-Control"] = "no-cache"
response.headers['X-Accel-Buffering'] = 'no' response.headers["X-Accel-Buffering"] = "no"
response.headers['Connection'] = 'keep-alive' response.headers["Connection"] = "keep-alive"
return response return response
@@ -478,11 +460,12 @@ def _get_timescale():
with _timescale_lock: with _timescale_lock:
if _timescale is None: if _timescale is None:
from skyfield.api import load from skyfield.api import load
_timescale = load.timescale(builtin=True) _timescale = load.timescale(builtin=True)
return _timescale return _timescale
@sstv_bp.route('/iss-schedule') @sstv_bp.route("/iss-schedule")
def iss_schedule(): def iss_schedule():
""" """
Get ISS pass schedule for SSTV reception. 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 global _iss_schedule_cache, _iss_schedule_cache_time, _iss_schedule_cache_key
lat = request.args.get('latitude', type=float) lat = request.args.get("latitude", type=float)
lon = request.args.get('longitude', type=float) lon = request.args.get("longitude", type=float)
hours = request.args.get('hours', 48, type=int) hours = request.args.get("hours", 48, type=int)
if lat is None or lon is None: if lat is None or lon is None:
return jsonify({ return jsonify({"status": "error", "message": "latitude and longitude parameters required"}), 400
'status': 'error',
'message': 'latitude and longitude parameters required'
}), 400
# Cache key: rounded lat/lon (1 decimal place) so nearby locations share cache # Cache key: rounded lat/lon (1 decimal place) so nearby locations share cache
cache_key = f"{round(lat, 1)}:{round(lon, 1)}:{hours}" cache_key = f"{round(lat, 1)}:{round(lon, 1)}:{hours}"
with _iss_schedule_lock: with _iss_schedule_lock:
now = time.time() now = time.time()
if (_iss_schedule_cache is not None if (
and cache_key == _iss_schedule_cache_key _iss_schedule_cache is not None
and (now - _iss_schedule_cache_time) < ISS_SCHEDULE_CACHE_TTL): and cache_key == _iss_schedule_cache_key
and (now - _iss_schedule_cache_time) < ISS_SCHEDULE_CACHE_TTL
):
return jsonify(_iss_schedule_cache) return jsonify(_iss_schedule_cache)
try: try:
@@ -526,15 +508,10 @@ def iss_schedule():
from skyfield.almanac import find_discrete from skyfield.almanac import find_discrete
from skyfield.api import EarthSatellite, wgs84 from skyfield.api import EarthSatellite, wgs84
from data.satellites import TLE_SATELLITES # Get ISS TLE from live cache (kept fresh by auto-refresh)
iss_tle = get_cached_tle("ISS")
# Get ISS TLE
iss_tle = TLE_SATELLITES.get('ISS')
if not iss_tle: if not iss_tle:
return jsonify({ return jsonify({"status": "error", "message": "ISS TLE data not available"}), 500
'status': 'error',
'message': 'ISS TLE data not available'
}), 500
ts = _get_timescale() ts = _get_timescale()
satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts) satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts)
@@ -549,7 +526,7 @@ def iss_schedule():
alt, _, _ = topocentric.altaz() alt, _, _ = topocentric.altaz()
return alt.degrees > 0 return alt.degrees > 0
above_horizon.step_days = 1/720 above_horizon.step_days = 1 / 720
times, events = find_discrete(t0, t1, above_horizon) times, events = find_discrete(t0, t1, above_horizon)
@@ -588,23 +565,25 @@ def iss_schedule():
max_el = alt.degrees max_el = alt.degrees
if max_el >= 10: # Min elevation filter if max_el >= 10: # Min elevation filter
passes.append({ passes.append(
'satellite': 'ISS', {
'startTime': rise_time.utc_datetime().strftime('%Y-%m-%d %H:%M UTC'), "satellite": "ISS",
'startTimeISO': rise_time.utc_datetime().isoformat(), "startTime": rise_time.utc_datetime().strftime("%Y-%m-%d %H:%M UTC"),
'maxEl': round(max_el, 1), "startTimeISO": rise_time.utc_datetime().isoformat(),
'duration': duration_minutes, "maxEl": round(max_el, 1),
'color': '#00ffff' "duration": duration_minutes,
}) "color": "#00ffff",
}
)
i += 1 i += 1
result = { result = {
'status': 'ok', "status": "ok",
'passes': passes, "passes": passes,
'count': len(passes), "count": len(passes),
'sstv_frequency': ISS_SSTV_FREQ, "sstv_frequency": ISS_SSTV_FREQ,
'note': 'ISS SSTV events are not continuous. Check ARISS.org for scheduled events.' "note": "ISS SSTV events are not continuous. Check ARISS.org for scheduled events.",
} }
# Update cache # Update cache
@@ -616,17 +595,11 @@ def iss_schedule():
return jsonify(result) return jsonify(result)
except ImportError: except ImportError:
return jsonify({ return jsonify({"status": "error", "message": "skyfield library not installed"}), 503
'status': 'error',
'message': 'skyfield library not installed'
}), 503
except Exception as e: except Exception as e:
logger.error(f"Error getting ISS schedule: {e}") logger.error(f"Error getting ISS schedule: {e}")
return jsonify({ return jsonify({"status": "error", "message": str(e)}), 500
'status': 'error',
'message': str(e)
}), 500
def _fetch_iss_position() -> dict | None: def _fetch_iss_position() -> dict | None:
@@ -644,14 +617,14 @@ def _fetch_iss_position() -> dict | None:
# Try primary API: Where The ISS At # Try primary API: Where The ISS At
try: 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: if response.status_code == 200:
data = response.json() data = response.json()
cached = { cached = {
'lat': float(data['latitude']), "lat": float(data["latitude"]),
'lon': float(data['longitude']), "lon": float(data["longitude"]),
'altitude': float(data.get('altitude', 420)), "altitude": float(data.get("altitude", 420)),
'source': 'wheretheiss', "source": "wheretheiss",
} }
except Exception as e: except Exception as e:
logger.warning(f"Where The ISS At API failed: {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 # Try fallback API: Open Notify
if cached is None: if cached is None:
try: 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: if response.status_code == 200:
data = response.json() data = response.json()
if data.get('message') == 'success': if data.get("message") == "success":
cached = { cached = {
'lat': float(data['iss_position']['latitude']), "lat": float(data["iss_position"]["latitude"]),
'lon': float(data['iss_position']['longitude']), "lon": float(data["iss_position"]["longitude"]),
'altitude': 420, "altitude": 420,
'source': 'open-notify', "source": "open-notify",
} }
except Exception as e: except Exception as e:
logger.warning(f"Open Notify API failed: {e}") logger.warning(f"Open Notify API failed: {e}")
@@ -680,7 +653,7 @@ def _fetch_iss_position() -> dict | None:
return cached return cached
@sstv_bp.route('/iss-position') @sstv_bp.route("/iss-position")
def iss_position(): def iss_position():
""" """
Get current ISS position from real-time API. Get current ISS position from real-time API.
@@ -698,28 +671,25 @@ def iss_position():
""" """
from datetime import datetime from datetime import datetime
observer_lat = request.args.get('latitude', type=float) observer_lat = request.args.get("latitude", type=float)
observer_lon = request.args.get('longitude', type=float) observer_lon = request.args.get("longitude", type=float)
pos = _fetch_iss_position() pos = _fetch_iss_position()
if pos is None: if pos is None:
return jsonify({ return jsonify({"status": "error", "message": "Unable to fetch ISS position from real-time APIs"}), 503
'status': 'error',
'message': 'Unable to fetch ISS position from real-time APIs'
}), 503
result = { result = {
'status': 'ok', "status": "ok",
'lat': pos['lat'], "lat": pos["lat"],
'lon': pos['lon'], "lon": pos["lon"],
'altitude': pos['altitude'], "altitude": pos["altitude"],
'timestamp': datetime.utcnow().isoformat(), "timestamp": datetime.utcnow().isoformat(),
'source': pos['source'], "source": pos["source"],
} }
# Calculate observer-relative data if location provided # Calculate observer-relative data if location provided
if observer_lat is not None and observer_lon is not None: 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) 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 # Haversine for ground distance
dlat = lat2 - lat1 dlat = lat2 - lat1
dlon = lon2 - lon1 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)) c = 2 * math.asin(math.sqrt(a))
ground_distance = earth_radius * c 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 = math.degrees(math.atan2(y, x))
azimuth = (azimuth + 360) % 360 azimuth = (azimuth + 360) % 360
return { return {"elevation": round(elevation, 1), "azimuth": round(azimuth, 1), "distance": round(slant_range, 1)}
'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(): def decode_file():
""" """
Decode SSTV from an uploaded audio file. Decode SSTV from an uploaded audio file.
@@ -780,23 +796,18 @@ def decode_file():
Returns: Returns:
JSON with decoded images. JSON with decoded images.
""" """
if 'audio' not in request.files: if "audio" not in request.files:
return jsonify({ return jsonify({"status": "error", "message": "No audio file provided"}), 400
'status': 'error',
'message': 'No audio file provided'
}), 400
audio_file = request.files['audio'] audio_file = request.files["audio"]
if not audio_file.filename: if not audio_file.filename:
return jsonify({ return jsonify({"status": "error", "message": "No file selected"}), 400
'status': 'error',
'message': 'No file selected'
}), 400
# Save to temp file # Save to temp file
import tempfile 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) audio_file.save(tmp.name)
tmp_path = tmp.name tmp_path = tmp.name
@@ -804,18 +815,11 @@ def decode_file():
decoder = get_sstv_decoder() decoder = get_sstv_decoder()
images = decoder.decode_file(tmp_path) images = decoder.decode_file(tmp_path)
return jsonify({ return jsonify({"status": "ok", "images": [img.to_dict() for img in images], "count": len(images)})
'status': 'ok',
'images': [img.to_dict() for img in images],
'count': len(images)
})
except Exception as e: except Exception as e:
logger.error(f"Error decoding file: {e}") logger.error(f"Error decoding file: {e}")
return jsonify({ return jsonify({"status": "error", "message": str(e)}), 500
'status': 'error',
'message': str(e)
}), 500
finally: finally:
# Clean up temp file # Clean up temp file
+63 -53
View File
@@ -13,8 +13,10 @@ const SSTV = (function() {
let issMap = null; let issMap = null;
let issMarker = null; let issMarker = null;
let issTrackLine = null; let issTrackLine = null;
let issTrackPast = null;
let issPosition = null; let issPosition = null;
let issUpdateInterval = null; let issUpdateInterval = null;
let issTrackInterval = null;
let countdownInterval = null; let countdownInterval = null;
let nextPassData = null; let nextPassData = null;
let pendingMapInvalidate = false; let pendingMapInvalidate = false;
@@ -250,12 +252,19 @@ const SSTV = (function() {
// Create ISS marker (will be positioned when we get data) // Create ISS marker (will be positioned when we get data)
issMarker = L.marker([0, 0], { icon: issIcon }).addTo(issMap); 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([], { issTrackLine = L.polyline([], {
color: '#00d4ff', color: '#00d4ff',
weight: 2, weight: 2,
opacity: 0.6, opacity: 0.7,
dashArray: '5, 5' dashArray: '6, 4'
}).addTo(issMap); }).addTo(issMap);
issMap.on('resize moveend zoomend', () => { issMap.on('resize moveend zoomend', () => {
@@ -272,9 +281,12 @@ const SSTV = (function() {
*/ */
function startIssTracking() { function startIssTracking() {
updateIssPosition(); updateIssPosition();
// Update every 5 seconds updateIssTrack();
if (issUpdateInterval) clearInterval(issUpdateInterval); if (issUpdateInterval) clearInterval(issUpdateInterval);
issUpdateInterval = setInterval(updateIssPosition, 5000); 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); clearInterval(issUpdateInterval);
issUpdateInterval = null; 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]); issMarker.setLatLng([lat, lon]);
} }
// Calculate and draw ground track // Track is fetched separately by updateIssTrack() via /sstv/iss-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 : []);
}
// Pan map to follow ISS only when the map pane is currently renderable. // Pan map to follow ISS only when the map pane is currently renderable.
if (isMapContainerVisible()) { if (isMapContainerVisible()) {