Initial travler-rotor library scaffolding

Extract Gabe Emerson's Trav'ler rotor scripts into a proper Python
library with firmware protocol abstraction (HAL 2.05 + HAL 0.0.00),
Hamlib rotctld TCP server, Click CLI, and isolated leap-frog algorithm
with the elevation copy-paste bug fixed.
This commit is contained in:
Ryan Malloy 2026-02-11 04:10:17 -07:00
commit c93bbef26d
17 changed files with 1374 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
dist/
build/
.eggs/
*.egg
.venv/
.env
*.so
.ruff_cache/

View File

@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org>

View File

@ -0,0 +1,220 @@
# Travler_Rotor
Multi-axis antenna rotor using Winegard "Trav'ler" satellite dish
Make sure to check your Winegard ODU firmware / HAL Version!
This variant of the software (v2.0B) works with the newer Winegard Trav'ler firmware; HAL 2.05.003.
It may work with other versions as well. Key differences between my other Trav'ler Tracker branch (2.0)
for HAL 0.0.00: The newer HAL uses a different initialization sequence and sends different status messages,
so the init script looks for those before cancelling the search routine. The newer HAL also uses different
submenu commands, namely "motor" (vs "mot" in HAL 0.00.00).
Gabe Emerson / Saveitforparts 2025. Email: gabe@saveitforparts.com
Video demo of version 1: https://youtu.be/X1hnReHepFI
Video demo of version 2: (in progress)
**Introduction:**
This code acts as an interface between Gpredict / Hamlib Rotctld and a Winegard brand "Trav'ler" satellite dish.
NOTE: Specifically one using HAL firmware version 2.05.003
These dishes are designed for vehicle use, but can be mounted to any stable surface. They can sometimes be found
2nd-hand from used RV dealerships, Craigslist / Facebook marketplace / etc.
The Winegard Trav'ler consists of an Outdoor Unit (ODU) or motorized "turret" with a standard Ku-band TV dish attached,
and an Indoor Unit (IDU), or small black box that has the power input and display screen. You will also need a DC power
supply, if the original is missing then something around 48-52VDC should work.
Note there are several versions of the Trav'ler dish and this code has only been tested with the LG-2112. Versions with
a GPS search may not respond to the init code that looks for a "NoGPS" flag from the firmware.
This code is experimental and will probably void the warranty on any antenna you try it with. Use at your own risk!
![Winegard Trav'ler](images/dish.jpg?raw=true "Winegard Trav'ler")
**Applications:**
This antenna works as a general-purpose az/el rotor, and could have skew added to the code if desired. I have been using
my Trav'ler dish as an S-band LEO weather satellite tracker. It can also be used to point at GEO satellites, which is closer
to the original intended use.
The code could be adapted for other purposes like sky surveys, Wifi or other RF surveys, etc. See my other Github projects
for some example sky surveys / radio telescope implementations using similar antennas.
This could possibly also be used for DIY radar applications, drone tracking, point-to-point Wifi, etc. Note that the dish
has some speed and motion limits which might not make it suitable for every application.
**Hardware Requirements:**
The stock LNB should be replaced with a feed appropriate to whatever frequency you intend to use. For example, I use 3-D
printed helical feeds designed by DerekSGC for L and S-band. These can be found at https://www.thingiverse.com/thing:4980180
![Helical Feed](images/feed.jpg?raw=true "Helical Feed")
The internal coax cable should not be used unless you disable / bypass the onboard power injector. Otherwise 14-18VDC will
be supplied to the feed/LNA, which will kill 5VDC equipment. Technically the internal wiring is the wrong impedance for SDR use,
but I have not noticed any issues mixing 50 and 75-ohm wiring at S-band frequencies.
I use a Microcircuits ZX60-242LN-S+ Low Noise Amplifier connected to my helical feed, powered from a 5v USB source. Below that
is an RTL-SDR Wideband LNA, powered by my SDR's bias-tee. I use a HackRF One for S-band. I keep the HackRF's onboard amp turned
off to minimize noise. This seems to work well for strong satellites like NOAA and DMSP, but not for weaker ones like HINODE. The stock
33"x23" dish is probably too small for weaker signals. It might be possible to add a slightly larger reflector, but YMMV.
Interfacing with the Trav'ler serial port requires an RS-485 cable and 6-pin phone connector (RJ-25). I have included a
photo of the USB adapter chain that I use. It includes a USB-to-Serial cable, a DTECH RS232-to-RS485 converter, and an RJ-25 jack
wired as follows (looking at the bottom of the phone connector with the tip up):
- Pin 1: GND
- Pin 2: T/R-
- Pin 3: T/R+
- Pin 4: RXD-
- Pin 5: RXD+
- Pin 6: Not used
![USB to serial to RS-485 to RJ-25 cable](images/cable1.jpg?raw=true "Cable for Winegard console")
![Pinout for RS-485 to RJ-25 cable](images/cable2.jpg?raw=true "Pins for RS-485 to RJ-25 cable")
**Software Requirements:**
This code was developed for Python 3. The code uses serial, socket, and regex. If not already installed, use "pip install -r
requirements.txt"
If your computer uses a different serial port for the RS-485 adapter (such as COM3 for Windows or /dev/ttyACM0) you will need
to edit line 17 of travler_rotor.py and line 10 of travler_init.py
**Trav'ler Dish Basic Firmware Info**
You can interact with the Trav'ler firmware directly by connecting the RS-485 cable chain to the "Factory Only" port on the
IDU. Then run "screen /dev/ttyUSB0 57600" (or appropriate port, you may need to check lsusb or equivalent command on your
local computer).
Once connected to the firmware, some basic commands are:
- "?": List available commands
- "motor": enter the motor submenu
- "a" (from within the motor submenu): Show current dish position, or set desired position by specifying motor # and degrees.
- "g" (from within motor submenu): go to a specified azimuth/elevation/skew (Some firmware has a typo listing az/sk/el).
- "q": exit the current submenu
- "nvs" - enter the non volatile storage (for changing long-term settings).
- "os": Enter the OS submenu
- "tasks": list running tasks (from within os submenu)
- "kill [name of task]": Kill a task (such as "kill Search" to disable the TV satellite search movement routine).
The firmware has a lot of options, not all of which I understand. There are several ways to do various things, including
multiple motor-related submenus that all behave slightly differently.
**Notes on Trav'ler limitations and quirks**
Physical and Firmware limitations:
The Tra'ler can physically move past 0 degrees and 90 degrees in elevation, but the firmware doesn't like to go below 15
degrees. This is likely a built-in safety feature to keep it from impacting other objects on an RV roof. There are two
options to address this:
1: I have put in a soft-coded limit of 15 degrees elevation in my code, which you can enable by un-commenting lines 116,
117, 124, and 125 of travler_rotor.py. I recommend telling Gpredict that the rotor's minimum elevation is 15 degrees.
2: You can edit the nvs settings in the firmware. Make sure your dish won't physically collide with anything at the new
minimum elevation! You will need to enter the firmware as described above, then the "nvs" submenu. Type the following:
"e 125 0"
"e 127 0"
This sets both the search and safe minimums to 0 elevation. Type "s" to save the new setting, "q" to return to the main
menu, and then "reboot" to use the new minimum elevation.
When using the "g" method to operate the motors, the dish will halt/abort movement if a new command (or any keystroke /
character) comes in.
When using the "a" method to operate motors, the dish will wait until the current motor stops moving before accepting a new
motor command (in the case of the AZ and EL motors. I believe the Skew / SK motor can run simultaneously with one
of the others).
Initial Calibration:
When first powered on, the Trav'ler goes through a series of calibration movements to establish position and wrap limits.
Afterwards, the default behavior is to search for a TV satellite, which for our purposes is a waste of time. The travler_init.py
script connects to the dish and waits for the calibration to complete, then kills the search in the firmware's task manager.
Stowing:
The Trav'ler dish has a built-in "stow" command which is intended to fold it flat against the roof of an RV or trailer.
I have not used this command in my code and I tend to ignore it. The modified L-band feed that replaced my LNB would
likely not survive a stow procedure.
Cable Wrap:
The dish also has a cable wrap system that prevents it from tangling its own internal wiring. This seems to be a somewhat
variable position, but is usually either at 360 or 455 degrees. Thus the dish can spin from 0 degrees, past 360, but will halt
and reverse to the other side of the wrap position upon hitting its limit. The wrap position can be found with the "a" command
in the firmware's "mot" submenu. I usually address this by using Gpredict to manually run the dish through the range of motion
required for the upcoming pass. This helps ensure it is on the correct side of the wrap position. If the wrap position is near
the start or end of a track, I will simply disengage Gpredict during that part.
Meridian Crossing:
The dish will cross the 0/360 position during tracking *most of the time. I have sometimes encountered issues with this where
the dynamic wrap position gets set to 0. In these cases, the dish will approach 0, then stop, reverse direction and come at
the new position from the other side. This can spoil a live track of a satellite. It's possible there's a way to avoid or
address this. I just never got around to implementing it and don't encounter the issue often enough.
Possible GPS issue:
Some versions of the Trav'ler have a GPS subsystem that assists with acquiring TV satellites. Other users have reported that
attempting to disable the GPS puts the IDU and ODU in a state where they no longer communicate, effectively bricking the dish.
I have not encountered this myself, as my dish does not have GPS, but it's something to watch out for.
IDU / ODU cable
If the cable between the IDU and ODU is cut (common for used units pulled from RVs), the wires to the IDU should be:
Top row: Green, Yellow, Orange.
Bottom row: Red, Brown, Black.
![IDU back](images/idu.jpg?raw=true "Indoor Unit (IDU)")
**Positioning the antenna**
The baseplate of the Winegard Trav'ler is marked with arrows and the word "BACK" at the 0/360-degree position (North).
Typically I place my dish so that these arrows are aligned with true North. If using the dish as a portable unit, not bolted
to a roof or vehicle, make sure to secure the base so that the dish cannot wiggle or fall over.
If the stow command / feature is used, North / "Back" is the position at which the dish will "faceplant" onto the ground/roof.
**Setting up rotor in Gpredict**
In Gpredict's rotor settings, you will want to create a new rotor at 127.0.0.1:4533, with 0->180->360 mode, minimum elevation
of 15 and maximum elevation of 90.
**Example setup and use procedure**
Your procedure may differ depending on the software and setup you use. My basic procedure for tracking a LEO satellite with
the Trav'ler is as follows:
- Connect serial cable to Trav'ler's IDU, run "./init.sh" on computer
- Power on IDU. Trav'ler will initialize and home, then init script will jump to rotor script.
- Power on the first LNA with external bias-tee
- Open SDR++, turn on SDR and activate second LNA with SDR's bias-tee function
- Set gains as desired (may need to max these out for faint signals)
- Keep onboard amp (for HackRF One) turned off.
- Set Gpredict Antenna Control for desired satellite, select the correct rotor.
- If rotor script is not already running and waiting for Gpredict, run "./rotor.sh"
- Prep SDR to record with desired bandwidth, baseband, int16
- Activate tracking and engage rotor in Gpredict.
- Record the pass (I like to keep an eye on the dish in case it does anything weird).
- Stop recording and disengage the rotor when the signal gets too low to be usable.
- Run the baseband recording through Satdump.
Some of these steps could be combined with Satdump, which can also handle the rotor control, recording, and live decoding.
Personally I like to track the current satellite in N2YO.com alongside the other windows, just to see where it is. I
also have a security camera aimed at my dish so I can watch it moving from my computer.
![S-Band Ground control setup](images/ground_control.jpg?raw=true "Ground control setup")

View File

@ -0,0 +1,5 @@
#!/bin/bash
#run init script
python3 travler_init.py

View File

@ -0,0 +1,5 @@
#Package requirements for carryout_rotor.py
pyserial
regex
socket

View File

@ -0,0 +1,5 @@
#!/bin/bash
#run rotor script
python3 travler_rotor.py

View File

@ -0,0 +1,45 @@
#Python program to initialize Winegard Trav'ler antenna
#(allow to home, then kill TV satellite search)
#Version 2.0B For HAL v2.05, NOT for HAL v0.0
#Gabe Emerson / Saveitforparts 2024, Email: gabe@saveitforparts.com
import serial
#define "antenna" as the serial port device to interface with
antenna = serial.Serial(
port='/dev/ttyUSB0', #pass this from command line in future?
baudrate = 57600,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout=1)
print ('Antenna connected on ', antenna.port)
print ('Waiting for system to boot, ensure IDU is powered on.')
while 1:
data = antenna.readline().decode(errors='ignore').strip() #read serial stream from antenna
print(data)
if "NoGPS" in data or "No LNB Voltage" in data: #Initial homing is finished, Trav'ler wants to look for TV sat
print('Antenna motor homing finished.')
print('Cancelling Search task.')
antenna.write(bytes(b'q\r')) #go back to root menu in case firmware was left in a submenu
antenna.write(bytes(b'\r')) #clear firmware prompt to avoid unknown command errors
antenna.write(bytes(b'ngsearch\r')) #enter ngsearch submenu (for newer HAL versions)
antenna.write(bytes(b's\r')) #kill Search task
antenna.write(bytes(b'q\r')) #go back to root menu
antenna.write(bytes(b'\r'))
print('Antenna is ready for rotor script.')
break
else:
continue
antenna.close()
exec(open("travler_rotor.py").read())
exit()

View File

@ -0,0 +1,159 @@
#Python program to control Winegard Trav'ler antenna as an AZ/EL Rotor
#Version 2.0B (For HAL Version 2.05, NOT for HAL v0.0)
#Gabe Emerson / Saveitforparts 2025, Email: gabe@saveitforparts.com
import serial
import socket
import regex as re
#initialize some variables
current_az = 0.0
current_el = 0.0
move_nbr = 0
#define "antenna" as the serial port device to interface with
antenna = serial.Serial(
port='/dev/ttyUSB0', #pass this from command line in future?
baudrate = 57600,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout=1)
print ('Antenna connected on ', antenna.port)
#Prep the Trav'ler firmware to accept commands
antenna.write(bytes(b'q\r')) #go back to root menu in case firmware was left in a submenu
antenna.write(bytes(b'\r')) #clear firmware prompt to avoid unknown command errors
antenna.write(bytes(b'motor\r')) #enter motor submenu
#listen to local port for rotctld commands
listen_ip = '127.0.0.1' #listen on localhost
listen_port = 4533 #pass this from command line in future?
client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client_socket.bind((listen_ip, listen_port))
client_socket.listen(1)
while 1:
print ('Listening for rotor commands on', listen_ip, ':', listen_port)
print ('Ctrl-C to exit')
conn, addr = client_socket.accept()
print ('Connection from ',addr)
#pass rotor commands to antenna
while 1:
data = conn.recv(100) #get Gpredict's message
if not data:
break
cmd = data.decode("utf-8").strip().split(" ") #grab the incoming command
if cmd[0] == "p": #Gpredict is requesting position
#ask antenna for position
antenna.write(bytes(b'a\r')) #get current Az/El/Sk
antenna.flush()
#read current position from antenna
reply1 = antenna.read(200).decode(errors='ignore').strip() #read dish response
#Regular expression patterns to find AZ and EL values
az_pattern = r"AZ =(\s+\d+\.\d+)"
el_pattern = r"EL =(\s+\d+\.\d+)"
#Use re.search to extract the values
az_match = re.search(az_pattern, reply1)
el_match = re.search(el_pattern, reply1)
#If matches are found, extract the numerical values
if az_match and el_match:
current_az = float(az_match.group(1))
current_el = float(el_match.group(1))
else:
print("AZ or EL not found.")
print("Current position:", current_az,", ", current_el)
response = "{}\n{}\n".format(current_az, current_el) #put az & el into the format Gpredict expects
conn.send(response.encode('utf-8')) #send response to Gpredict
antenna.flush()
elif cmd[0] == "P": #Gpredict is sending desired position
target_az = float(cmd[1])
target_el = float(cmd[2])
print('Gpredict requesting move to:', target_az,', ', target_el)
print('\n')
#Set movement a little farther than requested to "leap-frog" over position.
#This helps avoid the antenna lagging behind the satellite in the middle of the pass
if target_az - current_az > 2:
target_az+=1
elif target_az - current_az < -2:
target_az-=1
elif target_az - current_az > 1:
target_az+=0.5
elif target_az - current_az < -1:
target_az-=0.5
if target_el - current_el > 2:
target_az+=1
elif target_el - current_el < -2:
target_az-=1
elif target_el - current_el > 1:
target_az+=0.5
elif target_el - current_el < -1:
target_az-=0.5
#tell Antenna to move to target position
#Alternate AZ and EL motions to avoid one waiting on the other
if (move_nbr % 2) == 0: #Even numbered step, send AZ first
command = ('a 0 ' + str(target_az) + '\r').encode('ascii') #move azimuth motor
antenna.write(command)
#tell Antenna to move to target el
if (target_el < 15): #HAL 2.05 won't reliably go below 15 degrees with this method
target_el = 15
command = ('a 1 ' + str(target_el) + '\r').encode('ascii') #move elevation motor
antenna.write(command)
else: #Odd numbered step, send EL first
#tell Antenna to move to target el
if (target_el < 15):
target_el = 15
command = ('a 1 ' + str(target_el) + '\r').encode('ascii') #move elevation motor
antenna.write(command)
#tell Antenna to move to target az
command = ('a 0 ' + str(target_az) + '\r').encode('ascii') #move azimuth motor
antenna.write(command)
#Tell Gpredict things went correctly
response="RPRT 0\n " #Everything's under control, situation normal
conn.send(response.encode('utf-8'))
move_nbr+=1 #increment step counter
elif cmd[0] == "S": #Gpredict says to stop, so exit loop and listen for a new connection
print('Gpredict disconnected!')
move_nbr = 0
conn.close()
break
elif cmd[0] == "_": #Gpredict asks for model name (does it ever do this?)
response = "Saveitforparts Winegard Trav'ler Interface v2.0"
conn.send(response.encode('utf-8'))
else:
print('Exiting.')
antenna.write(bytes(b'q\r')) #go back to root menu
antenna.write(bytes(b'\r'))
conn.close()
antenna.close()
exit()

41
docs/bugs.md Normal file
View File

@ -0,0 +1,41 @@
# Known Bugs in Original Code
## Leap-Frog Elevation Bug
**Location:** `Trav-ler-Rotor-For-HAL-2.05/travler_rotor.py` lines 98-105
**Issue:** The elevation delta section of the leap-frog algorithm modifies
`target_az` instead of `target_el`. This is a copy-paste error from the
azimuth section above it.
**Original code (lines 90-105):**
```python
# Azimuth compensation (correct)
if target_az - current_az > 2:
target_az+=1
elif target_az - current_az < -2:
target_az-=1
elif target_az - current_az > 1:
target_az+=0.5
elif target_az - current_az < -1:
target_az-=0.5
# Elevation compensation (BUG: modifies target_az instead of target_el)
if target_el - current_el > 2:
target_az+=1 # <-- should be target_el
elif target_el - current_el < -2:
target_az-=1 # <-- should be target_el
elif target_el - current_el > 1:
target_az+=0.5 # <-- should be target_el
elif target_el - current_el < -1:
target_az-=0.5 # <-- should be target_el
```
**Impact:**
- Elevation leap-frog compensation was never applied, so the dish would
lag behind in elevation during fast satellite passes
- Azimuth received double compensation (its own delta + the elevation delta),
causing over-correction on the azimuth axis
**Fix:** In `travler_rotor.leapfrog.apply_leapfrog()`, the elevation
compensation correctly modifies `target_el`.

28
pyproject.toml Normal file
View File

@ -0,0 +1,28 @@
[build-system]
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"
license = "MIT"
requires-python = ">=3.11"
authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}]
dependencies = [
"pyserial>=3.5",
"click>=8.0",
]
[project.scripts]
travler-rotor = "travler_rotor.cli:main"
[tool.ruff]
target-version = "py311"
src = ["src"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
[tool.hatch.build.targets.wheel]
packages = ["src/travler_rotor"]

View File

@ -0,0 +1,22 @@
"""travler-rotor: Control Winegard Trav'ler satellite dishes via RS-485."""
from travler_rotor.antenna import AntennaConfig, TravlerAntenna
from travler_rotor.leapfrog import apply_leapfrog
from travler_rotor.protocol import (
FirmwareProtocol,
HAL000Protocol,
HAL205Protocol,
Position,
)
from travler_rotor.rotctld import RotctldServer
__all__ = [
"AntennaConfig",
"FirmwareProtocol",
"HAL000Protocol",
"HAL205Protocol",
"Position",
"RotctldServer",
"TravlerAntenna",
"apply_leapfrog",
]

View File

@ -0,0 +1,129 @@
"""High-level antenna control for the Winegard Trav'ler.
This is the main interface that consumers (CLI, rotctld server, future MCP
server) should use. It wraps the firmware protocol with position tracking,
leap-frog compensation, and motor command alternation.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from travler_rotor.leapfrog import apply_leapfrog
from travler_rotor.protocol import (
MOTOR_AZIMUTH,
MOTOR_ELEVATION,
FirmwareProtocol,
Position,
)
logger = logging.getLogger(__name__)
@dataclass
class AntennaConfig:
"""Configuration for a Trav'ler antenna connection."""
port: str = "/dev/ttyUSB0"
baudrate: int = 57600
min_elevation: float = 15.0
leapfrog_enabled: bool = True
class TravlerAntenna:
"""High-level interface to a Winegard Trav'ler dish.
Manages the full lifecycle: connect, initialize (boot + search kill),
track positions, and move with leap-frog compensation.
"""
def __init__(
self, protocol: FirmwareProtocol, config: AntennaConfig | None = None
) -> None:
self._protocol = protocol
self._config = config or AntennaConfig()
self._last_position = Position(azimuth=0.0, elevation=0.0)
self._move_count: int = 0
@property
def config(self) -> AntennaConfig:
return self._config
@property
def is_connected(self) -> bool:
return self._protocol.is_connected
def connect(self) -> None:
"""Open serial connection to the dish."""
self._protocol.connect(self._config.port, self._config.baudrate)
def disconnect(self) -> None:
"""Close serial connection."""
self._protocol.disconnect()
def initialize(self) -> None:
"""Full boot sequence: connect (if needed), wait for calibration, kill search.
After this completes the dish is ready to accept motor commands.
"""
if not self.is_connected:
self.connect()
def _boot_status(line: str) -> None:
logger.info("Boot: %s", line)
self._protocol.initialize(callback=_boot_status)
self._protocol.enter_motor_menu()
logger.info("Antenna initialized and ready")
def get_position(self) -> Position:
"""Query and return current dish position."""
pos = self._protocol.get_position()
self._last_position = pos
return pos
def move_to(self, azimuth: float, elevation: float) -> None:
"""Move dish to the specified AZ/EL with leap-frog compensation.
The elevation floor from config.min_elevation is enforced HAL 2.05
won't reliably go below ~15 degrees with direct motor commands.
Motor commands are alternated (AZ-first on even moves, EL-first on
odd) to prevent one axis from starving the other on the shared
serial bus.
"""
# Apply leap-frog prediction if enabled
if self._config.leapfrog_enabled:
azimuth, elevation = apply_leapfrog(
azimuth,
elevation,
self._last_position.azimuth,
self._last_position.elevation,
)
# Enforce elevation floor
if elevation < self._config.min_elevation:
elevation = self._config.min_elevation
logger.debug(
"Moving to AZ=%.1f EL=%.1f (move #%d)",
azimuth,
elevation,
self._move_count,
)
# Alternate motor command order to avoid one axis starving
if self._move_count % 2 == 0:
self._protocol.move_motor(MOTOR_AZIMUTH, azimuth)
self._protocol.move_motor(MOTOR_ELEVATION, elevation)
else:
self._protocol.move_motor(MOTOR_ELEVATION, elevation)
self._protocol.move_motor(MOTOR_AZIMUTH, azimuth)
self._move_count += 1
def stop(self) -> None:
"""Stop tracking and reset move counter."""
self._move_count = 0
logger.info("Tracking stopped")

202
src/travler_rotor/cli.py Normal file
View File

@ -0,0 +1,202 @@
"""CLI entry point for travler-rotor.
Provides subcommands for initialization, position queries, manual moves,
and running a full rotctld-compatible server for Gpredict integration.
"""
from __future__ import annotations
import logging
import sys
import click
from travler_rotor.antenna import AntennaConfig, TravlerAntenna
from travler_rotor.protocol import get_protocol
from travler_rotor.rotctld import RotctldServer
def _setup_logging(verbose: bool) -> None:
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(
level=level,
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
def _build_antenna(port: str, firmware: str, **config_kwargs) -> TravlerAntenna:
"""Create a TravlerAntenna from CLI options."""
protocol = get_protocol(firmware)
config = AntennaConfig(port=port, **config_kwargs)
return TravlerAntenna(protocol, config)
@click.group()
@click.version_option(package_name="travler-rotor")
@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."""
_setup_logging(verbose)
@main.command()
@click.option(
"--port",
envvar="TRAVLER_PORT",
default="/dev/ttyUSB0",
show_default=True,
help="Serial port for the RS-485 adapter.",
)
@click.option(
"--firmware",
envvar="TRAVLER_FIRMWARE",
default="hal205",
show_default=True,
type=click.Choice(["hal205", "hal000"], case_sensitive=False),
help="Firmware version on the dish.",
)
def init(port: str, firmware: str) -> None:
"""Initialize the antenna: wait for boot, kill satellite search."""
antenna = _build_antenna(port, firmware)
try:
click.echo(f"Connecting to {port} (firmware: {firmware})...")
antenna.initialize()
click.echo("Antenna initialized and ready.")
except KeyboardInterrupt:
click.echo("\nInterrupted.")
finally:
antenna.disconnect()
@main.command()
@click.option(
"--port",
envvar="TRAVLER_PORT",
default="/dev/ttyUSB0",
show_default=True,
help="Serial port for the RS-485 adapter.",
)
@click.option(
"--firmware",
envvar="TRAVLER_FIRMWARE",
default="hal205",
show_default=True,
type=click.Choice(["hal205", "hal000"], case_sensitive=False),
help="Firmware version on the dish.",
)
@click.option(
"--host",
envvar="TRAVLER_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",
default=4533,
show_default=True,
type=int,
help="TCP port for rotctld protocol.",
)
@click.option(
"--skip-init",
is_flag=True,
help="Skip boot wait and search kill (dish already initialized).",
)
def serve(
port: str, firmware: str, host: str, listen_port: int, skip_init: bool
) -> None:
"""Run a rotctld-compatible TCP server for Gpredict."""
antenna = _build_antenna(port, firmware)
try:
if skip_init:
click.echo(f"Connecting to {port} (skipping init)...")
antenna.connect()
antenna._protocol.enter_motor_menu()
else:
click.echo(f"Initializing antenna on {port}...")
antenna.initialize()
server = RotctldServer(antenna, host=host, port=listen_port)
click.echo(f"rotctld server listening on {host}:{listen_port}")
click.echo("Ctrl-C to stop")
server.serve_forever()
except KeyboardInterrupt:
click.echo("\nShutting down...")
finally:
antenna.disconnect()
@main.command()
@click.option(
"--port",
envvar="TRAVLER_PORT",
default="/dev/ttyUSB0",
show_default=True,
help="Serial port for the RS-485 adapter.",
)
@click.option(
"--firmware",
envvar="TRAVLER_FIRMWARE",
default="hal205",
show_default=True,
type=click.Choice(["hal205", "hal000"], case_sensitive=False),
help="Firmware version on the dish.",
)
def pos(port: str, firmware: str) -> None:
"""Query and print the current dish position."""
antenna = _build_antenna(port, firmware)
try:
antenna.connect()
antenna._protocol.enter_motor_menu()
position = antenna.get_position()
click.echo(f"AZ: {position.azimuth:.1f}")
click.echo(f"EL: {position.elevation:.1f}")
if position.skew is not None:
click.echo(f"SK: {position.skew:.1f}")
except Exception as exc:
click.echo(f"Error: {exc}", err=True)
sys.exit(1)
finally:
antenna.disconnect()
@main.command()
@click.option(
"--port",
envvar="TRAVLER_PORT",
default="/dev/ttyUSB0",
show_default=True,
help="Serial port for the RS-485 adapter.",
)
@click.option(
"--firmware",
envvar="TRAVLER_FIRMWARE",
default="hal205",
show_default=True,
type=click.Choice(["hal205", "hal000"], case_sensitive=False),
help="Firmware version on the dish.",
)
@click.option("--az", required=True, type=float, help="Target azimuth (degrees).")
@click.option("--el", required=True, type=float, help="Target elevation (degrees).")
@click.option(
"--no-leapfrog",
is_flag=True,
help="Disable leap-frog compensation for this move.",
)
def move(port: str, firmware: str, az: float, el: float, no_leapfrog: bool) -> None:
"""Move the dish to a specific AZ/EL position."""
antenna = _build_antenna(port, firmware, leapfrog_enabled=not no_leapfrog)
try:
antenna.connect()
antenna._protocol.enter_motor_menu()
click.echo(f"Moving to AZ={az:.1f} EL={el:.1f}...")
antenna.move_to(az, el)
click.echo("Command sent.")
except Exception as exc:
click.echo(f"Error: {exc}", err=True)
sys.exit(1)
finally:
antenna.disconnect()

View File

@ -0,0 +1,50 @@
"""Leap-frog prediction algorithm for mechanical lag compensation.
The Trav'ler dish has inherent mechanical lag — by the time the motors
reach the commanded position, a satellite in motion has already moved on.
This module applies a small predictive overshoot so the dish "leaps ahead"
of the target, reducing tracking error during a pass.
"""
def apply_leapfrog(
target_az: float,
target_el: float,
current_az: float,
current_el: float,
) -> tuple[float, float]:
"""Apply predictive overshoot to compensate for mechanical lag.
For each axis, if the delta exceeds a threshold, the target is nudged
further in the direction of travel. This keeps the dish slightly ahead
of fast-moving satellites.
Args:
target_az: Desired azimuth from tracking software (degrees).
target_el: Desired elevation from tracking software (degrees).
current_az: Last-known azimuth of the dish (degrees).
current_el: Last-known elevation of the dish (degrees).
Returns:
Adjusted (azimuth, elevation) with overshoot applied.
Note:
The original upstream code had a copy-paste bug where the elevation
delta adjustments modified target_az instead of target_el.
See docs/bugs.md for details.
"""
# Azimuth compensation
az_delta = target_az - current_az
if abs(az_delta) > 2:
target_az += 1.0 if az_delta > 0 else -1.0
elif abs(az_delta) > 1:
target_az += 0.5 if az_delta > 0 else -0.5
# Elevation compensation (bug fix: original modified target_az here)
el_delta = target_el - current_el
if abs(el_delta) > 2:
target_el += 1.0 if el_delta > 0 else -1.0
elif abs(el_delta) > 1:
target_el += 0.5 if el_delta > 0 else -0.5
return target_az, target_el

View File

@ -0,0 +1,244 @@
"""Firmware protocol abstraction for Winegard Trav'ler RS-485 communication.
Each firmware version (HAL) uses slightly different serial commands, boot
signals, and submenu structures. This module defines an abstract protocol
and concrete implementations so the rest of the library doesn't care which
firmware is on the other end of the wire.
"""
from __future__ import annotations
import logging
import re
import time
from abc import ABC, abstractmethod
from collections.abc import Callable
from dataclasses import dataclass
import serial
logger = logging.getLogger(__name__)
# Motor IDs used in "a <id> <degrees>" commands
MOTOR_AZIMUTH = 0
MOTOR_ELEVATION = 1
@dataclass
class Position:
"""Current dish orientation."""
azimuth: float
elevation: float
skew: float | None = None
class FirmwareProtocol(ABC):
"""Abstract base for Winegard firmware communication over RS-485."""
def __init__(self) -> None:
self._serial: serial.Serial | None = None
@property
def is_connected(self) -> bool:
return self._serial is not None and self._serial.is_open
def connect(self, port: str, baudrate: int = 57600) -> None:
"""Open the RS-485 serial connection."""
self._serial = serial.Serial(
port=port,
baudrate=baudrate,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS,
timeout=1,
)
logger.info("Connected on %s at %d baud", port, baudrate)
def disconnect(self) -> None:
"""Close the serial connection."""
if self._serial and self._serial.is_open:
self.reset_to_root()
self._serial.close()
logger.info("Disconnected")
self._serial = None
def _write(self, cmd: str) -> None:
"""Send a command string followed by carriage return."""
if not self._serial:
raise RuntimeError("Not connected")
self._serial.write(f"{cmd}\r".encode("ascii"))
def _read(self, size: int = 200) -> str:
"""Read up to `size` bytes from serial, decode with error tolerance."""
if not self._serial:
raise RuntimeError("Not connected")
self._serial.flush()
return self._serial.read(size).decode(errors="ignore").strip()
def _readline(self) -> str:
"""Read a single line from serial."""
if not self._serial:
raise RuntimeError("Not connected")
return self._serial.readline().decode(errors="ignore").strip()
def reset_to_root(self) -> None:
"""Return to the firmware root menu."""
self._write("q")
self._write("") # clear prompt
@abstractmethod
def initialize(self, callback: Callable[[str], None] | None = None) -> None:
"""Wait for boot to complete and kill the satellite search task.
Args:
callback: Optional function called with each status line
received during boot (useful for progress display).
"""
@abstractmethod
def enter_motor_menu(self) -> None:
"""Navigate into the motor control submenu."""
def get_position(self) -> Position:
"""Query the dish for its current AZ/EL/SK position."""
self._write("a")
reply = self._read()
az_match = re.search(r"AZ =\s*(\d+\.\d+)", reply)
el_match = re.search(r"EL =\s*(\d+\.\d+)", reply)
sk_match = re.search(r"SK =\s*(\d+\.\d+)", reply)
if not az_match or not el_match:
raise ValueError(f"Could not parse position from: {reply!r}")
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,
)
def move_motor(self, motor_id: int, degrees: float) -> None:
"""Command a single motor to an absolute position.
Args:
motor_id: MOTOR_AZIMUTH (0) or MOTOR_ELEVATION (1).
degrees: Target angle in degrees.
"""
self._write(f"a {motor_id} {degrees}")
@abstractmethod
def kill_search(self) -> None:
"""Cancel the firmware's automatic TV satellite search."""
class HAL205Protocol(FirmwareProtocol):
"""HAL 2.05.003 firmware.
Boot signals: "NoGPS" or "No LNB Voltage"
Motor submenu: "motor"
Search kill: ngsearch -> s -> q
"""
BOOT_SIGNALS = ("NoGPS", "No LNB Voltage")
MOTOR_COMMAND = "motor"
def initialize(self, callback: Callable[[str], None] | None = None) -> None:
logger.info("Waiting for HAL 2.05 boot (ensure IDU is powered on)...")
while True:
line = self._readline()
if not line:
continue
if callback:
callback(line)
logger.debug("Boot: %s", line)
if any(signal in line for signal in self.BOOT_SIGNALS):
logger.info("Boot complete — homing finished")
break
self.kill_search()
self.reset_to_root()
def kill_search(self) -> None:
self.reset_to_root()
self._write("ngsearch")
time.sleep(0.2)
self._write("s")
time.sleep(0.2)
self._write("q")
self._write("")
logger.info("Search task cancelled")
def enter_motor_menu(self) -> None:
self.reset_to_root()
self._write(self.MOTOR_COMMAND)
class HAL000Protocol(FirmwareProtocol):
"""HAL 0.0.00 firmware.
Uses shorter command names and a different init sequence.
Motor submenu: "mot"
"""
MOTOR_COMMAND = "mot"
def initialize(self, callback: Callable[[str], None] | None = None) -> None:
# HAL 0.0.00 has a different boot sequence — the exact signals
# are not documented in the upstream repo. This is a best-effort
# implementation that should be validated against real hardware.
logger.info("Waiting for HAL 0.0.00 boot...")
while True:
line = self._readline()
if not line:
continue
if callback:
callback(line)
logger.debug("Boot: %s", line)
# HAL 0.0.00 boot detection — adjust if hardware reveals
# different signals
if "NoGPS" in line or "ready" in line.lower():
logger.info("Boot complete")
break
self.kill_search()
self.reset_to_root()
def kill_search(self) -> None:
# HAL 0.0.00 may have a different search-kill sequence.
# Falling back to root-menu reset as a safe default.
self.reset_to_root()
logger.info("Search task cancelled (HAL 0.0.00)")
def enter_motor_menu(self) -> None:
self.reset_to_root()
self._write(self.MOTOR_COMMAND)
# Registry for firmware lookup by name
FIRMWARE_REGISTRY: dict[str, type[FirmwareProtocol]] = {
"hal205": HAL205Protocol,
"hal000": HAL000Protocol,
}
def get_protocol(name: str) -> FirmwareProtocol:
"""Instantiate a firmware protocol by short name.
Args:
name: One of "hal205", "hal000".
Raises:
KeyError: If the firmware name is not recognized.
"""
try:
return FIRMWARE_REGISTRY[name.lower()]()
except KeyError:
available = ", ".join(sorted(FIRMWARE_REGISTRY))
raise KeyError(f"Unknown firmware {name!r}. Available: {available}") from None

View File

@ -0,0 +1,135 @@
"""Hamlib rotctld-compatible TCP server.
Implements the subset of the rotctld protocol that Gpredict (and other
Hamlib clients) use for AZ/EL rotor control:
p get position (returns "AZ\\nEL\\n")
P set position ("P <az> <el>")
S stop / disconnect
_ get model name
q quit connection
"""
from __future__ import annotations
import logging
import socket
from travler_rotor.antenna import TravlerAntenna
logger = logging.getLogger(__name__)
MODEL_NAME = "Winegard Trav'ler RS-485 Rotor"
class RotctldServer:
"""TCP server speaking the Hamlib rotctld wire protocol."""
def __init__(
self,
antenna: TravlerAntenna,
host: str = "127.0.0.1",
port: int = 4533,
) -> None:
self._antenna = antenna
self._host = host
self._port = port
self._server_socket: socket.socket | None = None
self._running = False
def serve_forever(self) -> None:
"""Listen for connections and handle rotctld commands.
Blocks until shutdown() is called or the process is interrupted.
"""
self._server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self._server_socket.bind((self._host, self._port))
self._server_socket.listen(1)
self._server_socket.settimeout(1.0)
self._running = True
logger.info("rotctld listening on %s:%d", self._host, self._port)
while self._running:
try:
conn, addr = self._server_socket.accept()
except TimeoutError:
continue
logger.info("Connection from %s", addr)
try:
self._handle_connection(conn)
except Exception:
logger.exception("Error handling connection from %s", addr)
finally:
conn.close()
logger.info("Connection closed from %s", addr)
def shutdown(self) -> None:
"""Signal the server to stop accepting connections."""
self._running = False
if self._server_socket:
self._server_socket.close()
self._server_socket = None
def _handle_connection(self, conn: socket.socket) -> None:
"""Process commands from a single client connection."""
while self._running:
data = conn.recv(1024)
if not data:
break
cmd_parts = data.decode("utf-8").strip().split()
if not cmd_parts:
continue
cmd = cmd_parts[0]
if cmd == "p":
self._handle_get_position(conn)
elif cmd == "P":
self._handle_set_position(conn, cmd_parts)
elif cmd == "S":
self._handle_stop(conn)
break
elif cmd == "_":
self._handle_model_name(conn)
elif cmd == "q":
break
else:
logger.warning("Unknown command: %s", cmd)
conn.sendall(b"RPRT -1\n")
def _handle_get_position(self, conn: socket.socket) -> None:
"""Respond to 'p' — return current AZ and EL."""
try:
pos = self._antenna.get_position()
response = f"{pos.azimuth}\n{pos.elevation}\n"
conn.sendall(response.encode("utf-8"))
except Exception:
logger.exception("Failed to get position")
conn.sendall(b"RPRT -1\n")
def _handle_set_position(self, conn: socket.socket, parts: list[str]) -> None:
"""Respond to 'P <az> <el>' — move dish to target."""
try:
target_az = float(parts[1])
target_el = float(parts[2])
self._antenna.move_to(target_az, target_el)
conn.sendall(b"RPRT 0\n")
except (IndexError, ValueError):
logger.error("Bad P command: %s", parts)
conn.sendall(b"RPRT -1\n")
except Exception:
logger.exception("Failed to move")
conn.sendall(b"RPRT -1\n")
def _handle_stop(self, conn: socket.socket) -> None:
"""Respond to 'S' — stop tracking."""
self._antenna.stop()
logger.info("Client sent stop")
def _handle_model_name(self, conn: socket.socket) -> None:
"""Respond to '_' — return model identification string."""
conn.sendall(f"{MODEL_NAME}\n".encode())

48
uv.lock generated Normal file
View File

@ -0,0 +1,48 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "pyserial"
version = "3.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" }
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" },
]