Files
intercept/utils/ground_station/iq_bus.py
James Smith 4607c358ed 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>
2026-03-18 17:36:55 +00:00

308 lines
10 KiB
Python

"""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,
)