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>
195 lines
6.2 KiB
Python
195 lines
6.2 KiB
Python
"""Hamlib rotctld TCP client for antenna rotator control.
|
|
|
|
Communicates with a running ``rotctld`` daemon over TCP using the simple
|
|
line-based Hamlib protocol::
|
|
|
|
Client → ``P <azimuth> <elevation>\\n``
|
|
Server → ``RPRT 0\\n`` (success)
|
|
|
|
If ``rotctld`` is not reachable the controller silently operates in a
|
|
disabled state — the rest of the system functions normally.
|
|
|
|
Usage::
|
|
|
|
rotator = get_rotator()
|
|
if rotator.connect('127.0.0.1', 4533):
|
|
rotator.point_to(az=180.0, el=30.0)
|
|
rotator.park()
|
|
rotator.disconnect()
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import socket
|
|
import threading
|
|
|
|
from utils.logging import get_logger
|
|
|
|
logger = get_logger('intercept.rotator')
|
|
|
|
DEFAULT_HOST = '127.0.0.1'
|
|
DEFAULT_PORT = 4533
|
|
DEFAULT_TIMEOUT = 2.0 # seconds
|
|
|
|
|
|
class RotatorController:
|
|
"""Thin wrapper around the rotctld TCP protocol."""
|
|
|
|
def __init__(self):
|
|
self._sock: socket.socket | None = None
|
|
self._lock = threading.Lock()
|
|
self._host = DEFAULT_HOST
|
|
self._port = DEFAULT_PORT
|
|
self._enabled = False
|
|
self._current_az: float = 0.0
|
|
self._current_el: float = 0.0
|
|
|
|
# ------------------------------------------------------------------
|
|
# Connection management
|
|
# ------------------------------------------------------------------
|
|
|
|
def connect(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> bool:
|
|
"""Connect to rotctld. Returns True on success."""
|
|
with self._lock:
|
|
self._host = host
|
|
self._port = port
|
|
try:
|
|
s = socket.create_connection((host, port), timeout=DEFAULT_TIMEOUT)
|
|
s.settimeout(DEFAULT_TIMEOUT)
|
|
self._sock = s
|
|
self._enabled = True
|
|
logger.info(f"Rotator connected to rotctld at {host}:{port}")
|
|
return True
|
|
except OSError as e:
|
|
logger.warning(f"Could not connect to rotctld at {host}:{port}: {e}")
|
|
self._sock = None
|
|
self._enabled = False
|
|
return False
|
|
|
|
def disconnect(self) -> None:
|
|
"""Close the TCP connection."""
|
|
with self._lock:
|
|
if self._sock:
|
|
try:
|
|
self._sock.close()
|
|
except OSError:
|
|
pass
|
|
self._sock = None
|
|
self._enabled = False
|
|
logger.info("Rotator disconnected")
|
|
|
|
# ------------------------------------------------------------------
|
|
# Commands
|
|
# ------------------------------------------------------------------
|
|
|
|
def point_to(self, az: float, el: float) -> bool:
|
|
"""Send a ``P`` (set position) command.
|
|
|
|
Azimuth and elevation are clamped to valid ranges before sending.
|
|
|
|
Returns True if the command was acknowledged.
|
|
"""
|
|
az = max(0.0, min(360.0, float(az)))
|
|
el = max(0.0, min(90.0, float(el)))
|
|
|
|
ok = self._send_command(f'P {az:.1f} {el:.1f}')
|
|
if ok:
|
|
self._current_az = az
|
|
self._current_el = el
|
|
return ok
|
|
|
|
def park(self) -> bool:
|
|
"""Send rotator to park position (0° az, 0° el)."""
|
|
return self.point_to(0.0, 0.0)
|
|
|
|
def get_position(self) -> tuple[float, float] | None:
|
|
"""Query current position. Returns (az, el) or None on failure."""
|
|
with self._lock:
|
|
if not self._enabled or self._sock is None:
|
|
return None
|
|
try:
|
|
self._sock.sendall(b'p\n')
|
|
resp = self._recv_line()
|
|
if resp and 'RPRT' not in resp:
|
|
parts = resp.split()
|
|
if len(parts) >= 2:
|
|
return float(parts[0]), float(parts[1])
|
|
except Exception as e:
|
|
logger.warning(f"Rotator get_position failed: {e}")
|
|
self._enabled = False
|
|
self._sock = None
|
|
return None
|
|
|
|
# ------------------------------------------------------------------
|
|
# Status
|
|
# ------------------------------------------------------------------
|
|
|
|
@property
|
|
def enabled(self) -> bool:
|
|
return self._enabled
|
|
|
|
def get_status(self) -> dict:
|
|
return {
|
|
'enabled': self._enabled,
|
|
'host': self._host,
|
|
'port': self._port,
|
|
'current_az': self._current_az,
|
|
'current_el': self._current_el,
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Internal
|
|
# ------------------------------------------------------------------
|
|
|
|
def _send_command(self, cmd: str) -> bool:
|
|
with self._lock:
|
|
if not self._enabled or self._sock is None:
|
|
return False
|
|
try:
|
|
self._sock.sendall((cmd + '\n').encode())
|
|
resp = self._recv_line()
|
|
if resp and 'RPRT 0' in resp:
|
|
return True
|
|
logger.warning(f"Rotator unexpected response to '{cmd}': {resp!r}")
|
|
return False
|
|
except Exception as e:
|
|
logger.warning(f"Rotator command '{cmd}' failed: {e}")
|
|
self._enabled = False
|
|
try:
|
|
self._sock.close()
|
|
except OSError:
|
|
pass
|
|
self._sock = None
|
|
return False
|
|
|
|
def _recv_line(self, max_bytes: int = 256) -> str:
|
|
"""Read until newline (already holding _lock)."""
|
|
buf = b''
|
|
assert self._sock is not None
|
|
while len(buf) < max_bytes:
|
|
c = self._sock.recv(1)
|
|
if not c:
|
|
break
|
|
buf += c
|
|
if c == b'\n':
|
|
break
|
|
return buf.decode('ascii', errors='replace').strip()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Singleton
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_rotator: RotatorController | None = None
|
|
_rotator_lock = threading.Lock()
|
|
|
|
|
|
def get_rotator() -> RotatorController:
|
|
"""Get or create the global rotator controller instance."""
|
|
global _rotator
|
|
if _rotator is None:
|
|
with _rotator_lock:
|
|
if _rotator is None:
|
|
_rotator = RotatorController()
|
|
return _rotator
|