mirror of
https://github.com/smittix/intercept.git
synced 2026-07-02 14:58:58 -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:
+337
-262
File diff suppressed because it is too large
Load Diff
+227
-223
@@ -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
@@ -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()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user