Repository: Knio/pynmea2

Issue:
RMC message is parsed without a timezone
Per the spec: https://www.trimble.com/OEM_ReceiverHelp/V4.44/en/NMEA-0183messages_RMC.html the date time in the RMC message is UTC, however, pynmea2 parses it and creates a date time with no timezone, thus calling timestamp() on the returned date returns the wrong timestamp. 

To reproduce:
```
msg = '$GPRMC,184446.000,A,3720.18653,N,12153.38874,W,0.0,0.0,130220,,,A*7E'
parsed = pynmea2.parse(msg)
assert parsed.datetime.timestamp()  == 1581619486.0
```

The above assertion fails unless your computer is set to UTC timezone. 

The workaround to the bug for anyone else bumping against this is to `replace` the datetime with one that has the proper timezone: 
```
msg = '$GPRMC,184446.000,A,3720.18653,N,12153.38874,W,0.0,0.0,130220,,,A*7E'
parsed = pynmea2.parse(msg)
assert parsed.datetime.replace(tzinfo=timezone.utc).timestamp()  == 1581619486.0
```



Comments:

Patch:
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 @@ class ZDA(TalkerSentence):
         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)
 
 
 


Test patch:
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"