From e2e92b6b38dc9ea181237abdeb930996c90bac8b Mon Sep 17 00:00:00 2001 From: Smittix Date: Mon, 2 Mar 2026 18:05:57 +0000 Subject: [PATCH] fix: cache ISS position/schedule and parallelize SSTV init API calls SSTV mode was slow to populate next-pass countdown and ISS location map due to uncached skyfield computation and sequential JS API calls. - Cache ISS position (10s TTL) and schedule (15min TTL, keyed by rounded lat/lon) - Cache skyfield timescale object (expensive to create on every request) - Reduce external API timeouts from 5s to 3s - Fire checkStatus, loadImages, loadIssSchedule, updateIssPosition in parallel via Promise.all Co-Authored-By: Claude Opus 4.6 --- routes/sstv.py | 226 ++++++++++++++++++++++++++-------------- static/js/modes/sstv.js | 18 +++- 2 files changed, 160 insertions(+), 84 deletions(-) diff --git a/routes/sstv.py b/routes/sstv.py index 37df4e5..0b13cc5 100644 --- a/routes/sstv.py +++ b/routes/sstv.py @@ -7,15 +7,16 @@ ISS SSTV events occur during special commemorations and typically transmit on 14 from __future__ import annotations import queue +import threading import time from pathlib import Path -from typing import Generator +from typing import Any from flask import Blueprint, jsonify, request, Response, send_file import app as app_module from utils.logging import get_logger -from utils.sse import sse_stream_fanout +from utils.sse import sse_stream_fanout from utils.event_pipeline import process_event from utils.sstv import ( get_sstv_decoder, @@ -36,6 +37,24 @@ ISS_SSTV_FREQ_TOLERANCE_MHZ = 0.05 # Queue for SSE progress streaming _sstv_queue: queue.Queue = queue.Queue(maxsize=100) +# --------------------------------------------------------------------------- +# Caching — ISS position (external API) and schedule (skyfield computation) +# --------------------------------------------------------------------------- +_iss_position_cache: dict | None = None +_iss_position_cache_time: float = 0 +_iss_position_lock = threading.Lock() +ISS_POSITION_CACHE_TTL = 10 # seconds + +_iss_schedule_cache: dict | None = None +_iss_schedule_cache_time: float = 0 +_iss_schedule_cache_key: str | None = None +_iss_schedule_lock = threading.Lock() +ISS_SCHEDULE_CACHE_TTL = 900 # 15 minutes + +# Reusable skyfield timescale (expensive to create) +_timescale = None +_timescale_lock = threading.Lock() + # Track which device is being used sstv_active_device: int | None = None @@ -409,8 +428,8 @@ def delete_all_images(): return jsonify({'status': 'ok', 'deleted': count}) -@sstv_bp.route('/stream') -def stream_progress(): +@sstv_bp.route('/stream') +def stream_progress(): """ SSE stream of SSTV decode progress. @@ -422,31 +441,42 @@ def stream_progress(): Returns: SSE stream (text/event-stream) """ - def _on_msg(msg: dict[str, Any]) -> None: - process_event('sstv', msg, msg.get('type')) - - response = Response( - sse_stream_fanout( - source_queue=_sstv_queue, - channel_key='sstv', - timeout=1.0, - keepalive_interval=30.0, - on_message=_on_msg, - ), - mimetype='text/event-stream', - ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - response.headers['Connection'] = 'keep-alive' + def _on_msg(msg: dict[str, Any]) -> None: + process_event('sstv', msg, msg.get('type')) + + response = Response( + sse_stream_fanout( + source_queue=_sstv_queue, + channel_key='sstv', + timeout=1.0, + keepalive_interval=30.0, + on_message=_on_msg, + ), + mimetype='text/event-stream', + ) + 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() + return _timescale + + @sstv_bp.route('/iss-schedule') def iss_schedule(): """ Get ISS pass schedule for SSTV reception. Calculates ISS passes directly using skyfield. + Results are cached for 15 minutes per rounded location. Query parameters: latitude: Observer latitude (required) @@ -456,6 +486,8 @@ def iss_schedule(): Returns: JSON with ISS pass 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) @@ -466,8 +498,18 @@ def iss_schedule(): '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): + return jsonify(_iss_schedule_cache) + try: - from skyfield.api import load, wgs84, EarthSatellite + from skyfield.api import wgs84, EarthSatellite from skyfield.almanac import find_discrete from datetime import timedelta from data.satellites import TLE_SATELLITES @@ -480,7 +522,7 @@ def iss_schedule(): 'message': 'ISS TLE data not available' }), 500 - ts = load.timescale() + ts = _get_timescale() satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts) observer = wgs84.latlon(lat, lon) @@ -543,13 +585,21 @@ def iss_schedule(): i += 1 - return jsonify({ + 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.' - }) + } + + # Update cache + with _iss_schedule_lock: + _iss_schedule_cache = result + _iss_schedule_cache_time = time.time() + _iss_schedule_cache_key = cache_key + + return jsonify(result) except ImportError: return jsonify({ @@ -565,13 +615,65 @@ def iss_schedule(): }), 500 +def _fetch_iss_position() -> dict | None: + """Fetch raw ISS lat/lon/altitude from external APIs, with 10s cache.""" + global _iss_position_cache, _iss_position_cache_time + + with _iss_position_lock: + now = time.time() + if _iss_position_cache is not None and (now - _iss_position_cache_time) < ISS_POSITION_CACHE_TTL: + return _iss_position_cache + + import requests + + cached = None + + # Try primary API: Where The ISS At + try: + 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', + } + except Exception as e: + logger.warning(f"Where The ISS At API failed: {e}") + + # Try fallback API: Open Notify + if cached is None: + try: + 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': + cached = { + '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}") + + if cached is not None: + with _iss_position_lock: + _iss_position_cache = cached + _iss_position_cache_time = time.time() + + return cached + + @sstv_bp.route('/iss-position') def iss_position(): """ Get current ISS position from real-time API. - Uses the Open Notify API for accurate real-time position, - with fallback to "Where The ISS At" API. + Uses the "Where The ISS At" API for accurate real-time position, + with fallback to Open Notify API. Raw position is cached for 10 seconds; + observer-relative data (elevation/azimuth) is computed per-request. Query parameters: latitude: Observer latitude (optional, for elevation calc) @@ -580,68 +682,32 @@ def iss_position(): Returns: JSON with ISS current position. """ - import requests from datetime import datetime observer_lat = request.args.get('latitude', type=float) observer_lon = request.args.get('longitude', type=float) - # Try primary API: Where The ISS At - try: - response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5) - if response.status_code == 200: - data = response.json() - iss_lat = float(data['latitude']) - iss_lon = float(data['longitude']) + pos = _fetch_iss_position() + if pos is None: + return jsonify({ + 'status': 'error', + 'message': 'Unable to fetch ISS position from real-time APIs' + }), 503 - result = { - 'status': 'ok', - 'lat': iss_lat, - 'lon': iss_lon, - 'altitude': float(data.get('altitude', 420)), - 'timestamp': datetime.utcnow().isoformat(), - 'source': 'wheretheiss' - } + result = { + '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(iss_lat, iss_lon, observer_lat, observer_lon)) + # 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)) - return jsonify(result) - except Exception as e: - logger.warning(f"Where The ISS At API failed: {e}") - - # Try fallback API: Open Notify - try: - response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5) - if response.status_code == 200: - data = response.json() - if data.get('message') == 'success': - iss_lat = float(data['iss_position']['latitude']) - iss_lon = float(data['iss_position']['longitude']) - - result = { - 'status': 'ok', - 'lat': iss_lat, - 'lon': iss_lon, - 'altitude': 420, # Approximate ISS altitude in km - 'timestamp': datetime.utcnow().isoformat(), - 'source': 'open-notify' - } - - # Calculate observer-relative data if location provided - if observer_lat is not None and observer_lon is not None: - result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon)) - - return jsonify(result) - except Exception as e: - logger.warning(f"Open Notify API failed: {e}") - - # Both APIs failed - return jsonify({ - 'status': 'error', - 'message': 'Unable to fetch ISS position from real-time APIs' - }), 503 + return jsonify(result) def _calculate_observer_data(iss_lat: float, iss_lon: float, obs_lat: float, obs_lon: float) -> dict: diff --git a/static/js/modes/sstv.js b/static/js/modes/sstv.js index bb60d1d..434f0a5 100644 --- a/static/js/modes/sstv.js +++ b/static/js/modes/sstv.js @@ -39,13 +39,23 @@ const SSTV = (function() { * Initialize the SSTV mode */ function init() { - checkStatus(); - loadImages(); + // Load location inputs first (sync localStorage read needed for lat/lon params) loadLocationInputs(); - loadIssSchedule(); + + // Fire all API calls in parallel — schedule is the slowest, don't let it block + Promise.all([ + checkStatus(), + loadImages(), + loadIssSchedule(), + updateIssPosition(), + ]).catch(err => console.error('SSTV init error:', err)); + + // DOM-only setup (no network, fast) initMap(); - startIssTracking(); startCountdown(); + // ISS tracking interval (first call already in Promise.all above) + if (issUpdateInterval) clearInterval(issUpdateInterval); + issUpdateInterval = setInterval(updateIssPosition, 5000); // Ensure Leaflet recomputes dimensions after the SSTV pane becomes visible. setTimeout(() => invalidateMap(), 80); setTimeout(() => invalidateMap(), 260);