Fix weather sat auto-scheduler and Mercator tracking

This commit is contained in:
Smittix
2026-02-19 21:55:07 +00:00
parent 37ba12daaa
commit cfe03317c9
6 changed files with 482 additions and 178 deletions
+20 -13
View File
@@ -563,19 +563,26 @@ def enable_schedule():
'message': 'Invalid parameter value' 'message': 'Invalid parameter value'
}), 400 }), 400
scheduler = get_weather_sat_scheduler() scheduler = get_weather_sat_scheduler()
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback) scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
result = scheduler.enable( try:
lat=lat, result = scheduler.enable(
lon=lon, lat=lat,
min_elevation=min_elev, lon=lon,
device=device, min_elevation=min_elev,
gain=gain_val, device=device,
bias_t=bool(data.get('bias_t', False)), gain=gain_val,
) bias_t=bool(data.get('bias_t', False)),
)
return jsonify({'status': 'ok', **result}) 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']) @weather_sat_bp.route('/schedule/disable', methods=['POST'])
+58 -4
View File
@@ -509,10 +509,64 @@
max-height: 300px; max-height: 300px;
} }
.wxsat-ground-map { .wxsat-ground-map {
height: 200px; height: 200px;
background: var(--bg-primary, #0d1117); 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 ===== */ /* ===== Image Gallery Panel ===== */
.wxsat-gallery-panel { .wxsat-gallery-panel {
+266 -98
View File
@@ -1,7 +1,7 @@
/** /**
* Weather Satellite Mode * Weather Satellite Mode
* NOAA APT and Meteor LRPT decoder interface with auto-scheduler, * NOAA APT and Meteor LRPT decoder interface with auto-scheduler,
* polar plot, ground track map, countdown, and timeline. * polar plot, mercator map, countdown, and timeline.
*/ */
const WeatherSat = (function() { const WeatherSat = (function() {
@@ -13,10 +13,12 @@ const WeatherSat = (function() {
let selectedPassIndex = -1; let selectedPassIndex = -1;
let currentSatellite = null; let currentSatellite = null;
let countdownInterval = null; let countdownInterval = null;
let schedulerEnabled = false; let schedulerEnabled = false;
let groundMap = null; let groundMap = null;
let groundTrackLayer = null; let groundTrackLayer = null;
let observerMarker = null; let groundOverlayLayer = null;
let satCrosshairMarker = null;
let observerMarker = null;
let consoleEntries = []; let consoleEntries = [];
let consoleCollapsed = false; let consoleCollapsed = false;
let currentPhase = 'idle'; let currentPhase = 'idle';
@@ -40,7 +42,7 @@ const WeatherSat = (function() {
/** /**
* Load observer location into input fields * Load observer location into input fields
*/ */
function loadLocationInputs() { function loadLocationInputs() {
const latInput = document.getElementById('wxsatObsLat'); const latInput = document.getElementById('wxsatObsLat');
const lonInput = document.getElementById('wxsatObsLon'); const lonInput = document.getElementById('wxsatObsLon');
@@ -484,7 +486,7 @@ const WeatherSat = (function() {
/** /**
* Load pass predictions (with trajectory + ground track) * Load pass predictions (with trajectory + ground track)
*/ */
async function loadPasses() { async function loadPasses() {
let storedLat, storedLon; let storedLat, storedLon;
// Use ObserverLocation if available, otherwise fall back to localStorage // Use ObserverLocation if available, otherwise fall back to localStorage
@@ -497,31 +499,38 @@ const WeatherSat = (function() {
storedLon = localStorage.getItem('observerLon'); storedLon = localStorage.getItem('observerLon');
} }
if (!storedLat || !storedLon) { if (!storedLat || !storedLon) {
renderPasses([]); passes = [];
return; selectedPassIndex = -1;
} renderPasses([]);
renderTimeline([]);
updateCountdownFromPasses();
updateGroundTrack(null);
return;
}
try { try {
const url = `/weather-sat/passes?latitude=${storedLat}&longitude=${storedLon}&hours=24&min_elevation=15&trajectory=true&ground_track=true`; 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 response = await fetch(url);
const data = await response.json(); const data = await response.json();
if (data.status === 'ok') { if (data.status === 'ok') {
passes = data.passes || []; passes = data.passes || [];
selectedPassIndex = -1; selectedPassIndex = -1;
renderPasses(passes); renderPasses(passes);
renderTimeline(passes); renderTimeline(passes);
updateCountdownFromPasses(); updateCountdownFromPasses();
// Always select the first upcoming pass so the polar plot // Always select the first upcoming pass so the polar plot
// and ground track reflect the current list after every refresh. // and ground track reflect the current list after every refresh.
if (passes.length > 0) { if (passes.length > 0) {
selectPass(0); selectPass(0);
} } else {
} updateGroundTrack(null);
} catch (err) { }
console.error('Failed to load passes:', err); }
} } catch (err) {
console.error('Failed to load passes:', err);
}
} }
/** /**
@@ -747,17 +756,18 @@ const WeatherSat = (function() {
/** /**
* Initialize Leaflet ground track map * Initialize Leaflet ground track map
*/ */
function initGroundMap() { function initGroundMap() {
const container = document.getElementById('wxsatGroundMap'); const container = document.getElementById('wxsatGroundMap');
if (!container || groundMap) return; if (!container || groundMap) return;
if (typeof L === 'undefined') return; if (typeof L === 'undefined') return;
groundMap = L.map(container, { groundMap = L.map(container, {
center: [20, 0], center: [20, 0],
zoom: 2, zoom: 2,
zoomControl: false, zoomControl: false,
attributionControl: false, attributionControl: false,
}); crs: L.CRS.EPSG3857, // Web Mercator projection
});
// Check tile provider from settings // Check tile provider from settings
let tileUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; let tileUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
@@ -768,24 +778,39 @@ const WeatherSat = (function() {
} }
} catch (e) {} } catch (e) {}
L.tileLayer(tileUrl, { maxZoom: 10 }).addTo(groundMap); L.tileLayer(tileUrl, { maxZoom: 10 }).addTo(groundMap);
groundTrackLayer = L.layerGroup().addTo(groundMap); groundTrackLayer = L.layerGroup().addTo(groundMap);
groundOverlayLayer = L.layerGroup().addTo(groundMap);
// Delayed invalidation to fix sizing
setTimeout(() => { if (groundMap) groundMap.invalidateSize(); }, 200); 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 * Update ground track on the map
*/ */
function updateGroundTrack(pass) { function updateGroundTrack(pass) {
if (!groundMap || !groundTrackLayer) return; if (!groundMap || !groundTrackLayer) return;
groundTrackLayer.clearLayers(); groundTrackLayer.clearLayers();
if (!pass) {
const track = pass.groundTrack; updateSatelliteCrosshair(null);
if (!track || track.length === 0) return; return;
}
const track = pass.groundTrack;
if (!track || track.length === 0) {
updateSatelliteCrosshair(null);
return;
}
const color = pass.mode === 'LRPT' ? '#00ff88' : '#00d4ff'; const color = pass.mode === 'LRPT' ? '#00ff88' : '#00d4ff';
@@ -821,13 +846,134 @@ const WeatherSat = (function() {
}).addTo(groundTrackLayer); }).addTo(groundTrackLayer);
} }
// Fit bounds // Fit bounds
try { try {
const bounds = L.latLngBounds(latlngs); const bounds = L.latLngBounds(latlngs);
if (!isNaN(lat) && !isNaN(lon)) bounds.extend([lat, lon]); if (!isNaN(lat) && !isNaN(lon)) bounds.extend([lat, lon]);
groundMap.fitBounds(bounds, { padding: [20, 20] }); groundMap.fitBounds(bounds, { padding: [20, 20] });
} catch (e) {} } 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: `
<div class="wxsat-crosshair-marker">
<span class="wxsat-crosshair-h"></span>
<span class="wxsat-crosshair-v"></span>
<span class="wxsat-crosshair-ring"></span>
<span class="wxsat-crosshair-dot"></span>
</div>
`,
});
}
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 // Countdown
@@ -930,9 +1076,11 @@ const WeatherSat = (function() {
}); });
} }
// Keep timeline cursor in sync // Keep timeline cursor in sync
updateTimelineCursor(); updateTimelineCursor();
} // Keep selected satellite marker synchronized with time progression.
updateSatelliteCrosshair(getSelectedPass());
}
// ======================== // ========================
// Timeline // Timeline
@@ -1017,7 +1165,7 @@ const WeatherSat = (function() {
/** /**
* Enable auto-scheduler * Enable auto-scheduler
*/ */
async function enableScheduler() { async function enableScheduler() {
let lat, lon; let lat, lon;
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) { if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
const shared = ObserverLocation.getShared(); const shared = ObserverLocation.getShared();
@@ -1041,41 +1189,60 @@ const WeatherSat = (function() {
const gainInput = document.getElementById('weatherSatGain'); const gainInput = document.getElementById('weatherSatGain');
const biasTInput = document.getElementById('weatherSatBiasT'); const biasTInput = document.getElementById('weatherSatBiasT');
try { try {
const response = await fetch('/weather-sat/schedule/enable', { const response = await fetch('/weather-sat/schedule/enable', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
latitude: lat, latitude: lat,
longitude: lon, longitude: lon,
device: parseInt(deviceSelect?.value || '0', 10), device: parseInt(deviceSelect?.value || '0', 10),
gain: parseFloat(gainInput?.value || '40'), gain: parseFloat(gainInput?.value || '40'),
bias_t: biasTInput?.checked || false, bias_t: biasTInput?.checked || false,
}), }),
}); });
const data = await response.json(); let data = {};
schedulerEnabled = true; try {
updateSchedulerUI(data); data = await response.json();
startStream(); } catch (err) {
showNotification('Weather Sat', `Auto-scheduler enabled (${data.scheduled_count || 0} passes)`); data = {};
} catch (err) { }
console.error('Failed to enable scheduler:', err);
showNotification('Weather Sat', 'Failed to enable auto-scheduler'); 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 * Disable auto-scheduler
*/ */
async function disableScheduler() { async function disableScheduler() {
try { try {
await fetch('/weather-sat/schedule/disable', { method: 'POST' }); const response = await fetch('/weather-sat/schedule/disable', { method: 'POST' });
schedulerEnabled = false; if (!response.ok) {
updateSchedulerUI({ enabled: false }); showNotification('Weather Sat', 'Failed to disable auto-scheduler');
if (!isRunning) stopStream(); return;
showNotification('Weather Sat', 'Auto-scheduler disabled'); }
} catch (err) { schedulerEnabled = false;
updateSchedulerUI({ enabled: false });
if (!isRunning) stopStream();
showNotification('Weather Sat', 'Auto-scheduler disabled');
} catch (err) {
console.error('Failed to disable scheduler:', err); console.error('Failed to disable scheduler:', err);
} }
} }
@@ -1083,14 +1250,15 @@ const WeatherSat = (function() {
/** /**
* Check current scheduler status * Check current scheduler status
*/ */
async function checkSchedulerStatus() { async function checkSchedulerStatus() {
try { try {
const response = await fetch('/weather-sat/schedule/status'); const response = await fetch('/weather-sat/schedule/status');
const data = await response.json(); if (!response.ok) return;
schedulerEnabled = data.enabled; const data = await response.json();
updateSchedulerUI(data); schedulerEnabled = data.enabled;
if (schedulerEnabled) startStream(); updateSchedulerUI(data);
} catch (err) { if (schedulerEnabled) startStream();
} catch (err) {
// Scheduler endpoint may not exist yet // Scheduler endpoint may not exist yet
} }
} }
+2 -1
View File
@@ -2866,7 +2866,8 @@
</div> </div>
<div class="wxsat-map-container"> <div class="wxsat-map-container">
<div class="wxsat-panel-header"> <div class="wxsat-panel-header">
<span class="wxsat-panel-title">Ground Track</span> <span class="wxsat-panel-title">Mercator Projection</span>
<span class="wxsat-panel-subtitle" id="wxsatMercatorInfo">--</span>
</div> </div>
<div id="wxsatGroundMap" class="wxsat-ground-map"></div> <div id="wxsatGroundMap" class="wxsat-ground-map"></div>
</div> </div>
+59 -5
View File
@@ -16,6 +16,7 @@ from utils.weather_sat_scheduler import (
WeatherSatScheduler, WeatherSatScheduler,
ScheduledPass, ScheduledPass,
get_weather_sat_scheduler, get_weather_sat_scheduler,
_parse_utc_iso,
) )
@@ -327,7 +328,7 @@ class TestWeatherSatScheduler:
assert len(passes) == 1 assert len(passes) == 1
assert passes[0]['id'] == 'NOAA-18_202401011200' assert passes[0]['id'] == 'NOAA-18_202401011200'
@patch('utils.weather_sat_scheduler.predict_passes') @patch('utils.weather_sat_predict.predict_passes')
@patch('threading.Timer') @patch('threading.Timer')
def test_refresh_passes(self, mock_timer, mock_predict): def test_refresh_passes(self, mock_timer, mock_predict):
"""_refresh_passes() should schedule future passes.""" """_refresh_passes() should schedule future passes."""
@@ -361,7 +362,7 @@ class TestWeatherSatScheduler:
assert scheduler._passes[0].satellite == 'NOAA-18' assert scheduler._passes[0].satellite == 'NOAA-18'
mock_timer_instance.start.assert_called() 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): def test_refresh_passes_skip_past(self, mock_predict):
"""_refresh_passes() should skip passes that already started.""" """_refresh_passes() should skip passes that already started."""
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -389,7 +390,42 @@ class TestWeatherSatScheduler:
# Should not schedule past passes # Should not schedule past passes
assert len(scheduler._passes) == 0 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): def test_refresh_passes_disabled(self, mock_predict):
"""_refresh_passes() should do nothing when disabled.""" """_refresh_passes() should do nothing when disabled."""
scheduler = WeatherSatScheduler() scheduler = WeatherSatScheduler()
@@ -399,7 +435,7 @@ class TestWeatherSatScheduler:
mock_predict.assert_not_called() 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): def test_refresh_passes_error_handling(self, mock_predict):
"""_refresh_passes() should handle prediction errors.""" """_refresh_passes() should handle prediction errors."""
mock_predict.side_effect = Exception('TLE error') mock_predict.side_effect = Exception('TLE error')
@@ -716,10 +752,28 @@ class TestSchedulerConfiguration:
assert WEATHER_SAT_CAPTURE_BUFFER_SECONDS >= 0 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: class TestSchedulerIntegration:
"""Integration tests for scheduler.""" """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('utils.weather_sat_scheduler.get_weather_sat_decoder')
@patch('threading.Timer') @patch('threading.Timer')
def test_full_scheduling_cycle(self, mock_timer, mock_get_decoder, mock_predict): def test_full_scheduling_cycle(self, mock_timer, mock_get_decoder, mock_predict):
+77 -57
View File
@@ -4,13 +4,12 @@ Automatically captures satellite passes based on predicted pass times.
Uses threading.Timer for scheduling — no external dependencies required. Uses threading.Timer for scheduling — no external dependencies required.
""" """
from __future__ import annotations from __future__ import annotations
import threading import threading
import time import uuid
import uuid from datetime import datetime, timezone, timedelta
from datetime import datetime, timezone, timedelta from typing import Any, Callable
from typing import Any, Callable
from utils.logging import get_logger from utils.logging import get_logger
from utils.weather_sat import get_weather_sat_decoder, WEATHER_SATELLITES, CaptureProgress from utils.weather_sat import get_weather_sat_decoder, WEATHER_SATELLITES, CaptureProgress
@@ -28,7 +27,7 @@ except ImportError:
WEATHER_SAT_CAPTURE_BUFFER_SECONDS = 30 WEATHER_SAT_CAPTURE_BUFFER_SECONDS = 30
class ScheduledPass: class ScheduledPass:
"""A pass scheduled for automatic capture.""" """A pass scheduled for automatic capture."""
def __init__(self, pass_data: dict[str, Any]): def __init__(self, pass_data: dict[str, Any]):
@@ -47,21 +46,13 @@ class ScheduledPass:
self._timer: threading.Timer | None = None self._timer: threading.Timer | None = None
self._stop_timer: threading.Timer | None = None self._stop_timer: threading.Timer | None = None
@property @property
def start_dt(self) -> datetime: def start_dt(self) -> datetime:
dt = datetime.fromisoformat(self.start_time) return _parse_utc_iso(self.start_time)
if dt.tzinfo is None:
# Naive datetime - assume UTC @property
dt = dt.replace(tzinfo=timezone.utc) def end_dt(self) -> datetime:
return dt.astimezone(timezone.utc) return _parse_utc_iso(self.end_time)
@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)
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
return { return {
@@ -80,7 +71,7 @@ class ScheduledPass:
} }
class WeatherSatScheduler: class WeatherSatScheduler:
"""Auto-scheduler for weather satellite captures.""" """Auto-scheduler for weather satellite captures."""
def __init__(self): def __init__(self):
@@ -209,10 +200,10 @@ class WeatherSatScheduler:
with self._lock: with self._lock:
return [p.to_dict() for p in self._passes] return [p.to_dict() for p in self._passes]
def _refresh_passes(self) -> None: def _refresh_passes(self) -> None:
"""Recompute passes and schedule timers.""" """Recompute passes and schedule timers."""
if not self._enabled: if not self._enabled:
return return
try: try:
from utils.weather_sat_predict import predict_passes from utils.weather_sat_predict import predict_passes
@@ -236,30 +227,39 @@ class WeatherSatScheduler:
p._stop_timer.cancel() p._stop_timer.cancel()
# Keep completed/skipped for history, replace scheduled # Keep completed/skipped for history, replace scheduled
history = [p for p in self._passes if p.status in ('complete', 'skipped', 'capturing')] history = [p for p in self._passes if p.status in ('complete', 'skipped', 'capturing')]
self._passes = history self._passes = history
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
buffer = WEATHER_SAT_CAPTURE_BUFFER_SECONDS buffer = WEATHER_SAT_CAPTURE_BUFFER_SECONDS
for pass_data in passes: for pass_data in passes:
sp = ScheduledPass(pass_data) try:
sp = ScheduledPass(pass_data)
# Skip passes that already started start_dt = sp.start_dt
if sp.start_dt - timedelta(seconds=buffer) <= now: end_dt = sp.end_dt
continue except Exception as e:
logger.warning(f"Skipping invalid pass data: {e}")
# Check if already in history continue
if any(h.id == sp.id for h in history):
continue capture_start = start_dt - timedelta(seconds=buffer)
capture_end = end_dt + timedelta(seconds=buffer)
# Schedule capture timer
delay = (sp.start_dt - timedelta(seconds=buffer) - now).total_seconds() # Skip passes that are already over
if delay > 0: if capture_end <= now:
sp._timer = threading.Timer(delay, self._execute_capture, args=[sp]) continue
sp._timer.daemon = True
sp._timer.start() # Check if already in history
self._passes.append(sp) 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( logger.info(
f"Scheduler refreshed: {sum(1 for p in self._passes if p.status == 'scheduled')} " 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: def _emit_event(self, event: dict[str, Any]) -> None:
"""Emit scheduler event to callback.""" """Emit scheduler event to callback."""
if self._event_callback: if self._event_callback:
try: try:
self._event_callback(event) self._event_callback(event)
except Exception as e: except Exception as e:
logger.error(f"Error in scheduler event callback: {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 # Singleton