2021-03-25 21:03:37 -07:00
#!/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
2021-04-03 14:02:46 -07:00
import platform, sys, os, signal
2021-03-25 21:03:37 -07:00
import configparser
from optparse import OptionParser
#Name of our configuration file.
cf = "ir-aprsisd.cfg"
#Command-line options
op = OptionParser()
2021-03-29 00:55:29 -07:00
op.add_option("-C","--config",action="store",type="string",dest="config",help="Load the named configuration file.")
2021-03-25 21:03:37 -07:00
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")
2021-03-29 00:55:29 -07:00
op.add_option("--port",action="store",type="int",dest="port",help="APRS-IS port")
2021-03-25 21:03:37 -07:00
op.add_option("-u","--user",action="store",type="string",dest="user",help="inReach username")
2021-03-29 00:55:29 -07:00
op.add_option("-P","--irpass",action="store",type="string",dest="irpass",help="inReach feed password")
2021-03-25 21:03:37 -07:00
op.add_option("-U","--url",action="store",type="string",dest="url",help="URL for KML feed")
2021-03-29 00:55:29 -07:00
op.add_option("-i","--imei",action="store",type="int",dest="imei",help="This instance should watch *only* for the single IMEI given in this option. For a more complicated mapping, use the Device section in the configuration file.")
2021-03-25 21:03:37 -07:00
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")
2021-04-01 22:23:11 -07:00
op.add_option("--genpass",action="store_true",dest="genpass",help="Generate the correct passcode for the SSID given in the configuration, or on the command line, print it, and exit.")
2021-03-25 21:03:37 -07:00
(opts,args) = op.parse_args()
2021-04-01 22:23:11 -07:00
#Handle term and int signals
def trapexit(_signo,_stack_frame):
print()
print("Exiting.")
sys.exit(0)
signal.signal(signal.SIGTERM,trapexit)
signal.signal(signal.SIGINT,trapexit)
2021-03-29 00:55:29 -07:00
#This needs to be defined before the load below happens.
#Load a configuration file, and try to validate that it is loaded.
def loadConfig(cfile):
global conf
conf = configparser.ConfigParser()
if conf.read(cfile):
if conf.has_section('General'):
2021-04-03 14:02:46 -07:00
print("Loaded configuration: " + cfile)
2021-03-29 00:55:29 -07:00
return True
return False
#Handle loading of the configuration file first.
#Other command-line options may override things defined in the file.
if opts.config:
if not loadConfig(opts.config):
print("Can't load configuration: " + opts.config)
sys.exit(1)
else: #Default behavior if no file specified.
2021-04-03 14:02:46 -07:00
if not loadConfig(os.path.join("/etc", cf)):
if not loadConfig(os.path.join(os.path.dirname(os.path.abspath(__file__)),cf)):
if not loadConfig(cf):
print("Can't find configuration: " + cf)
sys.exit(1)
2021-03-29 00:55:29 -07:00
2021-03-25 21:03:37 -07:00
#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
2021-03-26 17:47:53 -07:00
if opts.irpass:
conf['inReach']['Password'] = opts.irpass
2021-03-25 21:03:37 -07:00
if opts.url:
conf['inReach']['URL'] = opts.url
if opts.comment:
conf['APRS']['Comment'] = opts.comment
if opts.delay:
conf['General']['Period'] = opts.delay
2021-04-01 22:23:11 -07:00
#SSID should be standardized to upper-case.
conf['APRS']['SSID'] = conf['APRS']['SSID'].upper()
#Running in passcode generator mode.
if opts.genpass:
print("Using SSID: " + conf['APRS']['SSID'])
print("The passcode is: " + str(aprslib.passcode(conf['APRS']['SSID'])))
print()
sys.exit(0)
2021-03-29 00:55:29 -07:00
#Handle the special case where we've specified an IMEI on the command-line
if opts.imei:
conf['Devices'] = {}
conf['Devices'][conf['APRS']['SSID']] = str(opts.imei)
#Get the number and call from from the default SSID
#If we have multiple devices with non-specific ID mapping, we'll make it up
# from this.
(Call,SSNum) = re.search('(\w+)-(\d+)$',conf['APRS']['SSID']).groups()
SSNum = int(SSNum)
2021-03-25 21:03:37 -07:00
#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.
2021-03-29 00:55:29 -07:00
# This is the part between the source and the gate.
2021-03-29 13:27:50 -07:00
ARPreamble = ''.join(['>APRS,','TCPIP*,','qAS,',conf['APRS']['SSID']])
2021-03-25 21:03:37 -07:00
NAME = "iR-APRSISD"
2021-03-31 22:20:25 -07:00
REV = "0.3"
2021-03-25 21:03:37 -07:00
2021-03-26 17:47:53 -07:00
#Set up the handler for HTTP connections
if conf.has_option('inReach','Password'):
passman = urllib.request.HTTPPasswordMgrWithDefaultRealm()
passman.add_password(None, conf['inReach']['URL'],'',conf['inReach']['Password'])
httpauth = urllib.request.HTTPBasicAuthHandler(passman)
http = urllib.request.build_opener(httpauth)
else:
http = urllib.request.build_opener()
urllib.request.install_opener(http)
2021-03-25 21:03:37 -07:00
#Handle connection to APRS-IS
def reconnect():
global AIS
2021-04-03 14:02:46 -07:00
attempt = 1
2021-03-25 21:03:37 -07:00
while True:
AIS = aprslib.IS(conf['APRS']['SSID'],passwd=conf['APRS']['Password'],port=conf['APRS']['Port'])
try:
AIS.connect()
break
except Exception as e:
2021-04-03 14:02:46 -07:00
print("Connection failed. Reconnecting: " + str(attempt))
attempt += 1
2021-03-31 22:20:25 -07:00
time.sleep(3)
continue
2021-03-25 21:03:37 -07:00
2021-03-31 22:20:25 -07:00
#We'll store the device to ssid mapping here.
2021-03-29 00:55:29 -07:00
SSIDList = {}
#We'll store timestamps here
lastUpdate = {}
2021-04-03 14:02:46 -07:00
#Packet counts here
transmitted = {}
#Last time stats() was run:
lastStats = calendar.timegm(time.gmtime())
2021-03-31 22:20:25 -07:00
#Load any preconfigured mappings
2021-03-29 00:55:29 -07:00
if conf.has_section('Devices'):
2021-04-03 14:02:46 -07:00
print("Loading predefined SSID mappings.")
2021-03-29 00:55:29 -07:00
for device in conf['Devices'].keys():
SSIDList[conf['Devices'][device]] = device.upper()
2021-04-03 14:02:46 -07:00
print("Static mapping: " + SSIDList[conf['Devices'][device]] + " -> " + device.upper())
2021-03-29 00:55:29 -07:00
#Get an SSID
2021-03-31 22:20:25 -07:00
def getSSID(DID):
2021-04-03 14:02:46 -07:00
global lastUpdate, SSIDList, transmitted, SSNum, Call
if not DID: # Don't map None
return None
2021-03-29 00:55:29 -07:00
#If we have a Devices section, the SSID list is static.
2021-03-31 22:20:25 -07:00
if DID not in SSIDList:
2021-03-29 00:55:29 -07:00
if conf.has_section('Devices'):
return None
2021-03-31 22:20:25 -07:00
SSIDList[DID] = ''.join([Call,"-",str(SSNum)])
2021-03-29 00:55:29 -07:00
SSNum = SSNum + 1
2021-04-03 14:02:46 -07:00
print("Mapping: " + DID + " -> " + SSIDList[DID])
2021-03-29 00:55:29 -07:00
#Add a timestamp on the first call
# This prevents us from redelivering an old message, which can stay
# in the feed.
2021-03-31 22:20:25 -07:00
if DID not in lastUpdate:
lastUpdate[DID] = calendar.timegm(time.gmtime())
2021-04-03 14:02:46 -07:00
if DID not in transmitted:
transmitted[DID] = 0
2021-03-31 22:20:25 -07:00
return SSIDList[DID]
2021-03-29 00:55:29 -07:00
2021-03-25 21:03:37 -07:00
#APRS-IS Setup
AIS = None
reconnect()
2021-03-31 22:20:25 -07:00
#Get information from a Placemark
def parsePlacemark(Placemark):
2021-03-29 00:55:29 -07:00
#We only care about the Placemarks with the ExtendedData sections.
if not Placemark.getElementsByTagName('ExtendedData'):
return None
2021-03-25 21:03:37 -07:00
2021-03-29 00:55:29 -07:00
#Now process the extended data into something easier to handle.
extended = {}
for xd in Placemark.getElementsByTagName('ExtendedData')[0].getElementsByTagName('Data'):
if not xd.getElementsByTagName('value')[0].firstChild == None:
extended[xd.getAttribute('name')] = xd.getElementsByTagName('value')[0].firstChild.nodeValue
2021-03-25 21:03:37 -07:00
2021-03-31 22:20:25 -07:00
#Make sure the device mapping is good.
if not 'IMEI' in extended:
return None
if not getSSID(extended['IMEI']):
return None
2021-03-25 21:03:37 -07:00
2021-03-31 22:20:25 -07:00
#Now build the position vector
latitude = None
longitude = None
elevation = None
velocity = None
course = None
uttime = None
device = None
IMEI = extended['IMEI']
if 'Latitude' in extended and 'Longitude' in extended:
latitude = float(extended['Latitude'])
longitude = float(extended['Longitude'])
if 'Elevation' in extended:
2021-03-25 21:03:37 -07:00
#Altitude needs to be in feet above sea-level
# what we get instead is a string with a number of meters
# at the beginning.
2021-03-31 22:20:25 -07:00
elevation = re.sub(r'^(\d+\.?\d+)\s*m.*',r'\1',extended['Elevation'])
elevation = float(elevation) * 3.2808399
if 'Velocity' in extended:
#Velocity in knots, according to APRS.
velocity = float(re.sub(r'(\d+\.?\d+).*',r'\1', extended['Velocity']))*0.5399568
if 'Course' in extended:
#... and the course is just a heading in degrees.
course = float(re.sub(r'(\d+\.?\d+).*',r'\1', extended['Course']))
uttime = time.strptime(extended['Time UTC'] + " UTC","%m/%d/%Y %I:%M:%S %p %Z")
device = extended['Device Type']
#If we have SMS data, add that.
if 'Text' in extended:
comment = extended['Text']
else: #Default comment
comment = conf['APRS']['Comment']
return [device,IMEI,ARPreamble,uttime,latitude,longitude,elevation,course,velocity,comment]
#Return a list of all events available. Each one is a list of arguments
# for the below sendAPRS function
def getEvents():
events = []
2021-03-29 00:55:29 -07:00
try:
KML = http.open(conf['inReach']['URL']).read()
except Exception as e:
2021-04-01 02:25:09 -07:00
print("Error reading URL: " + conf['inReach']['URL'])
2021-04-06 18:39:11 -07:00
return events
2021-03-29 00:55:29 -07:00
try:
data = xml.dom.minidom.parseString(KML).documentElement
except Exception as e:
print("Can't process KML feed on this pass.")
2021-04-06 18:39:11 -07:00
return events
2021-03-29 00:55:29 -07:00
#The first placemark will have the expanded current location information.
for PM in data.getElementsByTagName('Placemark'):
2021-03-31 22:20:25 -07:00
res = parsePlacemark(PM)
if res:
events.append(res)
return events
#Compile and send an APRS packet from the given information
#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.
## Arguments are:
# Device type, Device ID, APRS Preamble, struct Timestamp, float Latitude
# float Longitude, float Altitude in feet, int float course in degrees,
# float speed in knots, comment
def sendAPRS(device, DevID, ARPreamble, tstamp, lat, long, alt, course, speed, comment):
2021-04-06 12:27:53 -07:00
global conf, transmitted, lastUpdate
2021-03-31 22:20:25 -07:00
etime = calendar.timegm(tstamp)
#Latitude conversion
#We start with the truncated degrees, filled to two places
# then add fractional minutes two 2x2 digits.
slat = str(abs(math.trunc(lat))).zfill(2)
slat += '{:.02f}'.format(round((abs(lat)-abs(math.trunc(lat)))*60,2)).zfill(5)
if lat > 0:
slat += "N"
else:
slat += "S"
#Longitude
slong = str(abs(math.trunc(long))).zfill(3)
slong += '{:.02f}'.format(round((abs(long)-abs(math.trunc(long)))*60,2)).zfill(5)
if long > 0:
slong += "E"
else:
slong += "W"
pos = ''.join([slat,conf['APRS']['Separator'],slong,conf['APRS']['Symbol']])
2021-04-02 19:22:21 -07:00
gateInfo = ''.join([" : ", NAME, " v", REV, " : ", platform.system(), " on ", platform.machine(), " : "])
2021-03-31 22:20:25 -07:00
if device:
gateInfo = gateInfo + device
#In the format we're using, APRS comments can be 36 characters
# ... but APRS-IS doesn't seem to care, so leave this off.
#comment = comment[:36]
aprsPacket = ''.join([getSSID(DevID),ARPreamble, ':@', time.strftime("%d%H%Mz",tstamp), pos])
#Check to make sure the update is new:
2021-04-06 18:39:11 -07:00
if not etime > lastUpdate[DevID]:
2021-03-31 22:20:25 -07:00
return None
#In theory a course/speed of 000/000 sholdn't be much different
# from not reporting one, but also in theory, more space is
# available for a comment if we don't add the data extension.
if speed and course:
aprsPacket = ''.join([aprsPacket,str(round(course)).zfill(3), '/', str(min(round(speed),999)).zfill(3)])
#Same with altitude:
if alt:
#We need six digits of altitude.
aprsPacket = aprsPacket + "/A=" + str(round(alt)).zfill(6)[0:6]
# Regardless:
aprsPacket = aprsPacket + comment + gateInfo
try:
aprslib.parse(aprsPacket) # For syntax
AIS.sendall(aprsPacket)
2021-04-08 13:23:50 -07:00
#If the above doesn't raise an exception, we should assume we've sent the packet.
lastUpdate[DevID] = calendar.timegm(tstamp)
transmitted[DevID] += 1
print("Sent: " + aprsPacket)
2021-03-31 22:20:25 -07:00
except Exception as e:
print("Could not send APRS packet: ")
print(aprsPacket)
print("Attempting reconnect just in case.")
reconnect()
2021-04-08 13:23:50 -07:00
return None
2021-04-03 14:02:46 -07:00
def stats():
global transmitted, lastStats
lastStats = calendar.timegm(time.gmtime())
print("----------------Packet Forwarding Summary----------------")
print("|\t" + time.strftime("%Y-%m-%d %R",time.localtime()))
print("| SSID DevID Packets forwarded")
for device in transmitted:
print("| " + getSSID(device) + "\t" + device + "\t\t" + str(transmitted[device]))
print("---------------------------------------------------------")
print()
2021-03-31 22:20:25 -07:00
#... and here is the main loop.
while True:
for packet in getEvents():
sendAPRS(*packet) #Otherwise a list of sendAPRS args for the next packet to send.
2021-04-03 14:02:46 -07:00
if "Logstats" in conf["General"]:
if calendar.timegm(time.gmtime()) > lastStats + conf.getint("General","Logstats"):
stats()
2021-03-25 21:03:37 -07:00
time.sleep(conf.getfloat('General','Period'))