mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
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:
@@ -25,6 +25,7 @@ def register_blueprints(app):
|
||||
from .meteor_websocket import meteor_bp
|
||||
from .morse import morse_bp
|
||||
from .offline import offline_bp
|
||||
from .ground_station import ground_station_bp
|
||||
from .ook import ook_bp
|
||||
from .pager import pager_bp
|
||||
from .radiosonde import radiosonde_bp
|
||||
@@ -89,6 +90,7 @@ def register_blueprints(app):
|
||||
app.register_blueprint(radiosonde_bp) # Radiosonde weather balloon tracking
|
||||
app.register_blueprint(system_bp) # System health monitoring
|
||||
app.register_blueprint(ook_bp) # Generic OOK signal decoder
|
||||
app.register_blueprint(ground_station_bp) # Ground station automation
|
||||
|
||||
# Exempt all API blueprints from CSRF (they use JSON, not form tokens)
|
||||
if _csrf:
|
||||
|
||||
415
routes/ground_station.py
Normal file
415
routes/ground_station.py
Normal file
@@ -0,0 +1,415 @@
|
||||
"""Ground Station REST API + SSE + WebSocket endpoints.
|
||||
|
||||
Phases implemented here:
|
||||
1 — Profile CRUD, scheduler control, observation history, SSE stream
|
||||
3 — SigMF recording browser (list / download / delete)
|
||||
5 — /ws/satellite_waterfall WebSocket
|
||||
6 — Rotator config / status / point / park endpoints
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request, send_file
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import sse_stream_fanout
|
||||
|
||||
logger = get_logger('intercept.ground_station.routes')
|
||||
|
||||
ground_station_bp = Blueprint('ground_station', __name__, url_prefix='/ground_station')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_scheduler():
|
||||
from utils.ground_station.scheduler import get_ground_station_scheduler
|
||||
return get_ground_station_scheduler()
|
||||
|
||||
|
||||
def _get_queue():
|
||||
import app as _app
|
||||
return getattr(_app, 'ground_station_queue', None) or queue.Queue()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1 — Observation Profiles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@ground_station_bp.route('/profiles', methods=['GET'])
|
||||
def list_profiles():
|
||||
from utils.ground_station.observation_profile import list_profiles as _list
|
||||
return jsonify([p.to_dict() for p in _list()])
|
||||
|
||||
|
||||
@ground_station_bp.route('/profiles/<int:norad_id>', methods=['GET'])
|
||||
def get_profile(norad_id: int):
|
||||
from utils.ground_station.observation_profile import get_profile as _get
|
||||
p = _get(norad_id)
|
||||
if not p:
|
||||
return jsonify({'error': f'No profile for NORAD {norad_id}'}), 404
|
||||
return jsonify(p.to_dict())
|
||||
|
||||
|
||||
@ground_station_bp.route('/profiles', methods=['POST'])
|
||||
def create_profile():
|
||||
data = request.get_json(force=True) or {}
|
||||
try:
|
||||
_validate_profile(data)
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
|
||||
from utils.ground_station.observation_profile import ObservationProfile, save_profile
|
||||
profile = ObservationProfile(
|
||||
norad_id=int(data['norad_id']),
|
||||
name=str(data['name']),
|
||||
frequency_mhz=float(data['frequency_mhz']),
|
||||
decoder_type=str(data.get('decoder_type', 'fm')),
|
||||
gain=float(data.get('gain', 40.0)),
|
||||
bandwidth_hz=int(data.get('bandwidth_hz', 200_000)),
|
||||
min_elevation=float(data.get('min_elevation', 10.0)),
|
||||
enabled=bool(data.get('enabled', True)),
|
||||
record_iq=bool(data.get('record_iq', False)),
|
||||
iq_sample_rate=int(data.get('iq_sample_rate', 2_400_000)),
|
||||
)
|
||||
saved = save_profile(profile)
|
||||
return jsonify(saved.to_dict()), 201
|
||||
|
||||
|
||||
@ground_station_bp.route('/profiles/<int:norad_id>', methods=['PUT'])
|
||||
def update_profile(norad_id: int):
|
||||
data = request.get_json(force=True) or {}
|
||||
from utils.ground_station.observation_profile import get_profile as _get, save_profile
|
||||
existing = _get(norad_id)
|
||||
if not existing:
|
||||
return jsonify({'error': f'No profile for NORAD {norad_id}'}), 404
|
||||
|
||||
# Apply updates
|
||||
for field, cast in [
|
||||
('name', str), ('frequency_mhz', float), ('decoder_type', str),
|
||||
('gain', float), ('bandwidth_hz', int), ('min_elevation', float),
|
||||
]:
|
||||
if field in data:
|
||||
setattr(existing, field, cast(data[field]))
|
||||
for field in ('enabled', 'record_iq'):
|
||||
if field in data:
|
||||
setattr(existing, field, bool(data[field]))
|
||||
if 'iq_sample_rate' in data:
|
||||
existing.iq_sample_rate = int(data['iq_sample_rate'])
|
||||
|
||||
saved = save_profile(existing)
|
||||
return jsonify(saved.to_dict())
|
||||
|
||||
|
||||
@ground_station_bp.route('/profiles/<int:norad_id>', methods=['DELETE'])
|
||||
def delete_profile(norad_id: int):
|
||||
from utils.ground_station.observation_profile import delete_profile as _del
|
||||
ok = _del(norad_id)
|
||||
if not ok:
|
||||
return jsonify({'error': f'No profile for NORAD {norad_id}'}), 404
|
||||
return jsonify({'status': 'deleted', 'norad_id': norad_id})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1 — Scheduler control
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@ground_station_bp.route('/scheduler/status', methods=['GET'])
|
||||
def scheduler_status():
|
||||
return jsonify(_get_scheduler().get_status())
|
||||
|
||||
|
||||
@ground_station_bp.route('/scheduler/enable', methods=['POST'])
|
||||
def scheduler_enable():
|
||||
data = request.get_json(force=True) or {}
|
||||
try:
|
||||
lat = float(data.get('lat', 0.0))
|
||||
lon = float(data.get('lon', 0.0))
|
||||
device = int(data.get('device', 0))
|
||||
sdr_type = str(data.get('sdr_type', 'rtlsdr'))
|
||||
except (TypeError, ValueError) as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
|
||||
status = _get_scheduler().enable(lat=lat, lon=lon, device=device, sdr_type=sdr_type)
|
||||
return jsonify(status)
|
||||
|
||||
|
||||
@ground_station_bp.route('/scheduler/disable', methods=['POST'])
|
||||
def scheduler_disable():
|
||||
return jsonify(_get_scheduler().disable())
|
||||
|
||||
|
||||
@ground_station_bp.route('/scheduler/observations', methods=['GET'])
|
||||
def get_observations():
|
||||
return jsonify(_get_scheduler().get_scheduled_observations())
|
||||
|
||||
|
||||
@ground_station_bp.route('/scheduler/trigger/<int:norad_id>', methods=['POST'])
|
||||
def trigger_manual(norad_id: int):
|
||||
ok, msg = _get_scheduler().trigger_manual(norad_id)
|
||||
if not ok:
|
||||
return jsonify({'error': msg}), 400
|
||||
return jsonify({'status': 'started', 'message': msg})
|
||||
|
||||
|
||||
@ground_station_bp.route('/scheduler/stop', methods=['POST'])
|
||||
def stop_active():
|
||||
return jsonify(_get_scheduler().stop_active())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1 — Observation history (from DB)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@ground_station_bp.route('/observations', methods=['GET'])
|
||||
def observation_history():
|
||||
limit = min(int(request.args.get('limit', 50)), 200)
|
||||
try:
|
||||
from utils.database import get_db
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
'''SELECT * FROM ground_station_observations
|
||||
ORDER BY created_at DESC LIMIT ?''',
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return jsonify([dict(r) for r in rows])
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch observation history: {e}")
|
||||
return jsonify([])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1 — SSE stream
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@ground_station_bp.route('/stream')
|
||||
def sse_stream():
|
||||
gs_queue = _get_queue()
|
||||
return Response(
|
||||
sse_stream_fanout(gs_queue, 'ground_station'),
|
||||
mimetype='text/event-stream',
|
||||
headers={
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3 — SigMF recording browser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@ground_station_bp.route('/recordings', methods=['GET'])
|
||||
def list_recordings():
|
||||
try:
|
||||
from utils.database import get_db
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
'SELECT * FROM sigmf_recordings ORDER BY created_at DESC LIMIT 100'
|
||||
).fetchall()
|
||||
return jsonify([dict(r) for r in rows])
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch recordings: {e}")
|
||||
return jsonify([])
|
||||
|
||||
|
||||
@ground_station_bp.route('/recordings/<int:rec_id>', methods=['GET'])
|
||||
def get_recording(rec_id: int):
|
||||
try:
|
||||
from utils.database import get_db
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
'SELECT * FROM sigmf_recordings WHERE id=?', (rec_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
return jsonify(dict(row))
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@ground_station_bp.route('/recordings/<int:rec_id>', methods=['DELETE'])
|
||||
def delete_recording(rec_id: int):
|
||||
try:
|
||||
from utils.database import get_db
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
'SELECT sigmf_data_path, sigmf_meta_path FROM sigmf_recordings WHERE id=?',
|
||||
(rec_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
# Remove files
|
||||
for path_col in ('sigmf_data_path', 'sigmf_meta_path'):
|
||||
p = Path(row[path_col])
|
||||
if p.exists():
|
||||
p.unlink(missing_ok=True)
|
||||
conn.execute('DELETE FROM sigmf_recordings WHERE id=?', (rec_id,))
|
||||
return jsonify({'status': 'deleted', 'id': rec_id})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@ground_station_bp.route('/recordings/<int:rec_id>/download/<file_type>')
|
||||
def download_recording(rec_id: int, file_type: str):
|
||||
if file_type not in ('data', 'meta'):
|
||||
return jsonify({'error': 'file_type must be data or meta'}), 400
|
||||
try:
|
||||
from utils.database import get_db
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
'SELECT sigmf_data_path, sigmf_meta_path FROM sigmf_recordings WHERE id=?',
|
||||
(rec_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
|
||||
col = 'sigmf_data_path' if file_type == 'data' else 'sigmf_meta_path'
|
||||
p = Path(row[col])
|
||||
if not p.exists():
|
||||
return jsonify({'error': 'File not found on disk'}), 404
|
||||
|
||||
mimetype = 'application/octet-stream' if file_type == 'data' else 'application/json'
|
||||
return send_file(p, mimetype=mimetype, as_attachment=True, download_name=p.name)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 5 — Live waterfall WebSocket
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def init_ground_station_websocket(app) -> None:
|
||||
"""Register the /ws/satellite_waterfall WebSocket endpoint."""
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
except ImportError:
|
||||
logger.warning("flask-sock not installed — satellite waterfall WebSocket disabled")
|
||||
return
|
||||
|
||||
sock = Sock(app)
|
||||
|
||||
@sock.route('/ws/satellite_waterfall')
|
||||
def satellite_waterfall_ws(ws):
|
||||
"""Stream binary waterfall frames from the active ground station IQ bus."""
|
||||
import app as _app_mod
|
||||
scheduler = _get_scheduler()
|
||||
wf_queue = scheduler.waterfall_queue
|
||||
|
||||
from utils.sse import subscribe_fanout_queue
|
||||
sub_queue, unsubscribe = subscribe_fanout_queue(
|
||||
source_queue=wf_queue,
|
||||
channel_key='gs_waterfall',
|
||||
subscriber_queue_size=120,
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
frame = sub_queue.get(timeout=1.0)
|
||||
try:
|
||||
ws.send(frame)
|
||||
except Exception:
|
||||
break
|
||||
except queue.Empty:
|
||||
if not ws.connected:
|
||||
break
|
||||
finally:
|
||||
unsubscribe()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 6 — Rotator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@ground_station_bp.route('/rotator/status', methods=['GET'])
|
||||
def rotator_status():
|
||||
from utils.rotator import get_rotator
|
||||
return jsonify(get_rotator().get_status())
|
||||
|
||||
|
||||
@ground_station_bp.route('/rotator/config', methods=['POST'])
|
||||
def rotator_config():
|
||||
data = request.get_json(force=True) or {}
|
||||
host = str(data.get('host', '127.0.0.1'))
|
||||
port = int(data.get('port', 4533))
|
||||
from utils.rotator import get_rotator
|
||||
ok = get_rotator().connect(host, port)
|
||||
if not ok:
|
||||
return jsonify({'error': f'Could not connect to rotctld at {host}:{port}'}), 503
|
||||
return jsonify(get_rotator().get_status())
|
||||
|
||||
|
||||
@ground_station_bp.route('/rotator/point', methods=['POST'])
|
||||
def rotator_point():
|
||||
data = request.get_json(force=True) or {}
|
||||
try:
|
||||
az = float(data['az'])
|
||||
el = float(data['el'])
|
||||
except (KeyError, TypeError, ValueError) as e:
|
||||
return jsonify({'error': f'az and el required: {e}'}), 400
|
||||
from utils.rotator import get_rotator
|
||||
ok = get_rotator().point_to(az, el)
|
||||
if not ok:
|
||||
return jsonify({'error': 'Rotator command failed'}), 503
|
||||
return jsonify({'status': 'ok', 'az': az, 'el': el})
|
||||
|
||||
|
||||
@ground_station_bp.route('/rotator/park', methods=['POST'])
|
||||
def rotator_park():
|
||||
from utils.rotator import get_rotator
|
||||
ok = get_rotator().park()
|
||||
if not ok:
|
||||
return jsonify({'error': 'Rotator park failed'}), 503
|
||||
return jsonify({'status': 'parked'})
|
||||
|
||||
|
||||
@ground_station_bp.route('/rotator/disconnect', methods=['POST'])
|
||||
def rotator_disconnect():
|
||||
from utils.rotator import get_rotator
|
||||
get_rotator().disconnect()
|
||||
return jsonify({'status': 'disconnected'})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _validate_profile(data: dict) -> None:
|
||||
if 'norad_id' not in data:
|
||||
raise ValueError("norad_id is required")
|
||||
if 'name' not in data:
|
||||
raise ValueError("name is required")
|
||||
if 'frequency_mhz' not in data:
|
||||
raise ValueError("frequency_mhz is required")
|
||||
try:
|
||||
norad_id = int(data['norad_id'])
|
||||
if norad_id <= 0:
|
||||
raise ValueError("norad_id must be positive")
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("norad_id must be a positive integer")
|
||||
try:
|
||||
freq = float(data['frequency_mhz'])
|
||||
if not (0.1 <= freq <= 3000.0):
|
||||
raise ValueError("frequency_mhz must be between 0.1 and 3000")
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("frequency_mhz must be a number between 0.1 and 3000")
|
||||
valid_decoders = {'fm', 'afsk', 'gmsk', 'bpsk', 'iq_only'}
|
||||
dt = str(data.get('decoder_type', 'fm'))
|
||||
if dt not in valid_decoders:
|
||||
raise ValueError(f"decoder_type must be one of: {', '.join(sorted(valid_decoders))}")
|
||||
Reference in New Issue
Block a user