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:
parent
b68bb1f6f4
commit
e0488eb85a
45
CLAUDE.md
45
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 <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)
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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).")
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user