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:
commit
c93bbef26d
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.eggs/
|
||||||
|
*.egg
|
||||||
|
.venv/
|
||||||
|
.env
|
||||||
|
*.so
|
||||||
|
.ruff_cache/
|
||||||
24
Trav-ler-Rotor-For-HAL-2.05/LICENSE
Normal file
24
Trav-ler-Rotor-For-HAL-2.05/LICENSE
Normal 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>
|
||||||
220
Trav-ler-Rotor-For-HAL-2.05/README.md
Normal file
220
Trav-ler-Rotor-For-HAL-2.05/README.md
Normal 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!
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
5
Trav-ler-Rotor-For-HAL-2.05/init.sh
Normal file
5
Trav-ler-Rotor-For-HAL-2.05/init.sh
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
#run init script
|
||||||
|
python3 travler_init.py
|
||||||
|
|
||||||
5
Trav-ler-Rotor-For-HAL-2.05/requirements.txt
Normal file
5
Trav-ler-Rotor-For-HAL-2.05/requirements.txt
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
#Package requirements for carryout_rotor.py
|
||||||
|
|
||||||
|
pyserial
|
||||||
|
regex
|
||||||
|
socket
|
||||||
5
Trav-ler-Rotor-For-HAL-2.05/rotor.sh
Normal file
5
Trav-ler-Rotor-For-HAL-2.05/rotor.sh
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
#run rotor script
|
||||||
|
python3 travler_rotor.py
|
||||||
|
|
||||||
45
Trav-ler-Rotor-For-HAL-2.05/travler_init.py
Normal file
45
Trav-ler-Rotor-For-HAL-2.05/travler_init.py
Normal 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()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
159
Trav-ler-Rotor-For-HAL-2.05/travler_rotor.py
Normal file
159
Trav-ler-Rotor-For-HAL-2.05/travler_rotor.py
Normal 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
41
docs/bugs.md
Normal 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
28
pyproject.toml
Normal 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"]
|
||||||
22
src/travler_rotor/__init__.py
Normal file
22
src/travler_rotor/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
129
src/travler_rotor/antenna.py
Normal file
129
src/travler_rotor/antenna.py
Normal 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
202
src/travler_rotor/cli.py
Normal 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()
|
||||||
50
src/travler_rotor/leapfrog.py
Normal file
50
src/travler_rotor/leapfrog.py
Normal 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
|
||||||
244
src/travler_rotor/protocol.py
Normal file
244
src/travler_rotor/protocol.py
Normal 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
|
||||||
135
src/travler_rotor/rotctld.py
Normal file
135
src/travler_rotor/rotctld.py
Normal 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
48
uv.lock
generated
Normal 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" },
|
||||||
|
]
|
||||||
Loading…
x
Reference in New Issue
Block a user