Files
intercept/utils/rotator.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

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