From cfe03317c93661c7d9a5c24fc99426b7fcb3ff2d Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 19 Feb 2026 21:55:07 +0000 Subject: [PATCH] Fix weather sat auto-scheduler and Mercator tracking --- routes/weather_sat.py | 33 ++- static/css/modes/weather-satellite.css | 62 ++++- static/js/modes/weather-satellite.js | 364 ++++++++++++++++++------- templates/index.html | 3 +- tests/test_weather_sat_scheduler.py | 64 ++++- utils/weather_sat_scheduler.py | 134 +++++---- 6 files changed, 482 insertions(+), 178 deletions(-) diff --git a/routes/weather_sat.py b/routes/weather_sat.py index f7ae788..6222b52 100644 --- a/routes/weather_sat.py +++ b/routes/weather_sat.py @@ -563,19 +563,26 @@ def enable_schedule(): 'message': 'Invalid parameter value' }), 400 - scheduler = get_weather_sat_scheduler() - scheduler.set_callbacks(_progress_callback, _scheduler_event_callback) - - result = scheduler.enable( - lat=lat, - lon=lon, - min_elevation=min_elev, - device=device, - gain=gain_val, - bias_t=bool(data.get('bias_t', False)), - ) - - return jsonify({'status': 'ok', **result}) + scheduler = get_weather_sat_scheduler() + scheduler.set_callbacks(_progress_callback, _scheduler_event_callback) + + try: + result = scheduler.enable( + lat=lat, + lon=lon, + min_elevation=min_elev, + device=device, + gain=gain_val, + bias_t=bool(data.get('bias_t', False)), + ) + except Exception as e: + logger.exception("Failed to enable weather sat scheduler") + return jsonify({ + 'status': 'error', + 'message': 'Failed to enable scheduler' + }), 500 + + return jsonify({'status': 'ok', **result}) @weather_sat_bp.route('/schedule/disable', methods=['POST']) diff --git a/static/css/modes/weather-satellite.css b/static/css/modes/weather-satellite.css index a1f3392..3c4ee63 100644 --- a/static/css/modes/weather-satellite.css +++ b/static/css/modes/weather-satellite.css @@ -509,10 +509,64 @@ max-height: 300px; } -.wxsat-ground-map { - height: 200px; - background: var(--bg-primary, #0d1117); -} +.wxsat-ground-map { + height: 200px; + background: var(--bg-primary, #0d1117); +} + +.wxsat-crosshair-icon { + background: transparent; + border: none; +} + +.wxsat-crosshair-marker { + position: relative; + width: 26px; + height: 26px; +} + +.wxsat-crosshair-h, +.wxsat-crosshair-v, +.wxsat-crosshair-ring, +.wxsat-crosshair-dot { + position: absolute; + display: block; +} + +.wxsat-crosshair-h { + top: 50%; + left: 2px; + right: 2px; + height: 1px; + background: rgba(255, 76, 76, 0.9); + transform: translateY(-50%); +} + +.wxsat-crosshair-v { + left: 50%; + top: 2px; + bottom: 2px; + width: 1px; + background: rgba(255, 76, 76, 0.9); + transform: translateX(-50%); +} + +.wxsat-crosshair-ring { + inset: 5px; + border: 1px solid rgba(255, 76, 76, 0.95); + border-radius: 50%; + box-shadow: 0 0 8px rgba(255, 76, 76, 0.45); +} + +.wxsat-crosshair-dot { + width: 4px; + height: 4px; + left: 50%; + top: 50%; + border-radius: 50%; + background: #ff4c4c; + transform: translate(-50%, -50%); +} /* ===== Image Gallery Panel ===== */ .wxsat-gallery-panel { diff --git a/static/js/modes/weather-satellite.js b/static/js/modes/weather-satellite.js index fc0fdb8..6c45728 100644 --- a/static/js/modes/weather-satellite.js +++ b/static/js/modes/weather-satellite.js @@ -1,7 +1,7 @@ /** * Weather Satellite Mode - * NOAA APT and Meteor LRPT decoder interface with auto-scheduler, - * polar plot, ground track map, countdown, and timeline. + * NOAA APT and Meteor LRPT decoder interface with auto-scheduler, + * polar plot, mercator map, countdown, and timeline. */ const WeatherSat = (function() { @@ -13,10 +13,12 @@ const WeatherSat = (function() { let selectedPassIndex = -1; let currentSatellite = null; let countdownInterval = null; - let schedulerEnabled = false; - let groundMap = null; - let groundTrackLayer = null; - let observerMarker = null; + let schedulerEnabled = false; + let groundMap = null; + let groundTrackLayer = null; + let groundOverlayLayer = null; + let satCrosshairMarker = null; + let observerMarker = null; let consoleEntries = []; let consoleCollapsed = false; let currentPhase = 'idle'; @@ -40,7 +42,7 @@ const WeatherSat = (function() { /** * Load observer location into input fields */ - function loadLocationInputs() { + function loadLocationInputs() { const latInput = document.getElementById('wxsatObsLat'); const lonInput = document.getElementById('wxsatObsLon'); @@ -484,7 +486,7 @@ const WeatherSat = (function() { /** * Load pass predictions (with trajectory + ground track) */ - async function loadPasses() { + async function loadPasses() { let storedLat, storedLon; // Use ObserverLocation if available, otherwise fall back to localStorage @@ -497,31 +499,38 @@ const WeatherSat = (function() { storedLon = localStorage.getItem('observerLon'); } - if (!storedLat || !storedLon) { - renderPasses([]); - return; - } + if (!storedLat || !storedLon) { + passes = []; + selectedPassIndex = -1; + renderPasses([]); + renderTimeline([]); + updateCountdownFromPasses(); + updateGroundTrack(null); + return; + } try { 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 || []; - selectedPassIndex = -1; - renderPasses(passes); - renderTimeline(passes); - updateCountdownFromPasses(); - // Always select the first upcoming pass so the polar plot - // and ground track reflect the current list after every refresh. - if (passes.length > 0) { - selectPass(0); - } - } - } catch (err) { - console.error('Failed to load passes:', err); - } + if (data.status === 'ok') { + passes = data.passes || []; + selectedPassIndex = -1; + renderPasses(passes); + renderTimeline(passes); + updateCountdownFromPasses(); + // Always select the first upcoming pass so the polar plot + // and ground track reflect the current list after every refresh. + if (passes.length > 0) { + selectPass(0); + } else { + updateGroundTrack(null); + } + } + } catch (err) { + console.error('Failed to load passes:', err); + } } /** @@ -747,17 +756,18 @@ const WeatherSat = (function() { /** * 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, - }); + 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, + crs: L.CRS.EPSG3857, // Web Mercator projection + }); // Check tile provider from settings let tileUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; @@ -768,24 +778,39 @@ const WeatherSat = (function() { } } 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); - } + L.tileLayer(tileUrl, { maxZoom: 10 }).addTo(groundMap); + + groundTrackLayer = L.layerGroup().addTo(groundMap); + groundOverlayLayer = L.layerGroup().addTo(groundMap); + + const selected = getSelectedPass(); + if (selected) { + updateGroundTrack(selected); + } else { + updateSatelliteCrosshair(null); + } + + // 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; + function updateGroundTrack(pass) { + if (!groundMap || !groundTrackLayer) return; + + groundTrackLayer.clearLayers(); + if (!pass) { + updateSatelliteCrosshair(null); + return; + } + + const track = pass.groundTrack; + if (!track || track.length === 0) { + updateSatelliteCrosshair(null); + return; + } const color = pass.mode === 'LRPT' ? '#00ff88' : '#00d4ff'; @@ -821,13 +846,134 @@ const WeatherSat = (function() { }).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) {} - } + // 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) {} + + updateSatelliteCrosshair(pass); + } + + function updateMercatorInfo(text) { + const infoEl = document.getElementById('wxsatMercatorInfo'); + if (infoEl) infoEl.textContent = text || '--'; + } + + function clearSatelliteCrosshair() { + if (!groundOverlayLayer || !satCrosshairMarker) return; + groundOverlayLayer.removeLayer(satCrosshairMarker); + satCrosshairMarker = null; + } + + function createSatelliteCrosshairIcon() { + return L.divIcon({ + className: 'wxsat-crosshair-icon', + iconSize: [26, 26], + iconAnchor: [13, 13], + html: ` +
+ + + + +
+ `, + }); + } + + function getSelectedPass() { + if (selectedPassIndex < 0 || selectedPassIndex >= passes.length) return null; + return passes[selectedPassIndex]; + } + + function getSatellitePositionForPass(pass, atTime = new Date()) { + const track = pass?.groundTrack; + if (!Array.isArray(track) || track.length === 0) return null; + + const first = track[0]; + if (track.length === 1) { + const lat = Number(first.lat); + const lon = Number(first.lon); + if (!isFinite(lat) || !isFinite(lon)) return null; + return { lat, lon }; + } + + const start = parsePassDate(pass.startTimeISO); + const end = parsePassDate(pass.endTimeISO); + + let fraction = 0; + if (start && end && end > start) { + const totalMs = end.getTime() - start.getTime(); + const elapsedMs = atTime.getTime() - start.getTime(); + fraction = Math.max(0, Math.min(1, elapsedMs / totalMs)); + } + + const lastIndex = track.length - 1; + const idxFloat = fraction * lastIndex; + const idx0 = Math.floor(idxFloat); + const idx1 = Math.min(lastIndex, idx0 + 1); + const t = idxFloat - idx0; + + const p0 = track[idx0]; + const p1 = track[idx1]; + const lat0 = Number(p0?.lat); + const lon0 = Number(p0?.lon); + const lat1 = Number(p1?.lat); + const lon1 = Number(p1?.lon); + + if (!isFinite(lat0) || !isFinite(lon0) || !isFinite(lat1) || !isFinite(lon1)) { + return null; + } + + return { + lat: lat0 + ((lat1 - lat0) * t), + lon: lon0 + ((lon1 - lon0) * t), + }; + } + + function updateSatelliteCrosshair(pass) { + if (!groundMap || !groundOverlayLayer || typeof L === 'undefined') return; + + if (!pass) { + clearSatelliteCrosshair(); + updateMercatorInfo('--'); + return; + } + + const position = getSatellitePositionForPass(pass); + if (!position) { + clearSatelliteCrosshair(); + updateMercatorInfo(`${pass.name || pass.satellite || '--'} --`); + return; + } + + const latlng = [position.lat, position.lon]; + if (!satCrosshairMarker) { + satCrosshairMarker = L.marker(latlng, { + icon: createSatelliteCrosshairIcon(), + interactive: false, + keyboard: false, + zIndexOffset: 800, + }).addTo(groundOverlayLayer); + } else { + satCrosshairMarker.setLatLng(latlng); + } + + const tooltipText = `${pass.name || pass.satellite || 'Satellite'} ${position.lat.toFixed(2)}°, ${position.lon.toFixed(2)}°`; + if (!satCrosshairMarker.getTooltip()) { + satCrosshairMarker.bindTooltip(tooltipText, { + direction: 'top', + offset: [0, -10], + opacity: 0.9, + }); + } else { + satCrosshairMarker.setTooltipContent(tooltipText); + } + + updateMercatorInfo(tooltipText); + } // ======================== // Countdown @@ -930,9 +1076,11 @@ const WeatherSat = (function() { }); } - // Keep timeline cursor in sync - updateTimelineCursor(); - } + // Keep timeline cursor in sync + updateTimelineCursor(); + // Keep selected satellite marker synchronized with time progression. + updateSatelliteCrosshair(getSelectedPass()); + } // ======================== // Timeline @@ -1017,7 +1165,7 @@ const WeatherSat = (function() { /** * Enable auto-scheduler */ - async function enableScheduler() { + async function enableScheduler() { let lat, lon; if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) { const shared = ObserverLocation.getShared(); @@ -1041,41 +1189,60 @@ const WeatherSat = (function() { 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({ + 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'); - } - } + bias_t: biasTInput?.checked || false, + }), + }); + + let data = {}; + try { + data = await response.json(); + } catch (err) { + data = {}; + } + + if (!response.ok || !data || data.enabled !== true) { + schedulerEnabled = false; + updateSchedulerUI({ enabled: false, scheduled_count: 0 }); + showNotification('Weather Sat', data.message || 'Failed to enable auto-scheduler'); + return; + } + + 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); + schedulerEnabled = false; + updateSchedulerUI({ enabled: false, scheduled_count: 0 }); + 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) { + async function disableScheduler() { + try { + const response = await fetch('/weather-sat/schedule/disable', { method: 'POST' }); + if (!response.ok) { + showNotification('Weather Sat', 'Failed to disable auto-scheduler'); + return; + } + schedulerEnabled = false; + updateSchedulerUI({ enabled: false }); + if (!isRunning) stopStream(); + showNotification('Weather Sat', 'Auto-scheduler disabled'); + } catch (err) { console.error('Failed to disable scheduler:', err); } } @@ -1083,14 +1250,15 @@ const WeatherSat = (function() { /** * 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) { + async function checkSchedulerStatus() { + try { + const response = await fetch('/weather-sat/schedule/status'); + if (!response.ok) return; + const data = await response.json(); + schedulerEnabled = data.enabled; + updateSchedulerUI(data); + if (schedulerEnabled) startStream(); + } catch (err) { // Scheduler endpoint may not exist yet } } diff --git a/templates/index.html b/templates/index.html index b99ba5a..38e2a3b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2866,7 +2866,8 @@
- Ground Track + Mercator Projection + --
diff --git a/tests/test_weather_sat_scheduler.py b/tests/test_weather_sat_scheduler.py index 6f079b3..ccd4f88 100644 --- a/tests/test_weather_sat_scheduler.py +++ b/tests/test_weather_sat_scheduler.py @@ -16,6 +16,7 @@ from utils.weather_sat_scheduler import ( WeatherSatScheduler, ScheduledPass, get_weather_sat_scheduler, + _parse_utc_iso, ) @@ -327,7 +328,7 @@ class TestWeatherSatScheduler: assert len(passes) == 1 assert passes[0]['id'] == 'NOAA-18_202401011200' - @patch('utils.weather_sat_scheduler.predict_passes') + @patch('utils.weather_sat_predict.predict_passes') @patch('threading.Timer') def test_refresh_passes(self, mock_timer, mock_predict): """_refresh_passes() should schedule future passes.""" @@ -361,7 +362,7 @@ class TestWeatherSatScheduler: assert scheduler._passes[0].satellite == 'NOAA-18' mock_timer_instance.start.assert_called() - @patch('utils.weather_sat_scheduler.predict_passes') + @patch('utils.weather_sat_predict.predict_passes') def test_refresh_passes_skip_past(self, mock_predict): """_refresh_passes() should skip passes that already started.""" now = datetime.now(timezone.utc) @@ -389,7 +390,42 @@ class TestWeatherSatScheduler: # Should not schedule past passes assert len(scheduler._passes) == 0 - @patch('utils.weather_sat_scheduler.predict_passes') + @patch('utils.weather_sat_predict.predict_passes') + @patch('threading.Timer') + def test_refresh_passes_active_window_triggers_immediately(self, mock_timer, mock_predict): + """_refresh_passes() should trigger immediately during an active pass window.""" + now = datetime.now(timezone.utc) + active_pass = { + 'id': 'NOAA-18_ACTIVE', + 'satellite': 'NOAA-18', + 'name': 'NOAA 18', + 'frequency': 137.9125, + 'mode': 'APT', + 'startTimeISO': (now - timedelta(minutes=2)).isoformat(), + 'endTimeISO': (now + timedelta(minutes=8)).isoformat(), + 'maxEl': 45.0, + 'duration': 10.0, + 'quality': 'good', + } + mock_predict.return_value = [active_pass] + + pass_timer = MagicMock() + refresh_timer = MagicMock() + mock_timer.side_effect = [pass_timer, refresh_timer] + + scheduler = WeatherSatScheduler() + scheduler._enabled = True + scheduler._lat = 51.5 + scheduler._lon = -0.1 + + scheduler._refresh_passes() + + assert len(scheduler._passes) == 1 + first_delay = mock_timer.call_args_list[0][0][0] + assert first_delay == pytest.approx(0.0, abs=0.01) + pass_timer.start.assert_called_once() + + @patch('utils.weather_sat_predict.predict_passes') def test_refresh_passes_disabled(self, mock_predict): """_refresh_passes() should do nothing when disabled.""" scheduler = WeatherSatScheduler() @@ -399,7 +435,7 @@ class TestWeatherSatScheduler: mock_predict.assert_not_called() - @patch('utils.weather_sat_scheduler.predict_passes') + @patch('utils.weather_sat_predict.predict_passes') def test_refresh_passes_error_handling(self, mock_predict): """_refresh_passes() should handle prediction errors.""" mock_predict.side_effect = Exception('TLE error') @@ -716,10 +752,28 @@ class TestSchedulerConfiguration: assert WEATHER_SAT_CAPTURE_BUFFER_SECONDS >= 0 +class TestUtcIsoParsing: + """Tests for UTC ISO timestamp parsing.""" + + def test_parse_utc_iso_with_z_suffix(self): + """_parse_utc_iso should handle Z timestamps.""" + dt = _parse_utc_iso('2026-02-19T12:34:56Z') + assert dt.tzinfo == timezone.utc + assert dt.hour == 12 + assert dt.minute == 34 + assert dt.second == 56 + + def test_parse_utc_iso_with_legacy_suffix(self): + """_parse_utc_iso should handle legacy +00:00Z timestamps.""" + dt = _parse_utc_iso('2026-02-19T12:34:56+00:00Z') + assert dt.tzinfo == timezone.utc + assert dt.hour == 12 + + class TestSchedulerIntegration: """Integration tests for scheduler.""" - @patch('utils.weather_sat_scheduler.predict_passes') + @patch('utils.weather_sat_predict.predict_passes') @patch('utils.weather_sat_scheduler.get_weather_sat_decoder') @patch('threading.Timer') def test_full_scheduling_cycle(self, mock_timer, mock_get_decoder, mock_predict): diff --git a/utils/weather_sat_scheduler.py b/utils/weather_sat_scheduler.py index 6f16a54..d4ccb24 100644 --- a/utils/weather_sat_scheduler.py +++ b/utils/weather_sat_scheduler.py @@ -4,13 +4,12 @@ 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 __future__ import annotations + +import threading +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 @@ -28,7 +27,7 @@ except ImportError: WEATHER_SAT_CAPTURE_BUFFER_SECONDS = 30 -class ScheduledPass: +class ScheduledPass: """A pass scheduled for automatic capture.""" def __init__(self, pass_data: dict[str, Any]): @@ -47,21 +46,13 @@ class ScheduledPass: self._timer: threading.Timer | None = None self._stop_timer: threading.Timer | None = None - @property - def start_dt(self) -> datetime: - dt = datetime.fromisoformat(self.start_time) - if dt.tzinfo is None: - # Naive datetime - assume UTC - dt = dt.replace(tzinfo=timezone.utc) - return dt.astimezone(timezone.utc) - - @property - def end_dt(self) -> datetime: - dt = datetime.fromisoformat(self.end_time) - if dt.tzinfo is None: - # Naive datetime - assume UTC - dt = dt.replace(tzinfo=timezone.utc) - return dt.astimezone(timezone.utc) + @property + def start_dt(self) -> datetime: + return _parse_utc_iso(self.start_time) + + @property + def end_dt(self) -> datetime: + return _parse_utc_iso(self.end_time) def to_dict(self) -> dict[str, Any]: return { @@ -80,7 +71,7 @@ class ScheduledPass: } -class WeatherSatScheduler: +class WeatherSatScheduler: """Auto-scheduler for weather satellite captures.""" def __init__(self): @@ -209,10 +200,10 @@ class WeatherSatScheduler: 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 + def _refresh_passes(self) -> None: + """Recompute passes and schedule timers.""" + if not self._enabled: + return try: from utils.weather_sat_predict import predict_passes @@ -236,30 +227,39 @@ class WeatherSatScheduler: 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) + 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: + try: + sp = ScheduledPass(pass_data) + start_dt = sp.start_dt + end_dt = sp.end_dt + except Exception as e: + logger.warning(f"Skipping invalid pass data: {e}") + continue + + capture_start = start_dt - timedelta(seconds=buffer) + capture_end = end_dt + timedelta(seconds=buffer) + + # Skip passes that are already over + if capture_end <= now: + continue + + # Check if already in history + if any(h.id == sp.id for h in history): + continue + + # Schedule capture timer. If we're already inside the capture + # window, trigger immediately instead of skipping the pass. + delay = max(0.0, (capture_start - now).total_seconds()) + 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')} " @@ -374,11 +374,31 @@ class WeatherSatScheduler: 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}") + if self._event_callback: + try: + self._event_callback(event) + except Exception as e: + logger.error(f"Error in scheduler event callback: {e}") + + +def _parse_utc_iso(value: str) -> datetime: + """Parse UTC ISO8601 timestamp robustly across Python versions.""" + if not value: + raise ValueError("missing timestamp") + + text = str(value).strip() + # Backward compatibility for malformed legacy strings. + text = text.replace('+00:00Z', 'Z') + # Python <3.11 does not accept trailing 'Z' in fromisoformat. + if text.endswith('Z'): + text = text[:-1] + '+00:00' + + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + else: + dt = dt.astimezone(timezone.utc) + return dt # Singleton