From 800a25eecde601e2c8a85daa5d50cbfd6df51791 Mon Sep 17 00:00:00 2001 From: Michael Toren Date: Tue, 31 Oct 2017 14:05:00 -0700 Subject: [PATCH] Re-packaging for initial public release. (Happy Halloween!) --- LICENSE | 26 +++++ README.md | 49 ++++++++++ greenctld | 286 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 361 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100755 greenctld diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cd8a2ef --- /dev/null +++ b/LICENSE @@ -0,0 +1,26 @@ +Copyright (c) 2017, Astro Digital, Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +The views and conclusions contained in the software and documentation are those +of the authors and should not be interpreted as representing official policies, +either expressed or implied, of Astro Digital, Inc. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cb2f778 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# greenctld + +A hamlib-compatible driver for the [Green Heron Engineering RT-21 Digital Rotor +Controller](https://www.greenheronengineering.com/prod_documents/controllers/docs/RT-21_Manual_current.pdf). +hamlib does not support the rotor, but this program can be used as a drop-in +replacement for rotctld when commanding the RT-21. + +The RT-21 is unlike most of the other rotors hamlib supports in that it uses +two serial ports, one for controlling azimuth and one for controlling +elevation. The two serial ports are passed via the ```--az-device``` and +```--el-device``` command line arguments. + +The TCP network protocol is compatible with the hamlib protocol documented in +the [rotctld(8) man +page](http://manpages.ubuntu.com/manpages/zesty/man8/rotctld.8.html). This +driver only implements a subset of that protocol, which includes the subset +that [gpredict](http://gpredict.oz9aec.net/) uses. At [Astro +Digital](https://astrodigital.com/), this driver has been used extensively with +gpredict. For debugging the network protocol, the ```--dummy``` option can be +used to simulate a rotor without connecting to a real serial port. + +Like rotctld, this program does not daemonize on its own. It also produces +copious debugging output to stdout. + +### Usage + + * ```--az-device ```, the serial port to use for azimuth + + * ```--el-device ```, the serial port to use for elevation + + * ```--speed ```, the serial baud rate to use, defaults to 4800 + + * ```--timeout ```, the serial port timeout, defaults to 1.5 + + * ```--port ```, the TCP port to listen for connections on, defaults to 4533, the same as rotctld + + * ```--get-pos```, to query the serial ports for the current az/el, and immediately exit. Useful for testing the serial port configuration. + + * ```--dummy```, to speak the TCP network protocol only without connecting to a serial port, useful for debugging gpredict integration. + +### License + +Copyright (c) 2017 [Astro Digital, Inc](https://astrodigital.com/) + +Released under the terms of the Simplified BSD License; see the [LICENSE](LICENSE) file for details. + +### Author + +Michael Toren <mct@toren.net> diff --git a/greenctld b/greenctld new file mode 100755 index 0000000..97dd827 --- /dev/null +++ b/greenctld @@ -0,0 +1,286 @@ +#!/usr/bin/env python2 +# vim:set ts=4 sw=4 sts=4 ai et smarttab: + +# greenctld, a hamlib-compatible driver for the Green Heron Engineering RT-21 +# Digital Rotor Controller. The Green Heron serial protocol is documented at +# https://www.greenheronengineering.com/prod_documents/controllers/docs/RT-21_Manual_current.pdf +# +# The TCP network protocol is compatible with the hamlib rotctld protocol, which +# gpredict speaks. +# +# Copyright (c) 2017, Astro Digital, Inc. Released under the terms of the +# Simplified BSD License; see the LICENSE file for details. +# +# -- Michael Toren +# Astro Digital, Inc. + +import socket +import serial +import argparse +import traceback +import time +import select +import sys +import re +import os + +class DummyRotor(object): + ''' + A fake Rotor class, useful for debugging the TCPServer class on any + machine, even if the rotor is not physically connected to it. + ''' + az = 0 + el = 0 + + def set_pos(self, az, el): + print '==> %d,%d' % (az, el) + self.az = az + self.el = el + + def get_pos(self): + print '<== %d,%d' % (self.az, self.el) + return (self.az, self.el,) + + def stop(self): + print "==> Stop" + +class GreenHeronRotor(object): + ''' + Driver for the Green Heron Engineering RT-21 Digital Rotor Controller + ''' + az_serial = None + el_serial = None + + def __init__(self, az_device, el_device, baud, timeout): + self.az_serial = serial.Serial(az_device, baudrate=baud, timeout=timeout) + self.el_serial = serial.Serial(el_device, baudrate=baud, timeout=timeout) + print '--- Serial timeout set to', timeout + + def stop(self): + print "==> Stop" + self.az_serial.write(';') + self.el_serial.write(';') + + def set_pos(self, az, el): + print '==> %d,%d' % (az, el) + self.az_serial.write('AP1%03d\r;' % az) + self.el_serial.write('AP1%03d\r;' % el) + time.sleep(0.1) + + def __parse_response(self, buf): + match = re.match(r'^([0-9][0-9][0-9]);$', buf) + if not match: + return -1 + ret = match.groups()[0] + ret = int(ret) + return ret + + def get_pos(self): + ''' + On success, returns a tuple of (az, el) + On failure, returns False + ''' + self.az_serial.flushInput() + self.el_serial.flushInput() + + self.az_serial.write('AI1;') + self.el_serial.write('AI1;') + + az_buf = self.az_serial.read(4) + el_buf = self.el_serial.read(4) + + if len(az_buf) != 4 or len(el_buf) != 4: + print '!!! Serial read failure, received %s and %s' % (repr(az_buf), repr(el_buf)) + return False + + az = self.__parse_response(az_buf) + el = self.__parse_response(el_buf) + + if az < 0 or el < 0: + print '!!! Failed to parse response, received %s and %s' % (repr(az_buf), repr(el_buf)) + return False + + print '<== %d,%d' % (az, el) + return (az, el,) + +class TCPServer(object): + ''' + Implements a subset of the rotctld TCP protocol. gpredict only sends three + commands, all of which are supported: + + - "p" to request the current position + - "P " to set the desired position + - "q" to quit + + This driver also supports: + + - "S" to stop any current movement + + ''' + + # A mapping of client fd's -> receive buffers + client_buf = {} + + def __init__(self, port, rotor, ip=''): + self.rotor = rotor + self.listener = socket.socket() + self.listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.listener.bind((ip, port)) + self.listener.listen(4) + addr = self.listener.getsockname() + print '--- Listening for connections on %s:%d' % (addr[0], addr[1]) + + def close_client(self, fd): + self.rotor.stop() + try: + fd.close() + del self.client_buf[fd] + except: + pass + + def parse_client_command(self, fd, cmd): + cmd = cmd.strip() + + if cmd == '': + return + + print '<-- %s' % repr(cmd) + + # "q", to quit + if cmd == 'q': + self.close_client(fd) + return + + # "S", to stop the current rotation + if cmd == 'S': + self.rotor.stop() + print '--> RPRT 0' + fd.send('RPRT 0\n') + return + + # "p", to get current position + if cmd == 'p': + pos = self.rotor.get_pos() + if not pos: + print '--> RPRT -6' + fd.send('RPRT -6\n') + else: + az, el = pos + print '--> %d,%d' % (az, el) + fd.send('%.6f\n%.6f\n' % (az, el)) + return + + # "P " to set desired position + match = re.match(r'^P\s+([\d.]+)\s+([\d.]+)$', cmd) + if match: + az = match.groups()[0] + el = match.groups()[1] + try: + az = int(float(az)) + el = int(float(el)) + except: + print '--> RPRT -8 (could not parse)' + fd.send('RPRT -8\n') + return + + if az == 360: + az = 359 + + if az > 359: + print '--> RPRT -1 (az too large)' + fd.send('RPRT -1\n') + return + + if el > 90: + print '--> RPRT -1 (el too large)' + fd.send('RPRT -1\n') + return + + self.rotor.set_pos(az, el) + print '--> RPRT 0' + fd.send('RPRT 0\n') + return + + # Nothing else is supported + print '--> RPRT -4 (unknown command)' + fd.send('RPRT -4\n') + + def read_client(self, fd): + buf = fd.recv(1024) + + if len(buf) == 0: + print '<-- EOF' + self.close_client(fd) + return + + self.client_buf[fd] += buf + + while True: + cmd, sep, tail = self.client_buf[fd].partition('\n') + + # Check if a full line of input is present + if not sep: + return + else: + self.client_buf[fd] = tail + + self.parse_client_command(fd, cmd) + + # Check if the client sent a "q", to quit + if not self.client_buf.has_key(fd): + return + + def __run_once(self): + rlist = [ self.listener ] + self.client_buf.keys() + wlist = [] + xlist = [] + + rlist, wlist, xlist = select.select(rlist, wlist, xlist) + + for fd in rlist: + if fd == self.listener: + new_fd, addr = self.listener.accept() + new_fd.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1024*16) + new_fd.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 1024*16) + new_fd.setblocking(False) + self.client_buf[new_fd] = '' + print '<-- Connect %s:%d' % (addr[0], addr[1]) + + else: + try: + self.read_client(fd) + except Exception as e: + print 'Unhandled exception, killing client and issuing motor stop command:' + traceback.print_exc() + self.close_client(fd) + + print + + def loop(self): + while True: + self.__run_once() + +if __name__ == '__main__': + sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 0) + + parser = argparse.ArgumentParser() + parser.add_argument('--az-device', '-a', type=str, required=True, help='Serial device for azimuth') + parser.add_argument('--el-device', '-e', type=str, required=True, help='Serial device for elevation') + parser.add_argument('--speed', '-s', type=int, default=4800, help='Serial device speed') + parser.add_argument('--timeout', '-t', type=float, default=1.5, help='Serial timeout') + parser.add_argument('--port', '-p', type=int, default=4533, help='TCP port') + parser.add_argument('--get-pos', '-g', action='store_true', help='Issue get position, and exit; for serial comms test') + parser.add_argument('--dummy', action='store_true', help='Use a dummy rotor, not the real serial device') + args = parser.parse_args() + + if args.dummy: + rotor = DummyRotor() + else: + rotor = GreenHeronRotor(args.az_device, args.el_device, args.speed, args.timeout) + + if args.get_pos: + print rotor.get_pos() + sys.exit(0) + + server = TCPServer(args.port, rotor) + server.loop()