Files
intercept/utils/ground_station/consumers/sigmf_writer.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

76 lines
2.4 KiB
Python

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