-
Notifications
You must be signed in to change notification settings - Fork 5
/
ir-aprsisd
executable file
·372 lines (317 loc) · 12.4 KB
/
ir-aprsisd
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
#!/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, os, signal
import configparser
from optparse import OptionParser
#Name of our configuration file.
cf = "ir-aprsisd.cfg"
#Command-line options
op = OptionParser()
op.add_option("-C","--config",action="store",type="string",dest="config",help="Load the named configuration file.")
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("--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("-P","--irpass",action="store",type="string",dest="irpass",help="inReach feed password")
op.add_option("-U","--url",action="store",type="string",dest="url",help="URL for KML feed")
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.")
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")
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.")
(opts,args) = op.parse_args()
#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)
#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'):
print("Loaded configuration: " + cfile)
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.
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)
#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.irpass:
conf['inReach']['Password'] = opts.irpass
if opts.url:
conf['inReach']['URL'] = opts.url
if opts.comment:
conf['APRS']['Comment'] = opts.comment
if opts.delay:
conf['General']['Period'] = opts.delay
#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)
#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)
#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.
# This is the part between the source and the gate.
ARPreamble = ''.join(['>APRS,','TCPIP*,','qAS,',conf['APRS']['SSID']])
NAME = "iR-APRSISD"
REV = "0.3"
#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)
#Handle connection to APRS-IS
def reconnect():
global AIS
attempt = 1
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("Connection failed. Reconnecting: " + str(attempt))
attempt += 1
time.sleep(3)
continue
#We'll store the device to ssid mapping here.
SSIDList = {}
#We'll store timestamps here
lastUpdate = {}
#Packet counts here
transmitted = {}
#Last time stats() was run:
lastStats = calendar.timegm(time.gmtime())
#Load any preconfigured mappings
if conf.has_section('Devices'):
print("Loading predefined SSID mappings.")
for device in conf['Devices'].keys():
SSIDList[conf['Devices'][device]] = device.upper()
print("Static mapping: " + SSIDList[conf['Devices'][device]] + " -> " + device.upper())
#Get an SSID
def getSSID(DID):
global lastUpdate, SSIDList, transmitted, SSNum, Call
if not DID: # Don't map None
return None
#If we have a Devices section, the SSID list is static.
if DID not in SSIDList:
if conf.has_section('Devices'):
return None
SSIDList[DID] = ''.join([Call,"-",str(SSNum)])
SSNum = SSNum + 1
print("Mapping: " + DID + " -> " + SSIDList[DID])
#Add a timestamp on the first call
# This prevents us from redelivering an old message, which can stay
# in the feed.
if DID not in lastUpdate:
lastUpdate[DID] = calendar.timegm(time.gmtime())
if DID not in transmitted:
transmitted[DID] = 0
return SSIDList[DID]
#APRS-IS Setup
AIS = None
reconnect()
#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
#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
#Make sure the device mapping is good.
if not 'IMEI' in extended:
return None
if not getSSID(extended['IMEI']):
return None
#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.
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 = []
try:
KML = http.open(conf['inReach']['URL']).read()
except Exception as e:
print("Error reading URL: " + conf['inReach']['URL'])
return events
try:
data = xml.dom.minidom.parseString(KML).documentElement
except Exception as e:
print("Can't process KML feed on this pass.")
return events
#The first placemark will have the expanded current location information.
for PM in data.getElementsByTagName('Placemark'):
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, transmitted, lastUpdate
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([" : ", NAME, " v", REV, " : ", platform.system(), " on ", platform.machine(), " : "])
if device:
gateInfo = gateInfo + device
aprsPacket = ''.join([getSSID(DevID),ARPreamble, ':@', time.strftime("%d%H%Mz",tstamp), pos])
#Check to make sure the update is new:
if not etime > lastUpdate[DevID]:
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 != None) and (course != None):
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.
comment = "/A=" + str(round(alt)).zfill(6)[0:6] + comment
#Regardless:
comment = comment + gateInfo
#We have the whole comment in one place now.
#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 = aprsPacket + comment
try:
aprslib.parse(aprsPacket) # For syntax
AIS.sendall(aprsPacket)
#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)
except Exception as e:
print("Could not send APRS packet: ")
print(aprsPacket)
print("Attempting reconnect just in case.")
reconnect()
return None
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()
#... 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.
if "Logstats" in conf["General"]:
if calendar.timegm(time.gmtime()) > lastStats + conf.getint("General","Logstats"):
stats()
time.sleep(conf.getfloat('General','Period'))