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:
parent
ce24f7c478
commit
a249c98208
4 changed files with 689 additions and 9 deletions
227
tui/src/birdcage_tui/rotctld_server.py
Normal file
227
tui/src/birdcage_tui/rotctld_server.py
Normal 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")
|
||||
|
|
@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue