mirror of
https://github.com/smittix/intercept.git
synced 2026-04-25 07:10:00 -07:00
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>
308 lines
10 KiB
Python
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,
|
|
)
|