diff --git a/ir-aprsisd b/ir-aprsisd new file mode 100755 index 0000000..d13036e --- /dev/null +++ b/ir-aprsisd @@ -0,0 +1,214 @@ +#!/usr/bin/python3 + +############################################################################ +## +## ir-aprsisd +## +## This is a KML-feed to APRS-IS forwarding daemon. It was written +## for Garmin/DeLorme inReach devices, but might work elsewhere too. +## It polls a KML feed in which it expects to find a point in rougly +## the form that the inReach online feeds use, with attendant course +## and altitude data. It transfers each new point found there to +## APRS-IS. +## +## K0SIN +## +########################################################################### + + +import aprslib +import urllib.request +import xml.dom.minidom +import time, calendar, math, re +import platform, sys +import configparser +from optparse import OptionParser + + +#Name of our configuration file. +cf = "ir-aprsisd.cfg" + +#Configuration +conf = configparser.ConfigParser() +#Try /etc, then try the current directory, or just quit. +try: + conf.read("/etc/" + cf) + if not conf.has_section('General'): + conf.read(cf) + if not conf.has_section('General'): + sys.exit("Can't read configuration file: " + cf) +except: + sys.exit("Can't read configuration file: " + cf) + + +#Command-line options +op = OptionParser() + +op.add_option("-s","--ssid",action="store",type="string",dest="ssid",help="APRS SSID") +op.add_option("-p","--pass",action="store",type="int",dest="passwd",help="APRS-IS password") +op.add_option("-P","--port",action="store",type="int",dest="port",help="APRS-IS port") +op.add_option("-u","--user",action="store",type="string",dest="user",help="inReach username") +op.add_option("-U","--url",action="store",type="string",dest="url",help="URL for KML feed") +op.add_option("-c","--comment",action="store",type="string",dest="comment",help="APRS-IS location beacon comment text") +op.add_option("-d","--delay",action="store",type="int",dest="delay",help="Delay between polls of KML feed") +(opts,args) = op.parse_args() + +#Allow command-line arguments to override the config file. +if opts.ssid: + conf['APRS']['SSID'] = opts.ssid +if opts.passwd: + conf['APRS']['Password'] = str(opts.passwd) +if opts.port: + conf['APRS']['Port'] = str(opts.port) +if opts.user: + conf['inReach']['User'] = opts.user +if opts.url: + conf['inReach']['URL'] = opts.url +if opts.comment: + conf['APRS']['Comment'] = opts.comment +if opts.delay: + conf['General']['Period'] = opts.delay + +#The beginning of our APRS packets should contain a source, path, +# q construct, and gateway address. We'll reuse the same SSID as a gate. +ARPreamble = ''.join([conf['APRS']['SSID'],'>APRS,','TCPIP*,','qAS,',conf['APRS']['SSID']]) + +NAME = "iR-APRSISD" +REV = "0.1" + +#Start with the current time. We use this to keep track of whether we've +# already sent a position, so if we cut it off when the program starts, we +# won't (re-)send old stale entry from inReach +lastUpdate = calendar.timegm(time.gmtime()) + +#Handle connection to APRS-IS +def reconnect(): + global AIS + while True: + AIS = aprslib.IS(conf['APRS']['SSID'],passwd=conf['APRS']['Password'],port=conf['APRS']['Port']) + try: + AIS.connect() + break + except Exception as e: + print("Trouble connecting to APRS-IS server.") + print(e) + + +#APRS-IS Setup +AIS = None +reconnect() + + +while True: + try: + KML = urllib.request.urlopen(conf['inReach']['URL']).read() + except Exception as e: + print(''.join(["Error reading URL: ", conf['inReach']['URL']])) + continue + try: + data = xml.dom.minidom.parseString(KML).documentElement + #The first placemark will have the expanded current location information. + position = data.getElementsByTagName('Placemark')[0] + + #Now process the extended data into something easier to handle. + extended = {} + for xd in position.getElementsByTagName('ExtendedData')[0].getElementsByTagName('Data'): + if not xd.getElementsByTagName('value')[0].firstChild == None: + extended[xd.getAttribute('name')] = xd.getElementsByTagName('value')[0].firstChild.nodeValue + except Exception as e: + print(''.join(["Could not parse data from URL: ",conf['inReach']['URL']])) + continue + + try: + #Here is the position vector + latitude = extended['Latitude'] + longitude = extended['Longitude'] + elevation = extended['Elevation'] + velocity = extended['Velocity'] + course = extended['Course'] + uttime = extended['Time UTC'] + device = extended['Device Type'] + + #Some time conversions. First the time struct. + ts = time.strptime(''.join([uttime," UTC"]),"%m/%d/%Y %I:%M:%S %p %Z") + #Unix epoch time for local record-keeping. + etime = calendar.timegm(ts) + #MonthDayHoursMinutes_z for APRS-IS packet + aprstime = time.strftime("%d%H%Mz",ts) + + #Skip this one if it's old. + if lastUpdate > etime: + time.sleep(conf.getfloat('General','Period')) + continue + + #Append the device type to the comment + conf['APRS']['Comment'] = ''.join([" ",conf['APRS']['Comment']," : ", NAME," v",REV," : ",platform.system()," on ",platform.machine()," : ",device]) + + #Latitude conversion + #We start with the truncated degrees, filled to two places + # then add fractional minutes two 2x2 digits. + latitude = float(latitude) + aprslat = str(abs(math.trunc(latitude))).zfill(2) + aprslat += '{:.02f}'.format(round((abs(latitude)-abs(math.trunc(latitude)))*60,2)).zfill(5) + if latitude > 0: + aprslat += "N" + else: + aprslat += "S" + + #Longitude next. + longitude = float(longitude) + aprslong = str(abs(math.trunc(longitude))).zfill(3) + aprslong += '{:.02f}'.format(round((abs(longitude)-abs(math.trunc(longitude)))*60,2)).zfill(5) + if longitude > 0: + aprslong += "E" + else: + aprslong += "W" + + #Altitude needs to be in feet above sea-level + # what we get instead is a string with a number of meters + # at the beginning. + aprsalt = re.sub(r'^(\d+\.?\d+)\s*m.*',r'\1',elevation) + #We need integer feet, six digits + aprsalt = str(round(float(aprsalt) * 3.2808399)).zfill(6) + aprsalt = "/A=" + aprsalt[0:6] + + #Aprs position + aprspos = ''.join([aprslat,conf['APRS']['Separator'],aprslong,conf['APRS']['Symbol']]) + + #Course is already in degrees, so we just need to reformat it. + aprscourse = str(round(float(re.sub(r'(\d+\.?\d+).*',r'\1', course)))).zfill(3) + + #Speed is in km/h. + aprsspeed = float(re.sub(r'(\d+\.?\d+).*',r'\1',velocity))*0.53995681 + aprsspeed = str(min(round(aprsspeed),999)).zfill(3) + + #Here's the course/speed combination + aprscs = ''.join([aprscourse,'/',aprsspeed]) + + except Exception as e: + print("Some relevant position vector data is missing or malformatted in this record.") + print(e) + #Pass? Maybe... + pass + try: + #According to spec, one valid format for location beacons is + # @092345z/4903.50N/07201.75W>088/036 + # with a comment on the end that can include altitude and other + # information. + aprsPacket = ''.join([ARPreamble,':@',aprstime,aprspos,aprscs,aprsalt,conf['APRS']['Comment']]) + print(aprsPacket) + #This will throw an exception if the packet is somehow wrong. + aprslib.parse(aprsPacket) + AIS.sendall(aprsPacket) + lastUpdate = etime + except Exception as e: + print("Could not send the update: ") + if 'aprsPacket' in locals(): + print(aprsPacket) + print(e) + print("Attempting reconnection, just in case.") + reconnect() + pass + time.sleep(conf.getfloat('General','Period')) + + diff --git a/ir-aprsisd.cfg b/ir-aprsisd.cfg new file mode 100644 index 0000000..078ef3e --- /dev/null +++ b/ir-aprsisd.cfg @@ -0,0 +1,26 @@ +[inReach] +User = Your-inReach-Username +# This should be the location of your KML feed. +URL = https://share.garmin.com/Feed/Share/%(User)s + +[APRS] +SSID = N0CALL-10 +Password = 1234 +Port = 14580 + +#If the separator is /, your icon will come from the primary symbol table. +# if it is \, it will draw from the secondary table. +Separator = / +#This character represents an APRS icon from the table tied to Separator. +Symbol = ( + +#This information is included at the end of each packet, along with some +# other data. +Comment = APRS-IS KML forwarder, by K0SIN + +[General] +# KML polling interval in seconds. +Period = 300 + + +