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.
birdcage
Section titled “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.
protocol.py — firmware abstraction
Section titled “protocol.py — firmware abstraction”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:
| Delta | Overshoot |
|---|---|
| More than 2 degrees | +/- 1.0 degree |
| More than 1 degree | +/- 0.5 degree |
| 1 degree or less | No 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.
antenna.py — the consumer-facing API
Section titled “antenna.py — the consumer-facing API”BirdcageAntenna is what everything above the protocol should call. It wraps three concerns:
- Lifecycle management — connect, initialize (boot wait + search kill + motor menu entry), disconnect
- Leap-frog integration — applies
apply_leapfrog()to every move ifconfig.leapfrog_enabledis true - 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.
rotctld.py — Gpredict bridge
Section titled “rotctld.py — Gpredict bridge”A plain TCP socket server implementing the subset of the Hamlib rotctld protocol that Gpredict uses:
| Command | Handler | Response |
|---|---|---|
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:
| Command | Handler | Function |
|---|---|---|
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.
cli.py — the entry point
Section titled “cli.py — the entry point”A Click CLI with four subcommands:
init— connect, wait for boot, kill search, enter motor menuserve— run the rotctld TCP server (optionally skipping init)pos— query and print current AZ/ELmove— 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).
console-probe
Section titled “console-probe”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 dataclassesprofile.py — device model
Section titled “profile.py — device model”Everything known about the attached console is stored in a DeviceProfile:
@dataclassclass 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.
serial_io.py — prompt-terminated reads
Section titled “serial_io.py — prompt-terminated reads”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(">")discovery.py — the exploration engine
Section titled “discovery.py — the exploration engine”This module handles several distinct tasks:
Help parsing — parse_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 probing — discover_submenu_help() queries help in the current submenu and tries multiple help commands (? and man) for firmware with paginated output.
Error detection — detect_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 probing — probe_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 generation — generate_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.