mirror of
https://github.com/smittix/intercept.git
synced 2026-07-07 09:08:12 -07:00
96172ca593
First-time run of ruff-format via pre-commit hook normalises quote style, trailing commas, and whitespace across 188 Python files. No logic changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
590 lines
19 KiB
Python
590 lines
19 KiB
Python
"""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
|
|
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,
|
|
legacy_decoder_to_tasks,
|
|
normalize_tasks,
|
|
save_profile,
|
|
tasks_to_legacy_decoder,
|
|
)
|
|
|
|
tasks = normalize_tasks(data.get("tasks"))
|
|
if not tasks:
|
|
tasks = legacy_decoder_to_tasks(
|
|
str(data.get("decoder_type", "fm")),
|
|
bool(data.get("record_iq", False)),
|
|
)
|
|
profile = ObservationProfile(
|
|
norad_id=int(data["norad_id"]),
|
|
name=str(data["name"]),
|
|
frequency_mhz=float(data["frequency_mhz"]),
|
|
decoder_type=tasks_to_legacy_decoder(tasks),
|
|
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)) or ("record_iq" in tasks),
|
|
iq_sample_rate=int(data.get("iq_sample_rate", 2_400_000)),
|
|
tasks=tasks,
|
|
)
|
|
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,
|
|
)
|
|
from utils.ground_station.observation_profile import (
|
|
legacy_decoder_to_tasks,
|
|
normalize_tasks,
|
|
save_profile,
|
|
tasks_to_legacy_decoder,
|
|
)
|
|
|
|
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"])
|
|
if "tasks" in data:
|
|
existing.tasks = normalize_tasks(data["tasks"])
|
|
elif "decoder_type" in data:
|
|
existing.tasks = legacy_decoder_to_tasks(
|
|
str(data.get("decoder_type", existing.decoder_type)),
|
|
bool(data.get("record_iq", existing.record_iq)),
|
|
)
|
|
|
|
existing.decoder_type = tasks_to_legacy_decoder(existing.tasks)
|
|
existing.record_iq = bool(existing.record_iq) or ("record_iq" in existing.tasks)
|
|
|
|
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
|
|
|
|
|
|
@ground_station_bp.route("/outputs", methods=["GET"])
|
|
def list_outputs():
|
|
try:
|
|
query = """
|
|
SELECT * FROM ground_station_outputs
|
|
WHERE (? IS NULL OR norad_id = ?)
|
|
AND (? IS NULL OR observation_id = ?)
|
|
AND (? IS NULL OR output_type = ?)
|
|
ORDER BY created_at DESC
|
|
LIMIT 200
|
|
"""
|
|
norad_id = request.args.get("norad_id", type=int)
|
|
observation_id = request.args.get("observation_id", type=int)
|
|
output_type = request.args.get("type")
|
|
|
|
from utils.database import get_db
|
|
|
|
with get_db() as conn:
|
|
rows = conn.execute(
|
|
query,
|
|
(
|
|
norad_id,
|
|
norad_id,
|
|
observation_id,
|
|
observation_id,
|
|
output_type,
|
|
output_type,
|
|
),
|
|
).fetchall()
|
|
|
|
results = []
|
|
for row in rows:
|
|
item = dict(row)
|
|
metadata_raw = item.get("metadata_json")
|
|
if metadata_raw:
|
|
try:
|
|
item["metadata"] = json.loads(metadata_raw)
|
|
except json.JSONDecodeError:
|
|
item["metadata"] = {}
|
|
else:
|
|
item["metadata"] = {}
|
|
item.pop("metadata_json", None)
|
|
results.append(item)
|
|
return jsonify(results)
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@ground_station_bp.route("/outputs/<int:output_id>/download", methods=["GET"])
|
|
def download_output(output_id: int):
|
|
try:
|
|
from utils.database import get_db
|
|
|
|
with get_db() as conn:
|
|
row = conn.execute(
|
|
"SELECT file_path FROM ground_station_outputs WHERE id=?",
|
|
(output_id,),
|
|
).fetchone()
|
|
if not row:
|
|
return jsonify({"error": "Not found"}), 404
|
|
p = Path(row["file_path"])
|
|
if not p.exists():
|
|
return jsonify({"error": "File not found on disk"}), 404
|
|
return send_file(p, as_attachment=True, download_name=p.name)
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@ground_station_bp.route("/decode-jobs", methods=["GET"])
|
|
def list_decode_jobs():
|
|
try:
|
|
query = """
|
|
SELECT * FROM ground_station_decode_jobs
|
|
WHERE (? IS NULL OR norad_id = ?)
|
|
AND (? IS NULL OR observation_id = ?)
|
|
AND (? IS NULL OR backend = ?)
|
|
ORDER BY created_at DESC
|
|
LIMIT ?
|
|
"""
|
|
norad_id = request.args.get("norad_id", type=int)
|
|
observation_id = request.args.get("observation_id", type=int)
|
|
backend = request.args.get("backend")
|
|
limit = min(request.args.get("limit", 20, type=int) or 20, 200)
|
|
|
|
from utils.database import get_db
|
|
|
|
with get_db() as conn:
|
|
rows = conn.execute(
|
|
query,
|
|
(
|
|
norad_id,
|
|
norad_id,
|
|
observation_id,
|
|
observation_id,
|
|
backend,
|
|
backend,
|
|
limit,
|
|
),
|
|
).fetchall()
|
|
|
|
results = []
|
|
for row in rows:
|
|
item = dict(row)
|
|
details_raw = item.get("details_json")
|
|
if details_raw:
|
|
try:
|
|
item["details"] = json.loads(details_raw)
|
|
except json.JSONDecodeError:
|
|
item["details"] = {}
|
|
else:
|
|
item["details"] = {}
|
|
item.pop("details_json", None)
|
|
results.append(item)
|
|
return jsonify(results)
|
|
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."""
|
|
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")
|
|
from utils.ground_station.observation_profile import VALID_TASK_TYPES
|
|
|
|
valid_decoders = {"fm", "afsk", "gmsk", "bpsk", "iq_only"}
|
|
if "tasks" in data:
|
|
if not isinstance(data["tasks"], list):
|
|
raise ValueError("tasks must be a list")
|
|
invalid = [str(task) for task in data["tasks"] if str(task).strip().lower() not in VALID_TASK_TYPES]
|
|
if invalid:
|
|
raise ValueError(f"tasks contains unsupported values: {', '.join(invalid)}")
|
|
else:
|
|
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))}")
|