net.py: typo fix + catch the right exception
[breadcrumb.git] / code / breadcrumb / client / nmea.py
blob7a8867cb76e9b1dc579569335ac4585cf4a5f63f
1 # -*- coding: utf-8 -*-
2 """ Provides tools for parsing NMEA sentences. """
4 # Copyright (C) 2008 Laurens Van Houtven <lvh at laurensvh.be>
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation, either version 3 of the License, or
8 # (at your option) any later version.
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
15 # You should have received a copy of the GNU General Public License
16 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18 import datetime
19 import time
20 import logging
21 import sys
22 import os
24 from geopy.util import parse_geo
25 import geopy
27 from consts import SENTENCE_OFFSETS
29 # import ../common
30 sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
31 from common.checksum import is_valid_nmea_sentence
33 def parse(raw_sentence):
34 """ Parses an NMEA sentence.
36 Input should be a single NMEA sentence. Output is a dict of the relevant
37 key, value pairs, suitable for creating a DataPoint object.
39 Some GPSes keep spamming RMC/GGA sentences even though there is no fix.
40 These sentences are entirely empty except for the timestamp and header.
41 - example: the built-in GPS in my HTC Diamond
43 Other GPSes only send RMC/GGA when they actually have a (reliable) fix,
44 or send a lot less data, or send a bogus value for heading or velocity.
45 - example: my HOLUX GPSlim236
46 """
48 sentence = raw_sentence.strip()
49 logging.debug("Got sentence: %s" % sentence)
51 if not interesting(sentence):
52 logging.debug("Sentence was not interesting, ignoring...")
53 return None
55 if not is_valid_nmea_sentence(sentence):
56 return None
58 # Extract data from the unpacked sentence
59 unpacked = unpack(sentence)
60 if unpacked is None:
61 return None
62 else:
63 return extract(unpacked)
65 def interesting(sentence):
66 """ Determines if a sentence is interesting or not by looking at the header.
68 A sentence is interesting if it starts with $GP:
69 >>> interesting('$GP')
70 True
72 This includes most (probably even all) non-proprietary sentences.
74 Returns false for all other sentences:
75 >>> interesting('$XX')
76 False
77 """
78 if sentence[0:3] == '$GP':
79 return True
80 else:
81 return False
83 def unpack(sentence):
84 """ Returns the unpacked version of the sentence, minus header and checksum.
86 >>> unpack('$GPGGA,spam,eggs*00')
87 ['GGA', 'spam', 'eggs']
88 """
89 if sentence[-3] == "*": # sentence with checksum
90 return sentence[3:-3].split(',')
91 elif sentence[-1] == "*": # sentence without checksum
92 return sentence[3:-1].split(',')
93 else:
94 logging.debug("Couldn't figure out how to split %s.." % sentence)
95 return None
97 def extract(data):
98 """
99 Extracts data from a given array using given offsets.
101 Returns a dictionary with the necessary data.
103 point_data = {}
105 offsets = getoffsets(data)
107 # Put the data from the sentence into a dict:
108 for key, index in offsets:
109 if not data[index]:
110 logging.debug("Partially empty sentence, attr: %s" % key)
111 continue
113 # Special cases:
114 elif key == 'time':
115 # Convert timestamp from NMEA (to unix)
116 point_data.update(timestamp = gettimestamp(data[index]))
117 elif key == 'velocity_kt':
118 # Convert velocity from knots (to m/s)
119 point_data.update(velocity = float(data[index]) * 0.514)
120 elif key == 'valid_rmc':
121 if data[index] != 'A':
122 logging.error("Bogus RMC sentence, ignoring...")
123 return {}
124 else:
125 point_data.update({key: data[index]})
127 # Get the coordinates, too:
128 point_data = getcoordinates(point_data)
129 return point_data
131 def getoffsets(data_array):
132 """ Returns the right sentence offsets for a given data array. """
133 sentence_type = data_array[0]
135 if sentence_type in SENTENCE_OFFSETS:
136 return SENTENCE_OFFSETS[sentence_type].items()
137 else:
138 logging.error("No offsets for sentence type %s..." % sentence_type)
139 return {}.items()
141 def gettimestamp(nmea_timestamp):
142 """ Gets the number of seconds since epoch for a given NMEA timestamp.
144 NMEA timestamps only contain the time for that day (24 hour format).
146 This is turned into a UNIX time stamp (seconds since epoch). The date is
147 guessed by picking the current date. Since this conversion is done on the
148 client, and the latency between receiving and parsing is pretty small, this
149 should not cause big problems.
151 However, you might see serious problems if your GPS and your client don't
152 agree on the current time. If the clock on the GPS is ahead of that the
153 client and you're receiving points around midnight, your GPS will pretend
154 the points are from just after midnight, but your PC will be convinced they
155 are almost 24 hours ago (midnight today instead of midnight tomorrow).
157 If this turns out to be a problem, we can always ignore the NMEA time
158 stamp (either always, or some symmetric interval around midnight). This is
159 not implemented yet.
161 The argument is usually a string representation of an int, but passing an
162 integer works equally well:
165 # TODO: Implement functionality to ignore the NMEA timestamp and just get
166 # the client's timestamp.
168 # Hours, minutes, seconds
169 hrs = int(nmea_timestamp[0:2])
170 mins = int(nmea_timestamp[2:4])
171 if '.' in nmea_timestamp[4:]: # NMEA timestamp with microseconds
172 [raw_s, raw_ms] = nmea_timestamp[4:].split('.')
173 secs = int(raw_s)
174 ms = int(raw_ms)
175 else: # NMEA timestamp without microseconds
176 secs = int(nmea_timestamp[4:])
177 ms = 0
179 # Year, month, day -- assumes we're getting recent data
180 now = datetime.datetime.utcnow().timetuple()
181 y, m, d = now[0], now[1], now[2]
183 realtime = datetime.datetime(y, m, d, hrs, mins, secs, ms)
185 return int(time.mktime(realtime.timetuple()))
187 def getcoordinates(data):
188 """ Extract latitude and longitude from raw data.
190 First, the ugly NMEA notation is turned into something prettier. Then, the
191 geopy module is used to transform the decimal minutes notation into decimal
192 degrees, which allows representing a coordinate with a single float.
194 All of the source keys, which are basically the values from the sentence,
195 are deleted once the pretty coordinates have been calculated.
198 keys = ['lat_fl', 'lat_ns', 'lon_ew', 'lon_fl']
200 for key in keys:
201 if key not in data:
202 return data # Nothing for us to do here.
204 latstr = _extract_lat_or_lon(data['lat_fl'], data['lat_ns'])
205 lonstr = _extract_lat_or_lon(data['lon_fl'], data['lon_ew'])
207 for key in keys:
208 del data[key]
210 latitude, longitude = geopy.util.parse_geo("%s, %s" % (latstr, lonstr))
211 data.update(latitude = float(latitude), longitude = float(longitude))
213 return data
215 def _extract_lat_or_lon(ugly_float, hemisphere):
216 """ Parses the ugly NMEA notation for latitudes and longitudes.
218 This returns a pretty string with degrees and decimal minutes:
220 ugly_float is usually a string, since it comes from chopping NMEA sentences,
221 which are strings. It describes a float (specifically, a single coordinate
222 in degrees and decimal minutes), but it's encoded as a string.
224 All angles are positive. The correct hemisphere determined by appending one
225 of ('N','E','S','W'). The geopy module parses this later, resulting in a
226 two float value for coordinates (decimal degrees with signing determining
227 hemisphere).
229 Using a negative angle complains, but continues without the sign.
231 This method does not check correctness of the string. It will return the
232 most likely candidate (which will most likely still make geopy choke). This
233 should never happen unless the GPS that produced the sentence is horribly
234 broken (or simply malicious).
236 [left, right] = str(ugly_float).split('.')
237 degrees = int(left[:-2])
239 if degrees < 0:
240 logging.error('Angle < 0 in coord %s, fixing...' % ugly_float)
241 degrees = -degrees
243 minutes = "%s.%s" % (left[-2:], right)
244 return "%s° %s' %s" % (degrees, minutes, hemisphere)
246 if __name__ == "__main__":
247 import doctest
248 doctest.testmod()