Rename project from travler-rotor to birdcage

The radome looks like a birdcage, ham operators call satellites
"birds", and it's a nod to saveitforparts saving dishes "for parts."

Package, CLI entry point, class names (BirdcageAntenna), env vars
(BIRDCAGE_PORT, etc.), and CLAUDE.md updated. Hardware references
(Winegard Trav'ler, Trav'ler Pro, Carryout G2) unchanged.
This commit is contained in:
Ryan Malloy 2026-02-13 05:16:00 -07:00
parent c010cee282
commit a2e807f973
9 changed files with 207 additions and 57 deletions

161
CLAUDE.md
View File

@ -4,18 +4,19 @@ Control a Winegard Trav'ler motorized satellite dish via RS-485 for amateur radi
## Project
- **Package:** `travler-rotor` (installed via `uv sync`)
- **CLI entry point:** `travler-rotor` (init / serve / pos / move)
- **Source layout:** `src/travler_rotor/` (src-layout)
- **Packages:** `birdcage` + `console-probe` (installed via `uv sync`)
- **CLI entry points:** `birdcage` (init / serve / pos / move), `console-probe` (probe / discover)
- **Source layout:** `src/birdcage/` and `src/console_probe/` (src-layout)
- **Original upstream:** `Trav-ler-Rotor-For-HAL-2.05/` — Gabe Emerson's scripts, kept as reference (do not modify)
## Build & Lint
```bash
uv sync # Install deps + package
uv sync # Install deps + both packages
uv run ruff check src/ # Lint
uv run ruff format --check src/ # Format check
uv run travler-rotor --help # CLI smoke test
uv run birdcage --help # CLI smoke test
uv run console-probe --help # Probe tool smoke test
```
## Architecture
@ -25,13 +26,29 @@ protocol.py — FirmwareProtocol ABC + HAL205Protocol / HAL000Protocol
Serial I/O owned here. Each firmware version is a subclass.
leapfrog.py — Pure function: apply_leapfrog(target, current) -> adjusted
Predictive overshoot to compensate for mechanical motor lag.
antenna.py — TravlerAntenna: high-level control wrapping protocol + leapfrog
antenna.py — BirdcageAntenna: high-level control wrapping protocol + leapfrog
This is what consumers (CLI, rotctld, future MCP server) call.
rotctld.py — RotctldServer: Hamlib rotctld TCP protocol (p/P/S/_/q)
Bridges Gpredict to the antenna.
cli.py — Click CLI with init/serve/pos/move subcommands
```
### console-probe package
```
profile.py — DeviceProfile + HelpEntry dataclasses
serial_io.py — Prompt-aware serial I/O (fixes > termination bug)
discovery.py — Auto-discovery, help parsing, submenu probing, candidates
report.py — JSON report with format_version 2 (menus/help/undiscovered)
cli.py — argparse CLI: --discover-only, --deep, --submenu, --json
```
**Usage:**
```bash
console-probe --port /dev/ttyUSB2 --baud 115200 --discover-only --json /tmp/d.json
console-probe --port /dev/ttyUSB2 --baud 115200 --deep --wordlist scripts/wordlists/winegard.txt
```
## Firmware Variants
Five known Winegard dish variants documented by Gabe Emerson (KL1FI) / saveitforparts and cdavidson0522:
@ -455,6 +472,138 @@ v — go to velocity (continuous spin): `v [motor] [ustep/sec]`
? / q — help / return to TRK>
```
### K60 GPIO Functional Pin Map (Carryout G2)
Cross-referenced from live `gpio dir`/`gpio regs` queries (2026-02-13), K60 datasheet
pin mux table (MK60DN512VLQ10, 144-LQFP), boot log peripheral init, and A3981 datasheet.
**SPI1 — A3981 Stepper Motor Drivers (4 MHz, mode 0x03)**
| K60 Pin | GPIO | Alt | Function | Dir | State | Notes |
|---------|------|-----|----------|-----|-------|-------|
| PTE0 | E0 | ALT2 | SPI1_PCS1 | OUT | 1 | A3981 #2 chip select (EL motor) |
| PTE1 | E1 | ALT2 | SPI1_SOUT | (periph) | 1 | MOSI — MCU to A3981 |
| PTE2 | E2 | ALT2 | SPI1_SCK | (periph) | 1 | SPI clock |
| PTE3 | E3 | ALT2 | SPI1_SIN | (periph) | 0 | MISO — A3981 to MCU |
| PTE4 | E4 | ALT2 | SPI1_PCS0 | IN* | 1 | A3981 #1 chip select (AZ motor) |
| PTE5 | E5 | ALT2 | SPI1_PCS2 | OUT | 1 | Possibly A3981 RESET or enable |
*PTE4 shows INPUT in GPIO dir register, but this is irrelevant when muxed to SPI peripheral.
The SPI controller manages chip select assertion/deassertion directly.
**SPI2 — BCM4515 DVB-S2 Tuner (6.857 MHz, mode 0x03)**
| K60 Pin | GPIO | Alt | Function | Dir | State | Notes |
|---------|------|-----|----------|-----|-------|-------|
| PTD11 | D11 | ALT2 | SPI2_PCS0 | OUT | 1 | BCM4515 chip select |
| PTD12 | D12 | ALT2 | SPI2_SCK | IN* | 1 | SPI clock |
| PTD13 | D13 | ALT2 | SPI2_SOUT | IN* | 1 | MOSI — MCU to BCM4515 |
| PTD14 | D14 | ALT2 | SPI2_SIN | — | 0 | MISO — BCM4515 to MCU |
| PTD15 | D15 | ALT2 | SPI2_PCS1 | — | 0 | Secondary chip select (unused?) |
*GPIO dir register not meaningful for peripheral-muxed pins.
**UART4 — RS-422 Console (115200 baud)**
| K60 Pin | GPIO | Alt | Function | Dir | State | Notes |
|---------|------|-----|----------|-----|-------|-------|
| PTE24 | E24 | ALT3 | UART4_TX | OUT | 1 | Console TX (to computer RX pair) |
| PTE25 | E25 | ALT3 | UART4_RX | IN | 1 | Console RX (from computer TX pair) |
| PTE26 | E26 | ALT3 | UART4_CTS | IN | 1 | Hardware flow control (idle high) |
| PTE27 | E27 | — | GPIO | IN | 1 | Unknown (RTS? or pullup) |
| PTE28 | E28 | — | GPIO | IN | 1 | Unknown |
**DIP Switch GPIOs**
`dipswitch` reads raw value `val:ffffff01` (all OFF/up) → `app_dipswitch:101` (DISH 110+119+129W).
Exact GPIO pins TBD — likely Port A or Port C inputs with internal pullups. The 0xffffff01
raw value suggests a 32-bit register read where bits 1-24 are all high (pullup, switches open)
and bit 0 is high (LSB).
**A3981 Diagnostic Pins**
The `a3981 diag` command reads fault status from two GPIO pins (one per motor driver).
Confirmed both read "OK" when motors are healthy. The A3981 DIAG output is active-low
open-drain, pulled high when no fault. Exact GPIO pins TBD.
**Unidentified High-State Outputs**
| GPIO | Dir | State | Likely Function |
|------|-----|-------|-----------------|
| D10 | OUT | 1 | BCM4515 reset or power enable |
| B0-B3 | — | 1 | SPI0 or I2C bus (B0-B3 cluster) |
| B11 | — | 1 | Status LED or peripheral enable |
| C10-C13 | — | 1 | Contiguous block — possibly bus interface |
| C18 | — | 1 | LNB voltage control or relay |
### azscanwxp — Radio Telescope Mode (Carryout G2)
The `azscanwxp` command in MOT> performs an azimuth sweep while cycling through
DVB transponders at each position. This is the core of Davidson's winegard-sky-scan
project for RF imaging of the sky.
**Usage:** `azscanwxp [motor] [span] [resolution] [num_xponders]`
| Parameter | Type | Units | Description |
|-----------|------|-------|-------------|
| motor | int | — | Motor ID (0=AZ, 1=EL) |
| span | float | degrees | Total azimuth sweep range |
| resolution | int | centidegrees (0.01 deg) | Step size per position |
| num_xponders | int | — | Number of transponders to cycle at each position |
**Example:** `azscanwxp 0 10 100 3` — sweep 10 degrees on AZ at 1.00 degree steps,
checking 3 transponders per position.
**Output format** (from ADC `scan` documentation):
```
Motor:<id> Angle:<cdeg> RSSI:<adc> Lock:<0/1> SNR:<dB> Scan Delta:<step>
```
**Safety:** Requires homed motors. Do NOT run on uncalibrated axes — the firmware
may target INT_MAX (2147483647 steps) and deadlock the shell.
**For ham radio sky mapping:** Set the DVB tuner to a frequency near your target
(e.g., 10 GHz Ku-band downconverted through the LNB to ~1178 MHz IF), enable LNA
with `dvb``lnbdc odu`, then run azscanwxp. The RSSI values map RF power at
each AZ/EL grid point. Post-process the output into a 2D heatmap for sky imaging.
### DiSEqC 2.x Interface (Carryout G2)
The BCM4515 provides a DiSEqC 2.x controller accessible from the DVB> submenu.
DiSEqC (Digital Satellite Equipment Control) uses 22 kHz tone bursts on the coax
LNB bias line to control switches, LNB polarity, and band selection.
**Timing Parameters (confirmed live 2026-02-13):**
| Command | Value | Description |
|---------|-------|-------------|
| `ovraddr` | 0x11 | Target LNB address (standard first LNB) |
| `rrto` | 210 ms | Receive reply timeout |
| `pretx` | 15 ms | Pre-command TX delay |
| `tdthresh` | 110 | Tone detect threshold (0.16 counts/mV) |
**DiSEqC Commands:**
| Command | Function | Status |
|---------|----------|--------|
| `di2conf` | Read LNB config register | RxReplyTimeout (no switch connected) |
| `di2id` | Read LNB hardware ID | RxReplyTimeout |
| `di2stat` | Read LNB status flags | RxReplyTimeout |
| `di2rcs` | Read committed switch status | RxReplyTimeout |
| `di2cs` | Configure committed switch | Needs parameters |
| `di2sc` | Short circuit test | Untested |
| `send <hex>` | Raw DiSEqC packet (max 6 bytes) | Functional |
**Raw DiSEqC packets:** The `send` command accepts space-delimited hex bytes.
Standard DiSEqC 1.x commands use the format: `send E0 10 38 Fx` where the
last byte selects the switch port (F0-F3 for ports 1-4).
**For ham radio:** DiSEqC can control LNB polarity (13V=V-pol, 18V=H-pol) and
22 kHz tone (band select) without rewiring. The `lnbdc odu` command sets 13V;
boot default is 18V. Polarity affects which transponders are visible and RSSI
readings from `rssits` in the PEAK> submenu, which alternates between even
(H-pol/18V) and odd (V-pol/13V) transponders.
### Known NVS Indices
Full dump in `docs/g2-nvs-dump.md` (firmware 02.02.48, captured 2026-02-12).

View File

@ -3,9 +3,9 @@ requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "travler-rotor"
version = "2025.06.11"
description = "Python library for controlling Winegard Trav'ler satellite dishes via RS-485"
name = "birdcage"
version = "2026.02.12.1"
description = "Winegard satellite dish control for amateur radio sky tracking"
license = "MIT"
requires-python = ">=3.11"
authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}]
@ -15,7 +15,8 @@ dependencies = [
]
[project.scripts]
travler-rotor = "travler_rotor.cli:main"
birdcage = "birdcage.cli:main"
console-probe = "console_probe.cli:main"
[tool.ruff]
target-version = "py311"
@ -25,4 +26,4 @@ src = ["src"]
select = ["E", "F", "I", "UP", "B", "SIM"]
[tool.hatch.build.targets.wheel]
packages = ["src/travler_rotor"]
packages = ["src/birdcage", "src/console_probe"]

View File

@ -1,8 +1,8 @@
"""travler-rotor: Control Winegard Trav'ler satellite dishes via RS-485."""
"""birdcage: Winegard satellite dish control for amateur radio sky tracking."""
from travler_rotor.antenna import AntennaConfig, TravlerAntenna
from travler_rotor.leapfrog import apply_leapfrog
from travler_rotor.protocol import (
from birdcage.antenna import AntennaConfig, BirdcageAntenna
from birdcage.leapfrog import apply_leapfrog
from birdcage.protocol import (
CarryoutG2Protocol,
FirmwareProtocol,
HAL000Protocol,
@ -10,10 +10,11 @@ from travler_rotor.protocol import (
Position,
RssiReading,
)
from travler_rotor.rotctld import RotctldServer
from birdcage.rotctld import RotctldServer
__all__ = [
"AntennaConfig",
"BirdcageAntenna",
"CarryoutG2Protocol",
"FirmwareProtocol",
"HAL000Protocol",
@ -21,6 +22,5 @@ __all__ = [
"Position",
"RssiReading",
"RotctldServer",
"TravlerAntenna",
"apply_leapfrog",
]

View File

@ -10,8 +10,8 @@ from __future__ import annotations
import logging
from dataclasses import dataclass
from travler_rotor.leapfrog import apply_leapfrog
from travler_rotor.protocol import (
from birdcage.leapfrog import apply_leapfrog
from birdcage.protocol import (
MOTOR_AZIMUTH,
MOTOR_ELEVATION,
FirmwareProtocol,
@ -31,7 +31,7 @@ class AntennaConfig:
leapfrog_enabled: bool = True
class TravlerAntenna:
class BirdcageAntenna:
"""High-level interface to a Winegard Trav'ler dish.
Manages the full lifecycle: connect, initialize (boot + search kill),

View File

@ -1,4 +1,4 @@
"""CLI entry point for travler-rotor.
"""CLI entry point for birdcage.
Provides subcommands for initialization, position queries, manual moves,
and running a full rotctld-compatible server for Gpredict integration.
@ -11,9 +11,9 @@ import sys
import click
from travler_rotor.antenna import AntennaConfig, TravlerAntenna
from travler_rotor.protocol import get_protocol
from travler_rotor.rotctld import RotctldServer
from birdcage.antenna import AntennaConfig, BirdcageAntenna
from birdcage.protocol import get_protocol
from birdcage.rotctld import RotctldServer
def _setup_logging(verbose: bool) -> None:
@ -25,19 +25,19 @@ def _setup_logging(verbose: bool) -> None:
)
def _build_antenna(port: str, firmware: str, **config_kwargs) -> TravlerAntenna:
"""Create a TravlerAntenna from CLI options."""
def _build_antenna(port: str, firmware: str, **config_kwargs) -> BirdcageAntenna:
"""Create a BirdcageAntenna from CLI options."""
protocol = get_protocol(firmware)
# G2 defaults: 115200 baud, 18 deg min elevation
if firmware.lower() == "g2":
config_kwargs.setdefault("baudrate", 115200)
config_kwargs.setdefault("min_elevation", 18.0)
config = AntennaConfig(port=port, **config_kwargs)
return TravlerAntenna(protocol, config)
return BirdcageAntenna(protocol, config)
@click.group()
@click.version_option(package_name="travler-rotor")
@click.version_option(package_name="birdcage")
@click.option("-v", "--verbose", is_flag=True, help="Enable debug logging.")
def main(verbose: bool) -> None:
"""Control a Winegard Trav'ler satellite dish via RS-485."""
@ -47,14 +47,14 @@ def main(verbose: bool) -> None:
@main.command()
@click.option(
"--port",
envvar="TRAVLER_PORT",
envvar="BIRDCAGE_PORT",
default="/dev/ttyUSB0",
show_default=True,
help="Serial port for the RS-485 adapter.",
)
@click.option(
"--firmware",
envvar="TRAVLER_FIRMWARE",
envvar="BIRDCAGE_FIRMWARE",
default="hal205",
show_default=True,
type=click.Choice(["hal205", "hal000", "g2"], case_sensitive=False),
@ -76,14 +76,14 @@ def init(port: str, firmware: str) -> None:
@main.command()
@click.option(
"--port",
envvar="TRAVLER_PORT",
envvar="BIRDCAGE_PORT",
default="/dev/ttyUSB0",
show_default=True,
help="Serial port for the RS-485 adapter.",
)
@click.option(
"--firmware",
envvar="TRAVLER_FIRMWARE",
envvar="BIRDCAGE_FIRMWARE",
default="hal205",
show_default=True,
type=click.Choice(["hal205", "hal000", "g2"], case_sensitive=False),
@ -91,14 +91,14 @@ def init(port: str, firmware: str) -> None:
)
@click.option(
"--host",
envvar="TRAVLER_LISTEN_HOST",
envvar="BIRDCAGE_LISTEN_HOST",
default="127.0.0.1",
show_default=True,
help="Address to listen on for rotctld connections.",
)
@click.option(
"--listen-port",
envvar="TRAVLER_LISTEN_PORT",
envvar="BIRDCAGE_LISTEN_PORT",
default=4533,
show_default=True,
type=int,
@ -136,14 +136,14 @@ def serve(
@main.command()
@click.option(
"--port",
envvar="TRAVLER_PORT",
envvar="BIRDCAGE_PORT",
default="/dev/ttyUSB0",
show_default=True,
help="Serial port for the RS-485 adapter.",
)
@click.option(
"--firmware",
envvar="TRAVLER_FIRMWARE",
envvar="BIRDCAGE_FIRMWARE",
default="hal205",
show_default=True,
type=click.Choice(["hal205", "hal000", "g2"], case_sensitive=False),
@ -170,14 +170,14 @@ def pos(port: str, firmware: str) -> None:
@main.command()
@click.option(
"--port",
envvar="TRAVLER_PORT",
envvar="BIRDCAGE_PORT",
default="/dev/ttyUSB0",
show_default=True,
help="Serial port for the RS-485 adapter.",
)
@click.option(
"--firmware",
envvar="TRAVLER_FIRMWARE",
envvar="BIRDCAGE_FIRMWARE",
default="hal205",
show_default=True,
type=click.Choice(["hal205", "hal000", "g2"], case_sensitive=False),

View File

@ -21,12 +21,12 @@ from __future__ import annotations
import logging
import socket
from travler_rotor.antenna import TravlerAntenna
from travler_rotor.protocol import CarryoutG2Protocol
from birdcage.antenna import BirdcageAntenna
from birdcage.protocol import CarryoutG2Protocol
logger = logging.getLogger(__name__)
MODEL_NAME = "Winegard Trav'ler RS-485 Rotor"
MODEL_NAME = "Birdcage — Winegard RS-485 Rotor"
class RotctldServer:
@ -34,7 +34,7 @@ class RotctldServer:
def __init__(
self,
antenna: TravlerAntenna,
antenna: BirdcageAntenna,
host: str = "127.0.0.1",
port: int = 4533,
) -> None:

30
uv.lock generated
View File

@ -2,6 +2,21 @@ version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "birdcage"
version = "2026.2.12.1"
source = { editable = "." }
dependencies = [
{ name = "click" },
{ name = "pyserial" },
]
[package.metadata]
requires-dist = [
{ name = "click", specifier = ">=8.0" },
{ name = "pyserial", specifier = ">=3.5" },
]
[[package]]
name = "click"
version = "8.3.1"
@ -31,18 +46,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" },
]
[[package]]
name = "travler-rotor"
version = "2025.6.11"
source = { editable = "." }
dependencies = [
{ name = "click" },
{ name = "pyserial" },
]
[package.metadata]
requires-dist = [
{ name = "click", specifier = ">=8.0" },
{ name = "pyserial", specifier = ">=3.5" },
]