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

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()