mirror of
https://github.com/smittix/intercept.git
synced 2026-07-05 16:18:12 -07:00
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 <noreply@anthropic.com>
This commit is contained in:
+29
-10
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user