PstRotator and SATNOGs

I was able to control my 2m/70cm rotatable beam from SatNOGS using PstRotator software and custom protocol proxy written in python. The proxy is an ugly hack but it works for me, so hope it will be useful to someone. The PstRotator software supports a wast amount of rotors, it would be beneficial to use it in SatNOGS. Unfortunately at this moment PstRotator does not support SatNOGS version of hamlib (dont ask), so some proxy required in order to translate rotctl commands to something PstRotator will understand.

My configuration consists of RasPi running SatNOGs and Windows PC running PstRotator. The cheap Channel Master rotator controlled by PstRotator over USB-UIRT module (again, does not matter what rotor you have as long as it is supported by PstRotator).

You will need to specify the ROT_MODEL_NETROTCTL in your SatNOGS setup, along with the port and IP of your Windows PC (the one which will be running proxy).

In your PstRotator you will need to enable Setup --> UDP control option.

Save the script below to a file and update if you have different port/IP of your Windows box (I mentioned already that this script is an ugly hack).

Works with python 2.7 (python 3 needs some work). To start, issue “python filename.py”
Feel free to share or improve, this is not supported code by any means.

#!/usr/bin/env python2.7
#
#   Andriy Rokhmanov - w9km@yahoo.com
#   Protocol Proxy between PstRotator and Hamlib rotctl
#   Based on:
#   Project Horus - Rotator Abstraction Layers
#
#   Copyright (C) 2018  Mark Jessop <vk5qi@rfhead.net>
#   Released under GNU GPL v3 or later
#
import socket
import time
import logging
from threading import Thread

class PSTRotator(object):
    """ PSTRotator communication class """

    # Local store of current azimuth/elevation
    current_azimuth = None
    current_elevation = None

    azel_thread_running = False
    service_thread_running = False
    last_poll_time = 0

    def __init__(self, hostname='localhost', port=12000, poll_rate=1, service_port=4533):
        """ Start a PSTRotator connection instance """
        self.hostname = hostname
        self.port = port
        self.poll_rate = poll_rate
        self.service_port = service_port
        self.azel_thread_running = True
        self.service_thread_running = True

        self.t_rx = Thread(target=self.azel_rx_loop)
        self.t_rx.start()

        self.t_poll = Thread(target=self.azel_poll_loop)
        self.t_poll.start()

        #self.t_service = Thread(target=self.service_loop)
        #self.t_service.start()

    def close(self):
        self.azel_thread_running = False
        self.service_thread_running = False

    def set_azel(self,azimuth,elevation):
        """ Send an Azimuth/Elevation move command to PSTRotator """

        # Sanity check inputs.
        if elevation > 90.0:
            elevation = 90.0
        elif elevation < 0.0:
            elevation = 0.0

        if azimuth > 360.0:
            azimuth = azimuth % 360.0

        # Generate command
        pst_command = "<PST><TRACK>0</TRACK><AZIMUTH>%.1f</AZIMUTH><ELEVATION>%.1f</ELEVATION></PST>" % (azimuth,elevation)
        logging.debug("Sent command: %s" % pst_command)
        # Send!
        udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        udp_socket.sendto(pst_command.encode('ascii'), (self.hostname,self.port))
        udp_socket.close()

        return True

    def poll_azel(self):
        """ Poll PSTRotator for an Azimuth/Elevation Update """
        az_poll_command = "<PST>AZ?</PST>"
        el_poll_command = "<PST>EL?</PST>"
        try:
            udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
            udp_socket.sendto(az_poll_command.encode('ascii'), (self.hostname,self.port))
            time.sleep(0.2)
            udp_socket.sendto(el_poll_command.encode('ascii'), (self.hostname,self.port))
            udp_socket.close()
        except:
            pass
                    
    def service_loop(self):
        while self.service_thread_running:
            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
                s.bind(('127.0.0.1', self.service_port))
                s.listen()
                conn, addr = s.accept()
                with conn:
                    print('Connected by', addr)
                    while True:
                        data = conn.recv(1024)
                        if not data:
                            break
                        logging.debug("Rotctl Sent: %s" % data)
                        conn.sendall(data)
            
    def azel_poll_loop(self):
        while self.azel_thread_running:
            self.poll_azel()
            #logging.debug("Poll sent to PSTRotator.")
            time.sleep(self.poll_rate)

    def azel_rx_loop(self):
        """ Listen for Azimuth and Elevation reports from PSTRotator"""
        s = socket.socket(socket.AF_INET,socket.SOCK_DGRAM)
        s.settimeout(1)
        s.bind(('',(self.port+1)))
        logging.debug("Started PST Rotator Listener Thread.")
        while self.azel_thread_running:
            try:
                m = s.recvfrom(512)
            except socket.timeout:
                m = None
            
            if m != None:
                # Attempt to parse Azimuth / Elevation
                #logging.debug("Received: %s" % m[0])
                data = m[0].decode('ascii')
                if data[:2] == 'EL':
                    if data[3:]:
                        #logging.debug("Empty EL string.")
                        pass
                    else:
                        self.current_elevation = float(data[3:])
                elif data[:2] == 'AZ':
                    if data[3:]:
                        #logging.debug("Empty AZ string.")
                        pass
                    else:
                        self.current_azimuth = float(data[3:])

        
        logging.debug("Closing UDP Listener")
        s.close()


    def get_azel(self):
        return (self.current_azimuth, self.current_elevation)

if __name__ == "__main__":
    logging.basicConfig(format='%(asctime)s %(levelname)s:%(message)s', level=logging.DEBUG)

    rot = PSTRotator(hostname="192.168.0.208", poll_rate=10)

    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.bind(('192.168.0.208', 4533))
    sock.listen(1)
    while True:
        conn, addr = sock.accept()
        try:
            logging.debug("Connected by %s", addr)
            while True:
                data = conn.recv(1024)
                if not data:
                    break
                for line in data.splitlines():
                    #logging.debug("Received: %s", line)
                    if line.startswith('\dump_state'):                
                        logging.debug("dump_state: %s" % line)
                        dump_state = "10"+"\n"+"11"+"\n"+"0"+"\n"+"359"+"\n"+"0"+"\n"+"90"+"\n" 
                        conn.sendall(dump_state)
                    elif line.startswith('P'):
                        logging.debug("Set Position: %s" % line)
                        pos = line.split(' ')
                        rot.current_azimuth = pos[1]
                        rot.current_elevation = pos[2]
                        conn.sendall("RPRT 0\n")
                        rot.set_azel(float(pos[1]), float(pos[2]))
                        #rot.set_azel(120, 20)
                    elif line.startswith('p'):
                        logging.debug("Get Position: %s" % line)
                        if rot.current_azimuth == None:
                            rot.current_azimuth = "0"
                        if rot.current_elevation == None:
                            rot.current_elevation = "0"                        
                        current_pos = rot.current_azimuth + "\n" + rot.current_elevation
                        logging.debug("Reporting: %s", current_pos)
                        conn.sendall(current_pos + "\n")
                    else:
                        logging.debug("Other.. %s" % line)
        except:
            pass
        finally:
            conn.close()
7 Likes

I had been struggling with SATNOGs and PstRotator for some time. Finally I have a fully working station and I want to share what I did.

UHF station is now running SATNOGs 1.8.1 installed on a J1900 processor running Ubuntu 20

/etc/default/satnogs-client
Nothing new here, same as I used in the previous version 1.6

SATNOGS_ROT_MODEL="ROT_MODEL_NETROTCTL"
SATNOGS_ROT_BAUD="0"
SATNOGS_ROT_PORT="192.168.14.144:4533"

Here seems to be the solution, I found this in another post

In
/var/lib/satnogs/lib/python3.8/site-packages/satnogsclient/rotator.py
I added the last two lines to this section

    def __init__(self, model, baud, port):
        """Class constructor"""
        self.rot_port = port
        self.rot_baud = baud
        self.rot_name = getattr(Hamlib, model)
        self.rot = Hamlib.Rot(self.rot_name)
        self.rot.state.rotport.pathname = self.rot_port
        self.rot.state.rotport.parm.serial.rate = self.rot_baud
        self.rot.set_conf("max_az","450")**
        self.rot.set_conf("min_az","-180")**

and last in PstRotator go to the ‘SETUP’ and check the ROTCTLD hamlib server

2 Likes

Hello together!

Sorry for digging out this old post, but I had some minor problems by following the steps to setup SatNOGS with PstRotator. Therefore, especially for new unexperienced users I would like to give some additional information and present my lessons learned while following the work done by @n5zkk. :slight_smile: Thanks for that!

The “proxy” presented by @w9km is no longer required since PstRotator is capable of running a Rotctld Hamlib Server. In combination with the ROT_MODEL_NETROTCTL Rotctld daemon the connection between the SatNOGS Client and PstRotator is established. Therefore, some changes and settings which apply to the SatNOGS Client and PstRotator are required. Within the presented setup the SatNOGS Client 1.8.1 runs on a Raspberry Pi and the PstRotator v17.63 on a Windows PC. Both are connected to the same local network.

Settings and adaptations needed for SatNOGS
Starting with the SatNOGS Client the rotator settings can be applied in satnogs-setup → Advanced/Rotator. Here are my settings:


Here the ROT_PORT contains the IP address of the Windows PC running the PstRotator instance. I have also changed the standard port to 4535, since the 4533 port is already used for a Gpredict TCP client/server configuration together with PstRotator in my control setup.

Moreover, it is necessary to add the two lines mentioned by @n5zkk in the rotator.py script. Otherwise, passes are no longer commanded as soon as a rotator AZ or EL limit is reached! E.g. the tracking of following pass starts with 10° AZ but would stop, as soon as the 0° AZ limit is reached:
image
In my current setup the rotator.py script is found under /var/lib/satnogs/lib/python3.9/site-packages/satnogsclient/rotator.py. Here I added the respective lines, but without **! Otherwise I got a syntax error while recompiling the SatNOGS Client.

self.rot.set_conf("max_az","450")
self.rot.set_conf("min_az","-180")

Note, that this changes need to be applied by heading into the satnogs-setup, perform “some changes” and click on “Apply”. (Only clicking on “Apply” doesn’t have any effect on my setup, therefore something needs to be changed. This changes can be reverted after applying them. It is only important that the SatNOGS Client gets recompiled. :slight_smile: ) If someone has a smarter solution to force a recompile, please notify me. :wink:

Settings for PstRotator
As a starting point the PstRotator software has to be connected to your rotator controller, in my case a SPID MD-01 (with addional SPID-ETH module) conntected to a SPID BIG-RAS 0.5deg rotator, according to the user manual. In my setup this is established over Ethernet using TCP and the SPID ROT2 (Rot2Prog) protocol.

Within PstRotator go to Communication/Rotctld Server Setup... and check if the correct port (e.g. 4535) which was set within the SatNOGS rotator setup is applied. Under Setup check the Rotctld Hamlib Server option. Moreover, in the main window of PstRotator it doesn’t matter which mode (Manual or Tracking) is selected.

Have fun using PstRotator with SatNOGS! :slight_smile: In the next weeks I will perform some testing with respect to the WX Setup (connecting PstRotator to a local weather station) and its behaviour together with SatNOGS. Here I will uitilize the possibility of pointing the antenna into the wind or performing a safety shutdown due to high wind loads. This safety mechanism is required for our 4.5m S-Band dish and is the main reason for changing the setup to PstRotator. Feel free to ask, if you have any questions related to that. :slight_smile:

Cheers,
Alex, OE3ALA

Hi, and cool that you’re using a rotator (:

The settings comes from rotctld defaults and can be set with the commandline for it rotctld -C min_az=-180,max_az=450 <and the rest of your args> not sure how this is managed on pstrotator.

Well, a bit overkill perhaps. You can fork the -client master and implement the changes there, then point your setup to that repo.
sudo satnogs-setup and set the following parameters under Advanced → Software:
using this format, change the url and which branch you want it to use.
SATNOGS_CLIENT_URL = git+https://gitlab.com/librespacefoundation/satnogs/satnogs-client.git@master
Keep in mind that it will no longer follow the master branch unless you keep updating that fork when the master is updated.

This is not what is needed thou. A client restart is what is needed, there’s nothing being rebuilt. Only thing that could happen, if a new client version was released it could just overwrite your changes.
sudo systemctl restart satnogs-client

I run a G5500 and a SPID RAS both of them support overwind but I don’t use that. I don’t have the SPID-ETH, just using the usb-serial on MD-03.

In my case, I set it to min_az=0,max_az=360,min_el=0,max_el=180 and using my SATNOGS_ROT_INVERT implementation to figure out how to flip a 0-180 deg capable altitude rotator to avoid the azimuth endstop during the pass.

Yikes, that is some big gun dish :stuck_out_tongue:
That would deserve it’s own show and tell thread!

Cheers and good luck (: de SA2KNG

1 Like

Hi Daniel! As always, thanks for your answer. :wink:

I can try how this works out. So you suggest to run rotctld on startup, e.g. in a crontab at reboot with the respective arguments? Currently, I guess that this service is started through the NETROTCTL daemon at the start of an observation, where the respective server runs on PstRotator. When I run systemctl status rotctld outside of an observation, it gives:

● rotctld.service - rotctld server
     Loaded: loaded (/etc/systemd/system/rotctld.service; disabled; vendor preset: enabled)
     Active: inactive (dead)

So a client restart is sufficient to apply changes made to scripts of the SatNOGS Client? I was a little bit confused and thought it is necessary, since e.g when I make changes to gr-satellites it needs a rebuild using cmake, make, sudo make install, sudo ldconfig, ... to apply the changes. But I guess that a GNURadio OOT module is a totally different architecture and therefore not compareable in this case.

If I understood correctly, in the current setup my changes will be overwritten as soon as a new client version is released? Therefore, you suggest to fork the offical master branch, apply the changes in my own repository, link it to my SatNOGS Client and if necessary update the fork if there is a new release of the client?

Thanks for that hint, I will have a look. Currently, the rotator is limited to AZ 0-360° and EL 0-90° by the MD-01 controller and therefore no overwind region. This will be optimized later after checking the actual physical limitation to avoid tearing out the coaxial cables. :wink:

Yes it is. :grin: That dish already ran successfull in a test setup for S-Band within SatNOGS and 10 GHz QO-100 reception. :slight_smile: It will be used for our next CubeSat mission CLIMB, but it still needs optimization (antenna balance, calibration using sun and moon noise together with PstRotator and SDR Console, feed mount and placement, …). It will remain in “testing” mode, but we are planing on cooperating with other satellite teams to help on request by scheduling the respective passes in SatNOGS and providing the raw IQ-file. :slight_smile:

Still a big journey to go, but I am happy to be able to do the full commissioning of our VHF/UHF and S-band ground station throughout my Master thesis. :slight_smile:

73 de
Alex, OE3ALA

2 Likes