mirror of
				https://github.com/kemenril/iR-APRSISD.git
				synced 2025-03-05 20:51:35 -08:00 
			
		
		
		
	Initial commit. Error handling is still a bit of a mess.
This commit is contained in:
		
							parent
							
								
									9b497fe8d4
								
							
						
					
					
						commit
						d0dce8d84d
					
				
							
								
								
									
										214
									
								
								ir-aprsisd
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										214
									
								
								ir-aprsisd
									
									
									
									
									
										Executable file
									
								
							|  | @ -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')) | ||||
| 
 | ||||
| 
 | ||||
							
								
								
									
										26
									
								
								ir-aprsisd.cfg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								ir-aprsisd.cfg
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
		Loading…
	
		Reference in a new issue