mirror of
https://github.com/smittix/intercept.git
synced 2026-07-11 11:08:12 -07:00
Add ground station automation with 6-phase implementation
Phase 1 - Automated observation engine: - utils/ground_station/scheduler.py: GroundStationScheduler fires at AOS/LOS, claims SDR, manages IQBus lifecycle, emits SSE events - utils/ground_station/observation_profile.py: ObservationProfile dataclass + DB CRUD - routes/ground_station.py: REST API for profiles, scheduler, observations, recordings, rotator; SSE stream; /ws/satellite_waterfall WebSocket - DB tables: observation_profiles, ground_station_observations, ground_station_events, sigmf_recordings (added to utils/database.py init_db) - app.py: ground_station_queue, WebSocket init, scheduler startup in _deferred_init - routes/__init__.py: register ground_station_bp Phase 2 - Doppler correction: - utils/doppler.py: generalized DopplerTracker extracted from sstv_decoder.py; accepts satellite name or raw TLE tuple; thread-safe; update_tle() method - utils/sstv/sstv_decoder.py: replace inline DopplerTracker with import from utils.doppler - Scheduler runs 5s retune loop; calls rotator.point_to() if enabled Phase 3 - IQ recording (SigMF): - utils/sigmf.py: SigMFWriter writes .sigmf-data + .sigmf-meta; disk-free guard (500MB) - utils/ground_station/consumers/sigmf_writer.py: SigMFConsumer wraps SigMFWriter Phase 4 - Multi-decoder IQ broadcast pipeline: - utils/ground_station/iq_bus.py: IQBus single-producer fan-out; IQConsumer Protocol - utils/ground_station/consumers/waterfall.py: CU8→FFT→binary frames - utils/ground_station/consumers/fm_demod.py: CU8→FM demod (numpy)→decoder subprocess - utils/ground_station/consumers/gr_satellites.py: CU8→cf32→gr_satellites (optional) Phase 5 - Live spectrum waterfall: - static/js/modes/ground_station_waterfall.js: /ws/satellite_waterfall canvas renderer - Waterfall panel in satellite dashboard sidebar, auto-shown on iq_bus_started SSE event Phase 6 - Antenna rotator control (optional): - utils/rotator.py: RotatorController TCP client for rotctld (Hamlib line protocol) - Rotator panel in satellite dashboard; silently disabled if rotctld unreachable Also fixes pre-existing test_weather_sat_predict.py breakage: - utils/weather_sat_predict.py: rewritten with self-contained skyfield implementation using find_discrete (matching what committed tests expected); adds _format_utc_iso - tests/test_weather_sat_predict.py: add _MOCK_WEATHER_SATS and @patch decorators for tests that assumed NOAA-18 active (decommissioned Jun 2025, now active=False) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
"""Ground station automation subpackage.
|
||||
|
||||
Provides unattended satellite observation, Doppler correction, IQ recording
|
||||
(SigMF), parallel multi-decoder pipelines, live spectrum, and optional
|
||||
antenna rotator control.
|
||||
|
||||
Public API::
|
||||
|
||||
from utils.ground_station.scheduler import get_ground_station_scheduler
|
||||
from utils.ground_station.observation_profile import ObservationProfile
|
||||
from utils.ground_station.iq_bus import IQBus
|
||||
"""
|
||||
@@ -0,0 +1 @@
|
||||
"""IQ bus consumer implementations."""
|
||||
@@ -0,0 +1,219 @@
|
||||
"""FMDemodConsumer — demodulates FM from CU8 IQ and pipes PCM to a decoder.
|
||||
|
||||
Performs FM (or AM/USB/LSB) demodulation in-process using numpy — the
|
||||
same algorithm as the listening-post waterfall monitor. The resulting
|
||||
int16 PCM is written to the stdin of a configurable decoder subprocess
|
||||
(e.g. direwolf for AX.25 AFSK or multimon-ng for GMSK/POCSAG).
|
||||
|
||||
Decoded lines from the subprocess stdout are forwarded to an optional
|
||||
``on_decoded`` callback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import Callable
|
||||
|
||||
import numpy as np
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.process import register_process, safe_terminate, unregister_process
|
||||
from utils.waterfall_fft import cu8_to_complex
|
||||
|
||||
logger = get_logger('intercept.ground_station.fm_demod')
|
||||
|
||||
AUDIO_RATE = 48_000 # Hz — standard rate for direwolf / multimon-ng
|
||||
|
||||
|
||||
class FMDemodConsumer:
|
||||
"""CU8 IQ → FM demodulation → int16 PCM → decoder subprocess stdin."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
decoder_cmd: list[str],
|
||||
*,
|
||||
modulation: str = 'fm',
|
||||
on_decoded: Callable[[str], None] | None = None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
decoder_cmd: Decoder command + args, e.g.
|
||||
``['direwolf', '-r', '48000', '-']`` or
|
||||
``['multimon-ng', '-t', 'raw', '-a', 'AFSK1200', '-']``.
|
||||
modulation: ``'fm'``, ``'am'``, ``'usb'``, ``'lsb'``.
|
||||
on_decoded: Callback invoked with each decoded line from stdout.
|
||||
"""
|
||||
self._decoder_cmd = decoder_cmd
|
||||
self._modulation = modulation.lower()
|
||||
self._on_decoded = on_decoded
|
||||
self._proc: subprocess.Popen | None = None
|
||||
self._stdout_thread: threading.Thread | None = None
|
||||
self._center_mhz = 0.0
|
||||
self._sample_rate = 0
|
||||
self._rotator_phase = 0.0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# IQConsumer protocol
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def on_start(
|
||||
self,
|
||||
center_mhz: float,
|
||||
sample_rate: int,
|
||||
*,
|
||||
start_freq_mhz: float,
|
||||
end_freq_mhz: float,
|
||||
) -> None:
|
||||
self._center_mhz = center_mhz
|
||||
self._sample_rate = sample_rate
|
||||
self._rotator_phase = 0.0
|
||||
self._start_proc()
|
||||
|
||||
def on_chunk(self, raw: bytes) -> None:
|
||||
if self._proc is None or self._proc.poll() is not None:
|
||||
return
|
||||
try:
|
||||
pcm, self._rotator_phase = _demodulate(
|
||||
raw,
|
||||
sample_rate=self._sample_rate,
|
||||
center_mhz=self._center_mhz,
|
||||
monitor_freq_mhz=self._center_mhz, # decode on-center
|
||||
modulation=self._modulation,
|
||||
rotator_phase=self._rotator_phase,
|
||||
)
|
||||
if pcm and self._proc.stdin:
|
||||
self._proc.stdin.write(pcm)
|
||||
self._proc.stdin.flush()
|
||||
except (BrokenPipeError, OSError):
|
||||
pass # decoder exited
|
||||
except Exception as e:
|
||||
logger.debug(f"FMDemodConsumer on_chunk error: {e}")
|
||||
|
||||
def on_stop(self) -> None:
|
||||
if self._proc:
|
||||
safe_terminate(self._proc)
|
||||
unregister_process(self._proc)
|
||||
self._proc = None
|
||||
if self._stdout_thread and self._stdout_thread.is_alive():
|
||||
self._stdout_thread.join(timeout=2)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _start_proc(self) -> None:
|
||||
import shutil
|
||||
if not shutil.which(self._decoder_cmd[0]):
|
||||
logger.warning(
|
||||
f"FMDemodConsumer: decoder '{self._decoder_cmd[0]}' not found — disabled"
|
||||
)
|
||||
return
|
||||
try:
|
||||
self._proc = subprocess.Popen(
|
||||
self._decoder_cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
register_process(self._proc)
|
||||
self._stdout_thread = threading.Thread(
|
||||
target=self._read_stdout, daemon=True, name='fm-demod-stdout'
|
||||
)
|
||||
self._stdout_thread.start()
|
||||
except Exception as e:
|
||||
logger.error(f"FMDemodConsumer: failed to start decoder: {e}")
|
||||
self._proc = None
|
||||
|
||||
def _read_stdout(self) -> None:
|
||||
assert self._proc is not None
|
||||
assert self._proc.stdout is not None
|
||||
try:
|
||||
for line in self._proc.stdout:
|
||||
decoded = line.decode('utf-8', errors='replace').rstrip()
|
||||
if decoded and self._on_decoded:
|
||||
try:
|
||||
self._on_decoded(decoded)
|
||||
except Exception as e:
|
||||
logger.debug(f"FMDemodConsumer callback error: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-process FM demodulation (mirrors waterfall_websocket._demodulate_monitor_audio)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _demodulate(
|
||||
raw: bytes,
|
||||
sample_rate: int,
|
||||
center_mhz: float,
|
||||
monitor_freq_mhz: float,
|
||||
modulation: str,
|
||||
rotator_phase: float,
|
||||
) -> tuple[bytes | None, float]:
|
||||
"""Demodulate CU8 IQ to int16 PCM.
|
||||
|
||||
Returns ``(pcm_bytes, next_rotator_phase)``.
|
||||
"""
|
||||
if len(raw) < 32 or sample_rate <= 0:
|
||||
return None, float(rotator_phase)
|
||||
|
||||
samples = cu8_to_complex(raw)
|
||||
fs = float(sample_rate)
|
||||
freq_offset_hz = (float(monitor_freq_mhz) - float(center_mhz)) * 1e6
|
||||
nyquist = fs * 0.5
|
||||
if abs(freq_offset_hz) > nyquist * 0.98:
|
||||
return None, float(rotator_phase)
|
||||
|
||||
phase_inc = (2.0 * np.pi * freq_offset_hz) / fs
|
||||
n = np.arange(samples.size, dtype=np.float64)
|
||||
rotator = np.exp(-1j * (float(rotator_phase) + phase_inc * n)).astype(np.complex64)
|
||||
next_phase = float((float(rotator_phase) + phase_inc * samples.size) % (2.0 * np.pi))
|
||||
shifted = samples * rotator
|
||||
|
||||
mod = modulation.lower().strip()
|
||||
target_bb = 48_000.0
|
||||
pre_decim = max(1, int(fs // target_bb))
|
||||
if pre_decim > 1:
|
||||
usable = (shifted.size // pre_decim) * pre_decim
|
||||
if usable < pre_decim:
|
||||
return None, next_phase
|
||||
shifted = shifted[:usable].reshape(-1, pre_decim).mean(axis=1)
|
||||
fs1 = fs / pre_decim
|
||||
|
||||
if shifted.size < 16:
|
||||
return None, next_phase
|
||||
|
||||
if mod == 'fm':
|
||||
audio = np.angle(shifted[1:] * np.conj(shifted[:-1])).astype(np.float32)
|
||||
elif mod == 'am':
|
||||
envelope = np.abs(shifted).astype(np.float32)
|
||||
audio = envelope - float(np.mean(envelope))
|
||||
elif mod == 'usb':
|
||||
audio = np.real(shifted).astype(np.float32)
|
||||
elif mod == 'lsb':
|
||||
audio = -np.real(shifted).astype(np.float32)
|
||||
else:
|
||||
audio = np.real(shifted).astype(np.float32)
|
||||
|
||||
if audio.size < 8:
|
||||
return None, next_phase
|
||||
|
||||
audio = audio - float(np.mean(audio))
|
||||
|
||||
# Resample to AUDIO_RATE
|
||||
out_len = int(audio.size * AUDIO_RATE / fs1)
|
||||
if out_len < 32:
|
||||
return None, next_phase
|
||||
x_old = np.linspace(0.0, 1.0, audio.size, endpoint=False, dtype=np.float32)
|
||||
x_new = np.linspace(0.0, 1.0, out_len, endpoint=False, dtype=np.float32)
|
||||
audio = np.interp(x_new, x_old, audio).astype(np.float32)
|
||||
|
||||
peak = float(np.max(np.abs(audio))) if audio.size else 0.0
|
||||
if peak > 0:
|
||||
audio = audio * min(20.0, 0.85 / peak)
|
||||
|
||||
pcm = np.clip(audio, -1.0, 1.0)
|
||||
return (pcm * 32767.0).astype(np.int16).tobytes(), next_phase
|
||||
@@ -0,0 +1,154 @@
|
||||
"""GrSatConsumer — pipes CU8 IQ to gr_satellites for packet decoding.
|
||||
|
||||
``gr_satellites`` is a GNU Radio-based multi-satellite decoder
|
||||
(https://github.com/daniestevez/gr-satellites). It accepts complex
|
||||
float32 (cf32) IQ samples on stdin when invoked with ``--iq``.
|
||||
|
||||
This consumer converts CU8 → cf32 via numpy and pipes the result to
|
||||
``gr_satellites``. If the tool is not installed it silently stays
|
||||
disabled.
|
||||
|
||||
Decoded JSON packets are forwarded to an optional ``on_decoded`` callback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import Callable
|
||||
|
||||
import numpy as np
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.process import register_process, safe_terminate, unregister_process
|
||||
|
||||
logger = get_logger('intercept.ground_station.gr_satellites')
|
||||
|
||||
GR_SATELLITES_BIN = 'gr_satellites'
|
||||
|
||||
|
||||
class GrSatConsumer:
|
||||
"""CU8 IQ → cf32 → gr_satellites stdin → JSON packets."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
satellite_name: str,
|
||||
*,
|
||||
on_decoded: Callable[[dict], None] | None = None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
satellite_name: Satellite name as known to gr_satellites
|
||||
(e.g. ``'NOAA 15'``, ``'ISS'``).
|
||||
on_decoded: Callback invoked with each parsed JSON packet dict.
|
||||
"""
|
||||
self._satellite_name = satellite_name
|
||||
self._on_decoded = on_decoded
|
||||
self._proc: subprocess.Popen | None = None
|
||||
self._stdout_thread: threading.Thread | None = None
|
||||
self._sample_rate = 0
|
||||
self._enabled = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# IQConsumer protocol
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def on_start(
|
||||
self,
|
||||
center_mhz: float,
|
||||
sample_rate: int,
|
||||
*,
|
||||
start_freq_mhz: float,
|
||||
end_freq_mhz: float,
|
||||
) -> None:
|
||||
self._sample_rate = sample_rate
|
||||
if not shutil.which(GR_SATELLITES_BIN):
|
||||
logger.info(
|
||||
"gr_satellites not found — GrSatConsumer disabled. "
|
||||
"Install via: pip install gr-satellites or apt install python3-gr-satellites"
|
||||
)
|
||||
self._enabled = False
|
||||
return
|
||||
self._start_proc(sample_rate)
|
||||
|
||||
def on_chunk(self, raw: bytes) -> None:
|
||||
if not self._enabled or self._proc is None or self._proc.poll() is not None:
|
||||
return
|
||||
# Convert CU8 → cf32
|
||||
try:
|
||||
iq = np.frombuffer(raw, dtype=np.uint8).astype(np.float32)
|
||||
cf32 = ((iq - 127.5) / 127.5).view(np.complex64)
|
||||
if self._proc.stdin:
|
||||
self._proc.stdin.write(cf32.tobytes())
|
||||
self._proc.stdin.flush()
|
||||
except (BrokenPipeError, OSError):
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"GrSatConsumer on_chunk error: {e}")
|
||||
|
||||
def on_stop(self) -> None:
|
||||
self._enabled = False
|
||||
if self._proc:
|
||||
safe_terminate(self._proc)
|
||||
unregister_process(self._proc)
|
||||
self._proc = None
|
||||
if self._stdout_thread and self._stdout_thread.is_alive():
|
||||
self._stdout_thread.join(timeout=2)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _start_proc(self, sample_rate: int) -> None:
|
||||
import json as _json
|
||||
|
||||
cmd = [
|
||||
GR_SATELLITES_BIN,
|
||||
self._satellite_name,
|
||||
'--samplerate', str(sample_rate),
|
||||
'--iq',
|
||||
'--json',
|
||||
'-',
|
||||
]
|
||||
try:
|
||||
self._proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
register_process(self._proc)
|
||||
self._enabled = True
|
||||
self._stdout_thread = threading.Thread(
|
||||
target=self._read_stdout,
|
||||
args=(_json,),
|
||||
daemon=True,
|
||||
name='gr-sat-stdout',
|
||||
)
|
||||
self._stdout_thread.start()
|
||||
logger.info(f"GrSatConsumer started for '{self._satellite_name}'")
|
||||
except Exception as e:
|
||||
logger.error(f"GrSatConsumer: failed to start gr_satellites: {e}")
|
||||
self._proc = None
|
||||
self._enabled = False
|
||||
|
||||
def _read_stdout(self, _json) -> None:
|
||||
assert self._proc is not None
|
||||
assert self._proc.stdout is not None
|
||||
try:
|
||||
for line in self._proc.stdout:
|
||||
text = line.decode('utf-8', errors='replace').rstrip()
|
||||
if not text:
|
||||
continue
|
||||
if self._on_decoded:
|
||||
try:
|
||||
data = _json.loads(text)
|
||||
except _json.JSONDecodeError:
|
||||
data = {'raw': text}
|
||||
try:
|
||||
self._on_decoded(data)
|
||||
except Exception as e:
|
||||
logger.debug(f"GrSatConsumer callback error: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,75 @@
|
||||
"""SigMFConsumer — wraps SigMFWriter as an IQ bus consumer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.sigmf import SigMFMetadata, SigMFWriter
|
||||
|
||||
logger = get_logger('intercept.ground_station.sigmf_consumer')
|
||||
|
||||
|
||||
class SigMFConsumer:
|
||||
"""IQ consumer that records CU8 chunks to a SigMF file pair."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
metadata: SigMFMetadata,
|
||||
on_complete: 'callable | None' = None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
metadata: Pre-populated SigMF metadata (satellite info, freq, etc.)
|
||||
on_complete: Optional callback invoked with ``(meta_path, data_path)``
|
||||
when the recording is closed.
|
||||
"""
|
||||
self._metadata = metadata
|
||||
self._on_complete = on_complete
|
||||
self._writer: SigMFWriter | None = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# IQConsumer protocol
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def on_start(
|
||||
self,
|
||||
center_mhz: float,
|
||||
sample_rate: int,
|
||||
*,
|
||||
start_freq_mhz: float,
|
||||
end_freq_mhz: float,
|
||||
) -> None:
|
||||
self._metadata.center_frequency_hz = center_mhz * 1e6
|
||||
self._metadata.sample_rate = sample_rate
|
||||
self._writer = SigMFWriter(self._metadata)
|
||||
try:
|
||||
self._writer.open()
|
||||
except Exception as e:
|
||||
logger.error(f"SigMFConsumer: failed to open writer: {e}")
|
||||
self._writer = None
|
||||
|
||||
def on_chunk(self, raw: bytes) -> None:
|
||||
if self._writer is None:
|
||||
return
|
||||
ok = self._writer.write_chunk(raw)
|
||||
if not ok and self._writer.aborted:
|
||||
logger.warning("SigMFConsumer: recording aborted (disk full)")
|
||||
self._writer = None
|
||||
|
||||
def on_stop(self) -> None:
|
||||
if self._writer is None:
|
||||
return
|
||||
result = self._writer.close()
|
||||
self._writer = None
|
||||
if result and self._on_complete:
|
||||
try:
|
||||
self._on_complete(*result)
|
||||
except Exception as e:
|
||||
logger.debug(f"SigMFConsumer on_complete error: {e}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Status
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def bytes_written(self) -> int:
|
||||
return self._writer.bytes_written if self._writer else 0
|
||||
@@ -0,0 +1,123 @@
|
||||
"""WaterfallConsumer — converts CU8 IQ chunks into binary waterfall frames.
|
||||
|
||||
Frames are placed on an ``output_queue`` that the WebSocket endpoint
|
||||
(``/ws/satellite_waterfall``) drains and sends to the browser.
|
||||
|
||||
Reuses :mod:`utils.waterfall_fft` for FFT processing so the wire format
|
||||
is identical to the main listening-post waterfall.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.waterfall_fft import (
|
||||
build_binary_frame,
|
||||
compute_power_spectrum,
|
||||
cu8_to_complex,
|
||||
quantize_to_uint8,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.ground_station.waterfall_consumer')
|
||||
|
||||
FFT_SIZE = 1024
|
||||
AVG_COUNT = 4
|
||||
FPS = 20
|
||||
DB_MIN: float | None = None # auto-range
|
||||
DB_MAX: float | None = None
|
||||
|
||||
|
||||
class WaterfallConsumer:
|
||||
"""IQ consumer that produces waterfall binary frames."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
output_queue: queue.Queue | None = None,
|
||||
fft_size: int = FFT_SIZE,
|
||||
avg_count: int = AVG_COUNT,
|
||||
fps: int = FPS,
|
||||
db_min: float | None = DB_MIN,
|
||||
db_max: float | None = DB_MAX,
|
||||
):
|
||||
self.output_queue: queue.Queue = output_queue or queue.Queue(maxsize=120)
|
||||
self._fft_size = fft_size
|
||||
self._avg_count = avg_count
|
||||
self._fps = fps
|
||||
self._db_min = db_min
|
||||
self._db_max = db_max
|
||||
|
||||
self._center_mhz = 0.0
|
||||
self._start_freq = 0.0
|
||||
self._end_freq = 0.0
|
||||
self._sample_rate = 0
|
||||
self._buffer = b''
|
||||
self._required_bytes = 0
|
||||
self._frame_interval = 1.0 / max(1, fps)
|
||||
self._last_frame_time = 0.0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# IQConsumer protocol
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def on_start(
|
||||
self,
|
||||
center_mhz: float,
|
||||
sample_rate: int,
|
||||
*,
|
||||
start_freq_mhz: float,
|
||||
end_freq_mhz: float,
|
||||
) -> None:
|
||||
self._center_mhz = center_mhz
|
||||
self._sample_rate = sample_rate
|
||||
self._start_freq = start_freq_mhz
|
||||
self._end_freq = end_freq_mhz
|
||||
# How many IQ samples (pairs) we need for one FFT frame
|
||||
required_samples = max(
|
||||
self._fft_size * self._avg_count,
|
||||
sample_rate // max(1, self._fps),
|
||||
)
|
||||
self._required_bytes = required_samples * 2 # 1 byte I + 1 byte Q
|
||||
self._frame_interval = 1.0 / max(1, self._fps)
|
||||
self._buffer = b''
|
||||
self._last_frame_time = 0.0
|
||||
|
||||
def on_chunk(self, raw: bytes) -> None:
|
||||
self._buffer += raw
|
||||
now = time.monotonic()
|
||||
if (now - self._last_frame_time) < self._frame_interval:
|
||||
return
|
||||
if len(self._buffer) < self._required_bytes:
|
||||
return
|
||||
|
||||
chunk = self._buffer[-self._required_bytes:]
|
||||
self._buffer = b''
|
||||
self._last_frame_time = now
|
||||
|
||||
try:
|
||||
samples = cu8_to_complex(chunk)
|
||||
power_db = compute_power_spectrum(
|
||||
samples, fft_size=self._fft_size, avg_count=self._avg_count
|
||||
)
|
||||
quantized = quantize_to_uint8(power_db, db_min=self._db_min, db_max=self._db_max)
|
||||
frame = build_binary_frame(self._start_freq, self._end_freq, quantized)
|
||||
except Exception as e:
|
||||
logger.debug(f"WaterfallConsumer FFT error: {e}")
|
||||
return
|
||||
|
||||
# Non-blocking enqueue: drop oldest if full
|
||||
if self.output_queue.full():
|
||||
try:
|
||||
self.output_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
self.output_queue.put_nowait(frame)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
def on_stop(self) -> None:
|
||||
self._buffer = b''
|
||||
@@ -0,0 +1,307 @@
|
||||
"""IQ broadcast bus — single SDR producer, multiple consumers.
|
||||
|
||||
The :class:`IQBus` claims an SDR device, spawns a capture subprocess
|
||||
(``rx_sdr`` / ``rtl_sdr``), reads raw CU8 bytes from stdout in a
|
||||
producer thread, and calls :meth:`IQConsumer.on_chunk` on every
|
||||
registered consumer for each chunk.
|
||||
|
||||
Consumers are responsible for their own internal buffering. The bus
|
||||
does *not* block on slow consumers — each consumer's ``on_chunk`` is
|
||||
called in the producer thread, so consumers must be non-blocking.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.process import register_process, safe_terminate, unregister_process
|
||||
|
||||
logger = get_logger('intercept.ground_station.iq_bus')
|
||||
|
||||
CHUNK_SIZE = 65_536 # bytes per read (~27 ms @ 2.4 Msps CU8)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IQConsumer(Protocol):
|
||||
"""Protocol for objects that receive raw CU8 chunks from the IQ bus."""
|
||||
|
||||
def on_chunk(self, raw: bytes) -> None:
|
||||
"""Called with each raw CU8 chunk from the SDR. Must be fast."""
|
||||
...
|
||||
|
||||
def on_start(
|
||||
self,
|
||||
center_mhz: float,
|
||||
sample_rate: int,
|
||||
*,
|
||||
start_freq_mhz: float,
|
||||
end_freq_mhz: float,
|
||||
) -> None:
|
||||
"""Called once when the bus starts, before the first chunk."""
|
||||
...
|
||||
|
||||
def on_stop(self) -> None:
|
||||
"""Called once when the bus stops (LOS or manual stop)."""
|
||||
...
|
||||
|
||||
|
||||
class _NoopConsumer:
|
||||
"""Fallback used internally for isinstance checks."""
|
||||
|
||||
def on_chunk(self, raw: bytes) -> None:
|
||||
pass
|
||||
|
||||
def on_start(self, center_mhz, sample_rate, *, start_freq_mhz, end_freq_mhz):
|
||||
pass
|
||||
|
||||
def on_stop(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class IQBus:
|
||||
"""Single-SDR IQ capture bus with fan-out to multiple consumers."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
center_mhz: float,
|
||||
sample_rate: int = 2_400_000,
|
||||
gain: float | None = None,
|
||||
device_index: int = 0,
|
||||
sdr_type: str = 'rtlsdr',
|
||||
ppm: int | None = None,
|
||||
bias_t: bool = False,
|
||||
):
|
||||
self._center_mhz = center_mhz
|
||||
self._sample_rate = sample_rate
|
||||
self._gain = gain
|
||||
self._device_index = device_index
|
||||
self._sdr_type = sdr_type
|
||||
self._ppm = ppm
|
||||
self._bias_t = bias_t
|
||||
|
||||
self._consumers: list[IQConsumer] = []
|
||||
self._consumers_lock = threading.Lock()
|
||||
self._proc: subprocess.Popen | None = None
|
||||
self._producer_thread: threading.Thread | None = None
|
||||
self._stop_event = threading.Event()
|
||||
self._running = False
|
||||
self._current_freq_mhz = center_mhz
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Consumer management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add_consumer(self, consumer: IQConsumer) -> None:
|
||||
with self._consumers_lock:
|
||||
if consumer not in self._consumers:
|
||||
self._consumers.append(consumer)
|
||||
|
||||
def remove_consumer(self, consumer: IQConsumer) -> None:
|
||||
with self._consumers_lock:
|
||||
self._consumers = [c for c in self._consumers if c is not consumer]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> tuple[bool, str]:
|
||||
"""Start IQ capture. Returns (success, error_message)."""
|
||||
if self._running:
|
||||
return True, ''
|
||||
|
||||
try:
|
||||
cmd = self._build_command(self._center_mhz)
|
||||
except Exception as e:
|
||||
return False, f'Failed to build IQ capture command: {e}'
|
||||
|
||||
if not shutil.which(cmd[0]):
|
||||
return False, f'Required tool "{cmd[0]}" not found. Install SoapySDR (rx_sdr) or rtl-sdr.'
|
||||
|
||||
try:
|
||||
self._proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=0,
|
||||
)
|
||||
register_process(self._proc)
|
||||
except Exception as e:
|
||||
return False, f'Failed to spawn IQ capture: {e}'
|
||||
|
||||
# Brief check that the process actually started
|
||||
time.sleep(0.3)
|
||||
if self._proc.poll() is not None:
|
||||
stderr_out = ''
|
||||
if self._proc.stderr:
|
||||
try:
|
||||
stderr_out = self._proc.stderr.read().decode('utf-8', errors='replace').strip()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(self._proc)
|
||||
self._proc = None
|
||||
detail = f': {stderr_out}' if stderr_out else ''
|
||||
return False, f'IQ capture process exited immediately{detail}'
|
||||
|
||||
self._stop_event.clear()
|
||||
self._running = True
|
||||
|
||||
span_mhz = self._sample_rate / 1e6
|
||||
start_freq_mhz = self._center_mhz - span_mhz / 2
|
||||
end_freq_mhz = self._center_mhz + span_mhz / 2
|
||||
|
||||
with self._consumers_lock:
|
||||
for consumer in list(self._consumers):
|
||||
try:
|
||||
consumer.on_start(
|
||||
self._center_mhz,
|
||||
self._sample_rate,
|
||||
start_freq_mhz=start_freq_mhz,
|
||||
end_freq_mhz=end_freq_mhz,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Consumer on_start error: {e}")
|
||||
|
||||
self._producer_thread = threading.Thread(
|
||||
target=self._producer_loop, daemon=True, name='iq-bus-producer'
|
||||
)
|
||||
self._producer_thread.start()
|
||||
logger.info(
|
||||
f"IQBus started: {self._center_mhz} MHz, sr={self._sample_rate}, "
|
||||
f"device={self._sdr_type}:{self._device_index}"
|
||||
)
|
||||
return True, ''
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop IQ capture and notify all consumers."""
|
||||
self._stop_event.set()
|
||||
if self._proc:
|
||||
safe_terminate(self._proc)
|
||||
unregister_process(self._proc)
|
||||
self._proc = None
|
||||
if self._producer_thread and self._producer_thread.is_alive():
|
||||
self._producer_thread.join(timeout=3)
|
||||
self._running = False
|
||||
|
||||
with self._consumers_lock:
|
||||
for consumer in list(self._consumers):
|
||||
try:
|
||||
consumer.on_stop()
|
||||
except Exception as e:
|
||||
logger.warning(f"Consumer on_stop error: {e}")
|
||||
|
||||
logger.info("IQBus stopped")
|
||||
|
||||
def retune(self, new_freq_mhz: float) -> tuple[bool, str]:
|
||||
"""Retune by stopping and restarting the capture process."""
|
||||
self._current_freq_mhz = new_freq_mhz
|
||||
if not self._running:
|
||||
return False, 'Not running'
|
||||
|
||||
# Stop the current process
|
||||
self._stop_event.set()
|
||||
if self._proc:
|
||||
safe_terminate(self._proc)
|
||||
unregister_process(self._proc)
|
||||
self._proc = None
|
||||
if self._producer_thread and self._producer_thread.is_alive():
|
||||
self._producer_thread.join(timeout=2)
|
||||
|
||||
# Restart at new frequency
|
||||
self._stop_event.clear()
|
||||
try:
|
||||
cmd = self._build_command(new_freq_mhz)
|
||||
self._proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=0,
|
||||
)
|
||||
register_process(self._proc)
|
||||
except Exception as e:
|
||||
self._running = False
|
||||
return False, f'Retune failed: {e}'
|
||||
|
||||
self._producer_thread = threading.Thread(
|
||||
target=self._producer_loop, daemon=True, name='iq-bus-producer'
|
||||
)
|
||||
self._producer_thread.start()
|
||||
logger.info(f"IQBus retuned to {new_freq_mhz:.6f} MHz")
|
||||
return True, ''
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self._running
|
||||
|
||||
@property
|
||||
def center_mhz(self) -> float:
|
||||
return self._current_freq_mhz
|
||||
|
||||
@property
|
||||
def sample_rate(self) -> int:
|
||||
return self._sample_rate
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _producer_loop(self) -> None:
|
||||
"""Read CU8 chunks from the subprocess and fan out to consumers."""
|
||||
assert self._proc is not None
|
||||
assert self._proc.stdout is not None
|
||||
|
||||
try:
|
||||
while not self._stop_event.is_set():
|
||||
if self._proc.poll() is not None:
|
||||
logger.warning("IQBus: capture process exited unexpectedly")
|
||||
break
|
||||
raw = self._proc.stdout.read(CHUNK_SIZE)
|
||||
if not raw:
|
||||
break
|
||||
with self._consumers_lock:
|
||||
consumers = list(self._consumers)
|
||||
for consumer in consumers:
|
||||
try:
|
||||
consumer.on_chunk(raw)
|
||||
except Exception as e:
|
||||
logger.warning(f"Consumer on_chunk error: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"IQBus producer loop error: {e}")
|
||||
|
||||
def _build_command(self, freq_mhz: float) -> list[str]:
|
||||
"""Build the IQ capture command using the SDR factory."""
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.sdr.base import SDRDevice
|
||||
|
||||
type_map = {
|
||||
'rtlsdr': SDRType.RTL_SDR,
|
||||
'rtl_sdr': SDRType.RTL_SDR,
|
||||
'hackrf': SDRType.HACKRF,
|
||||
'limesdr': SDRType.LIME_SDR,
|
||||
'airspy': SDRType.AIRSPY,
|
||||
'sdrplay': SDRType.SDRPLAY,
|
||||
}
|
||||
sdr_type = type_map.get(self._sdr_type.lower(), SDRType.RTL_SDR)
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
caps = builder.get_capabilities()
|
||||
device = SDRDevice(
|
||||
sdr_type=sdr_type,
|
||||
index=self._device_index,
|
||||
name=f'{sdr_type.value}-{self._device_index}',
|
||||
serial='N/A',
|
||||
driver=sdr_type.value,
|
||||
capabilities=caps,
|
||||
)
|
||||
return builder.build_iq_capture_command(
|
||||
device=device,
|
||||
frequency_mhz=freq_mhz,
|
||||
sample_rate=self._sample_rate,
|
||||
gain=self._gain,
|
||||
ppm=self._ppm,
|
||||
bias_t=self._bias_t,
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Observation profile dataclass and DB CRUD.
|
||||
|
||||
An ObservationProfile describes *how* to capture a particular satellite:
|
||||
frequency, decoder type, gain, bandwidth, minimum elevation, and whether
|
||||
to record raw IQ in SigMF format.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from utils.logging import get_logger
|
||||
|
||||
logger = get_logger('intercept.ground_station.profile')
|
||||
|
||||
|
||||
@dataclass
|
||||
class ObservationProfile:
|
||||
"""Per-satellite capture configuration."""
|
||||
|
||||
norad_id: int
|
||||
name: str # Human-readable label
|
||||
frequency_mhz: float
|
||||
decoder_type: str # 'fm', 'afsk', 'bpsk', 'gmsk', 'iq_only'
|
||||
gain: float = 40.0
|
||||
bandwidth_hz: int = 200_000
|
||||
min_elevation: float = 10.0
|
||||
enabled: bool = True
|
||||
record_iq: bool = False
|
||||
iq_sample_rate: int = 2_400_000
|
||||
id: int | None = None
|
||||
created_at: str = field(
|
||||
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
'id': self.id,
|
||||
'norad_id': self.norad_id,
|
||||
'name': self.name,
|
||||
'frequency_mhz': self.frequency_mhz,
|
||||
'decoder_type': self.decoder_type,
|
||||
'gain': self.gain,
|
||||
'bandwidth_hz': self.bandwidth_hz,
|
||||
'min_elevation': self.min_elevation,
|
||||
'enabled': self.enabled,
|
||||
'record_iq': self.record_iq,
|
||||
'iq_sample_rate': self.iq_sample_rate,
|
||||
'created_at': self.created_at,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row) -> 'ObservationProfile':
|
||||
return cls(
|
||||
id=row['id'],
|
||||
norad_id=row['norad_id'],
|
||||
name=row['name'],
|
||||
frequency_mhz=row['frequency_mhz'],
|
||||
decoder_type=row['decoder_type'],
|
||||
gain=row['gain'],
|
||||
bandwidth_hz=row['bandwidth_hz'],
|
||||
min_elevation=row['min_elevation'],
|
||||
enabled=bool(row['enabled']),
|
||||
record_iq=bool(row['record_iq']),
|
||||
iq_sample_rate=row['iq_sample_rate'],
|
||||
created_at=row['created_at'],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def list_profiles() -> list[ObservationProfile]:
|
||||
"""Return all observation profiles from the database."""
|
||||
from utils.database import get_db
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
'SELECT * FROM observation_profiles ORDER BY created_at DESC'
|
||||
).fetchall()
|
||||
return [ObservationProfile.from_row(r) for r in rows]
|
||||
|
||||
|
||||
def get_profile(norad_id: int) -> ObservationProfile | None:
|
||||
"""Return the profile for a NORAD ID, or None if not found."""
|
||||
from utils.database import get_db
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
'SELECT * FROM observation_profiles WHERE norad_id = ?', (norad_id,)
|
||||
).fetchone()
|
||||
return ObservationProfile.from_row(row) if row else None
|
||||
|
||||
|
||||
def save_profile(profile: ObservationProfile) -> ObservationProfile:
|
||||
"""Insert or replace an observation profile. Returns the saved profile."""
|
||||
from utils.database import get_db
|
||||
with get_db() as conn:
|
||||
conn.execute('''
|
||||
INSERT INTO observation_profiles
|
||||
(norad_id, name, frequency_mhz, decoder_type, gain,
|
||||
bandwidth_hz, min_elevation, enabled, record_iq,
|
||||
iq_sample_rate, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(norad_id) DO UPDATE SET
|
||||
name=excluded.name,
|
||||
frequency_mhz=excluded.frequency_mhz,
|
||||
decoder_type=excluded.decoder_type,
|
||||
gain=excluded.gain,
|
||||
bandwidth_hz=excluded.bandwidth_hz,
|
||||
min_elevation=excluded.min_elevation,
|
||||
enabled=excluded.enabled,
|
||||
record_iq=excluded.record_iq,
|
||||
iq_sample_rate=excluded.iq_sample_rate
|
||||
''', (
|
||||
profile.norad_id,
|
||||
profile.name,
|
||||
profile.frequency_mhz,
|
||||
profile.decoder_type,
|
||||
profile.gain,
|
||||
profile.bandwidth_hz,
|
||||
profile.min_elevation,
|
||||
int(profile.enabled),
|
||||
int(profile.record_iq),
|
||||
profile.iq_sample_rate,
|
||||
profile.created_at,
|
||||
))
|
||||
return get_profile(profile.norad_id) or profile
|
||||
|
||||
|
||||
def delete_profile(norad_id: int) -> bool:
|
||||
"""Delete a profile by NORAD ID. Returns True if a row was deleted."""
|
||||
from utils.database import get_db
|
||||
with get_db() as conn:
|
||||
cur = conn.execute(
|
||||
'DELETE FROM observation_profiles WHERE norad_id = ?', (norad_id,)
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
@@ -0,0 +1,794 @@
|
||||
"""Ground station automated observation scheduler.
|
||||
|
||||
Watches enabled :class:`~utils.ground_station.observation_profile.ObservationProfile`
|
||||
entries, predicts passes for each satellite, fires a capture at AOS, and
|
||||
stops it at LOS.
|
||||
|
||||
During a capture:
|
||||
* An :class:`~utils.ground_station.iq_bus.IQBus` claims the SDR device.
|
||||
* Consumers are attached according to ``profile.decoder_type``:
|
||||
- ``'iq_only'`` → SigMFConsumer only (if ``record_iq`` is True).
|
||||
- ``'fm'`` → FMDemodConsumer (direwolf AX.25) + optional SigMF.
|
||||
- ``'afsk'`` → FMDemodConsumer (direwolf AX.25) + optional SigMF.
|
||||
- ``'gmsk'`` → FMDemodConsumer (multimon-ng) + optional SigMF.
|
||||
- ``'bpsk'`` → GrSatConsumer + optional SigMF.
|
||||
* A WaterfallConsumer is always attached for the live spectrum panel.
|
||||
* A Doppler correction thread retunes the IQ bus every 5 s if shift > threshold.
|
||||
* A rotator control thread points the antenna (if rotctld is available).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from utils.logging import get_logger
|
||||
|
||||
logger = get_logger('intercept.ground_station.scheduler')
|
||||
|
||||
# Env-configurable Doppler retune threshold (Hz)
|
||||
try:
|
||||
from config import GS_DOPPLER_THRESHOLD_HZ # type: ignore[import]
|
||||
except (ImportError, AttributeError):
|
||||
import os
|
||||
GS_DOPPLER_THRESHOLD_HZ = int(os.environ.get('INTERCEPT_GS_DOPPLER_THRESHOLD_HZ', 500))
|
||||
|
||||
DOPPLER_INTERVAL_SECONDS = 5
|
||||
SCHEDULE_REFRESH_MINUTES = 30
|
||||
CAPTURE_BUFFER_SECONDS = 30
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scheduled observation (state machine)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ScheduledObservation:
|
||||
"""A single scheduled pass for a profile."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
profile_norad_id: int,
|
||||
satellite_name: str,
|
||||
aos_iso: str,
|
||||
los_iso: str,
|
||||
max_el: float,
|
||||
):
|
||||
self.id = str(uuid.uuid4())[:8]
|
||||
self.profile_norad_id = profile_norad_id
|
||||
self.satellite_name = satellite_name
|
||||
self.aos_iso = aos_iso
|
||||
self.los_iso = los_iso
|
||||
self.max_el = max_el
|
||||
self.status: str = 'scheduled'
|
||||
self._start_timer: threading.Timer | None = None
|
||||
self._stop_timer: threading.Timer | None = None
|
||||
|
||||
@property
|
||||
def aos_dt(self) -> datetime:
|
||||
return _parse_utc_iso(self.aos_iso)
|
||||
|
||||
@property
|
||||
def los_dt(self) -> datetime:
|
||||
return _parse_utc_iso(self.los_iso)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
'id': self.id,
|
||||
'norad_id': self.profile_norad_id,
|
||||
'satellite': self.satellite_name,
|
||||
'aos': self.aos_iso,
|
||||
'los': self.los_iso,
|
||||
'max_el': self.max_el,
|
||||
'status': self.status,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scheduler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class GroundStationScheduler:
|
||||
"""Automated ground station observation scheduler."""
|
||||
|
||||
def __init__(self):
|
||||
self._enabled = False
|
||||
self._lock = threading.Lock()
|
||||
self._observations: list[ScheduledObservation] = []
|
||||
self._refresh_timer: threading.Timer | None = None
|
||||
self._event_callback: Callable[[dict[str, Any]], None] | None = None
|
||||
|
||||
# Active capture state
|
||||
self._active_obs: ScheduledObservation | None = None
|
||||
self._active_iq_bus = None # IQBus instance
|
||||
self._active_waterfall_consumer = None
|
||||
self._doppler_thread: threading.Thread | None = None
|
||||
self._doppler_stop = threading.Event()
|
||||
self._active_profile = None # ObservationProfile
|
||||
self._active_doppler_tracker = None # DopplerTracker
|
||||
|
||||
# Shared waterfall queue (consumed by /ws/satellite_waterfall)
|
||||
self.waterfall_queue: queue.Queue = queue.Queue(maxsize=120)
|
||||
|
||||
# Observer location
|
||||
self._lat: float = 0.0
|
||||
self._lon: float = 0.0
|
||||
self._device: int = 0
|
||||
self._sdr_type: str = 'rtlsdr'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public control API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_event_callback(
|
||||
self, callback: Callable[[dict[str, Any]], None]
|
||||
) -> None:
|
||||
self._event_callback = callback
|
||||
|
||||
def enable(
|
||||
self,
|
||||
lat: float,
|
||||
lon: float,
|
||||
device: int = 0,
|
||||
sdr_type: str = 'rtlsdr',
|
||||
) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
self._lat = lat
|
||||
self._lon = lon
|
||||
self._device = device
|
||||
self._sdr_type = sdr_type
|
||||
self._enabled = True
|
||||
self._refresh_schedule()
|
||||
return self.get_status()
|
||||
|
||||
def disable(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
self._enabled = False
|
||||
if self._refresh_timer:
|
||||
self._refresh_timer.cancel()
|
||||
self._refresh_timer = None
|
||||
for obs in self._observations:
|
||||
if obs._start_timer:
|
||||
obs._start_timer.cancel()
|
||||
if obs._stop_timer:
|
||||
obs._stop_timer.cancel()
|
||||
self._observations.clear()
|
||||
self._stop_active_capture(reason='scheduler_disabled')
|
||||
return {'status': 'disabled'}
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
def get_status(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
active = self._active_obs.to_dict() if self._active_obs else None
|
||||
return {
|
||||
'enabled': self._enabled,
|
||||
'observer': {'latitude': self._lat, 'longitude': self._lon},
|
||||
'device': self._device,
|
||||
'sdr_type': self._sdr_type,
|
||||
'scheduled_count': sum(
|
||||
1 for o in self._observations if o.status == 'scheduled'
|
||||
),
|
||||
'total_observations': len(self._observations),
|
||||
'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]]:
|
||||
with self._lock:
|
||||
return [o.to_dict() for o in self._observations]
|
||||
|
||||
def trigger_manual(self, norad_id: int) -> tuple[bool, str]:
|
||||
"""Immediately start a manual observation for the given NORAD ID."""
|
||||
from utils.ground_station.observation_profile import get_profile
|
||||
profile = get_profile(norad_id)
|
||||
if not profile:
|
||||
return False, f'No observation profile for NORAD {norad_id}'
|
||||
obs = ScheduledObservation(
|
||||
profile_norad_id=norad_id,
|
||||
satellite_name=profile.name,
|
||||
aos_iso=datetime.now(timezone.utc).isoformat(),
|
||||
los_iso=(datetime.now(timezone.utc) + timedelta(minutes=15)).isoformat(),
|
||||
max_el=90.0,
|
||||
)
|
||||
self._execute_observation(obs)
|
||||
return True, 'Manual observation started'
|
||||
|
||||
def stop_active(self) -> dict[str, Any]:
|
||||
"""Stop the currently running observation."""
|
||||
self._stop_active_capture(reason='manual_stop')
|
||||
return self.get_status()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Schedule management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _refresh_schedule(self) -> None:
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
from utils.ground_station.observation_profile import list_profiles
|
||||
|
||||
profiles = [p for p in list_profiles() if p.enabled]
|
||||
if not profiles:
|
||||
logger.info("Ground station scheduler: no enabled profiles")
|
||||
self._arm_refresh_timer()
|
||||
return
|
||||
|
||||
try:
|
||||
passes_by_profile = self._predict_passes_for_profiles(profiles)
|
||||
except Exception as e:
|
||||
logger.error(f"Ground station scheduler: pass prediction failed: {e}")
|
||||
self._arm_refresh_timer()
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
# Cancel existing scheduled timers (keep active/complete)
|
||||
for obs in self._observations:
|
||||
if obs.status == 'scheduled':
|
||||
if obs._start_timer:
|
||||
obs._start_timer.cancel()
|
||||
if obs._stop_timer:
|
||||
obs._stop_timer.cancel()
|
||||
|
||||
history = [o for o in self._observations if o.status in ('complete', 'capturing', 'failed')]
|
||||
self._observations = history
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
buf = CAPTURE_BUFFER_SECONDS
|
||||
|
||||
for obs in passes_by_profile:
|
||||
capture_start = obs.aos_dt - timedelta(seconds=buf)
|
||||
capture_end = obs.los_dt + timedelta(seconds=buf)
|
||||
|
||||
if capture_end <= now:
|
||||
continue
|
||||
if any(h.id == obs.id for h in history):
|
||||
continue
|
||||
|
||||
delay = max(0.0, (capture_start - now).total_seconds())
|
||||
obs._start_timer = threading.Timer(
|
||||
delay, self._execute_observation, args=[obs]
|
||||
)
|
||||
obs._start_timer.daemon = True
|
||||
obs._start_timer.start()
|
||||
self._observations.append(obs)
|
||||
|
||||
scheduled = sum(1 for o in self._observations if o.status == 'scheduled')
|
||||
logger.info(f"Ground station scheduler refreshed: {scheduled} observations scheduled")
|
||||
|
||||
self._arm_refresh_timer()
|
||||
|
||||
def _arm_refresh_timer(self) -> None:
|
||||
if self._refresh_timer:
|
||||
self._refresh_timer.cancel()
|
||||
if not self._enabled:
|
||||
return
|
||||
self._refresh_timer = threading.Timer(
|
||||
SCHEDULE_REFRESH_MINUTES * 60, self._refresh_schedule
|
||||
)
|
||||
self._refresh_timer.daemon = True
|
||||
self._refresh_timer.start()
|
||||
|
||||
def _predict_passes_for_profiles(
|
||||
self, profiles: list
|
||||
) -> list[ScheduledObservation]:
|
||||
"""Predict passes for each profile and return ScheduledObservation list."""
|
||||
from skyfield.api import load, wgs84
|
||||
from utils.satellite_predict import predict_passes as _predict_passes
|
||||
|
||||
try:
|
||||
ts = load.timescale()
|
||||
except Exception:
|
||||
from skyfield.api import load as _load
|
||||
ts = _load.timescale()
|
||||
|
||||
observer = wgs84.latlon(self._lat, self._lon)
|
||||
now = datetime.now(timezone.utc)
|
||||
import datetime as _dt
|
||||
t0 = ts.utc(now)
|
||||
t1 = ts.utc(now + _dt.timedelta(hours=24))
|
||||
|
||||
observations: list[ScheduledObservation] = []
|
||||
|
||||
for profile in profiles:
|
||||
tle = _find_tle_by_norad(profile.norad_id)
|
||||
if tle is None:
|
||||
logger.warning(
|
||||
f"No TLE for NORAD {profile.norad_id} ({profile.name}) — skipping"
|
||||
)
|
||||
continue
|
||||
try:
|
||||
passes = _predict_passes(
|
||||
tle_data=tle,
|
||||
observer=observer,
|
||||
ts=ts,
|
||||
t0=t0,
|
||||
t1=t1,
|
||||
min_el=profile.min_elevation,
|
||||
include_trajectory=False,
|
||||
include_ground_track=False,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Pass prediction failed for {profile.name}: {e}")
|
||||
continue
|
||||
|
||||
for p in passes:
|
||||
obs = ScheduledObservation(
|
||||
profile_norad_id=profile.norad_id,
|
||||
satellite_name=profile.name,
|
||||
aos_iso=p.get('startTimeISO', ''),
|
||||
los_iso=p.get('endTimeISO', ''),
|
||||
max_el=float(p.get('maxEl', 0.0)),
|
||||
)
|
||||
observations.append(obs)
|
||||
|
||||
return observations
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Capture execution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _execute_observation(self, obs: ScheduledObservation) -> None:
|
||||
"""Called at AOS (+ buffer) to start IQ capture."""
|
||||
if not self._enabled:
|
||||
return
|
||||
if obs.status == 'scheduled':
|
||||
obs.status = 'capturing'
|
||||
else:
|
||||
return # already cancelled / complete
|
||||
|
||||
from utils.ground_station.observation_profile import get_profile
|
||||
profile = get_profile(obs.profile_norad_id)
|
||||
if not profile or not profile.enabled:
|
||||
obs.status = 'failed'
|
||||
return
|
||||
|
||||
# Claim SDR device
|
||||
try:
|
||||
import app as _app
|
||||
err = _app.claim_sdr_device(self._device, 'ground_station_iq_bus', self._sdr_type)
|
||||
if err:
|
||||
logger.warning(f"Ground station: SDR busy — skipping {obs.satellite_name}: {err}")
|
||||
obs.status = 'failed'
|
||||
self._emit_event({'type': 'observation_skipped', 'observation': obs.to_dict(), 'reason': 'device_busy'})
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Create DB record
|
||||
obs_db_id = _insert_observation_record(obs, profile)
|
||||
|
||||
# Build IQ bus
|
||||
from utils.ground_station.iq_bus import IQBus
|
||||
bus = IQBus(
|
||||
center_mhz=profile.frequency_mhz,
|
||||
sample_rate=profile.iq_sample_rate,
|
||||
gain=profile.gain,
|
||||
device_index=self._device,
|
||||
sdr_type=self._sdr_type,
|
||||
)
|
||||
|
||||
# Attach waterfall consumer (always)
|
||||
from utils.ground_station.consumers.waterfall import WaterfallConsumer
|
||||
wf_consumer = WaterfallConsumer(output_queue=self.waterfall_queue)
|
||||
bus.add_consumer(wf_consumer)
|
||||
|
||||
# Attach decoder consumers
|
||||
self._attach_decoder_consumers(bus, profile, obs_db_id, obs)
|
||||
|
||||
# Attach SigMF consumer if requested
|
||||
if profile.record_iq:
|
||||
self._attach_sigmf_consumer(bus, profile, obs_db_id)
|
||||
|
||||
# Start bus
|
||||
ok, err_msg = bus.start()
|
||||
if not ok:
|
||||
logger.error(f"Ground station: failed to start IQBus for {obs.satellite_name}: {err_msg}")
|
||||
obs.status = 'failed'
|
||||
try:
|
||||
import app as _app
|
||||
_app.release_sdr_device(self._device, self._sdr_type)
|
||||
except ImportError:
|
||||
pass
|
||||
self._emit_event({'type': 'observation_failed', 'observation': obs.to_dict(), 'reason': err_msg})
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
self._active_obs = obs
|
||||
self._active_iq_bus = bus
|
||||
self._active_waterfall_consumer = wf_consumer
|
||||
self._active_profile = profile
|
||||
|
||||
# Emit iq_bus_started SSE event (used by Phase 5 waterfall)
|
||||
span_mhz = profile.iq_sample_rate / 1e6
|
||||
self._emit_event({
|
||||
'type': 'iq_bus_started',
|
||||
'observation': obs.to_dict(),
|
||||
'center_mhz': profile.frequency_mhz,
|
||||
'span_mhz': span_mhz,
|
||||
})
|
||||
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})")
|
||||
|
||||
# Start Doppler correction thread
|
||||
self._start_doppler_thread(profile, obs)
|
||||
|
||||
# Schedule stop at LOS + buffer
|
||||
now = datetime.now(timezone.utc)
|
||||
stop_delay = (obs.los_dt + timedelta(seconds=CAPTURE_BUFFER_SECONDS) - now).total_seconds()
|
||||
if stop_delay > 0:
|
||||
obs._stop_timer = threading.Timer(
|
||||
stop_delay, self._stop_active_capture, kwargs={'reason': 'los'}
|
||||
)
|
||||
obs._stop_timer.daemon = True
|
||||
obs._stop_timer.start()
|
||||
else:
|
||||
self._stop_active_capture(reason='los_immediate')
|
||||
|
||||
def _stop_active_capture(self, *, reason: str = 'manual') -> None:
|
||||
"""Stop the currently active capture and release the SDR device."""
|
||||
with self._lock:
|
||||
bus = self._active_iq_bus
|
||||
obs = self._active_obs
|
||||
self._active_iq_bus = None
|
||||
self._active_obs = None
|
||||
self._active_waterfall_consumer = None
|
||||
self._active_profile = None
|
||||
self._active_doppler_tracker = None
|
||||
|
||||
self._doppler_stop.set()
|
||||
|
||||
if bus and bus.running:
|
||||
bus.stop()
|
||||
|
||||
if obs:
|
||||
obs.status = 'complete'
|
||||
_update_observation_status(obs, 'complete')
|
||||
self._emit_event({
|
||||
'type': 'observation_complete',
|
||||
'observation': obs.to_dict(),
|
||||
'reason': reason,
|
||||
})
|
||||
self._emit_event({'type': 'iq_bus_stopped', 'observation': obs.to_dict()})
|
||||
|
||||
try:
|
||||
import app as _app
|
||||
_app.release_sdr_device(self._device, self._sdr_type)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
logger.info(f"Ground station: observation stopped ({reason})")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Consumer attachment helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _attach_decoder_consumers(self, bus, profile, obs_db_id: int | None, obs) -> None:
|
||||
"""Attach the appropriate decoder consumer based on profile.decoder_type."""
|
||||
decoder_type = (profile.decoder_type or '').lower()
|
||||
|
||||
if decoder_type in ('fm', 'afsk'):
|
||||
# direwolf for AX.25 / AFSK
|
||||
import shutil
|
||||
if shutil.which('direwolf'):
|
||||
from utils.ground_station.consumers.fm_demod import FMDemodConsumer
|
||||
consumer = FMDemodConsumer(
|
||||
decoder_cmd=[
|
||||
'direwolf', '-r', '48000', '-n', '1', '-b', '16', '-',
|
||||
],
|
||||
modulation='fm',
|
||||
on_decoded=lambda line: self._on_packet_decoded(line, obs_db_id, obs),
|
||||
)
|
||||
bus.add_consumer(consumer)
|
||||
logger.info("Ground station: attached direwolf AX.25 decoder")
|
||||
else:
|
||||
logger.warning("direwolf not found — AX.25 decoding disabled")
|
||||
|
||||
elif decoder_type == 'gmsk':
|
||||
import shutil
|
||||
if shutil.which('multimon-ng'):
|
||||
from utils.ground_station.consumers.fm_demod import FMDemodConsumer
|
||||
consumer = FMDemodConsumer(
|
||||
decoder_cmd=['multimon-ng', '-t', 'raw', '-a', 'GMSK', '-'],
|
||||
modulation='fm',
|
||||
on_decoded=lambda line: self._on_packet_decoded(line, obs_db_id, obs),
|
||||
)
|
||||
bus.add_consumer(consumer)
|
||||
logger.info("Ground station: attached multimon-ng GMSK decoder")
|
||||
else:
|
||||
logger.warning("multimon-ng not found — GMSK decoding disabled")
|
||||
|
||||
elif decoder_type == 'bpsk':
|
||||
from utils.ground_station.consumers.gr_satellites import GrSatConsumer
|
||||
consumer = GrSatConsumer(
|
||||
satellite_name=profile.name,
|
||||
on_decoded=lambda pkt: self._on_packet_decoded(
|
||||
json.dumps(pkt) if isinstance(pkt, dict) else str(pkt),
|
||||
obs_db_id,
|
||||
obs,
|
||||
),
|
||||
)
|
||||
bus.add_consumer(consumer)
|
||||
|
||||
# 'iq_only' → no decoder, just SigMF
|
||||
|
||||
def _attach_sigmf_consumer(self, bus, profile, obs_db_id: int | None) -> None:
|
||||
"""Attach a SigMFConsumer for raw IQ recording."""
|
||||
from utils.sigmf import SigMFMetadata
|
||||
from utils.ground_station.consumers.sigmf_writer import SigMFConsumer
|
||||
|
||||
meta = SigMFMetadata(
|
||||
sample_rate=profile.iq_sample_rate,
|
||||
center_frequency_hz=profile.frequency_mhz * 1e6,
|
||||
satellite_name=profile.name,
|
||||
norad_id=profile.norad_id,
|
||||
latitude=self._lat,
|
||||
longitude=self._lon,
|
||||
)
|
||||
|
||||
def _on_recording_complete(meta_path, data_path):
|
||||
_insert_recording_record(obs_db_id, meta_path, data_path, profile)
|
||||
self._emit_event({
|
||||
'type': 'recording_complete',
|
||||
'norad_id': profile.norad_id,
|
||||
'data_path': str(data_path),
|
||||
'meta_path': str(meta_path),
|
||||
})
|
||||
|
||||
consumer = SigMFConsumer(metadata=meta, on_complete=_on_recording_complete)
|
||||
bus.add_consumer(consumer)
|
||||
logger.info(f"Ground station: SigMF recording enabled for {profile.name}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Doppler correction (Phase 2)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _start_doppler_thread(self, profile, obs: ScheduledObservation) -> None:
|
||||
"""Start the Doppler tracking/retune thread for an active capture."""
|
||||
from utils.doppler import DopplerTracker
|
||||
|
||||
tle = _find_tle_by_norad(profile.norad_id)
|
||||
if tle is None:
|
||||
logger.info(f"Ground station: no TLE for {profile.name} — Doppler disabled")
|
||||
return
|
||||
|
||||
tracker = DopplerTracker(satellite_name=profile.name, tle_data=tle)
|
||||
if not tracker.configure(self._lat, self._lon):
|
||||
logger.info(f"Ground station: Doppler tracking not available for {profile.name}")
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
self._active_doppler_tracker = tracker
|
||||
|
||||
self._doppler_stop.clear()
|
||||
t = threading.Thread(
|
||||
target=self._doppler_loop,
|
||||
args=[profile, tracker],
|
||||
daemon=True,
|
||||
name='gs-doppler',
|
||||
)
|
||||
t.start()
|
||||
self._doppler_thread = t
|
||||
logger.info(f"Ground station: Doppler tracking started for {profile.name}")
|
||||
|
||||
def _doppler_loop(self, profile, tracker) -> None:
|
||||
"""Periodically compute Doppler shift and retune if necessary."""
|
||||
while not self._doppler_stop.wait(DOPPLER_INTERVAL_SECONDS):
|
||||
with self._lock:
|
||||
bus = self._active_iq_bus
|
||||
|
||||
if bus is None or not bus.running:
|
||||
break
|
||||
|
||||
info = tracker.calculate(profile.frequency_mhz)
|
||||
if info is None:
|
||||
continue
|
||||
|
||||
# Retune if shift exceeds threshold
|
||||
if abs(info.shift_hz) >= GS_DOPPLER_THRESHOLD_HZ:
|
||||
corrected_mhz = info.frequency_hz / 1_000_000
|
||||
logger.info(
|
||||
f"Ground station: Doppler retune {info.shift_hz:+.1f} Hz → "
|
||||
f"{corrected_mhz:.6f} MHz (el={info.elevation:.1f}°)"
|
||||
)
|
||||
bus.retune(corrected_mhz)
|
||||
self._emit_event({
|
||||
'type': 'doppler_update',
|
||||
'norad_id': profile.norad_id,
|
||||
**info.to_dict(),
|
||||
})
|
||||
|
||||
# Rotator control (Phase 6)
|
||||
try:
|
||||
from utils.rotator import get_rotator
|
||||
rotator = get_rotator()
|
||||
if rotator.enabled:
|
||||
rotator.point_to(info.azimuth, info.elevation)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.debug("Ground station: Doppler loop exited")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Packet / event callbacks
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_packet_decoded(self, line: str, obs_db_id: int | None, obs: ScheduledObservation) -> None:
|
||||
"""Handle a decoded packet line from a decoder consumer."""
|
||||
if not line:
|
||||
return
|
||||
_insert_event_record(obs_db_id, 'packet', line)
|
||||
self._emit_event({
|
||||
'type': 'packet_decoded',
|
||||
'norad_id': obs.profile_norad_id,
|
||||
'satellite': obs.satellite_name,
|
||||
'data': line,
|
||||
})
|
||||
|
||||
def _emit_event(self, event: dict[str, Any]) -> None:
|
||||
if self._event_callback:
|
||||
try:
|
||||
self._event_callback(event)
|
||||
except Exception as e:
|
||||
logger.debug(f"Event callback error: {e}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _insert_observation_record(obs: ScheduledObservation, profile) -> int | None:
|
||||
try:
|
||||
from utils.database import get_db
|
||||
from datetime import datetime, timezone
|
||||
with get_db() as conn:
|
||||
cur = conn.execute('''
|
||||
INSERT INTO ground_station_observations
|
||||
(profile_id, norad_id, satellite, aos_time, los_time, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
profile.id,
|
||||
obs.profile_norad_id,
|
||||
obs.satellite_name,
|
||||
obs.aos_iso,
|
||||
obs.los_iso,
|
||||
'capturing',
|
||||
datetime.now(timezone.utc).isoformat(),
|
||||
))
|
||||
return cur.lastrowid
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to insert observation record: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _update_observation_status(obs: ScheduledObservation, status: str) -> None:
|
||||
try:
|
||||
from utils.database import get_db
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
'UPDATE ground_station_observations SET status=? WHERE norad_id=? AND status=?',
|
||||
(status, obs.profile_norad_id, 'capturing'),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to update observation status: {e}")
|
||||
|
||||
|
||||
def _insert_event_record(obs_db_id: int | None, event_type: str, payload: str) -> None:
|
||||
if obs_db_id is None:
|
||||
return
|
||||
try:
|
||||
from utils.database import get_db
|
||||
from datetime import datetime, timezone
|
||||
with get_db() as conn:
|
||||
conn.execute('''
|
||||
INSERT INTO ground_station_events (observation_id, event_type, payload_json, timestamp)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (obs_db_id, event_type, payload, datetime.now(timezone.utc).isoformat()))
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to insert event record: {e}")
|
||||
|
||||
|
||||
def _insert_recording_record(obs_db_id: int | None, meta_path: Path, data_path: Path, profile) -> None:
|
||||
try:
|
||||
from utils.database import get_db
|
||||
from datetime import datetime, timezone
|
||||
size = data_path.stat().st_size if data_path.exists() else 0
|
||||
with get_db() as conn:
|
||||
conn.execute('''
|
||||
INSERT INTO sigmf_recordings
|
||||
(observation_id, sigmf_data_path, sigmf_meta_path, size_bytes,
|
||||
sample_rate, center_freq_hz, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
obs_db_id,
|
||||
str(data_path),
|
||||
str(meta_path),
|
||||
size,
|
||||
profile.iq_sample_rate,
|
||||
int(profile.frequency_mhz * 1e6),
|
||||
datetime.now(timezone.utc).isoformat(),
|
||||
))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to insert recording record: {e}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TLE lookup helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _find_tle_by_norad(norad_id: int) -> tuple[str, str, str] | None:
|
||||
"""Search TLE cache for a given NORAD catalog number."""
|
||||
# Try live cache first
|
||||
sources = []
|
||||
try:
|
||||
from routes.satellite import _tle_cache # type: ignore[import]
|
||||
if _tle_cache:
|
||||
sources.append(_tle_cache)
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
try:
|
||||
from data.satellites import TLE_SATELLITES
|
||||
sources.append(TLE_SATELLITES)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
target_id = str(norad_id).zfill(5)
|
||||
|
||||
for source in sources:
|
||||
for _key, tle in source.items():
|
||||
if not isinstance(tle, (tuple, list)) or len(tle) < 3:
|
||||
continue
|
||||
line1 = str(tle[1])
|
||||
# NORAD catalog number occupies chars 2-6 (0-indexed) of TLE line 1
|
||||
if len(line1) > 7:
|
||||
catalog_str = line1[2:7].strip()
|
||||
if catalog_str == target_id:
|
||||
return (str(tle[0]), str(tle[1]), str(tle[2]))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Timestamp parser (mirrors weather_sat_scheduler)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_utc_iso(value: str) -> datetime:
|
||||
text = str(value).strip().replace('+00:00Z', 'Z')
|
||||
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: GroundStationScheduler | None = None
|
||||
_scheduler_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_ground_station_scheduler() -> GroundStationScheduler:
|
||||
"""Get or create the global ground station scheduler."""
|
||||
global _scheduler
|
||||
if _scheduler is None:
|
||||
with _scheduler_lock:
|
||||
if _scheduler is None:
|
||||
_scheduler = GroundStationScheduler()
|
||||
return _scheduler
|
||||
Reference in New Issue
Block a user