mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 22:59:59 -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>
124 lines
3.7 KiB
Python
124 lines
3.7 KiB
Python
"""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''
|