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/>.
24 from geopy
.util
import parse_geo
27 from consts
import SENTENCE_OFFSETS
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
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...")
55 if not is_valid_nmea_sentence(sentence
):
58 # Extract data from the unpacked sentence
59 unpacked
= unpack(sentence
)
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')
72 This includes most (probably even all) non-proprietary sentences.
74 Returns false for all other sentences:
75 >>> interesting('$XX')
78 if sentence
[0:3] == '$GP':
84 """ Returns the unpacked version of the sentence, minus header and checksum.
86 >>> unpack('$GPGGA,spam,eggs*00')
87 ['GGA', 'spam', 'eggs']
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(',')
94 logging
.debug("Couldn't figure out how to split %s.." % sentence
)
99 Extracts data from a given array using given offsets.
101 Returns a dictionary with the necessary data.
105 offsets
= getoffsets(data
)
107 # Put the data from the sentence into a dict:
108 for key
, index
in offsets
:
110 logging
.debug("Partially empty sentence, attr: %s" % key
)
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...")
125 point_data
.update({key
: data
[index
]})
127 # Get the coordinates, too:
128 point_data
= getcoordinates(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()
138 logging
.error("No offsets for sentence type %s..." % sentence_type
)
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
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('.')
175 else: # NMEA timestamp without microseconds
176 secs
= int(nmea_timestamp
[4:])
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']
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'])
210 latitude
, longitude
= geopy
.util
.parse_geo("%s, %s" % (latstr
, lonstr
))
211 data
.update(latitude
= float(latitude
), longitude
= float(longitude
))
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
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])
240 logging
.error('Angle < 0 in coord %s, fixing...' % ugly_float
)
243 minutes
= "%s.%s" % (left
[-2:], right
)
244 return "%s° %s' %s" % (degrees
, minutes
, hemisphere
)
246 if __name__
== "__main__":