mirror of
https://github.com/smittix/intercept.git
synced 2026-07-12 03:28:11 -07:00
fix: point doppler and ground-station scheduler at unified TLE store
Both silently fell back to static bundled TLEs after the removal of routes.satellite._tle_cache. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+18
-24
@@ -14,7 +14,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
|
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
|
|
||||||
logger = get_logger('intercept.doppler')
|
logger = get_logger("intercept.doppler")
|
||||||
|
|
||||||
# Speed of light in m/s
|
# Speed of light in m/s
|
||||||
SPEED_OF_LIGHT = 299_792_458.0
|
SPEED_OF_LIGHT = 299_792_458.0
|
||||||
@@ -36,12 +36,12 @@ class DopplerInfo:
|
|||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
'frequency_hz': self.frequency_hz,
|
"frequency_hz": self.frequency_hz,
|
||||||
'shift_hz': round(self.shift_hz, 1),
|
"shift_hz": round(self.shift_hz, 1),
|
||||||
'range_rate_km_s': round(self.range_rate_km_s, 3),
|
"range_rate_km_s": round(self.range_rate_km_s, 3),
|
||||||
'elevation': round(self.elevation, 1),
|
"elevation": round(self.elevation, 1),
|
||||||
'azimuth': round(self.azimuth, 1),
|
"azimuth": round(self.azimuth, 1),
|
||||||
'timestamp': self.timestamp.isoformat(),
|
"timestamp": self.timestamp.isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ class DopplerTracker:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
satellite_name: str = 'ISS',
|
satellite_name: str = "ISS",
|
||||||
tle_data: tuple[str, str, str] | None = None,
|
tle_data: tuple[str, str, str] | None = None,
|
||||||
):
|
):
|
||||||
self._satellite_name = satellite_name
|
self._satellite_name = satellite_name
|
||||||
@@ -105,20 +105,13 @@ class DopplerTracker:
|
|||||||
self._observer_lon = longitude
|
self._observer_lon = longitude
|
||||||
self._enabled = True
|
self._enabled = True
|
||||||
|
|
||||||
logger.info(
|
logger.info(f"DopplerTracker configured for {self._satellite_name} at ({latitude}, {longitude})")
|
||||||
f"DopplerTracker configured for {self._satellite_name} "
|
|
||||||
f"at ({latitude}, {longitude})"
|
|
||||||
)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def update_tle(self, tle_data: tuple[str, str, str]) -> bool:
|
def update_tle(self, tle_data: tuple[str, str, str]) -> bool:
|
||||||
"""Update TLE data and re-configure if already enabled."""
|
"""Update TLE data and re-configure if already enabled."""
|
||||||
self._tle_data = tle_data
|
self._tle_data = tle_data
|
||||||
if (
|
if self._enabled and self._observer_lat is not None and self._observer_lon is not None:
|
||||||
self._enabled
|
|
||||||
and self._observer_lat is not None
|
|
||||||
and self._observer_lon is not None
|
|
||||||
):
|
|
||||||
return self.configure(self._observer_lat, self._observer_lon)
|
return self.configure(self._observer_lat, self._observer_lon)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -177,19 +170,20 @@ class DopplerTracker:
|
|||||||
if self._tle_data:
|
if self._tle_data:
|
||||||
return self._tle_data
|
return self._tle_data
|
||||||
|
|
||||||
# Try the live TLE cache maintained by routes/satellite.py
|
# Try the unified TLE store
|
||||||
try:
|
try:
|
||||||
from routes.satellite import _tle_cache # type: ignore[import]
|
from utils import tle_store
|
||||||
if _tle_cache:
|
|
||||||
tle = _tle_cache.get(self._satellite_name)
|
tle = tle_store.get_tle(self._satellite_name)
|
||||||
if tle:
|
if tle:
|
||||||
return tle
|
return tle
|
||||||
except (ImportError, AttributeError):
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Fall back to static bundled data
|
# Fall back to static bundled data
|
||||||
try:
|
try:
|
||||||
from data.satellites import TLE_SATELLITES
|
from data.satellites import TLE_SATELLITES
|
||||||
|
|
||||||
return TLE_SATELLITES.get(self._satellite_name)
|
return TLE_SATELLITES.get(self._satellite_name)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return None
|
return None
|
||||||
|
|||||||
+202
-169
@@ -29,14 +29,15 @@ from typing import Any, Callable
|
|||||||
|
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
|
|
||||||
logger = get_logger('intercept.ground_station.scheduler')
|
logger = get_logger("intercept.ground_station.scheduler")
|
||||||
|
|
||||||
# Env-configurable Doppler retune threshold (Hz)
|
# Env-configurable Doppler retune threshold (Hz)
|
||||||
try:
|
try:
|
||||||
from config import GS_DOPPLER_THRESHOLD_HZ # type: ignore[import]
|
from config import GS_DOPPLER_THRESHOLD_HZ # type: ignore[import]
|
||||||
except (ImportError, AttributeError):
|
except (ImportError, AttributeError):
|
||||||
import os
|
import os
|
||||||
GS_DOPPLER_THRESHOLD_HZ = int(os.environ.get('INTERCEPT_GS_DOPPLER_THRESHOLD_HZ', 500))
|
|
||||||
|
GS_DOPPLER_THRESHOLD_HZ = int(os.environ.get("INTERCEPT_GS_DOPPLER_THRESHOLD_HZ", 500))
|
||||||
|
|
||||||
DOPPLER_INTERVAL_SECONDS = 5
|
DOPPLER_INTERVAL_SECONDS = 5
|
||||||
SCHEDULE_REFRESH_MINUTES = 30
|
SCHEDULE_REFRESH_MINUTES = 30
|
||||||
@@ -47,6 +48,7 @@ CAPTURE_BUFFER_SECONDS = 30
|
|||||||
# Scheduled observation (state machine)
|
# Scheduled observation (state machine)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class ScheduledObservation:
|
class ScheduledObservation:
|
||||||
"""A single scheduled pass for a profile."""
|
"""A single scheduled pass for a profile."""
|
||||||
|
|
||||||
@@ -64,7 +66,7 @@ class ScheduledObservation:
|
|||||||
self.aos_iso = aos_iso
|
self.aos_iso = aos_iso
|
||||||
self.los_iso = los_iso
|
self.los_iso = los_iso
|
||||||
self.max_el = max_el
|
self.max_el = max_el
|
||||||
self.status: str = 'scheduled'
|
self.status: str = "scheduled"
|
||||||
self._start_timer: threading.Timer | None = None
|
self._start_timer: threading.Timer | None = None
|
||||||
self._stop_timer: threading.Timer | None = None
|
self._stop_timer: threading.Timer | None = None
|
||||||
|
|
||||||
@@ -78,13 +80,13 @@ class ScheduledObservation:
|
|||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
'id': self.id,
|
"id": self.id,
|
||||||
'norad_id': self.profile_norad_id,
|
"norad_id": self.profile_norad_id,
|
||||||
'satellite': self.satellite_name,
|
"satellite": self.satellite_name,
|
||||||
'aos': self.aos_iso,
|
"aos": self.aos_iso,
|
||||||
'los': self.los_iso,
|
"los": self.los_iso,
|
||||||
'max_el': self.max_el,
|
"max_el": self.max_el,
|
||||||
'status': self.status,
|
"status": self.status,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -92,6 +94,7 @@ class ScheduledObservation:
|
|||||||
# Scheduler
|
# Scheduler
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class GroundStationScheduler:
|
class GroundStationScheduler:
|
||||||
"""Automated ground station observation scheduler."""
|
"""Automated ground station observation scheduler."""
|
||||||
|
|
||||||
@@ -104,11 +107,11 @@ class GroundStationScheduler:
|
|||||||
|
|
||||||
# Active capture state
|
# Active capture state
|
||||||
self._active_obs: ScheduledObservation | None = None
|
self._active_obs: ScheduledObservation | None = None
|
||||||
self._active_iq_bus = None # IQBus instance
|
self._active_iq_bus = None # IQBus instance
|
||||||
self._active_waterfall_consumer = None
|
self._active_waterfall_consumer = None
|
||||||
self._doppler_thread: threading.Thread | None = None
|
self._doppler_thread: threading.Thread | None = None
|
||||||
self._doppler_stop = threading.Event()
|
self._doppler_stop = threading.Event()
|
||||||
self._active_profile = None # ObservationProfile
|
self._active_profile = None # ObservationProfile
|
||||||
self._active_doppler_tracker = None # DopplerTracker
|
self._active_doppler_tracker = None # DopplerTracker
|
||||||
|
|
||||||
# Shared waterfall queue (consumed by /ws/satellite_waterfall)
|
# Shared waterfall queue (consumed by /ws/satellite_waterfall)
|
||||||
@@ -118,15 +121,13 @@ class GroundStationScheduler:
|
|||||||
self._lat: float = 0.0
|
self._lat: float = 0.0
|
||||||
self._lon: float = 0.0
|
self._lon: float = 0.0
|
||||||
self._device: int = 0
|
self._device: int = 0
|
||||||
self._sdr_type: str = 'rtlsdr'
|
self._sdr_type: str = "rtlsdr"
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Public control API
|
# Public control API
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def set_event_callback(
|
def set_event_callback(self, callback: Callable[[dict[str, Any]], None]) -> None:
|
||||||
self, callback: Callable[[dict[str, Any]], None]
|
|
||||||
) -> None:
|
|
||||||
self._event_callback = callback
|
self._event_callback = callback
|
||||||
|
|
||||||
def enable(
|
def enable(
|
||||||
@@ -134,7 +135,7 @@ class GroundStationScheduler:
|
|||||||
lat: float,
|
lat: float,
|
||||||
lon: float,
|
lon: float,
|
||||||
device: int = 0,
|
device: int = 0,
|
||||||
sdr_type: str = 'rtlsdr',
|
sdr_type: str = "rtlsdr",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._lat = lat
|
self._lat = lat
|
||||||
@@ -157,8 +158,8 @@ class GroundStationScheduler:
|
|||||||
if obs._stop_timer:
|
if obs._stop_timer:
|
||||||
obs._stop_timer.cancel()
|
obs._stop_timer.cancel()
|
||||||
self._observations.clear()
|
self._observations.clear()
|
||||||
self._stop_active_capture(reason='scheduler_disabled')
|
self._stop_active_capture(reason="scheduler_disabled")
|
||||||
return {'status': 'disabled'}
|
return {"status": "disabled"}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def enabled(self) -> bool:
|
def enabled(self) -> bool:
|
||||||
@@ -168,17 +169,14 @@ class GroundStationScheduler:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
active = self._active_obs.to_dict() if self._active_obs else None
|
active = self._active_obs.to_dict() if self._active_obs else None
|
||||||
return {
|
return {
|
||||||
'enabled': self._enabled,
|
"enabled": self._enabled,
|
||||||
'observer': {'latitude': self._lat, 'longitude': self._lon},
|
"observer": {"latitude": self._lat, "longitude": self._lon},
|
||||||
'device': self._device,
|
"device": self._device,
|
||||||
'sdr_type': self._sdr_type,
|
"sdr_type": self._sdr_type,
|
||||||
'scheduled_count': sum(
|
"scheduled_count": sum(1 for o in self._observations if o.status == "scheduled"),
|
||||||
1 for o in self._observations if o.status == 'scheduled'
|
"total_observations": len(self._observations),
|
||||||
),
|
"active_observation": active,
|
||||||
'total_observations': len(self._observations),
|
"waterfall_active": self._active_iq_bus is not None and self._active_iq_bus.running,
|
||||||
'active_observation': active,
|
|
||||||
'waterfall_active': self._active_iq_bus is not None
|
|
||||||
and self._active_iq_bus.running,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_scheduled_observations(self) -> list[dict[str, Any]]:
|
def get_scheduled_observations(self) -> list[dict[str, Any]]:
|
||||||
@@ -188,9 +186,10 @@ class GroundStationScheduler:
|
|||||||
def trigger_manual(self, norad_id: int) -> tuple[bool, str]:
|
def trigger_manual(self, norad_id: int) -> tuple[bool, str]:
|
||||||
"""Immediately start a manual observation for the given NORAD ID."""
|
"""Immediately start a manual observation for the given NORAD ID."""
|
||||||
from utils.ground_station.observation_profile import get_profile
|
from utils.ground_station.observation_profile import get_profile
|
||||||
|
|
||||||
profile = get_profile(norad_id)
|
profile = get_profile(norad_id)
|
||||||
if not profile:
|
if not profile:
|
||||||
return False, f'No observation profile for NORAD {norad_id}'
|
return False, f"No observation profile for NORAD {norad_id}"
|
||||||
obs = ScheduledObservation(
|
obs = ScheduledObservation(
|
||||||
profile_norad_id=norad_id,
|
profile_norad_id=norad_id,
|
||||||
satellite_name=profile.name,
|
satellite_name=profile.name,
|
||||||
@@ -199,11 +198,11 @@ class GroundStationScheduler:
|
|||||||
max_el=90.0,
|
max_el=90.0,
|
||||||
)
|
)
|
||||||
self._execute_observation(obs)
|
self._execute_observation(obs)
|
||||||
return True, 'Manual observation started'
|
return True, "Manual observation started"
|
||||||
|
|
||||||
def stop_active(self) -> dict[str, Any]:
|
def stop_active(self) -> dict[str, Any]:
|
||||||
"""Stop the currently running observation."""
|
"""Stop the currently running observation."""
|
||||||
self._stop_active_capture(reason='manual_stop')
|
self._stop_active_capture(reason="manual_stop")
|
||||||
return self.get_status()
|
return self.get_status()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -232,13 +231,13 @@ class GroundStationScheduler:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
# Cancel existing scheduled timers (keep active/complete)
|
# Cancel existing scheduled timers (keep active/complete)
|
||||||
for obs in self._observations:
|
for obs in self._observations:
|
||||||
if obs.status == 'scheduled':
|
if obs.status == "scheduled":
|
||||||
if obs._start_timer:
|
if obs._start_timer:
|
||||||
obs._start_timer.cancel()
|
obs._start_timer.cancel()
|
||||||
if obs._stop_timer:
|
if obs._stop_timer:
|
||||||
obs._stop_timer.cancel()
|
obs._stop_timer.cancel()
|
||||||
|
|
||||||
history = [o for o in self._observations if o.status in ('complete', 'capturing', 'failed')]
|
history = [o for o in self._observations if o.status in ("complete", "capturing", "failed")]
|
||||||
self._observations = history
|
self._observations = history
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@@ -254,14 +253,12 @@ class GroundStationScheduler:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
delay = max(0.0, (capture_start - now).total_seconds())
|
delay = max(0.0, (capture_start - now).total_seconds())
|
||||||
obs._start_timer = threading.Timer(
|
obs._start_timer = threading.Timer(delay, self._execute_observation, args=[obs])
|
||||||
delay, self._execute_observation, args=[obs]
|
|
||||||
)
|
|
||||||
obs._start_timer.daemon = True
|
obs._start_timer.daemon = True
|
||||||
obs._start_timer.start()
|
obs._start_timer.start()
|
||||||
self._observations.append(obs)
|
self._observations.append(obs)
|
||||||
|
|
||||||
scheduled = sum(1 for o in self._observations if o.status == 'scheduled')
|
scheduled = sum(1 for o in self._observations if o.status == "scheduled")
|
||||||
logger.info(f"Ground station scheduler refreshed: {scheduled} observations scheduled")
|
logger.info(f"Ground station scheduler refreshed: {scheduled} observations scheduled")
|
||||||
|
|
||||||
self._arm_refresh_timer()
|
self._arm_refresh_timer()
|
||||||
@@ -271,15 +268,11 @@ class GroundStationScheduler:
|
|||||||
self._refresh_timer.cancel()
|
self._refresh_timer.cancel()
|
||||||
if not self._enabled:
|
if not self._enabled:
|
||||||
return
|
return
|
||||||
self._refresh_timer = threading.Timer(
|
self._refresh_timer = threading.Timer(SCHEDULE_REFRESH_MINUTES * 60, self._refresh_schedule)
|
||||||
SCHEDULE_REFRESH_MINUTES * 60, self._refresh_schedule
|
|
||||||
)
|
|
||||||
self._refresh_timer.daemon = True
|
self._refresh_timer.daemon = True
|
||||||
self._refresh_timer.start()
|
self._refresh_timer.start()
|
||||||
|
|
||||||
def _predict_passes_for_profiles(
|
def _predict_passes_for_profiles(self, profiles: list) -> list[ScheduledObservation]:
|
||||||
self, profiles: list
|
|
||||||
) -> list[ScheduledObservation]:
|
|
||||||
"""Predict passes for each profile and return ScheduledObservation list."""
|
"""Predict passes for each profile and return ScheduledObservation list."""
|
||||||
from skyfield.api import load, wgs84
|
from skyfield.api import load, wgs84
|
||||||
|
|
||||||
@@ -289,11 +282,13 @@ class GroundStationScheduler:
|
|||||||
ts = load.timescale(builtin=True)
|
ts = load.timescale(builtin=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
from skyfield.api import load as _load
|
from skyfield.api import load as _load
|
||||||
|
|
||||||
ts = _load.timescale(builtin=True)
|
ts = _load.timescale(builtin=True)
|
||||||
|
|
||||||
observer = wgs84.latlon(self._lat, self._lon)
|
observer = wgs84.latlon(self._lat, self._lon)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
import datetime as _dt
|
import datetime as _dt
|
||||||
|
|
||||||
t0 = ts.utc(now)
|
t0 = ts.utc(now)
|
||||||
t1 = ts.utc(now + _dt.timedelta(hours=24))
|
t1 = ts.utc(now + _dt.timedelta(hours=24))
|
||||||
|
|
||||||
@@ -302,9 +297,7 @@ class GroundStationScheduler:
|
|||||||
for profile in profiles:
|
for profile in profiles:
|
||||||
tle = _find_tle_by_norad(profile.norad_id)
|
tle = _find_tle_by_norad(profile.norad_id)
|
||||||
if tle is None:
|
if tle is None:
|
||||||
logger.warning(
|
logger.warning(f"No TLE for NORAD {profile.norad_id} ({profile.name}) — skipping")
|
||||||
f"No TLE for NORAD {profile.norad_id} ({profile.name}) — skipping"
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
passes = _predict_passes(
|
passes = _predict_passes(
|
||||||
@@ -325,9 +318,9 @@ class GroundStationScheduler:
|
|||||||
obs = ScheduledObservation(
|
obs = ScheduledObservation(
|
||||||
profile_norad_id=profile.norad_id,
|
profile_norad_id=profile.norad_id,
|
||||||
satellite_name=profile.name,
|
satellite_name=profile.name,
|
||||||
aos_iso=p.get('startTimeISO', ''),
|
aos_iso=p.get("startTimeISO", ""),
|
||||||
los_iso=p.get('endTimeISO', ''),
|
los_iso=p.get("endTimeISO", ""),
|
||||||
max_el=float(p.get('maxEl', 0.0)),
|
max_el=float(p.get("maxEl", 0.0)),
|
||||||
)
|
)
|
||||||
observations.append(obs)
|
observations.append(obs)
|
||||||
|
|
||||||
@@ -341,25 +334,27 @@ class GroundStationScheduler:
|
|||||||
"""Called at AOS (+ buffer) to start IQ capture."""
|
"""Called at AOS (+ buffer) to start IQ capture."""
|
||||||
if not self._enabled:
|
if not self._enabled:
|
||||||
return
|
return
|
||||||
if obs.status == 'scheduled':
|
if obs.status == "scheduled":
|
||||||
obs.status = 'capturing'
|
obs.status = "capturing"
|
||||||
else:
|
else:
|
||||||
return # already cancelled / complete
|
return # already cancelled / complete
|
||||||
|
|
||||||
from utils.ground_station.observation_profile import get_profile
|
from utils.ground_station.observation_profile import get_profile
|
||||||
|
|
||||||
profile = get_profile(obs.profile_norad_id)
|
profile = get_profile(obs.profile_norad_id)
|
||||||
if not profile or not profile.enabled:
|
if not profile or not profile.enabled:
|
||||||
obs.status = 'failed'
|
obs.status = "failed"
|
||||||
return
|
return
|
||||||
|
|
||||||
# Claim SDR device
|
# Claim SDR device
|
||||||
try:
|
try:
|
||||||
import app as _app
|
import app as _app
|
||||||
err = _app.claim_sdr_device(self._device, 'ground_station_iq_bus', self._sdr_type)
|
|
||||||
|
err = _app.claim_sdr_device(self._device, "ground_station_iq_bus", self._sdr_type)
|
||||||
if err:
|
if err:
|
||||||
logger.warning(f"Ground station: SDR busy — skipping {obs.satellite_name}: {err}")
|
logger.warning(f"Ground station: SDR busy — skipping {obs.satellite_name}: {err}")
|
||||||
obs.status = 'failed'
|
obs.status = "failed"
|
||||||
self._emit_event({'type': 'observation_skipped', 'observation': obs.to_dict(), 'reason': 'device_busy'})
|
self._emit_event({"type": "observation_skipped", "observation": obs.to_dict(), "reason": "device_busy"})
|
||||||
return
|
return
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
@@ -369,6 +364,7 @@ class GroundStationScheduler:
|
|||||||
|
|
||||||
# Build IQ bus
|
# Build IQ bus
|
||||||
from utils.ground_station.iq_bus import IQBus
|
from utils.ground_station.iq_bus import IQBus
|
||||||
|
|
||||||
bus = IQBus(
|
bus = IQBus(
|
||||||
center_mhz=profile.frequency_mhz,
|
center_mhz=profile.frequency_mhz,
|
||||||
sample_rate=profile.iq_sample_rate,
|
sample_rate=profile.iq_sample_rate,
|
||||||
@@ -379,6 +375,7 @@ class GroundStationScheduler:
|
|||||||
|
|
||||||
# Attach waterfall consumer (always)
|
# Attach waterfall consumer (always)
|
||||||
from utils.ground_station.consumers.waterfall import WaterfallConsumer
|
from utils.ground_station.consumers.waterfall import WaterfallConsumer
|
||||||
|
|
||||||
wf_consumer = WaterfallConsumer(output_queue=self.waterfall_queue)
|
wf_consumer = WaterfallConsumer(output_queue=self.waterfall_queue)
|
||||||
bus.add_consumer(wf_consumer)
|
bus.add_consumer(wf_consumer)
|
||||||
|
|
||||||
@@ -393,13 +390,14 @@ class GroundStationScheduler:
|
|||||||
ok, err_msg = bus.start()
|
ok, err_msg = bus.start()
|
||||||
if not ok:
|
if not ok:
|
||||||
logger.error(f"Ground station: failed to start IQBus for {obs.satellite_name}: {err_msg}")
|
logger.error(f"Ground station: failed to start IQBus for {obs.satellite_name}: {err_msg}")
|
||||||
obs.status = 'failed'
|
obs.status = "failed"
|
||||||
try:
|
try:
|
||||||
import app as _app
|
import app as _app
|
||||||
|
|
||||||
_app.release_sdr_device(self._device, self._sdr_type)
|
_app.release_sdr_device(self._device, self._sdr_type)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
self._emit_event({'type': 'observation_failed', 'observation': obs.to_dict(), 'reason': err_msg})
|
self._emit_event({"type": "observation_failed", "observation": obs.to_dict(), "reason": err_msg})
|
||||||
return
|
return
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -410,13 +408,15 @@ class GroundStationScheduler:
|
|||||||
|
|
||||||
# Emit iq_bus_started SSE event (used by Phase 5 waterfall)
|
# Emit iq_bus_started SSE event (used by Phase 5 waterfall)
|
||||||
span_mhz = profile.iq_sample_rate / 1e6
|
span_mhz = profile.iq_sample_rate / 1e6
|
||||||
self._emit_event({
|
self._emit_event(
|
||||||
'type': 'iq_bus_started',
|
{
|
||||||
'observation': obs.to_dict(),
|
"type": "iq_bus_started",
|
||||||
'center_mhz': profile.frequency_mhz,
|
"observation": obs.to_dict(),
|
||||||
'span_mhz': span_mhz,
|
"center_mhz": profile.frequency_mhz,
|
||||||
})
|
"span_mhz": span_mhz,
|
||||||
self._emit_event({'type': 'observation_started', 'observation': obs.to_dict()})
|
}
|
||||||
|
)
|
||||||
|
self._emit_event({"type": "observation_started", "observation": obs.to_dict()})
|
||||||
logger.info(f"Ground station: observation started for {obs.satellite_name} (NORAD {obs.profile_norad_id})")
|
logger.info(f"Ground station: observation started for {obs.satellite_name} (NORAD {obs.profile_norad_id})")
|
||||||
|
|
||||||
# Start Doppler correction thread
|
# Start Doppler correction thread
|
||||||
@@ -426,15 +426,13 @@ class GroundStationScheduler:
|
|||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
stop_delay = (obs.los_dt + timedelta(seconds=CAPTURE_BUFFER_SECONDS) - now).total_seconds()
|
stop_delay = (obs.los_dt + timedelta(seconds=CAPTURE_BUFFER_SECONDS) - now).total_seconds()
|
||||||
if stop_delay > 0:
|
if stop_delay > 0:
|
||||||
obs._stop_timer = threading.Timer(
|
obs._stop_timer = threading.Timer(stop_delay, self._stop_active_capture, kwargs={"reason": "los"})
|
||||||
stop_delay, self._stop_active_capture, kwargs={'reason': 'los'}
|
|
||||||
)
|
|
||||||
obs._stop_timer.daemon = True
|
obs._stop_timer.daemon = True
|
||||||
obs._stop_timer.start()
|
obs._stop_timer.start()
|
||||||
else:
|
else:
|
||||||
self._stop_active_capture(reason='los_immediate')
|
self._stop_active_capture(reason="los_immediate")
|
||||||
|
|
||||||
def _stop_active_capture(self, *, reason: str = 'manual') -> None:
|
def _stop_active_capture(self, *, reason: str = "manual") -> None:
|
||||||
"""Stop the currently active capture and release the SDR device."""
|
"""Stop the currently active capture and release the SDR device."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
bus = self._active_iq_bus
|
bus = self._active_iq_bus
|
||||||
@@ -451,17 +449,20 @@ class GroundStationScheduler:
|
|||||||
bus.stop()
|
bus.stop()
|
||||||
|
|
||||||
if obs:
|
if obs:
|
||||||
obs.status = 'complete'
|
obs.status = "complete"
|
||||||
_update_observation_status(obs, 'complete')
|
_update_observation_status(obs, "complete")
|
||||||
self._emit_event({
|
self._emit_event(
|
||||||
'type': 'observation_complete',
|
{
|
||||||
'observation': obs.to_dict(),
|
"type": "observation_complete",
|
||||||
'reason': reason,
|
"observation": obs.to_dict(),
|
||||||
})
|
"reason": reason,
|
||||||
self._emit_event({'type': 'iq_bus_stopped', 'observation': obs.to_dict()})
|
}
|
||||||
|
)
|
||||||
|
self._emit_event({"type": "iq_bus_stopped", "observation": obs.to_dict()})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import app as _app
|
import app as _app
|
||||||
|
|
||||||
_app.release_sdr_device(self._device, self._sdr_type)
|
_app.release_sdr_device(self._device, self._sdr_type)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
@@ -478,47 +479,53 @@ class GroundStationScheduler:
|
|||||||
|
|
||||||
tasks = _get_profile_tasks(profile)
|
tasks = _get_profile_tasks(profile)
|
||||||
|
|
||||||
if 'telemetry_ax25' in tasks:
|
if "telemetry_ax25" in tasks:
|
||||||
if shutil.which('direwolf'):
|
if shutil.which("direwolf"):
|
||||||
from utils.ground_station.consumers.fm_demod import FMDemodConsumer
|
from utils.ground_station.consumers.fm_demod import FMDemodConsumer
|
||||||
|
|
||||||
consumer = FMDemodConsumer(
|
consumer = FMDemodConsumer(
|
||||||
decoder_cmd=[
|
decoder_cmd=[
|
||||||
'direwolf', '-r', '48000', '-n', '1', '-b', '16', '-',
|
"direwolf",
|
||||||
|
"-r",
|
||||||
|
"48000",
|
||||||
|
"-n",
|
||||||
|
"1",
|
||||||
|
"-b",
|
||||||
|
"16",
|
||||||
|
"-",
|
||||||
],
|
],
|
||||||
modulation='fm',
|
modulation="fm",
|
||||||
on_decoded=lambda line: self._on_packet_decoded(
|
on_decoded=lambda line: self._on_packet_decoded(line, obs_db_id, obs, source="direwolf"),
|
||||||
line, obs_db_id, obs, source='direwolf'
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
bus.add_consumer(consumer)
|
bus.add_consumer(consumer)
|
||||||
logger.info("Ground station: attached direwolf AX.25 decoder")
|
logger.info("Ground station: attached direwolf AX.25 decoder")
|
||||||
else:
|
else:
|
||||||
logger.warning("direwolf not found — AX.25 decoding disabled")
|
logger.warning("direwolf not found — AX.25 decoding disabled")
|
||||||
|
|
||||||
if 'telemetry_gmsk' in tasks:
|
if "telemetry_gmsk" in tasks:
|
||||||
if shutil.which('multimon-ng'):
|
if shutil.which("multimon-ng"):
|
||||||
from utils.ground_station.consumers.fm_demod import FMDemodConsumer
|
from utils.ground_station.consumers.fm_demod import FMDemodConsumer
|
||||||
|
|
||||||
consumer = FMDemodConsumer(
|
consumer = FMDemodConsumer(
|
||||||
decoder_cmd=['multimon-ng', '-t', 'raw', '-a', 'GMSK', '-'],
|
decoder_cmd=["multimon-ng", "-t", "raw", "-a", "GMSK", "-"],
|
||||||
modulation='fm',
|
modulation="fm",
|
||||||
on_decoded=lambda line: self._on_packet_decoded(
|
on_decoded=lambda line: self._on_packet_decoded(line, obs_db_id, obs, source="multimon-ng"),
|
||||||
line, obs_db_id, obs, source='multimon-ng'
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
bus.add_consumer(consumer)
|
bus.add_consumer(consumer)
|
||||||
logger.info("Ground station: attached multimon-ng GMSK decoder")
|
logger.info("Ground station: attached multimon-ng GMSK decoder")
|
||||||
else:
|
else:
|
||||||
logger.warning("multimon-ng not found — GMSK decoding disabled")
|
logger.warning("multimon-ng not found — GMSK decoding disabled")
|
||||||
|
|
||||||
if 'telemetry_bpsk' in tasks:
|
if "telemetry_bpsk" in tasks:
|
||||||
from utils.ground_station.consumers.gr_satellites import GrSatConsumer
|
from utils.ground_station.consumers.gr_satellites import GrSatConsumer
|
||||||
|
|
||||||
consumer = GrSatConsumer(
|
consumer = GrSatConsumer(
|
||||||
satellite_name=profile.name,
|
satellite_name=profile.name,
|
||||||
on_decoded=lambda pkt: self._on_packet_decoded(
|
on_decoded=lambda pkt: self._on_packet_decoded(
|
||||||
pkt,
|
pkt,
|
||||||
obs_db_id,
|
obs_db_id,
|
||||||
obs,
|
obs,
|
||||||
source='gr_satellites',
|
source="gr_satellites",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
bus.add_consumer(consumer)
|
bus.add_consumer(consumer)
|
||||||
@@ -539,15 +546,18 @@ class GroundStationScheduler:
|
|||||||
|
|
||||||
def _on_recording_complete(meta_path, data_path):
|
def _on_recording_complete(meta_path, data_path):
|
||||||
_insert_recording_record(obs_db_id, meta_path, data_path, profile)
|
_insert_recording_record(obs_db_id, meta_path, data_path, profile)
|
||||||
self._emit_event({
|
self._emit_event(
|
||||||
'type': 'recording_complete',
|
{
|
||||||
'norad_id': profile.norad_id,
|
"type": "recording_complete",
|
||||||
'data_path': str(data_path),
|
"norad_id": profile.norad_id,
|
||||||
'meta_path': str(meta_path),
|
"data_path": str(data_path),
|
||||||
})
|
"meta_path": str(meta_path),
|
||||||
if 'weather_meteor_lrpt' in _get_profile_tasks(profile):
|
}
|
||||||
|
)
|
||||||
|
if "weather_meteor_lrpt" in _get_profile_tasks(profile):
|
||||||
try:
|
try:
|
||||||
from utils.ground_station.meteor_backend import launch_meteor_decode
|
from utils.ground_station.meteor_backend import launch_meteor_decode
|
||||||
|
|
||||||
launch_meteor_decode(
|
launch_meteor_decode(
|
||||||
obs_db_id=obs_db_id,
|
obs_db_id=obs_db_id,
|
||||||
norad_id=profile.norad_id,
|
norad_id=profile.norad_id,
|
||||||
@@ -559,13 +569,15 @@ class GroundStationScheduler:
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to launch Meteor decode backend: {e}")
|
logger.warning(f"Failed to launch Meteor decode backend: {e}")
|
||||||
self._emit_event({
|
self._emit_event(
|
||||||
'type': 'weather_decode_failed',
|
{
|
||||||
'norad_id': profile.norad_id,
|
"type": "weather_decode_failed",
|
||||||
'satellite': profile.name,
|
"norad_id": profile.norad_id,
|
||||||
'backend': 'meteor_lrpt',
|
"satellite": profile.name,
|
||||||
'message': str(e),
|
"backend": "meteor_lrpt",
|
||||||
})
|
"message": str(e),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
consumer = SigMFConsumer(metadata=meta, on_complete=_on_recording_complete)
|
consumer = SigMFConsumer(metadata=meta, on_complete=_on_recording_complete)
|
||||||
bus.add_consumer(consumer)
|
bus.add_consumer(consumer)
|
||||||
@@ -597,7 +609,7 @@ class GroundStationScheduler:
|
|||||||
target=self._doppler_loop,
|
target=self._doppler_loop,
|
||||||
args=[profile, tracker],
|
args=[profile, tracker],
|
||||||
daemon=True,
|
daemon=True,
|
||||||
name='gs-doppler',
|
name="gs-doppler",
|
||||||
)
|
)
|
||||||
t.start()
|
t.start()
|
||||||
self._doppler_thread = t
|
self._doppler_thread = t
|
||||||
@@ -624,15 +636,18 @@ class GroundStationScheduler:
|
|||||||
f"{corrected_mhz:.6f} MHz (el={info.elevation:.1f}°)"
|
f"{corrected_mhz:.6f} MHz (el={info.elevation:.1f}°)"
|
||||||
)
|
)
|
||||||
bus.retune(corrected_mhz)
|
bus.retune(corrected_mhz)
|
||||||
self._emit_event({
|
self._emit_event(
|
||||||
'type': 'doppler_update',
|
{
|
||||||
'norad_id': profile.norad_id,
|
"type": "doppler_update",
|
||||||
**info.to_dict(),
|
"norad_id": profile.norad_id,
|
||||||
})
|
**info.to_dict(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Rotator control (Phase 6)
|
# Rotator control (Phase 6)
|
||||||
try:
|
try:
|
||||||
from utils.rotator import get_rotator
|
from utils.rotator import get_rotator
|
||||||
|
|
||||||
rotator = get_rotator()
|
rotator = get_rotator()
|
||||||
if rotator.enabled:
|
if rotator.enabled:
|
||||||
rotator.point_to(info.azimuth, info.elevation)
|
rotator.point_to(info.azimuth, info.elevation)
|
||||||
@@ -651,20 +666,22 @@ class GroundStationScheduler:
|
|||||||
obs_db_id: int | None,
|
obs_db_id: int | None,
|
||||||
obs: ScheduledObservation,
|
obs: ScheduledObservation,
|
||||||
*,
|
*,
|
||||||
source: str = 'decoder',
|
source: str = "decoder",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle a decoded packet payload from a decoder consumer."""
|
"""Handle a decoded packet payload from a decoder consumer."""
|
||||||
if payload is None or payload == '':
|
if payload is None or payload == "":
|
||||||
return
|
return
|
||||||
|
|
||||||
packet_event = _build_packet_event(payload, source)
|
packet_event = _build_packet_event(payload, source)
|
||||||
_insert_event_record(obs_db_id, 'packet', json.dumps(packet_event))
|
_insert_event_record(obs_db_id, "packet", json.dumps(packet_event))
|
||||||
self._emit_event({
|
self._emit_event(
|
||||||
'type': 'packet_decoded',
|
{
|
||||||
'norad_id': obs.profile_norad_id,
|
"type": "packet_decoded",
|
||||||
'satellite': obs.satellite_name,
|
"norad_id": obs.profile_norad_id,
|
||||||
**packet_event,
|
"satellite": obs.satellite_name,
|
||||||
})
|
**packet_event,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
def _emit_event(self, event: dict[str, Any]) -> None:
|
def _emit_event(self, event: dict[str, Any]) -> None:
|
||||||
if self._event_callback:
|
if self._event_callback:
|
||||||
@@ -684,20 +701,24 @@ def _insert_observation_record(obs: ScheduledObservation, profile) -> int | None
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from utils.database import get_db
|
from utils.database import get_db
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = conn.execute('''
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
INSERT INTO ground_station_observations
|
INSERT INTO ground_station_observations
|
||||||
(profile_id, norad_id, satellite, aos_time, los_time, status, created_at)
|
(profile_id, norad_id, satellite, aos_time, los_time, status, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
''', (
|
""",
|
||||||
profile.id,
|
(
|
||||||
obs.profile_norad_id,
|
profile.id,
|
||||||
obs.satellite_name,
|
obs.profile_norad_id,
|
||||||
obs.aos_iso,
|
obs.satellite_name,
|
||||||
obs.los_iso,
|
obs.aos_iso,
|
||||||
'capturing',
|
obs.los_iso,
|
||||||
datetime.now(timezone.utc).isoformat(),
|
"capturing",
|
||||||
))
|
datetime.now(timezone.utc).isoformat(),
|
||||||
|
),
|
||||||
|
)
|
||||||
return cur.lastrowid
|
return cur.lastrowid
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to insert observation record: {e}")
|
logger.warning(f"Failed to insert observation record: {e}")
|
||||||
@@ -707,10 +728,11 @@ def _insert_observation_record(obs: ScheduledObservation, profile) -> int | None
|
|||||||
def _update_observation_status(obs: ScheduledObservation, status: str) -> None:
|
def _update_observation_status(obs: ScheduledObservation, status: str) -> None:
|
||||||
try:
|
try:
|
||||||
from utils.database import get_db
|
from utils.database import get_db
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
'UPDATE ground_station_observations SET status=? WHERE norad_id=? AND status=?',
|
"UPDATE ground_station_observations SET status=? WHERE norad_id=? AND status=?",
|
||||||
(status, obs.profile_norad_id, 'capturing'),
|
(status, obs.profile_norad_id, "capturing"),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Failed to update observation status: {e}")
|
logger.debug(f"Failed to update observation status: {e}")
|
||||||
@@ -723,17 +745,21 @@ def _insert_event_record(obs_db_id: int | None, event_type: str, payload: str) -
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from utils.database import get_db
|
from utils.database import get_db
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
conn.execute('''
|
conn.execute(
|
||||||
|
"""
|
||||||
INSERT INTO ground_station_events (observation_id, event_type, payload_json, timestamp)
|
INSERT INTO ground_station_events (observation_id, event_type, payload_json, timestamp)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
''', (obs_db_id, event_type, payload, datetime.now(timezone.utc).isoformat()))
|
""",
|
||||||
|
(obs_db_id, event_type, payload, datetime.now(timezone.utc).isoformat()),
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Failed to insert event record: {e}")
|
logger.debug(f"Failed to insert event record: {e}")
|
||||||
|
|
||||||
|
|
||||||
def _get_profile_tasks(profile) -> list[str]:
|
def _get_profile_tasks(profile) -> list[str]:
|
||||||
get_tasks = getattr(profile, 'get_tasks', None)
|
get_tasks = getattr(profile, "get_tasks", None)
|
||||||
if callable(get_tasks):
|
if callable(get_tasks):
|
||||||
return get_tasks()
|
return get_tasks()
|
||||||
return []
|
return []
|
||||||
@@ -741,26 +767,26 @@ def _get_profile_tasks(profile) -> list[str]:
|
|||||||
|
|
||||||
def _profile_requires_iq_recording(profile) -> bool:
|
def _profile_requires_iq_recording(profile) -> bool:
|
||||||
tasks = _get_profile_tasks(profile)
|
tasks = _get_profile_tasks(profile)
|
||||||
return bool(getattr(profile, 'record_iq', False) or 'record_iq' in tasks or 'weather_meteor_lrpt' in tasks)
|
return bool(getattr(profile, "record_iq", False) or "record_iq" in tasks or "weather_meteor_lrpt" in tasks)
|
||||||
|
|
||||||
|
|
||||||
def _build_packet_event(payload, source: str) -> dict[str, Any]:
|
def _build_packet_event(payload, source: str) -> dict[str, Any]:
|
||||||
event: dict[str, Any] = {
|
event: dict[str, Any] = {
|
||||||
'source': source,
|
"source": source,
|
||||||
'data': payload if isinstance(payload, str) else json.dumps(payload),
|
"data": payload if isinstance(payload, str) else json.dumps(payload),
|
||||||
'parsed': None,
|
"parsed": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
if isinstance(payload, dict):
|
if isinstance(payload, dict):
|
||||||
event['parsed'] = payload
|
event["parsed"] = payload
|
||||||
event['protocol'] = payload.get('protocol') or payload.get('type') or source
|
event["protocol"] = payload.get("protocol") or payload.get("type") or source
|
||||||
return event
|
return event
|
||||||
|
|
||||||
text = str(payload).strip()
|
text = str(payload).strip()
|
||||||
event['data'] = text
|
event["data"] = text
|
||||||
|
|
||||||
parsed = None
|
parsed = None
|
||||||
if source == 'gr_satellites':
|
if source == "gr_satellites":
|
||||||
try:
|
try:
|
||||||
candidate = json.loads(text)
|
candidate = json.loads(text)
|
||||||
if isinstance(candidate, dict):
|
if isinstance(candidate, dict):
|
||||||
@@ -774,7 +800,7 @@ def _build_packet_event(payload, source: str) -> dict[str, Any]:
|
|||||||
|
|
||||||
from utils.satellite_telemetry import auto_parse
|
from utils.satellite_telemetry import auto_parse
|
||||||
|
|
||||||
for token in text.replace(',', ' ').split():
|
for token in text.replace(",", " ").split():
|
||||||
cleaned = token.strip()
|
cleaned = token.strip()
|
||||||
if not cleaned or len(cleaned) < 8:
|
if not cleaned or len(cleaned) < 8:
|
||||||
continue
|
continue
|
||||||
@@ -789,9 +815,9 @@ def _build_packet_event(payload, source: str) -> dict[str, Any]:
|
|||||||
except Exception:
|
except Exception:
|
||||||
parsed = None
|
parsed = None
|
||||||
|
|
||||||
event['parsed'] = parsed
|
event["parsed"] = parsed
|
||||||
if isinstance(parsed, dict):
|
if isinstance(parsed, dict):
|
||||||
event['protocol'] = parsed.get('protocol') or source
|
event["protocol"] = parsed.get("protocol") or source
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
|
||||||
@@ -800,22 +826,26 @@ def _insert_recording_record(obs_db_id: int | None, meta_path: Path, data_path:
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from utils.database import get_db
|
from utils.database import get_db
|
||||||
|
|
||||||
size = data_path.stat().st_size if data_path.exists() else 0
|
size = data_path.stat().st_size if data_path.exists() else 0
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
conn.execute('''
|
conn.execute(
|
||||||
|
"""
|
||||||
INSERT INTO sigmf_recordings
|
INSERT INTO sigmf_recordings
|
||||||
(observation_id, sigmf_data_path, sigmf_meta_path, size_bytes,
|
(observation_id, sigmf_data_path, sigmf_meta_path, size_bytes,
|
||||||
sample_rate, center_freq_hz, created_at)
|
sample_rate, center_freq_hz, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
''', (
|
""",
|
||||||
obs_db_id,
|
(
|
||||||
str(data_path),
|
obs_db_id,
|
||||||
str(meta_path),
|
str(data_path),
|
||||||
size,
|
str(meta_path),
|
||||||
profile.iq_sample_rate,
|
size,
|
||||||
int(profile.frequency_mhz * 1e6),
|
profile.iq_sample_rate,
|
||||||
datetime.now(timezone.utc).isoformat(),
|
int(profile.frequency_mhz * 1e6),
|
||||||
))
|
datetime.now(timezone.utc).isoformat(),
|
||||||
|
),
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to insert recording record: {e}")
|
logger.warning(f"Failed to insert recording record: {e}")
|
||||||
|
|
||||||
@@ -837,12 +867,12 @@ def _insert_output_record(
|
|||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
'''
|
"""
|
||||||
INSERT INTO ground_station_outputs
|
INSERT INTO ground_station_outputs
|
||||||
(observation_id, norad_id, output_type, backend, file_path,
|
(observation_id, norad_id, output_type, backend, file_path,
|
||||||
preview_path, metadata_json, created_at)
|
preview_path, metadata_json, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
''',
|
""",
|
||||||
(
|
(
|
||||||
observation_id,
|
observation_id,
|
||||||
norad_id,
|
norad_id,
|
||||||
@@ -870,13 +900,16 @@ def _find_tle_by_norad(norad_id: int) -> tuple[str, str, str] | None:
|
|||||||
# Try live cache first
|
# Try live cache first
|
||||||
sources = []
|
sources = []
|
||||||
try:
|
try:
|
||||||
from routes.satellite import _tle_cache # type: ignore[import]
|
from utils import tle_store
|
||||||
if _tle_cache:
|
|
||||||
sources.append(_tle_cache)
|
live = tle_store.all_tles()
|
||||||
except (ImportError, AttributeError):
|
if live:
|
||||||
|
sources.append(live)
|
||||||
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
from data.satellites import TLE_SATELLITES
|
from data.satellites import TLE_SATELLITES
|
||||||
|
|
||||||
sources.append(TLE_SATELLITES)
|
sources.append(TLE_SATELLITES)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
@@ -903,9 +936,9 @@ def _find_tle_by_norad(norad_id: int) -> tuple[str, str, str] | None:
|
|||||||
|
|
||||||
|
|
||||||
def _parse_utc_iso(value: str) -> datetime:
|
def _parse_utc_iso(value: str) -> datetime:
|
||||||
text = str(value).strip().replace('+00:00Z', 'Z')
|
text = str(value).strip().replace("+00:00Z", "Z")
|
||||||
if text.endswith('Z'):
|
if text.endswith("Z"):
|
||||||
text = text[:-1] + '+00:00'
|
text = text[:-1] + "+00:00"
|
||||||
dt = datetime.fromisoformat(text)
|
dt = datetime.fromisoformat(text)
|
||||||
if dt.tzinfo is None:
|
if dt.tzinfo is None:
|
||||||
dt = dt.replace(tzinfo=timezone.utc)
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
|||||||
Reference in New Issue
Block a user