From e0488eb85aff2be86b27cca158816298c037666d Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Wed, 11 Feb 2026 10:06:58 -0700 Subject: [PATCH] Add Carryout G2 as fifth firmware variant with protocol support Implement CarryoutG2Protocol based on cdavidson0522/winegard-sky-scan: prompt-terminated reads via '>' char, 115200 baud RS-422, h motor homing, DVB/RSSI signal strength measurement. Update CLAUDE.md with G2 variant column, NVS index 20, dvb sub-commands, and wiring differences. CLI now accepts --firmware g2. --- CLAUDE.md | 45 +++++---- src/travler_rotor/__init__.py | 4 + src/travler_rotor/cli.py | 12 ++- src/travler_rotor/protocol.py | 172 +++++++++++++++++++++++++++++++++- 4 files changed, 212 insertions(+), 21 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 720d28e..c6fdce8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,21 +34,23 @@ cli.py — Click CLI with init/serve/pos/move subcommands ## Firmware Variants -Four known Winegard dish variants documented by Gabe Emerson (KL1FI) / saveitforparts: +Five known Winegard dish variants documented by Gabe Emerson (KL1FI) / saveitforparts and cdavidson0522: -| Detail | HAL 0.0.00 | HAL 2.05.003 | Trav'ler Pro | Carryout | -|--------|-----------|-------------|-------------|---------| -| **Repo** | Travler_Rotor | Trav-ler-Rotor-For-HAL-2.05 | Travler-Pro-Rotor | Carryout-Rotor | -| **Connection** | RS-485 / RJ-25 | RS-485 / RJ-25 | USB A-to-A (`ttyACM0`) | RS-485 / RJ-25 | -| **Motor submenu** | `mot` | `motor` | `odu` then `mot` | N/A (`target` + `g`) | -| **Motor control** | `a ` | `a ` | `a ` | `g ` only | -| **Search kill** | `os` -> `kill Search` | `ngsearch` -> `s` -> `q` | `os` -> `kill Search` | N/A | -| **Boot signal** | `NoGPS` | `NoGPS` or `No LNB Voltage` | undocumented | N/A | -| **Min elevation** | 15 deg (firmware) | 15 deg (firmware) | 12 deg (firmware) | 22 deg (firmware-enforced) | -| **Max elevation** | 90 deg | 90 deg | 75 deg (hardware cap!) | 73 deg (firmware default, NVS 102 override) | -| **Position query** | `a` -> `AZ = / EL =` | `a` -> `AZ = / EL =` | `a` -> `AZ = / EL =` | raw ints / 100 | -| **Tested model** | LG-2112 | LG-2112 | SK2DISH | 2003 Carryout | -| **HAL version** | 0.0.00 | 2.05.003 | unknown | 1.00.065 | +| Detail | HAL 0.0.00 | HAL 2.05.003 | Trav'ler Pro | Carryout | Carryout G2 | +|--------|-----------|-------------|-------------|---------|------------| +| **Repo** | Travler_Rotor | Trav-ler-Rotor-For-HAL-2.05 | Travler-Pro-Rotor | Carryout-Rotor | winegard-sky-scan | +| **Connection** | RS-485 / RJ-25 | RS-485 / RJ-25 | USB A-to-A (`ttyACM0`) | RS-485 / RJ-25 | RS-422 / RJ-12 6P6C | +| **Baud rate** | 57600 | 57600 | 57600 | 57600 | 115200 | +| **Motor submenu** | `mot` | `motor` | `odu` then `mot` | N/A (`target` + `g`) | `mot` | +| **Motor control** | `a ` | `a ` | `a ` | `g ` only | `a ` | +| **Search kill** | `os` -> `kill Search` | `ngsearch` -> `s` -> `q` | `os` -> `kill Search` | N/A | NVS 20 (permanent disable) | +| **Boot signal** | `NoGPS` | `NoGPS` or `No LNB Voltage` | undocumented | N/A | undocumented | +| **Min elevation** | 15 deg (firmware) | 15 deg (firmware) | 12 deg (firmware) | 22 deg (firmware-enforced) | 18 deg (firmware) | +| **Max elevation** | 90 deg | 90 deg | 75 deg (hardware cap!) | 73 deg (firmware default, NVS 102 override) | 65 deg (firmware) | +| **Position query** | `a` -> `AZ = / EL =` | `a` -> `AZ = / EL =` | `a` -> `AZ = / EL =` | raw ints / 100 | `a` -> floats | +| **Tested model** | LG-2112 | LG-2112 | SK2DISH | 2003 Carryout | Carryout G2 | +| **HAL version** | 0.0.00 | 2.05.003 | unknown | 1.00.065 | unknown | +| **Prompt char** | `>` (likely) | `>` (likely) | undocumented | undocumented | `>` (confirmed) | ### Key Variant Differences @@ -56,8 +58,12 @@ Four known Winegard dish variants documented by Gabe Emerson (KL1FI) / saveitfor - **Carryout uses `g` not `a`:** The Carryout has no individual motor addressing. It uses `target` to enter targeting mode, then `g ` for combined moves. It also can't query its initial position. - **Carryout has no limit switches:** Uses motor stalling to detect mechanical boundaries (audible grinding). - **Pro has the same leap-frog bug** as the regular Trav'ler (copy-pasted). -- **Pro NVS submenu** has a `d` command to dump all NVS values (undocumented for other variants). +- **NVS `d` command** dumps all NVS values. Confirmed on Pro and Carryout G2; likely available on all variants. - **Carryout DIP switches:** All switches to off (up) may disable search mode, but behavior varies by unit. +- **Carryout G2 uses `a` not `g`:** Unlike the 2003 Carryout, the G2 uses standard `a ` motor addressing and the `mot` submenu — protocol-compatible with the Trav'ler family. +- **Carryout G2 is RS-422 full-duplex:** Separate TX/RX pairs at 115200 baud via RJ-12 6P6C, vs. RS-485 half-duplex at 57600 on the Trav'ler variants. Requires a USB-to-RS422 converter (5V TTL). +- **Carryout G2 has `h ` homing:** Explicit motor home-to-reference command. Not documented on other variants. +- **Carryout G2 has DVB/RSSI:** Signal strength measurement via `dvb` submenu (`lnbdc odu` to enable LNA, `rssi ` to sample). Used for sky scanning / RF imaging. ## Hardware Specs (SK-1000) @@ -88,6 +94,7 @@ Four known Winegard dish variants documented by Gabe Emerson (KL1FI) / saveitfor - Elevation floor: HAL 2.05 unreliable below 15 degrees with direct motor commands - Cable wrap limit: usually 360 or 455 degrees, dish reverses at limit - Console does not accept backspace — hit enter to clear on typo +- Firmware prompt character is `>` (ASCII 62) — used for reliable response termination in prompt-terminated read strategies ### RS-485 Pinout (RJ-25, bottom view, tip up) @@ -107,6 +114,7 @@ Four known Winegard dish variants documented by Gabe Emerson (KL1FI) / saveitfor motor / mot — enter motor submenu (firmware-dependent) a — show position (in motor submenu) a — move motor to absolute position +h — home motor to reference position (G2, possibly others) g — go to AZ/EL (aborts on new input) q — exit current submenu odu — tunnel to outdoor unit (Trav'ler Pro only) @@ -116,11 +124,14 @@ os — enter OS submenu ngsearch — enter search submenu (HAL 2.05 only) s — stop search nvs — enter non-volatile storage submenu - d — dump all values (Pro only, undocumented on others) + d — dump all values (confirmed on Pro and G2) + d — dump single value with name/current/saved/default e — read NVS value e — write NVS value s — save changes dvb — signal info / LNB signal strength submenu + lnbdc odu — enable LNA in ODU mode (powers LNB for reception) + rssi — read RSSI signal strength averaged over n samples reboot — reboot firmware stow — fold dish flat (caution: modified feeds may not survive) ``` @@ -129,6 +140,7 @@ stow — fold dish flat (caution: modified feeds may not survive) | Index | Setting | |-------|---------| +| 20 | Disable tracker procedure (FALSE/TRUE) | | 102 | Max elevation | | 125 | Search minimum elevation | | 127 | Safe minimum elevation | @@ -179,6 +191,7 @@ Last resort only. 5/16" socket + 6" extension into auxiliary drive hole. Turn cl - github.com/saveitforparts/Travler-Pro-Rotor (Pro, USB) - github.com/saveitforparts/Carryout-Rotor (Carryout, HAL 1.00.065) - github.com/saveitforparts/Carryout-Radio-Telescope (RF scanning/imaging) +- github.com/cdavidson0522/winegard-sky-scan (Carryout G2 sky scan + rotator) - Gabe Emerson / KL1FI — gabe@saveitforparts.com - YouTube: Trav'ler v1 demo (youtu.be/X1hnReHepFI), v2 demo (youtube.com/watch?v=URJZjo5EcpQ) diff --git a/src/travler_rotor/__init__.py b/src/travler_rotor/__init__.py index 9908bc1..04aac48 100644 --- a/src/travler_rotor/__init__.py +++ b/src/travler_rotor/__init__.py @@ -3,19 +3,23 @@ from travler_rotor.antenna import AntennaConfig, TravlerAntenna from travler_rotor.leapfrog import apply_leapfrog from travler_rotor.protocol import ( + CarryoutG2Protocol, FirmwareProtocol, HAL000Protocol, HAL205Protocol, Position, + RssiReading, ) from travler_rotor.rotctld import RotctldServer __all__ = [ "AntennaConfig", + "CarryoutG2Protocol", "FirmwareProtocol", "HAL000Protocol", "HAL205Protocol", "Position", + "RssiReading", "RotctldServer", "TravlerAntenna", "apply_leapfrog", diff --git a/src/travler_rotor/cli.py b/src/travler_rotor/cli.py index c6cbf72..8a558dd 100644 --- a/src/travler_rotor/cli.py +++ b/src/travler_rotor/cli.py @@ -28,6 +28,10 @@ def _setup_logging(verbose: bool) -> None: def _build_antenna(port: str, firmware: str, **config_kwargs) -> TravlerAntenna: """Create a TravlerAntenna 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) @@ -53,7 +57,7 @@ def main(verbose: bool) -> None: envvar="TRAVLER_FIRMWARE", default="hal205", show_default=True, - type=click.Choice(["hal205", "hal000"], case_sensitive=False), + type=click.Choice(["hal205", "hal000", "g2"], case_sensitive=False), help="Firmware version on the dish.", ) def init(port: str, firmware: str) -> None: @@ -82,7 +86,7 @@ def init(port: str, firmware: str) -> None: envvar="TRAVLER_FIRMWARE", default="hal205", show_default=True, - type=click.Choice(["hal205", "hal000"], case_sensitive=False), + type=click.Choice(["hal205", "hal000", "g2"], case_sensitive=False), help="Firmware version on the dish.", ) @click.option( @@ -142,7 +146,7 @@ def serve( envvar="TRAVLER_FIRMWARE", default="hal205", show_default=True, - type=click.Choice(["hal205", "hal000"], case_sensitive=False), + type=click.Choice(["hal205", "hal000", "g2"], case_sensitive=False), help="Firmware version on the dish.", ) def pos(port: str, firmware: str) -> None: @@ -176,7 +180,7 @@ def pos(port: str, firmware: str) -> None: envvar="TRAVLER_FIRMWARE", default="hal205", show_default=True, - type=click.Choice(["hal205", "hal000"], case_sensitive=False), + type=click.Choice(["hal205", "hal000", "g2"], case_sensitive=False), help="Firmware version on the dish.", ) @click.option("--az", required=True, type=float, help="Target azimuth (degrees).") diff --git a/src/travler_rotor/protocol.py b/src/travler_rotor/protocol.py index 23beefd..ad78669 100644 --- a/src/travler_rotor/protocol.py +++ b/src/travler_rotor/protocol.py @@ -33,6 +33,15 @@ class Position: skew: float | None = None +@dataclass +class RssiReading: + """Signal strength reading from the DVB subsystem.""" + + reads: int + average: int + current: int + + class FirmwareProtocol(ABC): """Abstract base for Winegard firmware communication over RS-485.""" @@ -224,10 +233,171 @@ class HAL000Protocol(FirmwareProtocol): self._write(self.MOTOR_COMMAND) +class CarryoutG2Protocol(FirmwareProtocol): + """Winegard Carryout G2 firmware. + + Connection: RS-422 full-duplex / RJ-12 6P6C at 115200 baud + Motor submenu: "mot" + Motor control: a (standard Trav'ler-style addressing) + Search disable: NVS index 20 (permanent, one-time configuration) + Has DVB/RSSI signal strength capability and h motor homing. + + Source: github.com/cdavidson0522/winegard-sky-scan + """ + + MOTOR_COMMAND = "mot" + PROMPT_CHAR = 62 # ASCII '>' + + def connect(self, port: str, baudrate: int = 115200) -> None: + """Open RS-422 serial connection at 115200 baud (G2 default).""" + self._serial = serial.Serial( + port=port, + baudrate=baudrate, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + bytesize=serial.EIGHTBITS, + timeout=15, + ) + logger.info("Connected on %s at %d baud (Carryout G2)", port, baudrate) + + def _send(self, cmd: str) -> str: + """Send a command and read until the '>' prompt character. + + This is more reliable than the base class's fixed-buffer read because + the firmware always emits '>' when it's ready for the next command. + Returns the full response including the prompt. + """ + if not self._serial: + raise RuntimeError("Not connected") + 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 + + time.sleep(0.001) # brief settle before next command + return resp_data.decode("utf-8", errors="ignore") + + def initialize(self, callback: Callable[[str], None] | None = None) -> None: + """Prepare G2 for motor commands. + + The tracker procedure must be permanently disabled via NVS index 20 + before first use. This method enters the motor menu directly — no + boot-wait or search-kill is needed. + """ + logger.info( + "Initializing Carryout G2 (tracker must be pre-disabled via NVS 20)" + ) + self._send("q") + self.enter_motor_menu() + logger.info("Carryout G2 initialized and ready") + + def enter_motor_menu(self) -> None: + self._send("q") + self._send(self.MOTOR_COMMAND) + + def kill_search(self) -> None: + """No-op — G2 search is disabled permanently via NVS index 20.""" + logger.debug("G2 search kill is a no-op (NVS 20 disables tracker)") + + def reset_to_root(self) -> None: + """Return to the firmware root menu.""" + self._send("q") + + def get_position(self) -> Position: + """Query dish position. + + The G2 may return floats without AZ=/EL= labels, so we try the + labeled format first and fall back to raw float extraction. + """ + response = self._send("a") + + # Try labeled format (AZ = / EL =) for compatibility + az_match = re.search(r"AZ\s*=?\s*(\d+\.\d+)", response) + el_match = re.search(r"EL\s*=?\s*(\d+\.\d+)", response) + + if az_match and el_match: + sk_match = re.search(r"SK\s*=?\s*(\d+\.\d+)", response) + return Position( + azimuth=float(az_match.group(1)), + elevation=float(el_match.group(1)), + skew=float(sk_match.group(1)) if sk_match else None, + ) + + # Fall back to raw float extraction (G2-style: just two numbers) + floats = re.findall(r"\d+\.\d+", response) + if len(floats) >= 2: + return Position( + azimuth=float(floats[0]), + elevation=float(floats[1]), + ) + + raise ValueError(f"Could not parse position from: {response!r}") + + def move_motor(self, motor_id: int, degrees: float) -> None: + """Command a motor to an absolute position, waiting for prompt.""" + self._send(f"a {motor_id} {degrees}") + + def home_motor(self, motor_id: int) -> None: + """Home a motor to its reference position. + + Args: + motor_id: MOTOR_AZIMUTH (0) or MOTOR_ELEVATION (1). + """ + self._send(f"h {motor_id}") + logger.info("Homing motor %d", motor_id) + + # -- DVB / RSSI methods (signal strength measurement) -- + + def enter_dvb_menu(self) -> None: + """Enter DVB signal analysis submenu (must be at root menu).""" + self._send("q") # ensure root + self._send("dvb") + + def enable_lna(self) -> None: + """Enable LNA in ODU mode for signal reception (DVB submenu).""" + self._send("lnbdc odu") + logger.info("LNA enabled in ODU mode") + + def get_rssi(self, iterations: int = 10) -> RssiReading: + """Read averaged RSSI signal strength (DVB submenu). + + Args: + iterations: Number of samples to average. + + Returns: + RssiReading with reads, average, and current values. + + Raises: + ValueError: If the RSSI response can't be parsed. + """ + response = self._send(f"rssi {iterations}") + results = re.findall(r"\d+", response) + + if len(results) >= 6: + return RssiReading( + reads=int(results[3]), + average=int(results[4]), + current=int(results[5]), + ) + + raise ValueError(f"Could not parse RSSI from: {response!r}") + + def quit_submenu(self) -> None: + """Exit current submenu and return to parent.""" + self._send("q") + + # Registry for firmware lookup by name FIRMWARE_REGISTRY: dict[str, type[FirmwareProtocol]] = { "hal205": HAL205Protocol, "hal000": HAL000Protocol, + "g2": CarryoutG2Protocol, } @@ -235,7 +405,7 @@ def get_protocol(name: str) -> FirmwareProtocol: """Instantiate a firmware protocol by short name. Args: - name: One of "hal205", "hal000". + name: One of "hal205", "hal000", "g2". Raises: KeyError: If the firmware name is not recognized.