"""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 uuid from datetime import datetime, timedelta, timezone from typing import Any, Callable from utils.logging import get_logger from utils.weather_sat import CaptureProgress, get_weather_sat_decoder logger = get_logger('intercept.weather_sat_scheduler') # Import config defaults try: from config import ( WEATHER_SAT_CAPTURE_BUFFER_SECONDS, WEATHER_SAT_SAMPLE_RATE, WEATHER_SAT_SCHEDULE_REFRESH_MINUTES, ) except ImportError: WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = 30 WEATHER_SAT_CAPTURE_BUFFER_SECONDS = 30 WEATHER_SAT_SAMPLE_RATE = 2400000 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 _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 { '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: 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')} " 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 owner = None get_status = getattr(app_module, 'get_sdr_device_status', None) if callable(get_status): try: owner = get_status().get(self._device) except Exception: owner = None if owner and owner != 'weather_sat': logger.debug( "Skipping SDR release for device %s owned by %s", self._device, owner, ) return app_module.release_sdr_device(self._device) except ImportError: pass decoder.set_on_complete(lambda: self._on_capture_complete(sp, _release_device)) success, _error_msg = decoder.start( satellite=sp.satellite, device_index=self._device, gain=self._gain, sample_rate=WEATHER_SAT_SAMPLE_RATE, 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}") 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 _scheduler: WeatherSatScheduler | None = None _scheduler_lock = threading.Lock() def get_weather_sat_scheduler() -> WeatherSatScheduler: """Get or create the global weather satellite scheduler instance.""" global _scheduler if _scheduler is None: with _scheduler_lock: if _scheduler is None: _scheduler = WeatherSatScheduler() return _scheduler