From c93bbef26dbc5dfe2f89c38f1486decc36837533 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Wed, 11 Feb 2026 04:10:17 -0700 Subject: [PATCH] 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. --- .gitignore | 12 + Trav-ler-Rotor-For-HAL-2.05/LICENSE | 24 ++ Trav-ler-Rotor-For-HAL-2.05/README.md | 220 +++++++++++++++++ Trav-ler-Rotor-For-HAL-2.05/init.sh | 5 + Trav-ler-Rotor-For-HAL-2.05/requirements.txt | 5 + Trav-ler-Rotor-For-HAL-2.05/rotor.sh | 5 + Trav-ler-Rotor-For-HAL-2.05/travler_init.py | 45 ++++ Trav-ler-Rotor-For-HAL-2.05/travler_rotor.py | 159 ++++++++++++ docs/bugs.md | 41 ++++ pyproject.toml | 28 +++ src/travler_rotor/__init__.py | 22 ++ src/travler_rotor/antenna.py | 129 ++++++++++ src/travler_rotor/cli.py | 202 +++++++++++++++ src/travler_rotor/leapfrog.py | 50 ++++ src/travler_rotor/protocol.py | 244 +++++++++++++++++++ src/travler_rotor/rotctld.py | 135 ++++++++++ uv.lock | 48 ++++ 17 files changed, 1374 insertions(+) create mode 100644 .gitignore create mode 100644 Trav-ler-Rotor-For-HAL-2.05/LICENSE create mode 100644 Trav-ler-Rotor-For-HAL-2.05/README.md create mode 100644 Trav-ler-Rotor-For-HAL-2.05/init.sh create mode 100644 Trav-ler-Rotor-For-HAL-2.05/requirements.txt create mode 100644 Trav-ler-Rotor-For-HAL-2.05/rotor.sh create mode 100644 Trav-ler-Rotor-For-HAL-2.05/travler_init.py create mode 100644 Trav-ler-Rotor-For-HAL-2.05/travler_rotor.py create mode 100644 docs/bugs.md create mode 100644 pyproject.toml create mode 100644 src/travler_rotor/__init__.py create mode 100644 src/travler_rotor/antenna.py create mode 100644 src/travler_rotor/cli.py create mode 100644 src/travler_rotor/leapfrog.py create mode 100644 src/travler_rotor/protocol.py create mode 100644 src/travler_rotor/rotctld.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f38fe19 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +dist/ +build/ +.eggs/ +*.egg +.venv/ +.env +*.so +.ruff_cache/ diff --git a/Trav-ler-Rotor-For-HAL-2.05/LICENSE b/Trav-ler-Rotor-For-HAL-2.05/LICENSE new file mode 100644 index 0000000..f50ef62 --- /dev/null +++ b/Trav-ler-Rotor-For-HAL-2.05/LICENSE @@ -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 diff --git a/Trav-ler-Rotor-For-HAL-2.05/README.md b/Trav-ler-Rotor-For-HAL-2.05/README.md new file mode 100644 index 0000000..9a401c3 --- /dev/null +++ b/Trav-ler-Rotor-For-HAL-2.05/README.md @@ -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") + + + + + diff --git a/Trav-ler-Rotor-For-HAL-2.05/init.sh b/Trav-ler-Rotor-For-HAL-2.05/init.sh new file mode 100644 index 0000000..37d746b --- /dev/null +++ b/Trav-ler-Rotor-For-HAL-2.05/init.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +#run init script +python3 travler_init.py + diff --git a/Trav-ler-Rotor-For-HAL-2.05/requirements.txt b/Trav-ler-Rotor-For-HAL-2.05/requirements.txt new file mode 100644 index 0000000..3f96d9d --- /dev/null +++ b/Trav-ler-Rotor-For-HAL-2.05/requirements.txt @@ -0,0 +1,5 @@ +#Package requirements for carryout_rotor.py + +pyserial +regex +socket diff --git a/Trav-ler-Rotor-For-HAL-2.05/rotor.sh b/Trav-ler-Rotor-For-HAL-2.05/rotor.sh new file mode 100644 index 0000000..1aac9b8 --- /dev/null +++ b/Trav-ler-Rotor-For-HAL-2.05/rotor.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +#run rotor script +python3 travler_rotor.py + diff --git a/Trav-ler-Rotor-For-HAL-2.05/travler_init.py b/Trav-ler-Rotor-For-HAL-2.05/travler_init.py new file mode 100644 index 0000000..2631f72 --- /dev/null +++ b/Trav-ler-Rotor-For-HAL-2.05/travler_init.py @@ -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() + + + + + diff --git a/Trav-ler-Rotor-For-HAL-2.05/travler_rotor.py b/Trav-ler-Rotor-For-HAL-2.05/travler_rotor.py new file mode 100644 index 0000000..8ab80b6 --- /dev/null +++ b/Trav-ler-Rotor-For-HAL-2.05/travler_rotor.py @@ -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() + + + + + diff --git a/docs/bugs.md b/docs/bugs.md new file mode 100644 index 0000000..94215a8 --- /dev/null +++ b/docs/bugs.md @@ -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`. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1dbae39 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/src/travler_rotor/__init__.py b/src/travler_rotor/__init__.py new file mode 100644 index 0000000..9908bc1 --- /dev/null +++ b/src/travler_rotor/__init__.py @@ -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", +] diff --git a/src/travler_rotor/antenna.py b/src/travler_rotor/antenna.py new file mode 100644 index 0000000..0eb020b --- /dev/null +++ b/src/travler_rotor/antenna.py @@ -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") diff --git a/src/travler_rotor/cli.py b/src/travler_rotor/cli.py new file mode 100644 index 0000000..c6cbf72 --- /dev/null +++ b/src/travler_rotor/cli.py @@ -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() diff --git a/src/travler_rotor/leapfrog.py b/src/travler_rotor/leapfrog.py new file mode 100644 index 0000000..30b4385 --- /dev/null +++ b/src/travler_rotor/leapfrog.py @@ -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 diff --git a/src/travler_rotor/protocol.py b/src/travler_rotor/protocol.py new file mode 100644 index 0000000..9ea67f8 --- /dev/null +++ b/src/travler_rotor/protocol.py @@ -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 " 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 diff --git a/src/travler_rotor/rotctld.py b/src/travler_rotor/rotctld.py new file mode 100644 index 0000000..0102edc --- /dev/null +++ b/src/travler_rotor/rotctld.py @@ -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 ") + 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 ' — 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()) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..cfa2d67 --- /dev/null +++ b/uv.lock @@ -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" }, +]