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 <id>
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.
This commit is contained in:
Ryan Malloy 2026-02-11 10:06:58 -07:00
parent b68bb1f6f4
commit e0488eb85a
4 changed files with 212 additions and 21 deletions

View File

@ -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 <id> <deg>` | `a <id> <deg>` | `a <id> <deg>` | `g <az> <el>` 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 <id> <deg>` | `a <id> <deg>` | `a <id> <deg>` | `g <az> <el>` only | `a <id> <deg>` |
| **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 <az> <el>` 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 <id> <deg>` 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 <id>` 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 <n>` 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 <id> <deg> — move motor to absolute position
h <id> — home motor to reference position (G2, possibly others)
g <az> <el> — 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 <idx> — dump single value with name/current/saved/default
e <idx> — read NVS value
e <idx> <v> — 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 <n> — 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)

View File

@ -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",

View File

@ -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).")

View File

@ -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 <id> <deg> (standard Trav'ler-style addressing)
Search disable: NVS index 20 (permanent, one-time configuration)
Has DVB/RSSI signal strength capability and h <id> 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.