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:
James Smith
2026-03-18 17:36:55 +00:00
parent ed1461626b
commit 4607c358ed
21 changed files with 3709 additions and 181 deletions
+12
View File
@@ -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."""
+219
View File
@@ -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
+123
View File
@@ -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''
+307
View File
@@ -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,
)
+140
View File
@@ -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
+794
View File
@@ -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