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 @@
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