diff --git a/routes/satellite.py b/routes/satellite.py index a39f805..d35e199 100644 --- a/routes/satellite.py +++ b/routes/satellite.py @@ -3,13 +3,15 @@ from __future__ import annotations import math +import time import urllib.request from datetime import datetime, timedelta import requests -from flask import Blueprint, jsonify, render_template, request +from flask import Blueprint, Response, jsonify, render_template, request -from config import SHARED_OBSERVER_LOCATION_ENABLED +from config import DEFAULT_LATITUDE, DEFAULT_LONGITUDE, SHARED_OBSERVER_LOCATION_ENABLED +from utils.sse import sse_stream_fanout from data.satellites import TLE_SATELLITES from utils.database import ( add_tracked_satellite, @@ -44,6 +46,11 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www # Local TLE cache (can be updated via API) _tle_cache = dict(TLE_SATELLITES) +# Ground track cache: key=(sat_name, tle_line1[:20]) -> (track_data, computed_at_timestamp) +# TTL is 1800 seconds (30 minutes) +_track_cache: dict = {} +_TRACK_CACHE_TTL = 1800 + def _load_db_satellites_into_cache(): """Load user-tracked satellites from DB into the TLE cache.""" @@ -64,6 +71,112 @@ def _load_db_satellites_into_cache(): logger.warning(f"Failed to load DB satellites into TLE cache: {e}") +def _start_satellite_tracker(): + """Background thread: push live satellite positions to satellite_queue every second.""" + import app as app_module + + try: + from skyfield.api import EarthSatellite, wgs84 + except ImportError: + logger.warning("skyfield not installed; satellite tracker thread will not run") + return + + ts = _get_timescale() + logger.info("Satellite tracker thread started") + + while True: + try: + now = ts.now() + now_dt = now.utc_datetime() + + obs_lat = DEFAULT_LATITUDE + obs_lon = DEFAULT_LONGITUDE + has_observer = (obs_lat != 0.0 or obs_lon != 0.0) + observer = wgs84.latlon(obs_lat, obs_lon) if has_observer else None + + tracked = get_tracked_satellites(enabled_only=True) + positions = [] + + for sat_rec in tracked: + sat_name = sat_rec['name'] + norad_id = sat_rec.get('norad_id', 0) + tle1 = sat_rec.get('tle_line1') + tle2 = sat_rec.get('tle_line2') + if not tle1 or not tle2: + # Fall back to TLE cache + cache_key = sat_name.replace(' ', '-').upper() + if cache_key not in _tle_cache: + continue + tle_entry = _tle_cache[cache_key] + tle1 = tle_entry[1] + tle2 = tle_entry[2] + + try: + satellite = EarthSatellite(tle1, tle2, sat_name, ts) + geocentric = satellite.at(now) + subpoint = wgs84.subpoint(geocentric) + + pos = { + 'satellite': sat_name, + 'norad_id': norad_id, + 'lat': float(subpoint.latitude.degrees), + 'lon': float(subpoint.longitude.degrees), + 'altitude': float(geocentric.distance().km - 6371), + 'visible': False, + } + + if has_observer and observer is not None: + diff = satellite - observer + topocentric = diff.at(now) + alt, az, dist = topocentric.altaz() + pos['elevation'] = float(alt.degrees) + pos['azimuth'] = float(az.degrees) + pos['distance'] = float(dist.km) + pos['visible'] = bool(alt.degrees > 0) + + # Ground track with caching (90 points, TTL 1800s) + cache_key_track = (sat_name, tle1[:20]) + cached = _track_cache.get(cache_key_track) + if cached and (time.time() - cached[1]) < _TRACK_CACHE_TTL: + pos['groundTrack'] = cached[0] + else: + track = [] + for minutes_offset in range(-45, 46, 1): + t_point = ts.utc(now_dt + timedelta(minutes=minutes_offset)) + try: + geo = satellite.at(t_point) + sp = wgs84.subpoint(geo) + track.append({ + 'lat': float(sp.latitude.degrees), + 'lon': float(sp.longitude.degrees), + 'past': minutes_offset < 0, + }) + except Exception: + continue + _track_cache[cache_key_track] = (track, time.time()) + pos['groundTrack'] = track + + positions.append(pos) + except Exception: + continue + + if positions: + msg = { + 'type': 'positions', + 'positions': positions, + 'timestamp': datetime.utcnow().isoformat(), + } + try: + app_module.satellite_queue.put_nowait(msg) + except Exception: + pass + + except Exception as e: + logger.debug(f"Satellite tracker error: {e}") + + time.sleep(1) + + def init_tle_auto_refresh(): """Initialize TLE auto-refresh. Called by app.py after initialization.""" import threading @@ -81,6 +194,15 @@ def init_tle_auto_refresh(): threading.Timer(2.0, _auto_refresh_tle).start() logger.info("TLE auto-refresh scheduled") + # Start live position tracker thread + tracker_thread = threading.Thread( + target=_start_satellite_tracker, + daemon=True, + name='satellite-tracker', + ) + tracker_thread.start() + logger.info("Satellite tracker thread launched") + def _fetch_iss_realtime(observer_lat: float | None = None, observer_lon: float | None = None) -> dict | None: """ @@ -185,7 +307,6 @@ def satellite_dashboard(): def predict_passes(): """Calculate satellite passes using skyfield.""" try: - from skyfield.almanac import find_discrete from skyfield.api import EarthSatellite, wgs84 except ImportError: return jsonify({ @@ -193,6 +314,8 @@ def predict_passes(): 'message': 'skyfield library not installed. Run: pip install skyfield' }), 503 + from utils.satellite_predict import predict_passes as _predict_passes + data = request.json or {} # Validate inputs @@ -228,7 +351,6 @@ def predict_passes(): ts = _get_timescale() observer = wgs84.latlon(lat, lon) - t0 = ts.now() t1 = ts.utc(t0.utc_datetime() + timedelta(hours=hours)) @@ -237,97 +359,30 @@ def predict_passes(): continue tle_data = _tle_cache[sat_name] + + # Current position for map marker (computed once per satellite) + current_pos = None try: satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts) + geo = satellite.at(ts.now()) + sp = wgs84.subpoint(geo) + current_pos = { + 'lat': float(sp.latitude.degrees), + 'lon': float(sp.longitude.degrees), + } except Exception: - continue + pass - def above_horizon(t): - diff = satellite - observer - topocentric = diff.at(t) - alt, _, _ = topocentric.altaz() - return alt.degrees > 0 + sat_passes = _predict_passes(tle_data, observer, ts, t0, t1, min_el=min_el) + for p in sat_passes: + p['satellite'] = sat_name + p['norad'] = name_to_norad.get(sat_name, 0) + p['color'] = colors.get(sat_name, '#00ff00') + if current_pos: + p['currentPos'] = current_pos + passes.extend(sat_passes) - above_horizon.step_days = 1/720 - - try: - times, events = find_discrete(t0, t1, above_horizon) - except Exception: - continue - - i = 0 - while i < len(times): - if i < len(events) and events[i]: - rise_time = times[i] - set_time = None - for j in range(i + 1, len(times)): - if not events[j]: - set_time = times[j] - i = j - break - - if set_time is None: - i += 1 - continue - - trajectory = [] - max_elevation = 0 - num_points = 30 - - duration_seconds = (set_time.utc_datetime() - rise_time.utc_datetime()).total_seconds() - - for k in range(num_points): - frac = k / (num_points - 1) - t_point = ts.utc(rise_time.utc_datetime() + timedelta(seconds=duration_seconds * frac)) - - diff = satellite - observer - topocentric = diff.at(t_point) - alt, az, _ = topocentric.altaz() - - el = alt.degrees - azimuth = az.degrees - - if el > max_elevation: - max_elevation = el - - trajectory.append({'el': float(max(0, el)), 'az': float(azimuth)}) - - if max_elevation >= min_el: - duration_minutes = int(duration_seconds / 60) - - ground_track = [] - for k in range(60): - frac = k / 59 - t_point = ts.utc(rise_time.utc_datetime() + timedelta(seconds=duration_seconds * frac)) - geocentric = satellite.at(t_point) - subpoint = wgs84.subpoint(geocentric) - ground_track.append({ - 'lat': float(subpoint.latitude.degrees), - 'lon': float(subpoint.longitude.degrees) - }) - - current_geo = satellite.at(ts.now()) - current_subpoint = wgs84.subpoint(current_geo) - - passes.append({ - 'satellite': sat_name, - 'norad': name_to_norad.get(sat_name, 0), - 'startTime': rise_time.utc_datetime().strftime('%Y-%m-%d %H:%M UTC'), - 'startTimeISO': rise_time.utc_datetime().isoformat(), - 'maxEl': float(round(max_elevation, 1)), - 'duration': int(duration_minutes), - 'trajectory': trajectory, - 'groundTrack': ground_track, - 'currentPos': { - 'lat': float(current_subpoint.latitude.degrees), - 'lon': float(current_subpoint.longitude.degrees) - }, - 'color': colors.get(sat_name, '#00ff00') - }) - - i += 1 - - passes.sort(key=lambda p: p['startTime']) + passes.sort(key=lambda p: p['startTimeISO']) return jsonify({ 'status': 'success', @@ -458,6 +513,48 @@ def get_satellite_position(): }) +@satellite_bp.route('/transmitters/') +def get_transmitters_endpoint(norad_id: int): + """Return SatNOGS transmitter data for a satellite by NORAD ID.""" + from utils.satnogs import get_transmitters + transmitters = get_transmitters(norad_id) + return jsonify({'status': 'success', 'norad_id': norad_id, 'transmitters': transmitters}) + + +@satellite_bp.route('/parse-packet', methods=['POST']) +def parse_packet(): + """Parse a raw satellite telemetry packet (base64-encoded).""" + import base64 + from utils.satellite_telemetry import auto_parse + data = request.json or {} + try: + raw_bytes = base64.b64decode(data.get('data', '')) + except Exception: + return api_error('Invalid base64 data', 400) + result = auto_parse(raw_bytes) + return jsonify({'status': 'success', 'parsed': result}) + + +@satellite_bp.route('/stream_satellite') +def stream_satellite() -> Response: + """SSE endpoint streaming live satellite positions from the background tracker.""" + import app as app_module + + response = Response( + sse_stream_fanout( + source_queue=app_module.satellite_queue, + channel_key='satellite', + timeout=1.0, + keepalive_interval=30.0, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + response.headers['Connection'] = 'keep-alive' + return response + + def refresh_tle_data() -> list: """ Refresh TLE data from CelesTrak. diff --git a/setup.sh b/setup.sh index 72c8121..983f5c2 100755 --- a/setup.sh +++ b/setup.sh @@ -487,6 +487,16 @@ import sys raise SystemExit(0 if sys.version_info >= (3,9) else 1) PY ok "Python version OK (>= 3.9)" + + # Python 3.13+ warning: some packages (gevent, numpy, scipy) may not have + # pre-built wheels yet and will be skipped to avoid hanging on compilation. + python3 - <<'PY' +import sys +raise SystemExit(0 if sys.version_info >= (3,13) else 1) +PY + if [[ $? -eq 0 ]]; then + warn "Python 3.13+ detected: optional packages without pre-built wheels will be skipped (--prefer-binary)." + fi } install_python_deps() { @@ -520,8 +530,11 @@ install_python_deps() { source venv/bin/activate local PIP="venv/bin/python -m pip" local PY="venv/bin/python" + # --no-cache-dir avoids pip hanging on a corrupt/stale HTTP cache (cachecontrol .pyc issue) + # --timeout prevents pip from hanging indefinitely on slow/unresponsive PyPI connections + local PIP_OPTS="--no-cache-dir --timeout 120" - if ! $PIP install --upgrade pip setuptools wheel; then + if ! $PIP install $PIP_OPTS --upgrade pip setuptools wheel; then warn "pip/setuptools/wheel upgrade failed - continuing with existing versions" else ok "Upgraded pip tooling" @@ -530,16 +543,18 @@ install_python_deps() { progress "Installing Python dependencies" info "Installing core packages..." - $PIP install "flask>=3.0.0" "flask-wtf>=1.2.0" "flask-compress>=1.15" \ + $PIP install $PIP_OPTS "flask>=3.0.0" "flask-wtf>=1.2.0" "flask-compress>=1.15" \ "flask-limiter>=2.5.4" "requests>=2.28.0" \ "Werkzeug>=3.1.5" "pyserial>=3.5" || true - # Verify core packages are importable from the venv (not user site-packages) - $PY -s -c "import flask; import requests; from flask_limiter import Limiter; import flask_compress; import flask_wtf" 2>/dev/null || { - fail "Critical Python packages (flask, requests, flask-limiter, flask-compress, flask-wtf) not installed" - echo "Try: venv/bin/pip install flask requests flask-limiter flask-compress flask-wtf" - exit 1 - } + # Verify core packages are installed by checking pip's reported list (avoids hanging imports) + for core_pkg in flask requests flask-limiter flask-compress flask-wtf; do + if ! $PIP show "$core_pkg" >/dev/null 2>&1; then + fail "Critical Python package not installed: ${core_pkg}" + echo "Try: venv/bin/pip install ${core_pkg}" + exit 1 + fi + done ok "Core Python packages installed" info "Installing optional packages..." @@ -549,7 +564,8 @@ install_python_deps() { "gunicorn>=21.2.0" "gevent>=23.9.0" "psutil>=5.9.0"; do pkg_name="${pkg%%>=*}" info " Installing ${pkg_name}..." - if ! $PIP install "$pkg"; then + # --only-binary :all: skips packages with no pre-built wheel, preventing source compilation hangs + if ! $PIP install $PIP_OPTS --only-binary :all: "$pkg"; then warn "${pkg_name} failed to install (optional - related features may be unavailable)" fi done diff --git a/static/js/modes/weather-satellite.js b/static/js/modes/weather-satellite.js index 14dc0d4..1489aa5 100644 --- a/static/js/modes/weather-satellite.js +++ b/static/js/modes/weather-satellite.js @@ -302,6 +302,16 @@ const WeatherSat = (function() { } } + /** + * Pre-select a satellite without starting capture. + * Used by the satellite dashboard handoff so the user can review + * settings before hitting Start. + */ + function preSelect(satellite) { + const satSelect = document.getElementById('weatherSatSelect'); + if (satSelect) satSelect.value = satellite; + } + /** * Start capture for a specific pass */ @@ -1910,6 +1920,7 @@ const WeatherSat = (function() { destroy, start, stop, + preSelect, startPass, selectPass, testDecode, diff --git a/templates/index.html b/templates/index.html index cd58792..6b6a320 100644 --- a/templates/index.html +++ b/templates/index.html @@ -4571,6 +4571,12 @@ if (satFrame && satFrame.contentWindow) { satFrame.contentWindow.postMessage({type: 'satellite-visibility', visible: mode === 'satellite'}, '*'); } + + // Weather-sat handoff: when switching away from satellite mode, clear any pending handoff banner + if (mode !== 'satellite' && mode !== 'weathersat') { + const existing = document.getElementById('weatherSatHandoffBanner'); + if (existing) existing.remove(); + } if (aprsVisuals) aprsVisuals.style.display = mode === 'aprs' ? 'flex' : 'none'; if (tscmVisuals) tscmVisuals.style.display = mode === 'tscm' ? 'flex' : 'none'; if (spyStationsVisuals) spyStationsVisuals.style.display = mode === 'spystations' ? 'flex' : 'none'; @@ -16314,6 +16320,69 @@ if (typeof VoiceAlerts !== 'undefined') VoiceAlerts.init(); if (typeof KeyboardShortcuts !== 'undefined') KeyboardShortcuts.init(); }); + + // ── Weather-satellite handoff from the satellite dashboard iframe ───────── + window.addEventListener('message', (event) => { + if (!event.data || event.data.type !== 'weather-sat-handoff') return; + + const { satellite, aosTime, tcaEl, duration } = event.data; + if (!satellite) return; + + // Determine how far away the pass is + const aosMs = aosTime ? (new Date(aosTime) - Date.now()) : Infinity; + const minsAway = aosMs / 60000; + + // Switch to weather-satellite mode and pre-select the satellite + switchMode('weathersat', { updateUrl: true }).then(() => { + if (typeof WeatherSat !== 'undefined') { + if (minsAway <= 2) { + // Pass is imminent — start immediately + WeatherSat.startPass(satellite); + showNotification('Weather Sat', `Auto-starting capture: ${satellite}`); + } else { + // Pre-select so the user can review settings and hit Start + WeatherSat.preSelect(satellite); + showHandoffBanner(satellite, minsAway, tcaEl, duration); + } + } + }); + }); + + function showHandoffBanner(satellite, minsAway, tcaEl, duration) { + // Remove any existing banner + const existing = document.getElementById('weatherSatHandoffBanner'); + if (existing) existing.remove(); + + const mins = Math.round(minsAway); + const elStr = tcaEl != null ? `${Number(tcaEl).toFixed(0)}°` : '?°'; + const durStr = duration != null ? `${Math.round(duration)} min` : ''; + + const banner = document.createElement('div'); + banner.id = 'weatherSatHandoffBanner'; + banner.style.cssText = [ + 'position:fixed', 'top:60px', 'left:50%', 'transform:translateX(-50%)', + 'background:rgba(0,20,30,0.95)', 'border:1px solid rgba(0,255,136,0.5)', + 'color:#00ff88', 'font-family:var(--font-mono,monospace)', 'font-size:12px', + 'padding:10px 18px', 'border-radius:6px', 'z-index:9999', + 'display:flex', 'align-items:center', 'gap:12px', + 'box-shadow:0 0 20px rgba(0,255,136,0.2)' + ].join(';'); + + banner.innerHTML = ` + 📡 ${satellite} pass in ${mins} min · max ${elStr}${durStr ? ' · ' + durStr : ''} — satellite pre-selected + + + `; + + document.body.appendChild(banner); + + // Auto-dismiss after 2 minutes + setTimeout(() => { if (banner.parentNode) banner.remove(); }, 120000); + } diff --git a/templates/satellite_dashboard.html b/templates/satellite_dashboard.html index ca8f42b..250c091 100644 --- a/templates/satellite_dashboard.html +++ b/templates/satellite_dashboard.html @@ -194,6 +194,32 @@ + + +
+
+ TRANSMITTERS +
+
+
+
+ Select a satellite to load transmitters +
+
+
+ + +
+
+ DECODED PACKETS +
+
+
+
+ No packets received.
Packet decoding requires an AFSK/FSK decoder (coming soon). +
+
+
@@ -253,6 +279,75 @@ background: #ff4444; box-shadow: 0 0 6px #ff4444; } + + /* Pass event row */ + .pass-event-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 10px; + color: var(--accent-cyan); + opacity: 0.75; + margin-top: 4px; + font-family: var(--font-mono); + } + .pass-capture-btn { + background: rgba(0, 255, 136, 0.12); + border: 1px solid rgba(0, 255, 136, 0.4); + color: var(--accent-green, #00ff88); + font-size: 10px; + font-family: var(--font-mono); + padding: 2px 7px; + border-radius: 3px; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s; + } + .pass-capture-btn:hover { + background: rgba(0, 255, 136, 0.25); + } + + /* Transmitters panel */ + .transmitters-panel, .packets-panel { + margin-top: 10px; + } + .tx-item { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px; + border-bottom: 1px solid rgba(0,212,255,0.08); + font-size: 11px; + } + .tx-item:last-child { border-bottom: none; } + .tx-inactive { opacity: 0.5; } + .tx-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + margin-top: 3px; + } + .tx-body { flex: 1; min-width: 0; } + .tx-desc { + color: var(--text-primary); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .tx-freq { + color: var(--accent-cyan); + font-family: var(--font-mono); + font-size: 10px; + margin-top: 2px; + } + .tx-uplink { color: var(--accent-green, #00ff88); } + .tx-service { + color: var(--text-muted, #556677); + font-size: 10px; + margin-top: 1px; + }