diff --git a/CLAUDE.md b/CLAUDE.md index e167bd6..0c20c32 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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: Angle: RSSI: Lock:<0/1> SNR: Scan Delta: +``` + +**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 ` | 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). diff --git a/pyproject.toml b/pyproject.toml index 1dbae39..be32ad1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/travler_rotor/__init__.py b/src/birdcage/__init__.py similarity index 50% rename from src/travler_rotor/__init__.py rename to src/birdcage/__init__.py index 04aac48..b4e69c8 100644 --- a/src/travler_rotor/__init__.py +++ b/src/birdcage/__init__.py @@ -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", ] diff --git a/src/travler_rotor/antenna.py b/src/birdcage/antenna.py similarity index 94% rename from src/travler_rotor/antenna.py rename to src/birdcage/antenna.py index b62de2b..45ae903 100644 --- a/src/travler_rotor/antenna.py +++ b/src/birdcage/antenna.py @@ -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), diff --git a/src/travler_rotor/cli.py b/src/birdcage/cli.py similarity index 86% rename from src/travler_rotor/cli.py rename to src/birdcage/cli.py index 8a558dd..1633e26 100644 --- a/src/travler_rotor/cli.py +++ b/src/birdcage/cli.py @@ -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), diff --git a/src/travler_rotor/leapfrog.py b/src/birdcage/leapfrog.py similarity index 100% rename from src/travler_rotor/leapfrog.py rename to src/birdcage/leapfrog.py diff --git a/src/travler_rotor/protocol.py b/src/birdcage/protocol.py similarity index 100% rename from src/travler_rotor/protocol.py rename to src/birdcage/protocol.py diff --git a/src/travler_rotor/rotctld.py b/src/birdcage/rotctld.py similarity index 94% rename from src/travler_rotor/rotctld.py rename to src/birdcage/rotctld.py index 8bdd79e..8be5ae0 100644 --- a/src/travler_rotor/rotctld.py +++ b/src/birdcage/rotctld.py @@ -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: diff --git a/uv.lock b/uv.lock index cfa2d67..80b73c8 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, -]