Skip to content

Software Architecture

The project contains two Python packages. birdcage controls the dish for satellite tracking. console-probe is a separate tool for exploring and mapping unknown firmware consoles. They share no code at runtime but were developed together — console-probe is how we learned enough about the firmware to write the protocol implementations in birdcage.

The control stack is five layers deep, each with a single responsibility:

cli.py Click CLI: init / serve / pos / move
|
rotctld.py Hamlib rotctld TCP server (p/P/S/_/q + R/L/D extensions)
|
antenna.py BirdcageAntenna: high-level control, motor alternation, elevation floor
|
leapfrog.py Pure function: predictive overshoot compensation
|
protocol.py FirmwareProtocol ABC + HAL205 / HAL000 / CarryoutG2 subclasses
(owns the serial port)

Data flows top-down: Gpredict sends a position command to the rotctld server, which calls the antenna, which applies leapfrog correction and hands the adjusted target to the protocol, which sends the serial bytes to the dish.

This is where the serial port lives. The FirmwareProtocol abstract base class defines the contract that every firmware variant must implement:

class FirmwareProtocol(ABC):
def connect(self, port: str, baudrate: int = 57600) -> None: ...
def disconnect(self) -> None: ...
def get_position(self) -> Position: ...
def move_motor(self, motor_id: int, degrees: float) -> None: ...
@abstractmethod
def initialize(self, callback: Callable[[str], None] | None = None) -> None: ...
@abstractmethod
def enter_motor_menu(self) -> None: ...
@abstractmethod
def kill_search(self) -> None: ...

Three concrete subclasses implement this interface:

HAL205Protocol — for HAL 2.05.003 firmware. Boot signals are NoGPS or No LNB Voltage. Enters the motor menu via the motor command. Kills the search by navigating ngsearch -> s -> q.

HAL000Protocol — for HAL 0.0.00 firmware (older Trav’ler units). Same boot signal (NoGPS), but the motor command is mot and search kill goes through the OS task manager: os -> kill Search -> q.

CarryoutG2Protocol — for the Carryout G2. RS-422 at 115200 baud instead of RS-485 at 57600. Search is disabled permanently via NVS, so kill_search() is a no-op. This subclass also adds methods not in the ABC: home_motor(), enter_dvb_menu(), enable_lna(), and get_rssi().

The key architectural decision: the protocol owns all serial I/O. Nothing above this layer touches pyserial directly. The base class provides _write() and _read() for simple command/response. The G2 subclass overrides this with _send(), which reads byte-by-byte until the > prompt character (ASCII 62) — more reliable than fixed-buffer reads because the firmware always emits > when ready.

def _send(self, cmd: str) -> str:
"""Send a command and read until the '>' prompt character."""
self._serial.write(f"{cmd}\r".encode("ascii"))
resp_data: bytearray = bytearray()
while True:
byte = self._serial.read(1)
if len(byte) == 0:
raise TimeoutError(f"No prompt after command: {cmd!r}")
resp_data.append(byte[0])
if byte[0] == self.PROMPT_CHAR:
break
return resp_data.decode("utf-8", errors="ignore")

A firmware registry maps short names to classes:

FIRMWARE_REGISTRY: dict[str, type[FirmwareProtocol]] = {
"hal205": HAL205Protocol,
"hal000": HAL000Protocol,
"g2": CarryoutG2Protocol,
}

The CLI uses get_protocol("g2") to instantiate the right class. Adding a new firmware variant means writing a new subclass and adding it to this dictionary.

leapfrog.py — mechanical lag compensation

Section titled “leapfrog.py — mechanical lag compensation”

A single pure function with no side effects:

def apply_leapfrog(
target_az: float, target_el: float,
current_az: float, current_el: float,
) -> tuple[float, float]:

For each axis, if the delta between target and current position exceeds a threshold, the target is nudged further in the direction of travel:

DeltaOvershoot
More than 2 degrees+/- 1.0 degree
More than 1 degree+/- 0.5 degree
1 degree or lessNo adjustment

This compensates for the time it takes stepper motors to physically reach a position — by the time the dish arrives, the satellite has moved further along its track.

BirdcageAntenna is what everything above the protocol should call. It wraps three concerns:

  1. Lifecycle management — connect, initialize (boot wait + search kill + motor menu entry), disconnect
  2. Leap-frog integration — applies apply_leapfrog() to every move if config.leapfrog_enabled is true
  3. Motor command alternation — even-numbered moves send AZ first then EL; odd moves reverse the order. This prevents one axis from starving the other on the shared serial bus.
class BirdcageAntenna:
def __init__(self, protocol: FirmwareProtocol, config: AntennaConfig | None = None):
...
def initialize(self) -> None: ...
def get_position(self) -> Position: ...
def move_to(self, azimuth: float, elevation: float) -> None: ...
def stop(self) -> None: ...

AntennaConfig holds serial port, baud rate, minimum elevation, and the leapfrog toggle. The G2 defaults to 115200 baud and 18-degree minimum elevation; the Trav’ler defaults to 57600 and 15 degrees.

A plain TCP socket server implementing the subset of the Hamlib rotctld protocol that Gpredict uses:

CommandHandlerResponse
p_handle_get_position()<az>\n<el>\n
P <az> <el>_handle_set_position()RPRT 0\n or RPRT -1\n
S_handle_stop()(closes connection)
__handle_model_name()Winegard Trav'ler RS-485 Rotor\n
q(break loop)(closes connection)

For CarryoutG2 specifically, three extension commands are available:

CommandHandlerFunction
R [n]_handle_read_rssi()Read RSSI averaged over n samples
L_handle_enable_lna()Enable LNA for signal reception
D_handle_capabilities()Report supported extensions

The RSSI handler is noteworthy — it has to switch firmware submenus mid-operation. It exits the motor menu, enters the DVB menu, reads RSSI, exits DVB, and re-enters the motor menu. Non-G2 rotors return RPRT -6 (not available) for these commands.

A Click CLI with four subcommands:

  • init — connect, wait for boot, kill search, enter motor menu
  • serve — run the rotctld TCP server (optionally skipping init)
  • pos — query and print current AZ/EL
  • move — send a single move command to a specific AZ/EL

All subcommands accept --port and --firmware options, with environment variable fallbacks (BIRDCAGE_PORT, BIRDCAGE_FIRMWARE).

The probe tool has a parallel but simpler architecture:

cli.py argparse CLI: --discover-only, --deep, --submenu, --json
|
report.py JSON report generation (format_version 2)
|
discovery.py Auto-discovery, help parsing, submenu probing, candidate generation
|
serial_io.py Prompt-aware serial I/O
|
profile.py DeviceProfile + HelpEntry dataclasses

Everything known about the attached console is stored in a DeviceProfile:

@dataclass
class DeviceProfile:
port: str = "/dev/ttyUSB0"
baud: int = 115200
root_prompt: str = "" # e.g. "TRK>"
prompts: list[str] = ... # all known prompts
error_string: str = "" # e.g. "Invalid command."
known_commands: set[str] = ... # from help output
submenus: list[str] = ... # detected submenu names
exit_cmd: str = "q"
line_ending: str = "\r"
submenu_help: dict[str, list[HelpEntry]] = ...

Commands parsed from help output are captured as HelpEntry objects with name, description, and parameter syntax.

The key insight in console-probe’s serial I/O is the _is_prompt_terminated() function. Instead of reading a fixed number of bytes or waiting for a timeout, it checks whether the response ends with a recognized prompt string.

This required solving a subtle bug: help text like help [<command>] contains the > character inside parameter syntax (<command>). The function distinguishes between actual prompts and parameter syntax by checking for [ brackets on the last line:

def _is_prompt_terminated(text: str, profile: DeviceProfile) -> bool:
last_line = stripped.split("\n")[-1]
if profile.prompts:
# Check known prompts (fast path)
for p in profile.prompts:
if last_stripped.endswith(p):
return True
# Accept PROMPT_RE match only if no brackets on that line
if "[" not in last_line:
m = PROMPT_RE.search(last_line)
if m:
return True
return False
# No known prompts yet -- fallback to bare > check
return stripped.endswith(">")

This module handles several distinct tasks:

Help parsingparse_help_output() extracts command names and submenu hints from firmware help text. It handles multiple formats: command - description, angle-bracket syntax (Enter <a3981>), double-space separated columns, and bare command names. A set of known parameter placeholders (command, value, index, etc.) prevents false positives.

Submenu probingdiscover_submenu_help() queries help in the current submenu and tries multiple help commands (? and man) for firmware with paginated output.

Error detectiondetect_error_string() sends a garbage command and captures the firmware’s error message, which is then used to distinguish real command responses from error responses during probing.

Command probingprobe_commands() iterates through candidate command strings, sends each one, and checks whether the response differs from the error string. It recovers from accidental submenu exits and shell terminations.

Candidate generationgenerate_candidates() builds a list of potential commands from single characters, two-letter combinations, common embedded debug commands (memory access, flash, boot, GPIO, SPI, I2C, etc.), and optional external wordlists.