From 43064671a955c5c8a0f950c9bc6b6ac2516d8868 Mon Sep 17 00:00:00 2001 From: Tom Flanagan Date: Sat, 22 Oct 2022 20:22:57 -0700 Subject: [PATCH] Use UTC for timestamps. Fixes #100 (#151) use UTC for timestamps. Fixes #100 --- examples/nmea2gpx.py | 103 +++++++++++++++++++++++++++++++++++++++ pynmea2/nmea_utils.py | 14 +++++- pynmea2/types/talker.py | 6 +-- test/test_ash.py | 2 +- test/test_nor.py | 8 +-- test/test_proprietary.py | 8 +-- test/test_types.py | 20 ++++---- 7 files changed, 139 insertions(+), 22 deletions(-) create mode 100644 examples/nmea2gpx.py diff --git a/examples/nmea2gpx.py b/examples/nmea2gpx.py new file mode 100644 index 0000000..87154ee --- /dev/null +++ b/examples/nmea2gpx.py @@ -0,0 +1,103 @@ +''' +Convert a NMEA ascii log file into a GPX file +''' + +import argparse +import datetime +import logging +import pathlib +import re +import xml.dom.minidom + +log = logging.getLogger(__name__) + +try: + import pynmea2 +except ImportError: + import sys + import pathlib + p = pathlib.Path(__file__).parent.parent + sys.path.append(str(p)) + log.info(sys.path) + import pynmea2 + + +def main(): + parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('nmea_file') + + args = parser.parse_args() + nmea_file = pathlib.Path(args.nmea_file) + + if m := re.match(r'^(\d{2})(\d{2})(\d{2})', nmea_file.name): + date = datetime.date(year=2000 + int(m.group(1)), month=int(m.group(2)), day=int(m.group(3))) + log.debug('date parsed from filename: %r', date) + else: + date = None + + author = 'https://github.com/Knio/pynmea2' + doc = xml.dom.minidom.Document() + doc.appendChild(root := doc.createElement('gpx')) + root.setAttribute('xmlns', "http://www.topografix.com/GPX/1/1") + root.setAttribute('version', "1.1") + root.setAttribute('creator', author) + root.setAttribute('xmlns', "http://www.topografix.com/GPX/1/1") + root.setAttribute('xmlns:xsi', "http://www.w3.org/2001/XMLSchema-instance") + root.setAttribute('xsi:schemaLocation', "http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd") + + root.appendChild(meta := doc.createElement('metadata')) + root.appendChild(trk := doc.createElement('trk')) + meta.appendChild(meta_name := doc.createElement('name')) + meta.appendChild(meta_author := doc.createElement('author')) + trk.appendChild(trk_name := doc.createElement('name')) + trk.appendChild(trkseg := doc.createElement('trkseg')) + meta_name.appendChild(doc.createTextNode(nmea_file.name)) + trk_name. appendChild(doc.createTextNode(nmea_file.name)) + meta_author.appendChild(author_link := doc.createElement('link')) + author_link.setAttribute('href', author) + author_link.appendChild(author_text := doc.createElement('text')) + author_link.appendChild(author_type := doc.createElement('type')) + author_text.appendChild(doc.createTextNode('Pynmea2')) + author_type.appendChild(doc.createTextNode('text/html')) + + for line in open(args.nmea_file): + try: + msg = pynmea2.parse(line) + except Exception as e: + log.warning('Couldn\'t parse line: %r', e) + continue + + if not (hasattr(msg, 'latitude') and hasattr(msg, 'longitude')): + continue + + # if not hasattr(msg, 'altitude'): + # continue + + trkseg.appendChild(trkpt := doc.createElement('trkpt')) + + trkpt.setAttribute('lat', f'{msg.latitude:.6f}') + trkpt.setAttribute('lon', f'{msg.longitude:.6f}') + if hasattr(msg, 'altitude'): + trkpt.appendChild(ele := doc.createElement('ele')) + ele.appendChild(doc.createTextNode(f'{msg.altitude:.3f}')) + + # TODO try msg.datetime + + if date: + trkpt.appendChild(time := doc.createElement('time')) + dt = datetime.datetime.combine(date, msg.timestamp) + dts = dt.isoformat(timespec='milliseconds').replace('+00:00', 'Z') + time.appendChild(doc.createTextNode(dts)) + + xml_data = doc.toprettyxml( + indent=' ', + newl='\n', + encoding='utf8', + ).decode('utf8') + print(xml_data) + + + +if __name__ == '__main__': + logging.basicConfig(level=logging.DEBUG) + main() \ No newline at end of file diff --git a/pynmea2/nmea_utils.py b/pynmea2/nmea_utils.py index 8cb64e8..36f0f95 100644 --- a/pynmea2/nmea_utils.py +++ b/pynmea2/nmea_utils.py @@ -2,6 +2,17 @@ import datetime import re + +# python 2.7 backport +if not hasattr(datetime, 'timezone'): + class UTC(datetime.tzinfo): + def utcoffset(self, dt): + return datetime.timedelta(0) + class timezone(object): + utc = UTC() + datetime.timezone = timezone + + def valid(s): return s == 'A' @@ -18,7 +29,8 @@ def timestamp(s): hour=int(s[0:2]), minute=int(s[2:4]), second=int(s[4:6]), - microsecond=ms) + microsecond=ms, + tzinfo=datetime.timezone.utc) return t diff --git a/pynmea2/types/talker.py b/pynmea2/types/talker.py index d27ddfe..8c00c7a 100644 --- a/pynmea2/types/talker.py +++ b/pynmea2/types/talker.py @@ -507,7 +507,7 @@ class XTE(TalkerSentence): ) -class ZDA(TalkerSentence): +class ZDA(TalkerSentence, DatetimeFix): fields = ( ("Timestamp", "timestamp", timestamp), # hhmmss.ss = UTC ("Day", "day", int), # 01 to 31 @@ -526,9 +526,9 @@ def tzinfo(self): return TZInfo(self.local_zone, self.local_zone_minutes) @property - def datetime(self): + def localdatetime(self): d = datetime.datetime.combine(self.datestamp, self.timestamp) - return d.replace(tzinfo=self.tzinfo) + return d.astimezone(self.tzinfo) diff --git a/test/test_ash.py b/test/test_ash.py index 37ad969..b7a9425 100644 --- a/test/test_ash.py +++ b/test/test_ash.py @@ -19,7 +19,7 @@ def test_ashratt(): assert type(msg) == pynmea2.ash.ASHRATT assert msg.data == ['R', '130533.620', '0.311', 'T', '-80.467', '-1.395', '0.25', '0.066', '0.067', '0.215', '2', '3'] assert msg.manufacturer == 'ASH' - assert msg.timestamp == datetime.time(13, 5, 33, 620000) + assert msg.timestamp == datetime.time(13, 5, 33, 620000, tzinfo=datetime.timezone.utc) assert msg.true_heading == 0.311 assert msg.is_true_heading == 'T' assert msg.roll == -80.467 diff --git a/test/test_nor.py b/test/test_nor.py index a95d7a0..2c020b5 100644 --- a/test/test_nor.py +++ b/test/test_nor.py @@ -11,7 +11,7 @@ def test_norbt0(): assert msg.sentence_type == 'NORBT0' assert msg.beam == 1 assert msg.datestamp == datetime.date(2021, 7, 4) - assert msg.timestamp == datetime.time(13, 13, 35, 334100) + assert msg.timestamp == datetime.time(13, 13, 35, 334100, tzinfo=datetime.timezone.utc) assert msg.dt1 == 23.961 assert msg.dt2 == -48.122 assert msg.bv == -32.76800 @@ -164,7 +164,7 @@ def test_nors1(): assert msg.manufacturer == 'NOR' assert msg.sentence_type == 'NORS1' assert msg.datestamp == datetime.date(2009, 11, 16) - assert msg.timestamp == datetime.time(13, 24, 55) + assert msg.timestamp == datetime.time(13, 24, 55, tzinfo=datetime.timezone.utc) assert msg.ec == 0 assert msg.sc == '34000034' assert msg.battery_voltage == 23.9 @@ -203,7 +203,7 @@ def test_norc1(): assert type(msg) == pynmea2.nor.NORC1 assert msg.manufacturer == 'NOR' assert msg.sentence_type == 'NORC1' - assert msg.datetime == datetime.datetime(2009, 11, 16, 13, 24, 55) + assert msg.datetime == datetime.datetime(2009, 11, 16, 13, 24, 55, tzinfo=datetime.timezone.utc) assert msg.cn == 3 assert msg.cp == 11.0 assert msg.vx == 0.332 @@ -242,7 +242,7 @@ def test_norh4(): assert msg.manufacturer == 'NOR' assert msg.sentence_type == 'NORH4' assert msg.datestamp == datetime.date(2009, 11, 16) - assert msg.timestamp == datetime.time(14, 34, 59) + assert msg.timestamp == datetime.time(14, 34, 59, tzinfo=datetime.timezone.utc) assert msg.ec == 0 assert msg.sc == '204C0002' assert msg.render() == data diff --git a/test/test_proprietary.py b/test/test_proprietary.py index 3e6a526..58995f8 100644 --- a/test/test_proprietary.py +++ b/test/test_proprietary.py @@ -138,7 +138,7 @@ def test_ubx00(): assert type(msg) == pynmea2.ubx.UBX00 assert msg.identifier() == 'PUBX' assert msg.ubx_type == '00' - assert msg.timestamp == datetime.time(7, 44, 40) + assert msg.timestamp == datetime.time(7, 44, 40, tzinfo=datetime.timezone.utc) assert msg.latitude == 47.06236716666667 assert msg.lat_dir == 'N' assert msg.render() == data @@ -157,7 +157,7 @@ def test_ubx04(): msg = pynmea2.parse(data) assert type(msg) == pynmea2.ubx.UBX04 assert msg.date == datetime.date(2014, 10, 13) - assert msg.time == datetime.time(7, 38, 24) + assert msg.time == datetime.time(7, 38, 24, tzinfo=datetime.timezone.utc) assert msg.clk_bias == 495176 assert msg.render() == data @@ -239,7 +239,7 @@ def test_KWDWPL(): data = "$PKWDWPL,053125,V,4531.7900,N,12253.4800,W,,,200320,,AC7FD-1,/-*10" msg = pynmea2.parse(data) assert msg.manufacturer == "KWD" - assert msg.timestamp == datetime.time(5, 31, 25) + assert msg.timestamp == datetime.time(5, 31, 25, tzinfo=datetime.timezone.utc) assert msg.status == 'V' assert msg.is_valid == False assert msg.lat == '4531.7900' @@ -249,7 +249,7 @@ def test_KWDWPL(): assert msg.sog == None assert msg.cog == None assert msg.datestamp == datetime.date(2020, 3, 20) - assert msg.datetime == datetime.datetime(2020, 3, 20, 5, 31, 25) + assert msg.datetime == datetime.datetime(2020, 3, 20, 5, 31, 25, tzinfo=datetime.timezone.utc) assert msg.altitude == None assert msg.wname == 'AC7FD-1' assert msg.ts == '/-' diff --git a/test/test_types.py b/test/test_types.py index 565664d..1164d38 100644 --- a/test/test_types.py +++ b/test/test_types.py @@ -13,7 +13,7 @@ def test_GGA(): assert isinstance(msg, pynmea2.GGA) # Timestamp - assert msg.timestamp == datetime.time(18, 43, 53, 70000) + assert msg.timestamp == datetime.time(18, 43, 53, 70000, tzinfo=datetime.timezone.utc) # Latitude assert msg.lat == '1929.045' # Latitude Direction @@ -99,7 +99,7 @@ def test_GST(): data = "$GPGST,172814.0,0.006,0.023,0.020,273.6,0.023,0.020,0.031*6A" msg = pynmea2.parse(data) assert isinstance(msg, pynmea2.GST) - assert msg.timestamp == datetime.time(hour=17, minute=28, second=14) + assert msg.timestamp == datetime.time(hour=17, minute=28, second=14, tzinfo=datetime.timezone.utc) assert msg.rms == 0.006 assert msg.std_dev_major == 0.023 assert msg.std_dev_minor == 0.020 @@ -114,11 +114,11 @@ def test_RMC(): data = '''$GPRMC,225446,A,4916.45,N,12311.12,W,000.5,054.7,191194,020.3,E*68''' msg = pynmea2.parse(data) assert isinstance(msg, pynmea2.RMC) - assert msg.timestamp == datetime.time(hour=22, minute=54, second=46) + assert msg.timestamp == datetime.time(hour=22, minute=54, second=46, tzinfo=datetime.timezone.utc) assert msg.datestamp == datetime.date(1994, 11, 19) assert msg.latitude == 49.274166666666666 assert msg.longitude == -123.18533333333333 - assert msg.datetime == datetime.datetime(1994, 11, 19, 22, 54, 46) + assert msg.datetime == datetime.datetime(1994, 11, 19, 22, 54, 46, tzinfo=datetime.timezone.utc) assert msg.is_valid == True assert msg.render() == data @@ -129,7 +129,7 @@ def test_RMC_valid(): only test validation against supplied values. Supplied means that a `,` exists it does NOT mean that a value had to be - supplied in the space provided. See + supplied in the space provided. See https://orolia.com/manuals/VSP/Content/NC_and_SS/Com/Topics/APPENDIX/NMEA_RMCmess.htm @@ -140,7 +140,7 @@ def test_RMC_valid(): '$GPRMC,123519.00,A,4807.038,N,01131.000,E,,,230394,,*33', '$GPRMC,123519.00,V,4807.038,N,01131.000,E,,,230394,,*24', '$GPRMC,123519.00,,4807.038,N,01131.000,E,,,230394,,*72', - + # RMC Timing Messages '$GPRMC,123519.00,A,4807.038,N,01131.000,E,,,230394,,,S*4C', '$GPRMC,123519.00,A,4807.038,N,01131.000,E,,,230394,,,N*51', @@ -151,7 +151,7 @@ def test_RMC_valid(): '$GPRMC,123519.00,,4807.038,N,01131.000,E,,,230394,,,S*0D', '$GPRMC,123519.00,,4807.038,N,01131.000,E,,,230394,,,N*10', '$GPRMC,123519.00,,4807.038,N,01131.000,E,,,230394,,,*5E', - + # RMC Nav Messags '$GPRMC,123519.00,A,4807.038,N,01131.000,E,,,230394,,,S,S*33', '$GPRMC,123519.00,A,4807.038,N,01131.000,E,,,230394,,,S,V*36', @@ -204,14 +204,16 @@ def test_ZDA(): data = '''$GPZDA,010203.05,06,07,2008,-08,30''' msg = pynmea2.parse(data) assert isinstance(msg, pynmea2.ZDA) - assert msg.timestamp == datetime.time(hour=1, minute=2, second=3, microsecond=50000) + assert msg.timestamp == datetime.time(hour=1, minute=2, second=3, microsecond=50000, tzinfo=datetime.timezone.utc) assert msg.day == 6 assert msg.month == 7 assert msg.year == 2008 + assert msg.tzinfo.utcoffset(0) == datetime.timedelta(hours=-8, minutes=30) assert msg.local_zone == -8 assert msg.local_zone_minutes == 30 assert msg.datestamp == datetime.date(2008, 7, 6) - assert msg.datetime == datetime.datetime(2008, 7, 6, 1, 2, 3, 50000, msg.tzinfo) + assert msg.datetime == datetime.datetime(2008, 7, 6, 1, 2, 3, 50000, tzinfo=datetime.timezone.utc) + assert msg.localdatetime == datetime.datetime(2008, 7, 5, 17, 32, 3, 50000, tzinfo=msg.tzinfo) def test_VPW(): data = "$XXVPW,1.2,N,3.4,M"