birdcage API
The birdcage package provides a layered Python API for controlling Winegard satellite dishes. The architecture separates firmware communication (protocol), high-level control (antenna), network integration (rotctld), and predictive compensation (leapfrog).
Architecture Overview
Section titled “Architecture Overview”cli.py Click CLI with init/serve/pos/move subcommands | vantenna.py BirdcageAntenna: high-level control (consumers call this) | +---> leapfrog.py Pure function: apply_leapfrog(target, current) | vprotocol.py FirmwareProtocol ABC + HAL205Protocol / HAL000Protocol / CarryoutG2Protocol Serial I/O owned here. Each firmware version is a subclass. | vrotctld.py RotctldServer: Hamlib rotctld TCP protocol (p/P/S/_/q/R/L/D) Bridges Gpredict to the antenna.protocol.py — Firmware Protocol Abstraction
Section titled “protocol.py — Firmware Protocol Abstraction”Data Classes
Section titled “Data Classes”@dataclassclass Position: """Current dish orientation.""" azimuth: float elevation: float skew: float | None = None
@dataclassclass RssiReading: """Signal strength reading from the DVB subsystem.""" reads: int average: int current: intConstants
Section titled “Constants”MOTOR_AZIMUTH = 0MOTOR_ELEVATION = 1FirmwareProtocol (ABC)
Section titled “FirmwareProtocol (ABC)”The abstract base class that all firmware implementations inherit from. Owns the serial connection and defines the contract for firmware interaction.
class FirmwareProtocol(ABC): def connect(self, port: str, baudrate: int = 57600) -> None: ... def disconnect(self) -> None: ... def reset_to_root(self) -> None: ...
@abstractmethod def initialize(self, callback: Callable[[str], None] | None = None) -> None: ...
@abstractmethod def enter_motor_menu(self) -> None: ...
def get_position(self) -> Position: ... def move_motor(self, motor_id: int, degrees: float) -> None: ...
@abstractmethod def kill_search(self) -> None: ...
@property def is_connected(self) -> bool: ...Key methods:
| Method | Description |
|---|---|
connect(port, baudrate) | Open the serial connection (8N1, 1s timeout) |
disconnect() | Reset to root menu and close serial |
initialize(callback) | Wait for boot, kill satellite search. Callback receives status lines. |
enter_motor_menu() | Navigate into the motor control submenu |
get_position() | Query current AZ/EL/SK position (returns Position) |
move_motor(motor_id, degrees) | Command a single motor to an absolute position |
kill_search() | Cancel the firmware’s automatic TV satellite search |
reset_to_root() | Send q to return to root menu |
HAL205Protocol
Section titled “HAL205Protocol”HAL 2.05.003 firmware implementation.
- Boot signals:
"NoGPS"or"No LNB Voltage" - Motor submenu command:
"motor" - Search kill sequence:
ngsearch->s->q - Default baud: 57600
HAL000Protocol
Section titled “HAL000Protocol”HAL 0.0.00 firmware implementation (older Trav’ler units).
- Boot signal:
"NoGPS" - Motor submenu command:
"mot" - Search kill sequence:
os->kill Search->q(OS task manager approach) - Default baud: 57600
CarryoutG2Protocol
Section titled “CarryoutG2Protocol”Winegard Carryout G2 firmware implementation. Extends the base protocol with prompt-terminated reads, DVB signal measurement, and motor homing.
- Default baud: 115200
- Motor submenu command:
"mot" - Search disable: NVS index 20 (permanent, no runtime kill needed)
- Read strategy: Reads byte-by-byte until
>(ASCII 62) prompt character
class CarryoutG2Protocol(FirmwareProtocol): def connect(self, port: str, baudrate: int = 115200) -> None: ... def get_position(self) -> Position: ... # Parses Angle[0]/Angle[1] format def move_motor(self, motor_id: int, degrees: float) -> None: ... def home_motor(self, motor_id: int) -> None: ...
# DVB / RSSI methods def enter_dvb_menu(self) -> None: ... def enable_lna(self) -> None: ... def get_rssi(self, iterations: int = 10) -> RssiReading: ... def quit_submenu(self) -> None: ...G2-specific methods:
| Method | Description |
|---|---|
home_motor(motor_id) | Home a motor to its reference position via stall detection |
enter_dvb_menu() | Enter the DVB signal analysis submenu |
enable_lna() | Enable LNA in ODU mode (13V) for signal reception |
get_rssi(iterations) | Read averaged RSSI signal strength (returns RssiReading) |
quit_submenu() | Exit current submenu |
Firmware Registry
Section titled “Firmware Registry”FIRMWARE_REGISTRY: dict[str, type[FirmwareProtocol]] = { "hal205": HAL205Protocol, "hal000": HAL000Protocol, "g2": CarryoutG2Protocol,}
def get_protocol(name: str) -> FirmwareProtocol: """Instantiate a firmware protocol by short name. Raises KeyError if name is not recognized. """antenna.py — High-Level Antenna Control
Section titled “antenna.py — High-Level Antenna Control”AntennaConfig
Section titled “AntennaConfig”@dataclassclass AntennaConfig: port: str = "/dev/ttyUSB0" baudrate: int = 57600 min_elevation: float = 15.0 leapfrog_enabled: bool = TrueBirdcageAntenna
Section titled “BirdcageAntenna”The main interface that consumers (CLI, rotctld server, future MCP server) should use. Wraps the firmware protocol with position tracking, leap-frog compensation, and motor command alternation.
class BirdcageAntenna: def __init__( self, protocol: FirmwareProtocol, config: AntennaConfig | None = None, ) -> None: ...
@property def config(self) -> AntennaConfig: ...
@property def protocol(self) -> FirmwareProtocol: ...
@property def is_connected(self) -> bool: ...
def connect(self) -> None: ... def disconnect(self) -> None: ... def initialize(self) -> None: ... def get_position(self) -> Position: ... def move_to(self, azimuth: float, elevation: float) -> None: ... def stop(self) -> None: ...Key behaviors:
| Method | Description |
|---|---|
initialize() | Connect (if needed), run boot sequence, enter motor menu |
get_position() | Query position and cache as _last_position for leapfrog |
move_to(az, el) | Move with leapfrog compensation + elevation floor enforcement. Motor commands alternate order (AZ-first on even moves, EL-first on odd) to prevent one axis from starving. |
stop() | Reset move counter |
leapfrog.py — Predictive Overshoot
Section titled “leapfrog.py — Predictive Overshoot”A pure function that compensates for mechanical motor lag during satellite tracking. When the delta between target and current position exceeds a threshold, the target is nudged further in the direction of travel.
def apply_leapfrog( target_az: float, target_el: float, current_az: float, current_el: float,) -> tuple[float, float]: """Apply predictive overshoot to compensate for mechanical lag.
Returns adjusted (azimuth, elevation) with overshoot applied. """Compensation table:
| Delta | Overshoot applied |
|---|---|
| More than 2 degrees | +/- 1.0 degree in direction of travel |
| More than 1 degree | +/- 0.5 degree in direction of travel |
| 1 degree or less | No overshoot |
rotctld.py — Hamlib rotctld Server
Section titled “rotctld.py — Hamlib rotctld Server”Implements the subset of the Hamlib rotctld TCP protocol that Gpredict and other Hamlib clients use for AZ/EL rotor control. Extended with custom commands for sky-scan integration on the Carryout G2.
class RotctldServer: def __init__( self, antenna: BirdcageAntenna, host: str = "127.0.0.1", port: int = 4533, ) -> None: ...
def serve_forever(self) -> None: ... def shutdown(self) -> None: ...Standard rotctld Commands
Section titled “Standard rotctld Commands”| Command | Description | Response |
|---|---|---|
p | Get position | <azimuth>\n<elevation>\n |
P <az> <el> | Set position (move dish) | RPRT 0\n on success |
S | Stop tracking | Calls antenna.stop() |
_ | Get model name | Winegard Trav'ler RS-485 Rotor\n |
q | Quit connection | Closes the TCP connection |
Extended Commands (Carryout G2 only)
Section titled “Extended Commands (Carryout G2 only)”These custom extensions are available when the underlying protocol is CarryoutG2Protocol. Non-G2 rotors return RPRT -6 (not available).
| Command | Description | Response |
|---|---|---|
R [n] | Read RSSI signal strength (n = iterations, default 10) | <reads>\n<average>\n<current>\n |
L | Enable LNA for signal reception (one-time setup) | RPRT 0\n on success |
D | Discover supported capabilities | CAPS:rssi,lna\n (G2) or CAPS:\n |
cli.py — Command-Line Interface
Section titled “cli.py — Command-Line Interface”Built with Click. All commands accept --port and --firmware options (also configurable via BIRDCAGE_PORT and BIRDCAGE_FIRMWARE environment variables).
Commands
Section titled “Commands”birdcage init
Section titled “birdcage init”Initialize the antenna: wait for boot, kill satellite search.
birdcage init --port /dev/ttyUSB0 --firmware hal205birdcage init --port /dev/ttyUSB2 --firmware g2birdcage serve
Section titled “birdcage serve”Run a rotctld-compatible TCP server for Gpredict.
birdcage serve --port /dev/ttyUSB0 --firmware hal205birdcage serve --port /dev/ttyUSB2 --firmware g2 --host 0.0.0.0 --listen-port 4533birdcage serve --port /dev/ttyUSB2 --firmware g2 --skip-init| Option | Default | Description |
|---|---|---|
--host | 127.0.0.1 | Address to listen on |
--listen-port | 4533 | TCP port for rotctld protocol |
--skip-init | false | Skip boot wait and search kill (dish already initialized) |
birdcage pos
Section titled “birdcage pos”Query and print the current dish position.
birdcage pos --port /dev/ttyUSB2 --firmware g2# Output:# AZ: 180.0# EL: 45.0birdcage move
Section titled “birdcage move”Move the dish to a specific AZ/EL position.
birdcage move --port /dev/ttyUSB2 --firmware g2 --az 180.0 --el 45.0birdcage move --port /dev/ttyUSB0 --firmware hal205 --az 90.0 --el 30.0 --no-leapfrog| Option | Description |
|---|---|
--az | Target azimuth (degrees, required) |
--el | Target elevation (degrees, required) |
--no-leapfrog | Disable leap-frog compensation for this move |
Global Options
Section titled “Global Options”| Option | Env Var | Default | Description |
|---|---|---|---|
-v / --verbose | — | false | Enable debug logging |
--port | BIRDCAGE_PORT | /dev/ttyUSB0 | Serial port |
--firmware | BIRDCAGE_FIRMWARE | hal205 | Firmware version (hal205, hal000, g2) |