Implement Track mode (Phase 6) with rotctld server

TUI-compatible Hamlib rotctld TCP server that works with the
existing SerialBridge/DemoDevice interface. F2 Control's Track
tab now starts/stops a real TCP server on the configured port.
Status callbacks report connection state, move count, and command
rate back to the TrackingPanel via thread-safe call_from_thread.

Includes 7 pilot tests exercising the full protocol path through
asyncio TCP clients against the DemoDevice.
This commit is contained in:
Ryan Malloy 2026-02-15 15:52:49 -07:00
parent ce24f7c478
commit a249c98208
4 changed files with 689 additions and 9 deletions

View file

@ -0,0 +1,227 @@
"""Lightweight rotctld TCP server for the Birdcage TUI.
Implements the Hamlib rotctld wire protocol (p/P/S/_/q) using the TUI's
device interface (SerialBridge or DemoDevice) instead of BirdcageAntenna.
Runs in a background thread with status callbacks to the TUI event loop.
"""
import contextlib
import logging
import socket
import threading
import time
from collections.abc import Callable
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
MODEL_NAME = "Birdcage TUI"
@dataclass
class TrackingState:
status: str = "STOPPED"
client: str = ""
moves: int = 0
rate: float = 0.0
_move_timestamps: list[float] = field(
default_factory=list, repr=False
)
def record_move(self) -> None:
self.moves += 1
now = time.monotonic()
self._move_timestamps.append(now)
# Keep only last 30 seconds of timestamps for rate calc.
cutoff = now - 30.0
self._move_timestamps = [
t for t in self._move_timestamps if t > cutoff
]
elapsed = now - self._move_timestamps[0]
if elapsed > 0:
self.rate = len(self._move_timestamps) / elapsed
else:
self.rate = 0.0
class TuiRotctldServer:
"""TCP server speaking Hamlib rotctld, backed by a TUI device.
Args:
device: SerialBridge or DemoDevice instance.
host: Bind address.
port: Bind port.
on_status: Callback invoked on state changes. Called from the
server thread -- caller is responsible for thread safety
(e.g., app.call_from_thread).
"""
def __init__(
self,
device: object,
host: str = "127.0.0.1",
port: int = 4533,
on_status: Callable[[TrackingState], None] | None = None,
) -> None:
self._device = device
self._host = host
self._port = port
self._on_status = on_status
self._server_socket: socket.socket | None = None
self._running = False
self._state = TrackingState()
self._lock = threading.Lock()
@property
def state(self) -> TrackingState:
return self._state
def _notify(self) -> None:
if self._on_status:
self._on_status(self._state)
def serve_forever(self) -> None:
"""Listen for connections and handle rotctld commands.
Blocks until stop() is called.
"""
self._server_socket = socket.socket(
socket.AF_INET, socket.SOCK_STREAM
)
self._server_socket.setsockopt(
socket.SOL_SOCKET, socket.SO_REUSEADDR, 1
)
try:
self._server_socket.bind((self._host, self._port))
except OSError:
logger.error(
"Failed to bind %s:%d", self._host, self._port
)
self._state.status = "STOPPED"
self._notify()
return
self._server_socket.listen(1)
self._server_socket.settimeout(1.0)
self._running = True
self._state.status = "LISTENING"
self._state.client = ""
self._state.moves = 0
self._state.rate = 0.0
self._notify()
logger.info(
"TUI rotctld listening on %s:%d",
self._host,
self._port,
)
while self._running:
try:
conn, addr = self._server_socket.accept()
except TimeoutError:
continue
except OSError:
break
addr_str = f"{addr[0]}:{addr[1]}"
logger.info("rotctld client connected: %s", addr_str)
self._state.status = "CONNECTED"
self._state.client = addr_str
self._notify()
try:
self._handle_connection(conn)
except Exception:
logger.exception(
"Error handling rotctld client %s", addr_str
)
finally:
conn.close()
logger.info(
"rotctld client disconnected: %s", addr_str
)
if self._running:
self._state.status = "LISTENING"
self._state.client = ""
self._notify()
self._state.status = "STOPPED"
self._state.client = ""
self._notify()
def stop(self) -> None:
self._running = False
if self._server_socket:
with contextlib.suppress(OSError):
self._server_socket.close()
self._server_socket = None
def _handle_connection(self, conn: socket.socket) -> None:
conn.settimeout(1.0)
while self._running:
try:
data = conn.recv(1024)
except TimeoutError:
continue
except OSError:
break
if not data:
break
for line in data.decode("utf-8", errors="replace").splitlines():
line = line.strip()
if not line:
continue
parts = line.split()
cmd = parts[0]
if cmd == "p":
self._cmd_get_position(conn)
elif cmd == "P":
self._cmd_set_position(conn, parts)
elif cmd == "S":
conn.sendall(b"RPRT 0\n")
return
elif cmd == "_":
conn.sendall(
f"{MODEL_NAME}\n".encode()
)
elif cmd == "q":
return
else:
logger.warning(
"Unknown rotctld command: %s", cmd
)
conn.sendall(b"RPRT -1\n")
def _cmd_get_position(self, conn: socket.socket) -> None:
try:
pos = self._device.get_position()
az = pos["azimuth"]
el = pos["elevation"]
conn.sendall(f"{az}\n{el}\n".encode())
except Exception:
logger.exception("Failed to get position for rotctld")
conn.sendall(b"RPRT -1\n")
def _cmd_set_position(
self, conn: socket.socket, parts: list[str]
) -> None:
try:
target_az = float(parts[1])
target_el = float(parts[2])
self._device.move_motor(0, target_az)
self._device.move_motor(1, target_el)
self._state.record_move()
self._notify()
conn.sendall(b"RPRT 0\n")
except (IndexError, ValueError):
logger.error("Bad P command: %s", parts)
conn.sendall(b"RPRT -1\n")
except Exception:
logger.exception("Failed to move for rotctld")
conn.sendall(b"RPRT -1\n")

View file

@ -7,6 +7,7 @@ mode manages saved AZ/EL targets. Track mode wraps the rotctld server lifecycle.
import contextlib
import logging
import threading
from textual import work
from textual.app import ComposeResult
@ -15,6 +16,7 @@ from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Button, ContentSwitcher, Input, Static
from textual.worker import Worker
from birdcage_tui.rotctld_server import TrackingState, TuiRotctldServer
from birdcage_tui.widgets.compass_rose import CompassRose
from birdcage_tui.widgets.mode_bar import ModeBar
from birdcage_tui.widgets.motor_status import MotorStatus
@ -52,6 +54,8 @@ class ControlScreen(Container):
self._last_az = 180.0
self._last_el = 45.0
self._step_size: float = 1.0
self._rotctld_server: TuiRotctldServer | None = None
self._rotctld_thread: threading.Thread | None = None
# ------------------------------------------------------------------
# Compose
@ -157,8 +161,9 @@ class ControlScreen(Container):
self._start_data_poll()
def on_unmount(self) -> None:
"""Stop polling thread on teardown."""
"""Stop polling thread and rotctld server on teardown."""
self._polling = False
self._stop_rotctld()
# ------------------------------------------------------------------
# Mode switching
@ -460,29 +465,79 @@ class ControlScreen(Container):
def on_tracking_panel_start_requested(
self, event: TrackingPanel.StartRequested
) -> None:
"""Handle rotctld start request (placeholder for Phase 6)."""
if self._device is None:
self.app.notify(
"No device connected", severity="warning"
)
return
if self._rotctld_server is not None:
self.app.notify(
"Server already running", severity="warning"
)
return
log.info(
"Tracking start requested: %s:%d min_el=%.1f",
event.host,
event.port,
event.min_el,
)
panel = self.query_one("#ctrl-tracking-panel", TrackingPanel)
panel.set_status(state="LISTENING")
self.app.notify(
f"Tracking server listening on {event.host}:{event.port}",
severity="information",
def status_callback(state: TrackingState) -> None:
self.app.call_from_thread(
self._apply_tracking_state, state
)
self._rotctld_server = TuiRotctldServer(
device=self._device,
host=event.host,
port=event.port,
on_status=status_callback,
)
self._rotctld_thread = threading.Thread(
target=self._rotctld_server.serve_forever,
name="rotctld",
daemon=True,
)
self._rotctld_thread.start()
def on_tracking_panel_stop_requested(
self, _event: TrackingPanel.StopRequested
) -> None:
"""Handle rotctld stop request (placeholder for Phase 6)."""
log.info("Tracking stop requested")
self._stop_rotctld()
def _stop_rotctld(self) -> None:
if self._rotctld_server is None:
return
log.info("Stopping rotctld server")
self._rotctld_server.stop()
if self._rotctld_thread is not None:
self._rotctld_thread.join(timeout=3.0)
self._rotctld_thread = None
self._rotctld_server = None
panel = self.query_one("#ctrl-tracking-panel", TrackingPanel)
panel.set_status(state="STOPPED")
self.app.notify("Tracking server stopped")
def _apply_tracking_state(self, state: TrackingState) -> None:
try:
panel = self.query_one(
"#ctrl-tracking-panel", TrackingPanel
)
panel.set_status(
state=state.status,
client=state.client,
moves=state.moves,
rate=state.rate,
)
except Exception:
pass
# ------------------------------------------------------------------
# Key binding actions
# ------------------------------------------------------------------