From b860a4309b5a6e5b35e96e481499b0c7a9548808 Mon Sep 17 00:00:00 2001 From: Mitch Ross Date: Thu, 5 Feb 2026 19:32:12 -0500 Subject: [PATCH] Add weather satellite auto-scheduler, polar plot, ground track map, and rtlamr Docker support - Fix SDR device stuck claimed on capture failure via on_complete callback - Improve SatDump output parsing to emit all lines (throttled 2s) for real-time feedback - Extract shared pass prediction into utils/weather_sat_predict.py with trajectory/ground track support - Add auto-scheduler (utils/weather_sat_scheduler.py) using threading.Timer for unattended captures - Add scheduler API endpoints (enable/disable/status/passes/skip) with SSE event notifications - Add countdown timer (D/H/M/S) with imminent/active glow states - Add 24h timeline bar with colored pass markers and current-time cursor - Add canvas polar plot showing az/el trajectory arc with cardinal directions - Add Leaflet ground track map with satellite path and observer marker - Restructure to 3-column layout (passes | polar+map | gallery) with responsive stacking - Add auto-schedule toggle in strip bar and sidebar - Add rtlamr (Go utility meter decoder) to Dockerfile Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 8 + config.py | 2 + routes/weather_sat.py | 264 +++++---- static/css/modes/weather-satellite.css | 292 ++++++++- static/js/modes/weather-satellite.js | 555 +++++++++++++++++- templates/index.html | 56 +- .../partials/modes/weather-satellite.html | 17 + utils/weather_sat.py | 39 +- utils/weather_sat_predict.py | 179 ++++++ utils/weather_sat_scheduler.py | 385 ++++++++++++ 10 files changed, 1653 insertions(+), 144 deletions(-) create mode 100644 utils/weather_sat_predict.py create mode 100644 utils/weather_sat_scheduler.py diff --git a/Dockerfile b/Dockerfile index a67f33b..e111850 100644 --- a/Dockerfile +++ b/Dockerfile @@ -145,6 +145,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && ldconfig \ && cd /tmp \ && rm -rf /tmp/SatDump \ + # Build rtlamr (utility meter decoder - requires Go) + && cd /tmp \ + && curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \ + && export PATH="$PATH:/usr/local/go/bin" \ + && export GOPATH=/tmp/gopath \ + && go install github.com/bemasher/rtlamr@latest \ + && cp /tmp/gopath/bin/rtlamr /usr/bin/rtlamr \ + && rm -rf /usr/local/go /tmp/gopath \ # Cleanup build tools to reduce image size && apt-get remove -y \ build-essential \ diff --git a/config.py b/config.py index 09de9c6..72b9235 100644 --- a/config.py +++ b/config.py @@ -196,6 +196,8 @@ WEATHER_SAT_DEFAULT_GAIN = _get_env_float('WEATHER_SAT_GAIN', 40.0) WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 1000000) WEATHER_SAT_MIN_ELEVATION = _get_env_float('WEATHER_SAT_MIN_ELEVATION', 15.0) WEATHER_SAT_PREDICTION_HOURS = _get_env_int('WEATHER_SAT_PREDICTION_HOURS', 24) +WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEATHER_SAT_SCHEDULE_REFRESH_MINUTES', 30) +WEATHER_SAT_CAPTURE_BUFFER_SECONDS = _get_env_int('WEATHER_SAT_CAPTURE_BUFFER_SECONDS', 30) # Update checking GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept') diff --git a/routes/weather_sat.py b/routes/weather_sat.py index 4c9b32d..7170155 100644 --- a/routes/weather_sat.py +++ b/routes/weather_sat.py @@ -162,8 +162,18 @@ def start_capture(): except queue.Empty: break - # Set callback and start + # Set callback and on-complete handler for SDR release decoder.set_callback(_progress_callback) + + def _release_device(): + try: + import app as app_module + app_module.release_sdr_device(device_index) + except ImportError: + pass + + decoder.set_on_complete(_release_device) + success = decoder.start( satellite=satellite, device_index=device_index, @@ -182,11 +192,7 @@ def start_capture(): }) else: # Release device on failure - try: - import app as app_module - app_module.release_sdr_device(device_index) - except ImportError: - pass + _release_device() return jsonify({ 'status': 'error', 'message': 'Failed to start capture' @@ -333,6 +339,8 @@ def get_passes(): longitude: Observer longitude (required) hours: Hours to predict ahead (default: 24, max: 72) min_elevation: Minimum elevation in degrees (default: 15) + trajectory: Include az/el trajectory points (default: false) + ground_track: Include lat/lon ground track points (default: false) Returns: JSON with upcoming passes for all weather satellites. @@ -341,6 +349,8 @@ def get_passes(): lon = request.args.get('longitude', type=float) hours = request.args.get('hours', 24, type=int) min_elevation = request.args.get('min_elevation', 15, type=float) + include_trajectory = request.args.get('trajectory', 'false').lower() in ('true', '1') + include_ground_track = request.args.get('ground_track', 'false').lower() in ('true', '1') if lat is None or lon is None: return jsonify({ @@ -357,119 +367,16 @@ def get_passes(): min_elevation = max(0, min(min_elevation, 90)) try: - from skyfield.api import load, wgs84, EarthSatellite - from skyfield.almanac import find_discrete - from data.satellites import TLE_SATELLITES + from utils.weather_sat_predict import predict_passes - ts = load.timescale() - observer = wgs84.latlon(lat, lon) - t0 = ts.now() - t1 = ts.utc(t0.utc_datetime() + __import__('datetime').timedelta(hours=hours)) - - all_passes = [] - - for sat_key, sat_info in WEATHER_SATELLITES.items(): - if not sat_info['active']: - continue - - tle_data = TLE_SATELLITES.get(sat_info['tle_key']) - if not tle_data: - continue - - satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts) - - def above_horizon(t, _sat=satellite): - diff = _sat - observer - topocentric = diff.at(t) - alt, _, _ = topocentric.altaz() - return alt.degrees > 0 - - 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]: # Rising - rise_time = times[i] - set_time = None - - for j in range(i + 1, len(times)): - if not events[j]: # Setting - set_time = times[j] - i = j - break - else: - i += 1 - continue - - if set_time is None: - i += 1 - continue - - # Calculate max elevation - max_el = 0 - max_el_az = 0 - duration_seconds = ( - set_time.utc_datetime() - rise_time.utc_datetime() - ).total_seconds() - duration_minutes = round(duration_seconds / 60, 1) - - for k in range(30): - frac = k / 29 - t_point = ts.utc( - rise_time.utc_datetime() - + __import__('datetime').timedelta( - seconds=duration_seconds * frac - ) - ) - diff = satellite - observer - topocentric = diff.at(t_point) - alt, az, _ = topocentric.altaz() - if alt.degrees > max_el: - max_el = alt.degrees - max_el_az = az.degrees - - if max_el >= min_elevation: - # Calculate rise/set azimuth - rise_diff = satellite - observer - rise_topo = rise_diff.at(rise_time) - _, rise_az, _ = rise_topo.altaz() - - set_diff = satellite - observer - set_topo = set_diff.at(set_time) - _, set_az, _ = set_topo.altaz() - - pass_data = { - 'satellite': sat_key, - 'name': sat_info['name'], - 'frequency': sat_info['frequency'], - 'mode': sat_info['mode'], - 'startTime': rise_time.utc_datetime().strftime( - '%Y-%m-%d %H:%M UTC' - ), - 'startTimeISO': rise_time.utc_datetime().isoformat(), - 'endTimeISO': set_time.utc_datetime().isoformat(), - 'maxEl': round(max_el, 1), - 'maxElAz': round(max_el_az, 1), - 'riseAz': round(rise_az.degrees, 1), - 'setAz': round(set_az.degrees, 1), - 'duration': duration_minutes, - 'quality': ( - 'excellent' if max_el >= 60 - else 'good' if max_el >= 30 - else 'fair' - ), - } - all_passes.append(pass_data) - - i += 1 - - # Sort by start time - all_passes.sort(key=lambda p: p['startTimeISO']) + all_passes = predict_passes( + lat=lat, + lon=lon, + hours=hours, + min_elevation=min_elevation, + include_trajectory=include_trajectory, + include_ground_track=include_ground_track, + ) return jsonify({ 'status': 'ok', @@ -492,3 +399,124 @@ def get_passes(): 'status': 'error', 'message': str(e) }), 500 + + +# ======================== +# Auto-Scheduler Endpoints +# ======================== + + +def _scheduler_event_callback(event: dict) -> None: + """Forward scheduler events to the SSE queue.""" + try: + _weather_sat_queue.put_nowait(event) + except queue.Full: + try: + _weather_sat_queue.get_nowait() + _weather_sat_queue.put_nowait(event) + except queue.Empty: + pass + + +@weather_sat_bp.route('/schedule/enable', methods=['POST']) +def enable_schedule(): + """Enable auto-scheduling of weather satellite captures. + + JSON body: + { + "latitude": 51.5, // Required + "longitude": -0.1, // Required + "min_elevation": 15, // Minimum pass elevation (default: 15) + "device": 0, // RTL-SDR device index (default: 0) + "gain": 40.0, // SDR gain (default: 40) + "bias_t": false // Enable bias-T (default: false) + } + + Returns: + JSON with scheduler status. + """ + from utils.weather_sat_scheduler import get_weather_sat_scheduler + + data = request.get_json(silent=True) or {} + + lat = data.get('latitude') + lon = data.get('longitude') + + if lat is None or lon is None: + return jsonify({ + 'status': 'error', + 'message': 'latitude and longitude required' + }), 400 + + try: + lat = float(lat) + lon = float(lon) + if not (-90 <= lat <= 90) or not (-180 <= lon <= 180): + raise ValueError + except (TypeError, ValueError): + return jsonify({ + 'status': 'error', + 'message': 'Invalid coordinates' + }), 400 + + scheduler = get_weather_sat_scheduler() + scheduler.set_callbacks(_progress_callback, _scheduler_event_callback) + + result = scheduler.enable( + lat=lat, + lon=lon, + min_elevation=float(data.get('min_elevation', 15)), + device=int(data.get('device', 0)), + gain=float(data.get('gain', 40.0)), + bias_t=bool(data.get('bias_t', False)), + ) + + return jsonify({'status': 'ok', **result}) + + +@weather_sat_bp.route('/schedule/disable', methods=['POST']) +def disable_schedule(): + """Disable auto-scheduling.""" + from utils.weather_sat_scheduler import get_weather_sat_scheduler + + scheduler = get_weather_sat_scheduler() + result = scheduler.disable() + return jsonify(result) + + +@weather_sat_bp.route('/schedule/status') +def schedule_status(): + """Get current scheduler state.""" + from utils.weather_sat_scheduler import get_weather_sat_scheduler + + scheduler = get_weather_sat_scheduler() + return jsonify(scheduler.get_status()) + + +@weather_sat_bp.route('/schedule/passes') +def schedule_passes(): + """List scheduled passes.""" + from utils.weather_sat_scheduler import get_weather_sat_scheduler + + scheduler = get_weather_sat_scheduler() + passes = scheduler.get_passes() + return jsonify({ + 'status': 'ok', + 'passes': passes, + 'count': len(passes), + }) + + +@weather_sat_bp.route('/schedule/skip/', methods=['POST']) +def skip_pass(pass_id: str): + """Skip a scheduled pass.""" + from utils.weather_sat_scheduler import get_weather_sat_scheduler + + if not pass_id.replace('_', '').replace('-', '').isalnum(): + return jsonify({'status': 'error', 'message': 'Invalid pass ID'}), 400 + + scheduler = get_weather_sat_scheduler() + if scheduler.skip_pass(pass_id): + return jsonify({'status': 'skipped', 'pass_id': pass_id}) + else: + return jsonify({'status': 'error', 'message': 'Pass not found or already processed'}), 404 diff --git a/static/css/modes/weather-satellite.css b/static/css/modes/weather-satellite.css index 5e16c37..7712f41 100644 --- a/static/css/modes/weather-satellite.css +++ b/static/css/modes/weather-satellite.css @@ -107,6 +107,30 @@ color: var(--accent-cyan, #00d4ff); } +/* ===== Auto-Schedule Toggle ===== */ +.wxsat-schedule-toggle { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + font-size: 10px; + font-family: 'JetBrains Mono', monospace; + color: var(--text-dim, #666); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.wxsat-schedule-toggle input[type="checkbox"] { + width: 14px; + height: 14px; + cursor: pointer; + accent-color: #00ff88; +} + +.wxsat-schedule-toggle input:checked + .wxsat-toggle-label { + color: #00ff88; +} + /* ===== Location inputs in strip ===== */ .wxsat-strip-location { display: flex; @@ -142,16 +166,160 @@ .wxsat-content { display: flex; - gap: 16px; - padding: 16px; + gap: 12px; + padding: 12px; flex: 1; min-height: 0; overflow: auto; } +/* ===== Countdown Bar ===== */ +.wxsat-countdown-bar { + display: flex; + align-items: center; + gap: 16px; + padding: 10px 16px; + background: var(--bg-secondary, #141820); + border-bottom: 1px solid var(--border-color, #2a3040); +} + +.wxsat-countdown-next { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +.wxsat-countdown-boxes { + display: flex; + gap: 4px; +} + +.wxsat-countdown-box { + display: flex; + flex-direction: column; + align-items: center; + padding: 4px 8px; + background: var(--bg-primary, #0d1117); + border: 1px solid var(--border-color, #2a3040); + border-radius: 4px; + min-width: 40px; +} + +.wxsat-countdown-box.imminent { + border-color: #ffbb00; + box-shadow: 0 0 8px rgba(255, 187, 0, 0.2); +} + +.wxsat-countdown-box.active { + border-color: #00ff88; + box-shadow: 0 0 8px rgba(0, 255, 136, 0.3); + animation: wxsat-glow 1.5s ease-in-out infinite; +} + +@keyframes wxsat-glow { + 0%, 100% { box-shadow: 0 0 8px rgba(0, 255, 136, 0.3); } + 50% { box-shadow: 0 0 16px rgba(0, 255, 136, 0.5); } +} + +.wxsat-cd-value { + font-size: 16px; + font-weight: 700; + font-family: 'JetBrains Mono', monospace; + color: var(--text-primary, #e0e0e0); + line-height: 1; +} + +.wxsat-cd-unit { + font-size: 8px; + color: var(--text-dim, #666); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-top: 2px; +} + +.wxsat-countdown-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.wxsat-countdown-sat { + font-size: 12px; + font-weight: 600; + color: var(--accent-cyan, #00d4ff); + font-family: 'JetBrains Mono', monospace; +} + +.wxsat-countdown-detail { + font-size: 10px; + color: var(--text-dim, #666); + font-family: 'JetBrains Mono', monospace; +} + +/* ===== Timeline ===== */ +.wxsat-timeline { + flex: 1; + position: relative; + height: 36px; + min-width: 200px; +} + +.wxsat-timeline-track { + position: absolute; + top: 4px; + left: 0; + right: 0; + height: 16px; + background: var(--bg-primary, #0d1117); + border: 1px solid var(--border-color, #2a3040); + border-radius: 3px; + overflow: hidden; +} + +.wxsat-timeline-pass { + position: absolute; + top: 0; + height: 100%; + border-radius: 2px; + cursor: pointer; + opacity: 0.8; + transition: opacity 0.2s; +} + +.wxsat-timeline-pass:hover { + opacity: 1; +} + +.wxsat-timeline-pass.apt { background: rgba(0, 212, 255, 0.6); } +.wxsat-timeline-pass.lrpt { background: rgba(0, 255, 136, 0.6); } +.wxsat-timeline-pass.scheduled { border: 1px solid #ffbb00; } + +.wxsat-timeline-cursor { + position: absolute; + top: 2px; + width: 2px; + height: 20px; + background: #ff4444; + border-radius: 1px; + z-index: 2; +} + +.wxsat-timeline-labels { + position: absolute; + bottom: 0; + left: 0; + right: 0; + display: flex; + justify-content: space-between; + font-size: 8px; + color: var(--text-dim, #666); + font-family: 'JetBrains Mono', monospace; +} + /* ===== Pass Predictions Panel ===== */ .wxsat-passes-panel { - flex: 0 0 320px; + flex: 0 0 280px; display: flex; flex-direction: column; gap: 0; @@ -205,11 +373,25 @@ background: var(--bg-hover, #252a3a); } -.wxsat-pass-card.active { +.wxsat-pass-card.active, +.wxsat-pass-card.selected { border-color: #00ff88; background: rgba(0, 255, 136, 0.05); } +.wxsat-pass-card .wxsat-scheduled-badge { + display: inline-block; + font-size: 8px; + padding: 1px 4px; + border-radius: 2px; + background: rgba(255, 187, 0, 0.15); + color: #ffbb00; + margin-left: 6px; + font-family: 'JetBrains Mono', monospace; + text-transform: uppercase; + letter-spacing: 0.5px; +} + .wxsat-pass-sat { display: flex; align-items: center; @@ -281,6 +463,57 @@ color: #ffbb00; } +/* ===== Center Panel (Polar + Map) ===== */ +.wxsat-center-panel { + flex: 0 0 320px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.wxsat-polar-container, +.wxsat-map-container { + background: var(--bg-secondary, #141820); + border: 1px solid var(--border-color, #2a3040); + border-radius: 6px; + overflow: hidden; +} + +.wxsat-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 14px; + background: var(--bg-tertiary, #1a1f2e); + border-bottom: 1px solid var(--border-color, #2a3040); +} + +.wxsat-panel-title { + font-size: 11px; + font-weight: 600; + color: var(--text-primary, #e0e0e0); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.wxsat-panel-subtitle { + font-size: 10px; + color: var(--accent-cyan, #00d4ff); + font-family: 'JetBrains Mono', monospace; +} + +#wxsatPolarCanvas { + display: block; + width: 100%; + height: auto; + max-height: 300px; +} + +.wxsat-ground-map { + height: 200px; + background: var(--bg-primary, #0d1117); +} + /* ===== Image Gallery Panel ===== */ .wxsat-gallery-panel { flex: 1; @@ -322,7 +555,7 @@ overflow-y: auto; padding: 12px; display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; align-content: start; } @@ -421,12 +654,18 @@ font-size: 11px; color: var(--text-secondary, #999); font-family: 'JetBrains Mono', monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + margin-right: 12px; } .wxsat-capture-elapsed { font-size: 11px; color: var(--text-dim, #666); font-family: 'JetBrains Mono', monospace; + flex-shrink: 0; } .wxsat-progress-bar { @@ -496,14 +735,53 @@ } /* ===== Responsive ===== */ -@media (max-width: 900px) { +@media (max-width: 1100px) { .wxsat-content { flex-direction: column; } .wxsat-passes-panel { flex: none; - max-height: 300px; + max-height: 250px; + } + + .wxsat-center-panel { + flex: none; + flex-direction: row; + gap: 12px; + } + + .wxsat-polar-container, + .wxsat-map-container { + flex: 1; + } + + .wxsat-countdown-bar { + flex-wrap: wrap; + } + + .wxsat-timeline { + min-width: 0; + flex: 1 1 200px; + } +} + +@media (max-width: 768px) { + .wxsat-center-panel { + flex-direction: column; + } + + .wxsat-countdown-boxes { + gap: 2px; + } + + .wxsat-countdown-box { + min-width: 32px; + padding: 3px 5px; + } + + .wxsat-cd-value { + font-size: 13px; } .wxsat-gallery-grid { diff --git a/static/js/modes/weather-satellite.js b/static/js/modes/weather-satellite.js index a845dda..8601380 100644 --- a/static/js/modes/weather-satellite.js +++ b/static/js/modes/weather-satellite.js @@ -1,6 +1,7 @@ /** * Weather Satellite Mode - * NOAA APT and Meteor LRPT decoder interface + * NOAA APT and Meteor LRPT decoder interface with auto-scheduler, + * polar plot, ground track map, countdown, and timeline. */ const WeatherSat = (function() { @@ -9,7 +10,13 @@ const WeatherSat = (function() { let eventSource = null; let images = []; let passes = []; + let selectedPassIndex = -1; let currentSatellite = null; + let countdownInterval = null; + let schedulerEnabled = false; + let groundMap = null; + let groundTrackLayer = null; + let observerMarker = null; /** * Initialize the Weather Satellite mode @@ -19,6 +26,9 @@ const WeatherSat = (function() { loadImages(); loadLocationInputs(); loadPasses(); + startCountdownTimer(); + checkSchedulerStatus(); + initGroundMap(); } /** @@ -261,6 +271,8 @@ const WeatherSat = (function() { const data = JSON.parse(e.data); if (data.type === 'weather_sat_progress') { handleProgress(data); + } else if (data.type && data.type.startsWith('schedule_')) { + handleSchedulerSSE(data); } } catch (err) { console.error('Failed to parse SSE:', err); @@ -269,7 +281,7 @@ const WeatherSat = (function() { eventSource.onerror = () => { setTimeout(() => { - if (isRunning) startStream(); + if (isRunning || schedulerEnabled) startStream(); }, 3000); }; } @@ -312,7 +324,7 @@ const WeatherSat = (function() { if (!data.image) { // Capture ended isRunning = false; - stopStream(); + if (!schedulerEnabled) stopStream(); updateStatusUI('idle', 'Capture complete'); if (captureStatus) captureStatus.classList.remove('active'); } @@ -324,6 +336,26 @@ const WeatherSat = (function() { } } + /** + * Handle scheduler SSE events + */ + function handleSchedulerSSE(data) { + if (data.type === 'schedule_capture_start') { + isRunning = true; + const p = data.pass || {}; + currentSatellite = p.satellite; + updateStatusUI('capturing', `Auto: ${p.name || p.satellite} ${p.frequency} MHz`); + showNotification('Weather Sat', `Auto-capture started: ${p.name || p.satellite}`); + } else if (data.type === 'schedule_capture_complete') { + showNotification('Weather Sat', `Auto-capture complete: ${(data.pass || {}).name || ''}`); + loadImages(); + } else if (data.type === 'schedule_capture_skipped') { + const reason = data.reason || 'unknown'; + const p = data.pass || {}; + showNotification('Weather Sat', `Pass skipped (${reason}): ${p.name || p.satellite}`); + } + } + /** * Format elapsed seconds */ @@ -334,7 +366,7 @@ const WeatherSat = (function() { } /** - * Load pass predictions + * Load pass predictions (with trajectory + ground track) */ async function loadPasses() { const storedLat = localStorage.getItem('observerLat'); @@ -346,19 +378,49 @@ const WeatherSat = (function() { } try { - const url = `/weather-sat/passes?latitude=${storedLat}&longitude=${storedLon}&hours=24&min_elevation=15`; + const url = `/weather-sat/passes?latitude=${storedLat}&longitude=${storedLon}&hours=24&min_elevation=15&trajectory=true&ground_track=true`; const response = await fetch(url); const data = await response.json(); if (data.status === 'ok') { passes = data.passes || []; renderPasses(passes); + renderTimeline(passes); + updateCountdownFromPasses(); + // Auto-select first pass + if (passes.length > 0 && selectedPassIndex < 0) { + selectPass(0); + } } } catch (err) { console.error('Failed to load passes:', err); } } + /** + * Select a pass to display in polar plot and map + */ + function selectPass(index) { + if (index < 0 || index >= passes.length) return; + selectedPassIndex = index; + const pass = passes[index]; + + // Highlight active card + document.querySelectorAll('.wxsat-pass-card').forEach((card, i) => { + card.classList.toggle('selected', i === index); + }); + + // Update polar plot + drawPolarPlot(pass); + + // Update ground track + updateGroundTrack(pass); + + // Update polar panel subtitle + const polarSat = document.getElementById('wxsatPolarSat'); + if (polarSat) polarSat.textContent = `${pass.name} ${pass.maxEl}\u00b0`; + } + /** * Render pass predictions list */ @@ -387,6 +449,7 @@ const WeatherSat = (function() { const passStart = new Date(pass.startTimeISO); const diffMs = passStart - now; const diffMins = Math.floor(diffMs / 60000); + const isSelected = idx === selectedPassIndex; let countdown = ''; if (diffMs < 0) { @@ -400,7 +463,7 @@ const WeatherSat = (function() { } return ` -
+
${escapeHtml(pass.name)} ${escapeHtml(pass.mode)} @@ -419,11 +482,479 @@ const WeatherSat = (function() { ${pass.quality} ${countdown}
+
+ +
`; }).join(''); } + // ======================== + // Polar Plot + // ======================== + + /** + * Draw polar plot for a pass trajectory + */ + function drawPolarPlot(pass) { + const canvas = document.getElementById('wxsatPolarCanvas'); + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + const w = canvas.width; + const h = canvas.height; + const cx = w / 2; + const cy = h / 2; + const r = Math.min(cx, cy) - 20; + + ctx.clearRect(0, 0, w, h); + + // Background + ctx.fillStyle = '#0d1117'; + ctx.fillRect(0, 0, w, h); + + // Grid circles (30, 60, 90 deg elevation) + ctx.strokeStyle = '#2a3040'; + ctx.lineWidth = 0.5; + [90, 60, 30].forEach((el, i) => { + const gr = r * (1 - el / 90); + ctx.beginPath(); + ctx.arc(cx, cy, gr, 0, Math.PI * 2); + ctx.stroke(); + // Label + ctx.fillStyle = '#555'; + ctx.font = '9px JetBrains Mono, monospace'; + ctx.textAlign = 'left'; + ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2); + }); + + // Horizon circle + ctx.strokeStyle = '#3a4050'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.stroke(); + + // Cardinal directions + ctx.fillStyle = '#666'; + ctx.font = '10px JetBrains Mono, monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('N', cx, cy - r - 10); + ctx.fillText('S', cx, cy + r + 10); + ctx.fillText('E', cx + r + 10, cy); + ctx.fillText('W', cx - r - 10, cy); + + // Cross hairs + ctx.strokeStyle = '#2a3040'; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(cx, cy - r); + ctx.lineTo(cx, cy + r); + ctx.moveTo(cx - r, cy); + ctx.lineTo(cx + r, cy); + ctx.stroke(); + + // Trajectory + const trajectory = pass.trajectory; + if (!trajectory || trajectory.length === 0) return; + + const color = pass.mode === 'LRPT' ? '#00ff88' : '#00d4ff'; + + ctx.beginPath(); + ctx.strokeStyle = color; + ctx.lineWidth = 2; + + trajectory.forEach((pt, i) => { + const elRad = (90 - pt.el) / 90; + const azRad = (pt.az - 90) * Math.PI / 180; // offset: N is up + const px = cx + r * elRad * Math.cos(azRad); + const py = cy + r * elRad * Math.sin(azRad); + + if (i === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + }); + ctx.stroke(); + + // Start point (green dot) + const start = trajectory[0]; + const startR = (90 - start.el) / 90; + const startAz = (start.az - 90) * Math.PI / 180; + ctx.fillStyle = '#00ff88'; + ctx.beginPath(); + ctx.arc(cx + r * startR * Math.cos(startAz), cy + r * startR * Math.sin(startAz), 4, 0, Math.PI * 2); + ctx.fill(); + + // End point (red dot) + const end = trajectory[trajectory.length - 1]; + const endR = (90 - end.el) / 90; + const endAz = (end.az - 90) * Math.PI / 180; + ctx.fillStyle = '#ff4444'; + ctx.beginPath(); + ctx.arc(cx + r * endR * Math.cos(endAz), cy + r * endR * Math.sin(endAz), 4, 0, Math.PI * 2); + ctx.fill(); + + // Max elevation marker + let maxEl = 0; + let maxPt = trajectory[0]; + trajectory.forEach(pt => { if (pt.el > maxEl) { maxEl = pt.el; maxPt = pt; } }); + const maxR = (90 - maxPt.el) / 90; + const maxAz = (maxPt.az - 90) * Math.PI / 180; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(cx + r * maxR * Math.cos(maxAz), cy + r * maxR * Math.sin(maxAz), 3, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = color; + ctx.font = '9px JetBrains Mono, monospace'; + ctx.textAlign = 'center'; + ctx.fillText(Math.round(maxEl) + '\u00b0', cx + r * maxR * Math.cos(maxAz), cy + r * maxR * Math.sin(maxAz) - 8); + } + + // ======================== + // Ground Track Map + // ======================== + + /** + * Initialize Leaflet ground track map + */ + function initGroundMap() { + const container = document.getElementById('wxsatGroundMap'); + if (!container || groundMap) return; + if (typeof L === 'undefined') return; + + groundMap = L.map(container, { + center: [20, 0], + zoom: 2, + zoomControl: false, + attributionControl: false, + }); + + // Check tile provider from settings + let tileUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; + try { + const provider = localStorage.getItem('tileProvider'); + if (provider === 'osm') { + tileUrl = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'; + } + } catch (e) {} + + L.tileLayer(tileUrl, { maxZoom: 10 }).addTo(groundMap); + + groundTrackLayer = L.layerGroup().addTo(groundMap); + + // Delayed invalidation to fix sizing + setTimeout(() => { if (groundMap) groundMap.invalidateSize(); }, 200); + } + + /** + * Update ground track on the map + */ + function updateGroundTrack(pass) { + if (!groundMap || !groundTrackLayer) return; + + groundTrackLayer.clearLayers(); + + const track = pass.groundTrack; + if (!track || track.length === 0) return; + + const color = pass.mode === 'LRPT' ? '#00ff88' : '#00d4ff'; + + // Draw polyline + const latlngs = track.map(p => [p.lat, p.lon]); + L.polyline(latlngs, { color, weight: 2, opacity: 0.8 }).addTo(groundTrackLayer); + + // Start marker + L.circleMarker(latlngs[0], { + radius: 5, color: '#00ff88', fillColor: '#00ff88', fillOpacity: 1, weight: 0, + }).addTo(groundTrackLayer); + + // End marker + L.circleMarker(latlngs[latlngs.length - 1], { + radius: 5, color: '#ff4444', fillColor: '#ff4444', fillOpacity: 1, weight: 0, + }).addTo(groundTrackLayer); + + // Observer marker + const lat = parseFloat(localStorage.getItem('observerLat')); + const lon = parseFloat(localStorage.getItem('observerLon')); + if (!isNaN(lat) && !isNaN(lon)) { + L.circleMarker([lat, lon], { + radius: 6, color: '#ffbb00', fillColor: '#ffbb00', fillOpacity: 0.8, weight: 1, + }).addTo(groundTrackLayer); + } + + // Fit bounds + try { + const bounds = L.latLngBounds(latlngs); + if (!isNaN(lat) && !isNaN(lon)) bounds.extend([lat, lon]); + groundMap.fitBounds(bounds, { padding: [20, 20] }); + } catch (e) {} + } + + // ======================== + // Countdown + // ======================== + + /** + * Start the countdown interval timer + */ + function startCountdownTimer() { + if (countdownInterval) clearInterval(countdownInterval); + countdownInterval = setInterval(updateCountdownFromPasses, 1000); + } + + /** + * Update countdown display from passes array + */ + function updateCountdownFromPasses() { + const now = new Date(); + let nextPass = null; + let isActive = false; + + for (const pass of passes) { + const start = new Date(pass.startTimeISO); + const end = new Date(pass.endTimeISO); + if (end > now) { + nextPass = pass; + isActive = start <= now; + break; + } + } + + const daysEl = document.getElementById('wxsatCdDays'); + const hoursEl = document.getElementById('wxsatCdHours'); + const minsEl = document.getElementById('wxsatCdMins'); + const secsEl = document.getElementById('wxsatCdSecs'); + const satEl = document.getElementById('wxsatCountdownSat'); + const detailEl = document.getElementById('wxsatCountdownDetail'); + const boxes = document.getElementById('wxsatCountdownBoxes'); + + if (!nextPass) { + if (daysEl) daysEl.textContent = '--'; + if (hoursEl) hoursEl.textContent = '--'; + if (minsEl) minsEl.textContent = '--'; + if (secsEl) secsEl.textContent = '--'; + if (satEl) satEl.textContent = '--'; + if (detailEl) detailEl.textContent = 'No passes predicted'; + if (boxes) boxes.querySelectorAll('.wxsat-countdown-box').forEach(b => { + b.classList.remove('imminent', 'active'); + }); + return; + } + + const target = new Date(nextPass.startTimeISO); + let diffMs = target - now; + + if (isActive) { + diffMs = 0; + } + + const totalSec = Math.max(0, Math.floor(diffMs / 1000)); + const d = Math.floor(totalSec / 86400); + const h = Math.floor((totalSec % 86400) / 3600); + const m = Math.floor((totalSec % 3600) / 60); + const s = totalSec % 60; + + if (daysEl) daysEl.textContent = d.toString().padStart(2, '0'); + if (hoursEl) hoursEl.textContent = h.toString().padStart(2, '0'); + if (minsEl) minsEl.textContent = m.toString().padStart(2, '0'); + if (secsEl) secsEl.textContent = s.toString().padStart(2, '0'); + if (satEl) satEl.textContent = `${nextPass.name} ${nextPass.frequency} MHz`; + if (detailEl) { + if (isActive) { + detailEl.textContent = `ACTIVE - ${nextPass.maxEl}\u00b0 max el`; + } else { + detailEl.textContent = `${nextPass.maxEl}\u00b0 max el / ${nextPass.duration} min`; + } + } + + // Countdown box states + if (boxes) { + const isImminent = totalSec < 600 && totalSec > 0; // < 10 min + boxes.querySelectorAll('.wxsat-countdown-box').forEach(b => { + b.classList.toggle('imminent', isImminent); + b.classList.toggle('active', isActive); + }); + } + } + + // ======================== + // Timeline + // ======================== + + /** + * Render 24h timeline with pass markers + */ + function renderTimeline(passList) { + const track = document.getElementById('wxsatTimelineTrack'); + const cursor = document.getElementById('wxsatTimelineCursor'); + if (!track) return; + + // Clear existing pass markers + track.querySelectorAll('.wxsat-timeline-pass').forEach(el => el.remove()); + + const now = new Date(); + const dayStart = new Date(now); + dayStart.setHours(0, 0, 0, 0); + const dayMs = 24 * 60 * 60 * 1000; + + passList.forEach((pass, idx) => { + const start = new Date(pass.startTimeISO); + const end = new Date(pass.endTimeISO); + + const startPct = Math.max(0, Math.min(100, ((start - dayStart) / dayMs) * 100)); + const endPct = Math.max(0, Math.min(100, ((end - dayStart) / dayMs) * 100)); + const widthPct = Math.max(0.5, endPct - startPct); + + const marker = document.createElement('div'); + marker.className = `wxsat-timeline-pass ${pass.mode === 'LRPT' ? 'lrpt' : 'apt'}`; + marker.style.left = startPct + '%'; + marker.style.width = widthPct + '%'; + marker.title = `${pass.name} ${pass.startTime} (${pass.maxEl}\u00b0)`; + marker.onclick = () => selectPass(idx); + track.appendChild(marker); + }); + + // Update cursor position + updateTimelineCursor(); + } + + /** + * Update timeline cursor to current time + */ + function updateTimelineCursor() { + const cursor = document.getElementById('wxsatTimelineCursor'); + if (!cursor) return; + + const now = new Date(); + const dayStart = new Date(now); + dayStart.setHours(0, 0, 0, 0); + const pct = ((now - dayStart) / (24 * 60 * 60 * 1000)) * 100; + cursor.style.left = pct + '%'; + } + + // ======================== + // Auto-Scheduler + // ======================== + + /** + * Toggle auto-scheduler + */ + async function toggleScheduler() { + const stripCheckbox = document.getElementById('wxsatAutoSchedule'); + const sidebarCheckbox = document.getElementById('wxsatSidebarAutoSchedule'); + const checked = stripCheckbox?.checked || sidebarCheckbox?.checked; + + // Sync both checkboxes + if (stripCheckbox) stripCheckbox.checked = checked; + if (sidebarCheckbox) sidebarCheckbox.checked = checked; + + if (checked) { + await enableScheduler(); + } else { + await disableScheduler(); + } + } + + /** + * Enable auto-scheduler + */ + async function enableScheduler() { + const lat = parseFloat(localStorage.getItem('observerLat')); + const lon = parseFloat(localStorage.getItem('observerLon')); + + if (isNaN(lat) || isNaN(lon)) { + showNotification('Weather Sat', 'Set observer location first'); + const stripCheckbox = document.getElementById('wxsatAutoSchedule'); + const sidebarCheckbox = document.getElementById('wxsatSidebarAutoSchedule'); + if (stripCheckbox) stripCheckbox.checked = false; + if (sidebarCheckbox) sidebarCheckbox.checked = false; + return; + } + + const deviceSelect = document.getElementById('deviceSelect'); + const gainInput = document.getElementById('weatherSatGain'); + const biasTInput = document.getElementById('weatherSatBiasT'); + + try { + const response = await fetch('/weather-sat/schedule/enable', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + latitude: lat, + longitude: lon, + device: parseInt(deviceSelect?.value || '0', 10), + gain: parseFloat(gainInput?.value || '40'), + bias_t: biasTInput?.checked || false, + }), + }); + + const data = await response.json(); + schedulerEnabled = true; + updateSchedulerUI(data); + startStream(); + showNotification('Weather Sat', `Auto-scheduler enabled (${data.scheduled_count || 0} passes)`); + } catch (err) { + console.error('Failed to enable scheduler:', err); + showNotification('Weather Sat', 'Failed to enable auto-scheduler'); + } + } + + /** + * Disable auto-scheduler + */ + async function disableScheduler() { + try { + await fetch('/weather-sat/schedule/disable', { method: 'POST' }); + schedulerEnabled = false; + updateSchedulerUI({ enabled: false }); + if (!isRunning) stopStream(); + showNotification('Weather Sat', 'Auto-scheduler disabled'); + } catch (err) { + console.error('Failed to disable scheduler:', err); + } + } + + /** + * Check current scheduler status + */ + async function checkSchedulerStatus() { + try { + const response = await fetch('/weather-sat/schedule/status'); + const data = await response.json(); + schedulerEnabled = data.enabled; + updateSchedulerUI(data); + if (schedulerEnabled) startStream(); + } catch (err) { + // Scheduler endpoint may not exist yet + } + } + + /** + * Update scheduler UI elements + */ + function updateSchedulerUI(data) { + const stripCheckbox = document.getElementById('wxsatAutoSchedule'); + const sidebarCheckbox = document.getElementById('wxsatSidebarAutoSchedule'); + const statusEl = document.getElementById('wxsatSchedulerStatus'); + + if (stripCheckbox) stripCheckbox.checked = data.enabled; + if (sidebarCheckbox) sidebarCheckbox.checked = data.enabled; + if (statusEl) { + if (data.enabled) { + statusEl.textContent = `Active: ${data.scheduled_count || 0} passes queued`; + statusEl.style.color = '#00ff88'; + } else { + statusEl.textContent = 'Disabled'; + statusEl.style.color = ''; + } + } + } + + // ======================== + // Images + // ======================== + /** * Load decoded images */ @@ -544,17 +1075,29 @@ const WeatherSat = (function() { return div.innerHTML; } + /** + * Invalidate ground map size (call after container becomes visible) + */ + function invalidateMap() { + if (groundMap) { + setTimeout(() => groundMap.invalidateSize(), 100); + } + } + // Public API return { init, start, stop, startPass, + selectPass, loadImages, loadPasses, showImage, closeImage, useGPS, + toggleScheduler, + invalidateMap, }; })(); diff --git a/templates/index.html b/templates/index.html index cbfb1b9..389b756 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1925,6 +1925,36 @@
+
+
+ +
+ + + +
+
+
+
--DAYS
+
--HRS
+
--MIN
+
--SEC
+
+
+ -- + No passes predicted +
+
+
+
+
+
+ 00:0006:0012:0018:0024:00 +
+
@@ -1938,9 +1968,9 @@ - +
- +
Upcoming Passes @@ -1953,7 +1983,24 @@
- + +
+
+
+ Polar Plot + -- +
+ +
+
+
+ Ground Track +
+
+
+
+ + +
+

Auto-Scheduler

+

+ Automatically capture satellite passes based on predictions. + Set your location above and toggle AUTO in the strip bar. +

+
+ +
+
+ Disabled +
+
+

Resources

diff --git a/utils/weather_sat.py b/utils/weather_sat.py index ce00390..8450931 100644 --- a/utils/weather_sat.py +++ b/utils/weather_sat.py @@ -149,6 +149,7 @@ class WeatherSatDecoder: self._capture_start_time: float = 0 self._device_index: int = 0 self._capture_output_dir: Path | None = None + self._on_complete_callback: Callable[[], None] | None = None # Ensure output directory exists self._output_dir.mkdir(parents=True, exist_ok=True) @@ -189,6 +190,10 @@ class WeatherSatDecoder: """Set callback for capture progress updates.""" self._callback = callback + def set_on_complete(self, callback: Callable[[], None]) -> None: + """Set callback invoked when capture process ends (for SDR release).""" + self._on_complete_callback = callback + def start( self, satellite: str, @@ -320,6 +325,8 @@ class WeatherSatDecoder: if not self._process or not self._process.stdout: return + last_emit_time = 0.0 + try: for line in iter(self._process.stdout.readline, ''): if not self._running: @@ -331,12 +338,11 @@ class WeatherSatDecoder: logger.debug(f"satdump: {line}") - # Parse progress from SatDump output elapsed = int(time.time() - self._capture_start_time) + now = time.time() - # SatDump outputs progress info - parse key indicators + # Parse progress from SatDump output if 'Progress' in line or 'progress' in line: - # Try to extract percentage match = re.search(r'(\d+(?:\.\d+)?)\s*%', line) pct = int(float(match.group(1))) if match else 0 self._emit_progress(CaptureProgress( @@ -348,6 +354,7 @@ class WeatherSatDecoder: progress_percent=pct, elapsed_seconds=elapsed, )) + last_emit_time = now elif 'Saved' in line or 'saved' in line or 'Writing' in line: self._emit_progress(CaptureProgress( status='decoding', @@ -357,6 +364,7 @@ class WeatherSatDecoder: message=line, elapsed_seconds=elapsed, )) + last_emit_time = now elif 'error' in line.lower() or 'fail' in line.lower(): self._emit_progress(CaptureProgress( status='capturing', @@ -366,25 +374,29 @@ class WeatherSatDecoder: message=line, elapsed_seconds=elapsed, )) + last_emit_time = now else: - # Generic progress update every ~10 seconds - if elapsed % 10 == 0: + # Emit all output lines, throttled to every 2 seconds + if now - last_emit_time >= 2.0: self._emit_progress(CaptureProgress( status='capturing', satellite=self._current_satellite, frequency=self._current_frequency, mode=self._current_mode, - message=f"Capturing... ({elapsed}s elapsed)", + message=line, elapsed_seconds=elapsed, )) + last_emit_time = now except Exception as e: logger.error(f"Error reading SatDump output: {e}") finally: - # Process ended - if self._running: - self._running = False - elapsed = int(time.time() - self._capture_start_time) + # Process ended — release resources + was_running = self._running + self._running = False + elapsed = int(time.time() - self._capture_start_time) if self._capture_start_time else 0 + + if was_running: self._emit_progress(CaptureProgress( status='complete', satellite=self._current_satellite, @@ -394,6 +406,13 @@ class WeatherSatDecoder: elapsed_seconds=elapsed, )) + # Notify route layer to release SDR device + if self._on_complete_callback: + try: + self._on_complete_callback() + except Exception as e: + logger.error(f"Error in on_complete callback: {e}") + def _watch_images(self) -> None: """Watch output directory for new decoded images.""" if not self._capture_output_dir: diff --git a/utils/weather_sat_predict.py b/utils/weather_sat_predict.py new file mode 100644 index 0000000..b5d6f2d --- /dev/null +++ b/utils/weather_sat_predict.py @@ -0,0 +1,179 @@ +"""Weather satellite pass prediction utility. + +Shared prediction logic used by both the API endpoint and the auto-scheduler. +""" + +from __future__ import annotations + +import datetime +from typing import Any + +from utils.logging import get_logger +from utils.weather_sat import WEATHER_SATELLITES + +logger = get_logger('intercept.weather_sat_predict') + + +def predict_passes( + lat: float, + lon: float, + hours: int = 24, + min_elevation: float = 15.0, + include_trajectory: bool = False, + include_ground_track: bool = False, +) -> list[dict[str, Any]]: + """Predict upcoming weather satellite passes for an observer location. + + Args: + lat: Observer latitude (-90 to 90) + lon: Observer longitude (-180 to 180) + hours: Hours ahead to predict (1-72) + min_elevation: Minimum max elevation in degrees (0-90) + include_trajectory: Include az/el trajectory points (30 points) + include_ground_track: Include lat/lon ground track points (60 points) + + Returns: + List of pass dicts sorted by start time. + + Raises: + ImportError: If skyfield is not installed. + """ + from skyfield.api import load, wgs84, EarthSatellite + from skyfield.almanac import find_discrete + from data.satellites import TLE_SATELLITES + + ts = load.timescale() + observer = wgs84.latlon(lat, lon) + t0 = ts.now() + t1 = ts.utc(t0.utc_datetime() + datetime.timedelta(hours=hours)) + + all_passes: list[dict[str, Any]] = [] + + for sat_key, sat_info in WEATHER_SATELLITES.items(): + if not sat_info['active']: + continue + + tle_data = TLE_SATELLITES.get(sat_info['tle_key']) + if not tle_data: + continue + + satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts) + + def above_horizon(t, _sat=satellite): + diff = _sat - observer + topocentric = diff.at(t) + alt, _, _ = topocentric.altaz() + return alt.degrees > 0 + + 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]: # Rising + rise_time = times[i] + set_time = None + + for j in range(i + 1, len(times)): + if not events[j]: # Setting + set_time = times[j] + i = j + break + else: + i += 1 + continue + + if set_time is None: + i += 1 + continue + + duration_seconds = ( + set_time.utc_datetime() - rise_time.utc_datetime() + ).total_seconds() + duration_minutes = round(duration_seconds / 60, 1) + + # Calculate max elevation and trajectory + max_el = 0.0 + max_el_az = 0.0 + trajectory: list[dict[str, float]] = [] + num_traj_points = 30 + + for k in range(num_traj_points): + frac = k / (num_traj_points - 1) + t_point = ts.utc( + rise_time.utc_datetime() + + datetime.timedelta(seconds=duration_seconds * frac) + ) + diff = satellite - observer + topocentric = diff.at(t_point) + alt, az, _ = topocentric.altaz() + if alt.degrees > max_el: + max_el = alt.degrees + max_el_az = az.degrees + if include_trajectory: + trajectory.append({ + 'el': float(max(0, alt.degrees)), + 'az': float(az.degrees), + }) + + if max_el < min_elevation: + i += 1 + continue + + # Rise/set azimuths + rise_topo = (satellite - observer).at(rise_time) + _, rise_az, _ = rise_topo.altaz() + + set_topo = (satellite - observer).at(set_time) + _, set_az, _ = set_topo.altaz() + + pass_data: dict[str, Any] = { + 'id': f"{sat_key}_{rise_time.utc_datetime().strftime('%Y%m%d%H%M')}", + 'satellite': sat_key, + 'name': sat_info['name'], + 'frequency': sat_info['frequency'], + 'mode': sat_info['mode'], + 'startTime': rise_time.utc_datetime().strftime('%Y-%m-%d %H:%M UTC'), + 'startTimeISO': rise_time.utc_datetime().isoformat(), + 'endTimeISO': set_time.utc_datetime().isoformat(), + 'maxEl': round(max_el, 1), + 'maxElAz': round(max_el_az, 1), + 'riseAz': round(rise_az.degrees, 1), + 'setAz': round(set_az.degrees, 1), + 'duration': duration_minutes, + 'quality': ( + 'excellent' if max_el >= 60 + else 'good' if max_el >= 30 + else 'fair' + ), + } + + if include_trajectory: + pass_data['trajectory'] = trajectory + + if include_ground_track: + ground_track: list[dict[str, float]] = [] + for k in range(60): + frac = k / 59 + t_point = ts.utc( + rise_time.utc_datetime() + + 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), + }) + pass_data['groundTrack'] = ground_track + + all_passes.append(pass_data) + + i += 1 + + all_passes.sort(key=lambda p: p['startTimeISO']) + return all_passes diff --git a/utils/weather_sat_scheduler.py b/utils/weather_sat_scheduler.py new file mode 100644 index 0000000..f08ec6b --- /dev/null +++ b/utils/weather_sat_scheduler.py @@ -0,0 +1,385 @@ +"""Weather satellite auto-scheduler. + +Automatically captures satellite passes based on predicted pass times. +Uses threading.Timer for scheduling — no external dependencies required. +""" + +from __future__ import annotations + +import threading +import time +import uuid +from datetime import datetime, timezone, timedelta +from typing import Any, Callable + +from utils.logging import get_logger +from utils.weather_sat import get_weather_sat_decoder, WEATHER_SATELLITES, CaptureProgress + +logger = get_logger('intercept.weather_sat_scheduler') + +# Import config defaults +try: + from config import ( + WEATHER_SAT_SCHEDULE_REFRESH_MINUTES, + WEATHER_SAT_CAPTURE_BUFFER_SECONDS, + ) +except ImportError: + WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = 30 + WEATHER_SAT_CAPTURE_BUFFER_SECONDS = 30 + + +class ScheduledPass: + """A pass scheduled for automatic capture.""" + + def __init__(self, pass_data: dict[str, Any]): + self.id: str = pass_data.get('id', str(uuid.uuid4())[:8]) + self.satellite: str = pass_data['satellite'] + self.name: str = pass_data['name'] + self.frequency: float = pass_data['frequency'] + self.mode: str = pass_data['mode'] + self.start_time: str = pass_data['startTimeISO'] + self.end_time: str = pass_data['endTimeISO'] + self.max_el: float = pass_data['maxEl'] + self.duration: float = pass_data['duration'] + self.quality: str = pass_data['quality'] + self.status: str = 'scheduled' # scheduled, capturing, complete, skipped + self.skipped: bool = False + self._timer: threading.Timer | None = None + self._stop_timer: threading.Timer | None = None + + @property + def start_dt(self) -> datetime: + return datetime.fromisoformat(self.start_time).replace(tzinfo=timezone.utc) + + @property + def end_dt(self) -> datetime: + return datetime.fromisoformat(self.end_time).replace(tzinfo=timezone.utc) + + def to_dict(self) -> dict[str, Any]: + return { + 'id': self.id, + 'satellite': self.satellite, + 'name': self.name, + 'frequency': self.frequency, + 'mode': self.mode, + 'startTimeISO': self.start_time, + 'endTimeISO': self.end_time, + 'maxEl': self.max_el, + 'duration': self.duration, + 'quality': self.quality, + 'status': self.status, + 'skipped': self.skipped, + } + + +class WeatherSatScheduler: + """Auto-scheduler for weather satellite captures.""" + + def __init__(self): + self._enabled = False + self._lock = threading.Lock() + self._passes: list[ScheduledPass] = [] + self._refresh_timer: threading.Timer | None = None + self._lat: float = 0.0 + self._lon: float = 0.0 + self._min_elevation: float = 15.0 + self._device: int = 0 + self._gain: float = 40.0 + self._bias_t: bool = False + self._progress_callback: Callable[[CaptureProgress], None] | None = None + self._event_callback: Callable[[dict[str, Any]], None] | None = None + + @property + def enabled(self) -> bool: + return self._enabled + + def set_callbacks( + self, + progress_callback: Callable[[CaptureProgress], None], + event_callback: Callable[[dict[str, Any]], None], + ) -> None: + """Set callbacks for progress and scheduler events.""" + self._progress_callback = progress_callback + self._event_callback = event_callback + + def enable( + self, + lat: float, + lon: float, + min_elevation: float = 15.0, + device: int = 0, + gain: float = 40.0, + bias_t: bool = False, + ) -> dict[str, Any]: + """Enable auto-scheduling. + + Args: + lat: Observer latitude + lon: Observer longitude + min_elevation: Minimum pass elevation to capture + device: RTL-SDR device index + gain: SDR gain in dB + bias_t: Enable bias-T + + Returns: + Status dict with scheduled passes. + """ + with self._lock: + self._lat = lat + self._lon = lon + self._min_elevation = min_elevation + self._device = device + self._gain = gain + self._bias_t = bias_t + self._enabled = True + + self._refresh_passes() + + return self.get_status() + + def disable(self) -> dict[str, Any]: + """Disable auto-scheduling and cancel all timers.""" + with self._lock: + self._enabled = False + + # Cancel refresh timer + if self._refresh_timer: + self._refresh_timer.cancel() + self._refresh_timer = None + + # Cancel all pass timers + for p in self._passes: + if p._timer: + p._timer.cancel() + p._timer = None + if p._stop_timer: + p._stop_timer.cancel() + p._stop_timer = None + + self._passes.clear() + + logger.info("Weather satellite auto-scheduler disabled") + return {'status': 'disabled'} + + def skip_pass(self, pass_id: str) -> bool: + """Manually skip a scheduled pass.""" + with self._lock: + for p in self._passes: + if p.id == pass_id and p.status == 'scheduled': + p.skipped = True + p.status = 'skipped' + if p._timer: + p._timer.cancel() + p._timer = None + logger.info(f"Skipped pass: {p.satellite} at {p.start_time}") + self._emit_event({ + 'type': 'schedule_capture_skipped', + 'pass': p.to_dict(), + 'reason': 'manual', + }) + return True + return False + + def get_status(self) -> dict[str, Any]: + """Get current scheduler status.""" + with self._lock: + return { + 'enabled': self._enabled, + 'observer': {'latitude': self._lat, 'longitude': self._lon}, + 'device': self._device, + 'gain': self._gain, + 'bias_t': self._bias_t, + 'min_elevation': self._min_elevation, + 'scheduled_count': sum( + 1 for p in self._passes if p.status == 'scheduled' + ), + 'total_passes': len(self._passes), + } + + def get_passes(self) -> list[dict[str, Any]]: + """Get list of scheduled passes.""" + with self._lock: + return [p.to_dict() for p in self._passes] + + def _refresh_passes(self) -> None: + """Recompute passes and schedule timers.""" + if not self._enabled: + return + + try: + from utils.weather_sat_predict import predict_passes + + passes = predict_passes( + lat=self._lat, + lon=self._lon, + hours=24, + min_elevation=self._min_elevation, + ) + except Exception as e: + logger.error(f"Failed to predict passes for scheduler: {e}") + passes = [] + + with self._lock: + # Cancel existing timers + for p in self._passes: + if p._timer: + p._timer.cancel() + if p._stop_timer: + p._stop_timer.cancel() + + # Keep completed/skipped for history, replace scheduled + history = [p for p in self._passes if p.status in ('complete', 'skipped', 'capturing')] + self._passes = history + + now = datetime.now(timezone.utc) + buffer = WEATHER_SAT_CAPTURE_BUFFER_SECONDS + + for pass_data in passes: + sp = ScheduledPass(pass_data) + + # Skip passes that already started + if sp.start_dt - timedelta(seconds=buffer) <= now: + continue + + # Check if already in history + if any(h.id == sp.id for h in history): + continue + + # Schedule capture timer + delay = (sp.start_dt - timedelta(seconds=buffer) - now).total_seconds() + if delay > 0: + sp._timer = threading.Timer(delay, self._execute_capture, args=[sp]) + sp._timer.daemon = True + sp._timer.start() + self._passes.append(sp) + + logger.info( + f"Scheduler refreshed: {sum(1 for p in self._passes if p.status == 'scheduled')} " + f"passes scheduled" + ) + + # Schedule next refresh + if self._refresh_timer: + self._refresh_timer.cancel() + self._refresh_timer = threading.Timer( + WEATHER_SAT_SCHEDULE_REFRESH_MINUTES * 60, + self._refresh_passes, + ) + self._refresh_timer.daemon = True + self._refresh_timer.start() + + def _execute_capture(self, sp: ScheduledPass) -> None: + """Execute capture for a scheduled pass.""" + if not self._enabled or sp.skipped: + return + + decoder = get_weather_sat_decoder() + + if decoder.is_running: + logger.info(f"SDR busy, skipping scheduled pass: {sp.satellite}") + sp.status = 'skipped' + sp.skipped = True + self._emit_event({ + 'type': 'schedule_capture_skipped', + 'pass': sp.to_dict(), + 'reason': 'sdr_busy', + }) + return + + # Claim SDR device + try: + import app as app_module + error = app_module.claim_sdr_device(self._device, 'weather_sat') + if error: + logger.info(f"SDR device busy, skipping: {sp.satellite} - {error}") + sp.status = 'skipped' + sp.skipped = True + self._emit_event({ + 'type': 'schedule_capture_skipped', + 'pass': sp.to_dict(), + 'reason': 'device_busy', + }) + return + except ImportError: + pass + + sp.status = 'capturing' + + # Set up callbacks + if self._progress_callback: + decoder.set_callback(self._progress_callback) + + def _release_device(): + try: + import app as app_module + app_module.release_sdr_device(self._device) + except ImportError: + pass + + decoder.set_on_complete(lambda: self._on_capture_complete(sp, _release_device)) + + success = decoder.start( + satellite=sp.satellite, + device_index=self._device, + gain=self._gain, + bias_t=self._bias_t, + ) + + if success: + logger.info(f"Auto-scheduler started capture: {sp.satellite}") + self._emit_event({ + 'type': 'schedule_capture_start', + 'pass': sp.to_dict(), + }) + + # Schedule stop timer at pass end + buffer + now = datetime.now(timezone.utc) + stop_delay = (sp.end_dt + timedelta(seconds=WEATHER_SAT_CAPTURE_BUFFER_SECONDS) - now).total_seconds() + if stop_delay > 0: + sp._stop_timer = threading.Timer(stop_delay, self._stop_capture, args=[sp]) + sp._stop_timer.daemon = True + sp._stop_timer.start() + else: + sp.status = 'skipped' + _release_device() + self._emit_event({ + 'type': 'schedule_capture_skipped', + 'pass': sp.to_dict(), + 'reason': 'start_failed', + }) + + def _stop_capture(self, sp: ScheduledPass) -> None: + """Stop capture at pass end.""" + decoder = get_weather_sat_decoder() + if decoder.is_running: + decoder.stop() + logger.info(f"Auto-scheduler stopped capture: {sp.satellite}") + + def _on_capture_complete(self, sp: ScheduledPass, release_fn: Callable) -> None: + """Handle capture completion.""" + sp.status = 'complete' + release_fn() + self._emit_event({ + 'type': 'schedule_capture_complete', + 'pass': sp.to_dict(), + }) + + def _emit_event(self, event: dict[str, Any]) -> None: + """Emit scheduler event to callback.""" + if self._event_callback: + try: + self._event_callback(event) + except Exception as e: + logger.error(f"Error in scheduler event callback: {e}") + + +# Singleton +_scheduler: WeatherSatScheduler | None = None + + +def get_weather_sat_scheduler() -> WeatherSatScheduler: + """Get or create the global weather satellite scheduler instance.""" + global _scheduler + if _scheduler is None: + _scheduler = WeatherSatScheduler() + return _scheduler