From 964efba308f2d4ce331eaad4a9ddb652038ef784 Mon Sep 17 00:00:00 2001 From: Christopher Smith Date: Thu, 1 Apr 2021 00:20:25 -0500 Subject: [PATCH] Most all of the code refactored to be less ridiculous. Sending of APRS position reports is now decoupled from fetching position updates. Main loop nearly simplified into obvlivion. getEvents() function added to get a list of all new events. sendAPRS now works on an individual event in the list. Most of the large try blocks eleminated in favor of less absurd things. Some other minor changes too. --- ir-aprsisd | 257 +++++++++++++++++++++++++++++------------------------ 1 file changed, 140 insertions(+), 117 deletions(-) diff --git a/ir-aprsisd b/ir-aprsisd index a4fcd3e..af6c87f 100755 --- a/ir-aprsisd +++ b/ir-aprsisd @@ -102,13 +102,7 @@ SSNum = int(SSNum) ARPreamble = ''.join(['>APRS,','TCPIP*,','qAS,',conf['APRS']['SSID']]) NAME = "iR-APRSISD" -REV = "0.2" - -#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()) - +REV = "0.3" #Set up the handler for HTTP connections if conf.has_option('inReach','Password'): @@ -131,41 +125,50 @@ def reconnect(): except Exception as e: print("Trouble connecting to APRS-IS server.") print(e) + time.sleep(3) + continue -#We'll store the IMEI to ssid mapping here. +#We'll store the device to ssid mapping here. SSIDList = {} #We'll store timestamps here lastUpdate = {} -#Load any preconfigured IMEI mappings +#Load any preconfigured mappings if conf.has_section('Devices'): for device in conf['Devices'].keys(): SSIDList[conf['Devices'][device]] = device.upper() #Get an SSID -def getSSID(IMEI): +#An apocryphal concern is that this will happily generate an SSID for None, +# or whatever else might get passed along to it. +# There's a record in the usual set of inReach Placemarks where this happens, +# but because the record has no extended data, no packets are ever generated +# for it. Such a record is generally not interesting and on the end of the list, +# so there has been no reason to explicitly skip it. +def getSSID(DID): global lastUpdate, SSIDList, SSNum, Call #If we have a Devices section, the SSID list is static. - if IMEI not in SSIDList: + if DID not in SSIDList: if conf.has_section('Devices'): + print("No device mapping") return None - SSIDList[IMEI] = ''.join([Call,"-",str(SSNum)]) + SSIDList[DID] = ''.join([Call,"-",str(SSNum)]) SSNum = SSNum + 1 #Add a timestamp on the first call # This prevents us from redelivering an old message, which can stay # in the feed. - if IMEI not in lastUpdate: - lastUpdate[IMEI] = calendar.timegm(time.gmtime()) - return SSIDList[IMEI] + if DID not in lastUpdate: + lastUpdate[DID] = calendar.timegm(time.gmtime()) + return SSIDList[DID] #APRS-IS Setup AIS = None reconnect() -#Generate an APRS packet from a Placemark -def sendAPRS(Placemark): +#Get information from a Placemark +def parsePlacemark(Placemark): #We only care about the Placemarks with the ExtendedData sections. if not Placemark.getElementsByTagName('ExtendedData'): return None @@ -175,125 +178,145 @@ def sendAPRS(Placemark): 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 - 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'] - IMEI = extended['IMEI'] - SSID = getSSID(IMEI) - #If there's no valid SSID mapping, we don't process this one. - if not SSID: - return None + #Make sure the device mapping is good. + if not 'IMEI' in extended: + return None + if not getSSID(extended['IMEI']): + return None - #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[IMEI] > etime: - time.sleep(conf.getfloat('General','Period')) - return None - - #If we have SMS data, add that. - if 'Text' in extended: - comment = extended['Text'] - else: #Default comment - comment = conf['APRS']['Comment'] - comment = ''.join([" ", comment, " : ", NAME, " v", REV, " : ", platform.system(), " on ", platform.machine(), " : ", 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] - - #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" + #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: #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] + 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'] - #Aprs position - aprspos = ''.join([aprslat,conf['APRS']['Separator'],aprslong,conf['APRS']['Symbol']]) + #If we have SMS data, add that. + if 'Text' in extended: + comment = extended['Text'] + else: #Default comment + comment = conf['APRS']['Comment'] - #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) + return [device,IMEI,ARPreamble,uttime,latitude,longitude,elevation,course,velocity,comment] - #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([getSSID(IMEI),ARPreamble,':@',aprstime,aprspos,aprscs,aprsalt,comment]) - #This will throw an exception if the packet is somehow wrong. - aprslib.parse(aprsPacket) - AIS.sendall(aprsPacket) - lastUpdate[IMEI] = etime - return aprsPacket - - 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 - -while True: +#Return a list of all events available. Each one is a list of arguments +# for the below sendAPRS function +def getEvents(): + events = [] try: KML = http.open(conf['inReach']['URL']).read() except Exception as e: print(''.join(["Error reading URL: ", conf['inReach']['URL']])) - continue + return None try: data = xml.dom.minidom.parseString(KML).documentElement except Exception as e: print("Can't process KML feed on this pass.") - continue + return None #The first placemark will have the expanded current location information. for PM in data.getElementsByTagName('Placemark'): - sendAPRS(PM) + 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): + global conf + 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']]) + gateInfo = ''.join([" ", comment, " : ", NAME, " v", REV, " : ", platform.system(), " on ", platform.machine(), " : "]) + + 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: + if lastUpdate[DevID] > etime: + 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) + except Exception as e: + print("Could not send APRS packet: ") + print(aprsPacket) + print("Attempting reconnect just in case.") + reconnect() + pass + #Last update in UTC + lastUpdate[DevID] = calendar.timegm(tstamp) + +#... 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. time.sleep(conf.getfloat('General','Period'))