diff --git a/.github/workflows/.coveragerc b/.github/workflows/.coveragerc new file mode 100644 index 0000000..f396e78 --- /dev/null +++ b/.github/workflows/.coveragerc @@ -0,0 +1,2 @@ +[run] +relative_files = True diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..afb0254 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: pynmea2 +on: + push: + branches: ["master"] + pull_request: + branches: ["master"] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python: ["3"] + os: ["ubuntu-latest"] + include: + - {python: "3.8", os: "ubuntu-22.04"} + - {python: "3.9", os: "ubuntu-22.04"} + - {python: "3.10", os: "ubuntu-22.04"} + - {python: "3.11", os: "ubuntu-22.04"} + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install pytest + python -m pip install flake8 + python -m pip install importlib_metadata + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=80 --statistics + - name: Build and test + run: | + python setup.py sdist --formats=zip + pip install dist/pynmea2*.zip + pytest + - name: Coveralls + env: + COVERAGE_RCFILE: ".github/workflows/.coveragerc" + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + python -m pip install "coverage" + python -m pip install "coveralls" + coverage run --source=pynmea2 -m pytest + python -m coveralls --service=github || true diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 3f278c6..0000000 --- a/.travis.yml +++ /dev/null @@ -1,30 +0,0 @@ -language: python - -python: - - "2.7" - - "3.3" - - "3.4" - - "3.5" - - "3.6" - - "pypy" - - "pypy3" - -# command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors -install: - - pip install pytest>=2.7.3 --upgrade - - pip install pylint - -# command to run tests, e.g. python setup.py test -script: - - python setup.py sdist --format=zip - - pip install dist/pynmea2*.zip - - py.test - - pylint -E pynmea2 - -after_success: - - pip install coveralls coverage - - PYTHONPATH=. coverage run --source=pynmea2 -m pytest - - coverage report - - coveralls - -sudo: false diff --git a/MANIFEST.in b/MANIFEST.in index 4cb8ee8..fc00aff 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,5 @@ include README.md -include LICENSE.txt +include LICENSE recursive-include test * recursive-include examples * global-exclude __pycache__ diff --git a/Makefile b/Makefile index 75bfc4e..7c66da9 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,11 @@ - -test: - py.test - -publish: - python setup.py sdist upload +test: + python2 -m pytest . + python3 -m pytest . + +publish: test + rm dist/ -r + python3 setup.py sdist + python3 setup.py bdist_wheel + python3 -m twine upload dist/* + +.PHONY: test publish diff --git a/README.md b/README.md index ba91344..06f922d 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,13 @@ pynmea2 The `pynmea2` homepage is located at http://github.com/Knio/pynmea2 + ### Compatibility -### Compatibility +`pynmea2` is compatable with Python 2.7 and Python 3.4+ -`pynmea2` is compatable with Python 2.7 and Python 3.3 - -[![Build Status](https://travis-ci.org/Knio/pynmea2.png?branch=master)](https://travis-ci.org/Knio/pynmea2) -[![Coverage Status](https://coveralls.io/repos/Knio/pynmea2/badge.png?branch=master)](https://coveralls.io/r/Knio/pynmea2?branch=master) -[![Code Health](https://landscape.io/github/Knio/pynmea2/master/landscape.svg?style=flat)](https://landscape.io/github/Knio/pynmea2/master) +![Python version](https://img.shields.io/pypi/pyversions/pynmea2.svg?style=flat) +[![Build status](https://github.com/Knio/pynmea2/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/Knio/pynmea2/actions/workflows/ci.yml?query=branch%3Amaster+) +[![Coverage status](https://img.shields.io/coveralls/github/Knio/pynmea2/master.svg?style=flat)](https://coveralls.io/r/Knio/pynmea2?branch=master) ### Installation @@ -23,7 +22,8 @@ The recommended way to install `pynmea2` is with pip install pynmea2 -[![PyPI version](https://badge.fury.io/py/pynmea2.png)](http://badge.fury.io/py/pynmea2) +[![PyPI version](https://img.shields.io/pypi/v/pynmea2.svg?style=flat)](https://pypi.org/project/pynmea2/) +[![PyPI downloads](https://img.shields.io/pypi/dm/pynmea2.svg?style=flat)](https://pypi.org/project/pynmea2/) Parsing ------- @@ -97,6 +97,7 @@ Generating You can create a `NMEASentence` object by calling the constructor with talker, message type, and data fields: ```python +>>> import pynmea2 >>> msg = pynmea2.GGA('GP', 'GGA', ('184353.07', '1929.045', 'S', '02410.506', 'E', '1', '04', '2.6', '100.00', 'M', '-33.9', 'M', '', '0000')) ``` @@ -108,28 +109,51 @@ and generate a NMEA string from a `NMEASentence` object: '$GPGGA,184353.07,1929.045,S,02410.506,E,1,04,2.6,100.00,M,-33.9,M,,0000*6D' ``` -Streaming ---------- -`pynmea2` can also process streams of NMEA sentences like so, by feeding chunks of data -manually: +File reading example +-------- + +See [examples/read_file.py](/examples/read_file.py) ```python -streamreader = pynmea2.NMEAStreamReader() -while 1: - data = input.read() - for msg in streamreader.next(data): - print msg +import pynmea2 + +file = open('examples/data.log', encoding='utf-8') + +for line in file.readlines(): + try: + msg = pynmea2.parse(line) + print(repr(msg)) + except pynmea2.ParseError as e: + print('Parse error: {}'.format(e)) + continue ``` -or given a file-like device, automatically: + +pySerial device example +--------- + +See [examples/read_serial.py](/examples/read_serial.py) ```python - streamreader = pynmea2.NMEAStreamReader(input) - while 1: - for msg in streamreader.next(): - print msg -``` +import io + +import pynmea2 +import serial -If your stream is noisy and contains errors, you can set some basic error handling with the [`errors` parameter of the `NMEAStreamReader` constructor.](pynmea2/stream.py#L12) +ser = serial.Serial('/dev/ttyS1', 9600, timeout=5.0) +sio = io.TextIOWrapper(io.BufferedRWPair(ser, ser)) + +while 1: + try: + line = sio.readline() + msg = pynmea2.parse(line) + print(repr(msg)) + except serial.SerialException as e: + print('Device error: {}'.format(e)) + break + except pynmea2.ParseError as e: + print('Parse error: {}'.format(e)) + continue +``` diff --git a/examples/data.log b/examples/data.log new file mode 100644 index 0000000..b93f526 --- /dev/null +++ b/examples/data.log @@ -0,0 +1,5 @@ +$GPGGA,184353.07,1929.045,S,02410.506,E,1,04,2.6,100.00,M,-33.9,M,,0000*6D +$GPRTE,2,1,c,0,PBRCPK,PBRTO,PTELGR,PPLAND,PYAMBU,PPFAIR,PWARRN,PMORTL,PLISMR*73 +$GPR00,A,B,C*29 +foobar +$IIMWV,271.0,R,000.2,N,A*3B \ No newline at end of file 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/examples/read_file.py b/examples/read_file.py new file mode 100644 index 0000000..ebd46ea --- /dev/null +++ b/examples/read_file.py @@ -0,0 +1,11 @@ +import pynmea2 + +file = open('examples/data.log', encoding='utf-8') + +for line in file.readlines(): + try: + msg = pynmea2.parse(line) + print(repr(msg)) + except pynmea2.ParseError as e: + print('Parse error: {}'.format(e)) + continue diff --git a/examples/read_serial.py b/examples/read_serial.py new file mode 100644 index 0000000..09bf00e --- /dev/null +++ b/examples/read_serial.py @@ -0,0 +1,20 @@ +import io + +import pynmea2 +import serial + + +ser = serial.Serial('/dev/ttyS1', 9600, timeout=5.0) +sio = io.TextIOWrapper(io.BufferedRWPair(ser, ser)) + +while 1: + try: + line = sio.readline() + msg = pynmea2.parse(line) + print(repr(msg)) + except serial.SerialException as e: + print('Device error: {}'.format(e)) + break + except pynmea2.ParseError as e: + print('Parse error: {}'.format(e)) + continue \ No newline at end of file diff --git a/examples/serial.py b/examples/serial.py deleted file mode 100644 index 8629a24..0000000 --- a/examples/serial.py +++ /dev/null @@ -1,32 +0,0 @@ -import time -import serial -import pynmea2 - - -def read(filename): - f = open(filename) - reader = pynmea2.NMEAStreamReader(f) - - while 1: - for msg in reader.next(): - print(msg) - - -def read_serial(filename): - com = None - reader = pynmea2.NMEAStreamReader() - - while 1: - - if com is None: - try: - com = serial.Serial(filename, timeout=5.0) - except serial.SerialException: - print('could not connect to %s' % filename) - time.sleep(5.0) - continue - - data = com.read(16) - for msg in reader.next(data): - print(msg) - diff --git a/pynmea2/_version.py b/pynmea2/_version.py index 5ca0f70..a71f144 100644 --- a/pynmea2/_version.py +++ b/pynmea2/_version.py @@ -1 +1 @@ -__version__ = '1.12.0' +__version__ = '1.19.0' diff --git a/pynmea2/nmea.py b/pynmea2/nmea.py index b9f5f2e..a7c13d0 100644 --- a/pynmea2/nmea.py +++ b/pynmea2/nmea.py @@ -232,7 +232,7 @@ def __init__(self, talker, listener, sentence_type): self.data = [] def identifier(self): - return '%s%sQ,%s,' % (self.talker, self.listener, self.sentence_type) + return '%s%sQ,%s' % (self.talker, self.listener, self.sentence_type) class ProprietarySentence(NMEASentence): diff --git a/pynmea2/nmea_utils.py b/pynmea2/nmea_utils.py index 02d3589..36f0f95 100644 --- a/pynmea2/nmea_utils.py +++ b/pynmea2/nmea_utils.py @@ -1,9 +1,25 @@ #pylint: disable=invalid-name 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' + def timestamp(s): ''' - Converts a timestamp given in "hhmmss[.ss]" ASCII format to a + Converts a timestamp given in "hhmmss[.ss]" ASCII text format to a datetime.time object ''' ms_s = s[6:] @@ -13,40 +29,43 @@ 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 def datestamp(s): ''' - Converts a datestamp given in "DDMMYY" ASCII format to a + Converts a datestamp given in "DDMMYY" ASCII text format to a datetime.datetime object ''' return datetime.datetime.strptime(s, '%d%m%y').date() -import re def dm_to_sd(dm): ''' - Converts a geographic coordiante given in "degres/minutes" dddmm.mmmm - format (ie, "12319.943281" = 123 degrees, 19.953281 minutes) to a signed + Converts a geographic co-ordinate given in "degrees/minutes" dddmm.mmmm + format (eg, "12319.943281" = 123 degrees, 19.943281 minutes) to a signed decimal (python float) format ''' # '12319.943281' if not dm or dm == '0': return 0. - d, m = re.match(r'^(\d+)(\d\d\.\d+)$', dm).groups() + r = re.match(r'^(\d+)(\d\d\.\d+)$', dm) + if not r: + raise ValueError("Geographic coordinate value '{}' is not valid DDDMM.MMM".format(dm)) + d, m = r.groups() return float(d) + float(m) / 60 class LatLonFix(object): - '''Mixin to add `lattitude` and `longitude` properties as signed decimals - to NMEA sentences which have coordiantes given as degrees/minutes (lat, lon) + '''Mixin to add `latitude` and `longitude` properties as signed decimals + to NMEA sentences which have co-ordinates given as degrees/minutes (lat, lon) and cardinal directions (lat_dir, lon_dir)''' #pylint: disable=no-member @property def latitude(self): - '''Lattitude in signed degrees (python float)''' + '''Latitude in signed degrees (python float)''' sd = dm_to_sd(self.lat) if self.lat_dir == 'N': return +sd @@ -105,6 +124,18 @@ def is_valid(self): return self.status == 'A' +class ValidRMCStatusFix(ValidStatusFix): + #pylint: disable=no-member + @property + def is_valid(self): + status = super(ValidRMCStatusFix, self).is_valid + if self.name_to_idx["mode_indicator"] < len(self.data): + status &= self.mode_indicator in tuple('ADEFMPRS') + if self.name_to_idx["nav_status"] < len(self.data): + status &= self.nav_status in tuple('SCU') + return status + + class ValidGSAFix(object): #pylint: disable=no-member @property diff --git a/pynmea2/stream.py b/pynmea2/stream.py index e56814d..31970fb 100644 --- a/pynmea2/stream.py +++ b/pynmea2/stream.py @@ -59,3 +59,17 @@ def next(self, data=None): yield e if self.errors == 'ignore': pass + + __next__ = next + + def __iter__(self): + ''' + Support the iterator protocol. + + This allows NMEAStreamReader object to be used in a for loop. + + for batch in NMEAStreamReader(stream): + for msg in batch: + print msg + ''' + return self diff --git a/pynmea2/types/proprietary/__init__.py b/pynmea2/types/proprietary/__init__.py index b5db7c7..e9be193 100644 --- a/pynmea2/types/proprietary/__init__.py +++ b/pynmea2/types/proprietary/__init__.py @@ -1,8 +1,12 @@ from . import ash from . import grm +from . import kwd +from . import mgn from . import rdi from . import srf from . import sxn from . import tnl from . import ubx +from . import vtx +from . import nor diff --git a/pynmea2/types/proprietary/ash.py b/pynmea2/types/proprietary/ash.py index d62a10e..aec2bd2 100644 --- a/pynmea2/types/proprietary/ash.py +++ b/pynmea2/types/proprietary/ash.py @@ -34,7 +34,7 @@ class ASHRATT(ASH): ''' @staticmethod def match(data): - return re.match(r'^\d{6}\.\d{3}$', data[1]) + return re.match(r'^\d{6}\.\d{2,3}$', data[1]) def __init__(self, *args, **kwargs): self.subtype = 'ATT' @@ -47,7 +47,7 @@ def __init__(self, *args, **kwargs): ('Is True Heading', 'is_true_heading'), ('Roll Angle', 'roll', float), ('Pitch Angle', 'pitch', float), - ('Heave', 'heading', float), + ('Heave', 'heave', float), ('Roll Accuracy Estimate', 'roll_accuracy', float), ('Pitch Accuracy Estimate', 'pitch_accuracy', float), ('Heading Accuracy Estimate', 'heading_accuracy', float), diff --git a/pynmea2/types/proprietary/grm.py b/pynmea2/types/proprietary/grm.py index bd925a6..be8af19 100644 --- a/pynmea2/types/proprietary/grm.py +++ b/pynmea2/types/proprietary/grm.py @@ -27,7 +27,7 @@ class GRME(GRM): ("Estimated Vert. Position Error", "vpe", Decimal), ("Estimated Vert. Position Error Unit (M)", "vpe_unit"), ("Estimated Horiz. Position Error", "osepe", Decimal), - ("Overall Spherical Equiv. Position Error", "osepe_unit") + ("Overall Spherical Equiv. Position Error", "osepe_unit"), ) @@ -40,6 +40,31 @@ class GRMM(GRM): ) +class GRMW(GRM): + """ GARMIN Waypoint Information + + https://www8.garmin.com/support/pdf/NMEA_0183.pdf + https://github.com/wb2osz/direwolf/blob/master/waypoint.c + + $PGRMW,wname,alt,symbol,comment*99 + Where, + wname is waypoint name. Must match existing waypoint. + alt is altitude in meters. + symbol is symbol code. Hexadecimal up to FFFF. + See Garmin Device Interface Specification + 001-0063-00 for values of "symbol_type." + comment is comment for the waypoint. + *99 is checksum + """ + fields = ( + ("Subtype", "subtype"), + ("Waypoint Name", "wname"), + ("Altitude", "altitude", Decimal), + ("Symbol", "symbol"), + ("Comment", "comment"), + ) + + class GRMZ(GRM): """ GARMIN Altitude Information """ @@ -47,8 +72,5 @@ class GRMZ(GRM): ("Subtype", "subtype"), ("Altitude", "altitude", Decimal), ("Altitude Units (Feet)", "altitude_unit"), - ("Positional Fix Dimension (2=user, 3=GPS)", "pos_fix_dim") + ("Positional Fix Dimension (2=user, 3=GPS)", "pos_fix_dim"), ) - - - diff --git a/pynmea2/types/proprietary/kwd.py b/pynmea2/types/proprietary/kwd.py new file mode 100644 index 0000000..132572f --- /dev/null +++ b/pynmea2/types/proprietary/kwd.py @@ -0,0 +1,236 @@ +# Kenwood + +from decimal import Decimal +from datetime import date, time + +from ... import nmea +from ... import nmea_utils + +class KLD(nmea.ProprietarySentence): + sentence_types = {} + + def __new__(_cls, manufacturer, data): + name = manufacturer + data[0] + cls = _cls.sentence_types.get(name, _cls) + return super(KLD, cls).__new__(cls) + + def __init__(self, manufacturer, data): + self.sentence_type = manufacturer + data[0] + super(KLD, self).__init__(manufacturer, data) + +class KND(nmea.ProprietarySentence): + sentence_types = {} + + def __new__(_cls, manufacturer, data): + name = manufacturer + data[0] + cls = _cls.sentence_types.get(name, _cls) + return super(KND, cls).__new__(cls) + + def __init__(self, manufacturer, data): + self.sentence_type = manufacturer + data[0] + super(KND, self).__init__(manufacturer, data) + +class KLS(nmea.ProprietarySentence): + sentence_types = {} + + def __new__(_cls, manufacturer, data): + name = manufacturer + data[0] + cls = _cls.sentence_types.get(name, _cls) + return super(KLS, cls).__new__(cls) + + def __init__(self, manufacturer, data): + self.sentence_type = manufacturer + data[0] + super(KLS, self).__init__(manufacturer, data) + +class KNS(nmea.ProprietarySentence): + sentence_types = {} + + def __new__(_cls, manufacturer, data): + name = manufacturer + data[0] + cls = _cls.sentence_types.get(name, _cls) + return super(KNS, cls).__new__(cls) + + def __init__(self, manufacturer, data): + self.sentence_type = manufacturer + data[0] + super(KNS, self).__init__(manufacturer, data) + + +class KWD(nmea.ProprietarySentence): + sentence_types = {} + + def __new__(_cls, manufacturer, data): + name = manufacturer + data[0] + cls = _cls.sentence_types.get(name, _cls) + return super(KWD, cls).__new__(cls) + + def __init__(self, manufacturer, data): + self.sentence_type = manufacturer + data[0] + super(KWD, self).__init__(manufacturer, data) + + +class KWDWPL(KWD, nmea_utils.LatLonFix, nmea_utils.DatetimeFix, nmea_utils.ValidStatusFix): + """ Kenwood Waypoint Location + + https://github.com/wb2osz/direwolf/blob/master/waypoint.c + + $PKWDWPL,hhmmss,v,ddmm.mm,ns,dddmm.mm,ew,speed,course,ddmmyy,alt,wname,ts*99 + Where, + hhmmss is time in UTC from the clock in the transceiver. + This will be bogus if the clock was not set properly. + It does not use the timestamp from a position + report which could be useful. + + GPS Status A = active, V = void. + It looks like this might be modeled after the GPS status values + we see in $GPRMC. i.e. Does the transceiver know its location? + I don't see how that information would be relevant in this context. + I've observed this under various conditions (No GPS, GPS with/without + fix) and it has always been "V." + + ddmm.mm,ns is latitude. N or S. + dddmm.mm,ew is longitude. E or W. + speed is speed over ground, knots. + course is course over ground, degrees. + ddmmyy is date. See comments for time. + alt is altitude, meters above mean sea level. + wname is the waypoint name. For an Object Report, the id is the object name. + For a position report, it is the call of the sending station. + An Object name can contain any printable characters. + What if object name contains , or * characters? + Those are field delimiter characters and it would be unfortunate + if they appeared in a NMEA sentence data field. + + If there is a comma in the name, such as "test,5" the Kenwood TM-D710A displays + it fine but we end up with an extra field. + + $PKWDWPL,150803,V,4237.14,N,07120.83,W,,,190316,,test,5,/'*30 + + If the name contains an asterisk, it doesn't show up on the + display and no waypoint sentence is generated. + Some other talkers substitute these two characters following the AvMap precedent. + + $PKWDWPL,204714,V,4237.1400,N,07120.8300,W,,,200316,,test|5,/'*61 + $PKWDWPL,204719,V,4237.1400,N,07120.8300,W,,,200316,,test~6,/'*6D + + ts are the table and symbol. + + What happens if the symbol is comma or asterisk? + , Boy Scouts / Girl Scouts + * SnowMobile / Snow + + the D710A just pushes them thru without checking. + These would not be parsed properly: + + $PKWDWPL,150753,V,4237.14,N,07120.83,W,,,190316,,test3,/,*1B + $PKWDWPL,150758,V,4237.14,N,07120.83,W,,,190316,,test4,/ **3B + + Other talkers do the usual substitution and the other end would + need to change them back after extracting from NMEA sentence. + + $PKWDWPL,204704,V,4237.1400,N,07120.8300,W,,,200316,,test3,/|*41 + $PKWDWPL,204709,V,4237.1400,N,07120.8300,W,,,200316,,test4,/~*49 + + + *99 is checksum + + Oddly, there is no place for comment. + """ + fields = ( + ("Subtype", "subtype"), + ("Time of Receipt", "timestamp", nmea_utils.timestamp), + ("GPS Status (Void)","status"), + ("Latitude", "lat"), + ("Latitude Direction", "lat_dir"), + ("Longitude", "lon"), + ("Longitude Direction", "lon_dir"), + ("Speed over Ground", "sog", float), + ("Course over Ground", "cog", float), + ("Date", "datestamp", nmea_utils.datestamp), + ("Altitude", "altitude", Decimal), + ("Waypoint Name", "wname"), + ("Table and Symbol", "ts"), + ) + +class KLDS(KLD, nmea_utils.LatLonFix, nmea_utils.DatetimeFix, nmea_utils.ValidStatusFix): + """ + $PKLDS,hhmmss,v,ddmm.mm,ns,dddmm.mm,ew,speed,course,ddmmyy,DD.dd,ewSV,fleet,svid,status,fut*99 + $PKLDS,001235,A,3544.6650,N,13940.1900,E,015.0,038.8,110498,10.80,W00,100,2000,15,00,*?? + """ + fields = ( + ("Subtype", "subtype"), + ("Time of Receipt", "timestamp", nmea_utils.timestamp), + ("GPS Status (Void)","status"), + ("Latitude", "lat"), + ("Latitude Direction", "lat_dir"), + ("Longitude", "lon"), + ("Longitude Direction", "lon_dir"), + ("Speed over Ground Knot", "sog", float), + ("Course over Ground", "cog", float), + ("Date", "datestamp", nmea_utils.datestamp), + ("Magnetic variation", "declination", float), + ("Declination Direction", "dec_dir"), + ("Fleet", "fleet", Decimal), + ("Sender ID", "senderid"), + ("Sender Status", "senderstatus", Decimal), + ("Future Reserved", "future", Decimal), + ) + + + +class KNDS(KND, nmea_utils.LatLonFix, nmea_utils.DatetimeFix, nmea_utils.ValidStatusFix): + """ + $PKNDS,hhmmss,v,ddmm.mm,ns,dddmm.mm,ew,speed,course,ddmmyy,DD.dd,ewSV,svid,status,fut*99 + $PKNDS,124640,A,4954.1458,N,11923.5992,W,000.0,000.0,120223,19.20,W00,U00002,207,00,*29 + + """ + fields = ( + ("Subtype", "subtype"), + ("Time of Receipt", "timestamp", nmea_utils.timestamp), + ("GPS Status (Void)","status"), + ("Latitude", "lat"), + ("Latitude Direction", "lat_dir"), + ("Longitude", "lon"), + ("Longitude Direction", "lon_dir"), + ("Speed over Ground Knot", "sog", float), + ("Course over Ground", "cog", float), + ("Date", "datestamp", nmea_utils.datestamp), + ("Magnetic variation", "declination", float), + ("Declination Direction", "dec_dir"), + ("Sender ID", "senderid"), + ("Sender Status", "senderstatus", Decimal), + ("Future Reserved", "future", Decimal), + ) + +class KLSH(KLS, nmea_utils.LatLonFix, nmea_utils.DatetimeFix, nmea_utils.ValidStatusFix): + """ + $PKLSH,ddmm.mm,ns,dddmm.mm,ew,hhmmss,v,fleet,svid,*99 + $PKLSH,4000.0000,N,13500.0000,E,021720,A,100,2000,* ?? + """ + fields = ( + ("Subtype", "subtype"), + ("Latitude", "lat"), + ("Latitude Direction", "lat_dir"), + ("Longitude", "lon"), + ("Longitude Direction", "lon_dir"), + ("Time of Receipt", "timestamp", nmea_utils.timestamp), + ("GPS Status (Void)","status"), + ("Fleet", "fleet", Decimal), + ("Sender ID", "senderid"), + ) + +class KNSH(KNS, nmea_utils.LatLonFix, nmea_utils.DatetimeFix, nmea_utils.ValidStatusFix): + """ + $PKLSH,ddmm.mm,ns,dddmm.mm,ew,hhmmss,v,svid,*99 + $PKNSH,4000.0000,N,13500.0000,E,021720,A,U00001,* ?? + """ + fields = ( + ("Subtype", "subtype"), + ("Latitude", "lat"), + ("Latitude Direction", "lat_dir"), + ("Longitude", "lon"), + ("Longitude Direction", "lon_dir"), + ("Time of Receipt", "timestamp", nmea_utils.timestamp), + ("GPS Status (Void)","status"), + ("Sender ID", "senderid"), + ) + diff --git a/pynmea2/types/proprietary/mgn.py b/pynmea2/types/proprietary/mgn.py new file mode 100644 index 0000000..a2dc7df --- /dev/null +++ b/pynmea2/types/proprietary/mgn.py @@ -0,0 +1,52 @@ +# Magellan + +from decimal import Decimal + +from ... import nmea +from ... import nmea_utils + + +class MGN(nmea.ProprietarySentence): + sentence_types = {} + + def __new__(_cls, manufacturer, data): + name = manufacturer + data[0] + cls = _cls.sentence_types.get(name, _cls) + return super(MGN, cls).__new__(cls) + + def __init__(self, manufacturer, data): + self.sentence_type = manufacturer + data[0] + super(MGN, self).__init__(manufacturer, data) + + +class MGNWPL(MGN, nmea_utils.LatLonFix): + """ Magellan Waypoint Location + + https://github.com/wb2osz/direwolf/blob/master/waypoint.c + + $PMGNWPL,ddmm.mmmm,ns,dddmm.mmmm,ew,alt,unit,wname,comment,icon,xx*99 + Where, + ddmm.mmmm,ns is latitude + dddmm.mmmm,ew is longitude + alt is altitude + unit is M for meters or F for feet + wname is the waypoint name + comment is message or comment + icon is one or two letters for icon code + xx is waypoint type which is optional, not well + defined, and not used in their example. + *99 is checksum + """ + fields = ( + ("Subtype", "subtype"), + ("Latitude", "lat"), + ("Latitude Direction", "lat_dir"), + ("Longitude", "lon"), + ("Longitude Direction", "lon_dir"), + ("Altitude", "altitude", Decimal), + ("Altitude Units (Feet/Meters)", "altitude_unit"), + ("Waypoint Name", "wname"), + ("Comment", "comment"), + ("Icon", "icon"), + ("Waypoint Type", "type") + ) diff --git a/pynmea2/types/proprietary/nor.py b/pynmea2/types/proprietary/nor.py new file mode 100644 index 0000000..c6e75c9 --- /dev/null +++ b/pynmea2/types/proprietary/nor.py @@ -0,0 +1,270 @@ +''' +Support for proprietary messages from Nortek Doppler Velocity Log (DVL). +''' + +from ... import ProprietarySentence, nmea_utils +from datetime import datetime + +class NOR(ProprietarySentence): + sentence_types = {} + + def __new__(_cls, manufacturer, data): + name = manufacturer + data[0] + cls = _cls.sentence_types.get(name, _cls) + return super(NOR, cls).__new__(cls) + + def __init__(self, manufacturer, data): + self.sentence_type = manufacturer + data[0] + super(NOR, self).__init__(manufacturer, data[1:]) + + def identifier(self): + return 'P%s,' % (self.sentence_type) + +################################################################## +## ## +## DVL Bottom Track ASCII formats ## +## ## +## Invalid estimates of Velocity are set to set to -32.768. ## +## Invalid estimates of Range are set to 0.0. ## +## Invalid estimates of FOM are set to 10.0 ## +################################################################## + +class NORBT0(NOR, nmea_utils.DatetimeFix): + # Bottom Track DF350/DF351 - NMEA $PNORBT1/$PNORBT0 + # Example: $PNORBT0,1,040721,131335.3341,23.961,-48.122,-32.76800,10.00000,0.00,0x00000000*48 + + fields = ( + ('Beam number', 'beam', int), + ('Date', 'datestamp', nmea_utils.datestamp), + ('Time', 'timestamp', nmea_utils.timestamp), + ('Time (Trigger)', 'dt1', float), + ('Time (NMEA)', 'dt2', float), + ('Beam Velocity', 'bv', float), + ('Figure of Merit', 'fom', float), + ('Vertical Distance', 'dist', float), + ('Status ', 'stat'), + ) + +class NORBT4(NOR, nmea_utils.DatetimeFix): + # Bottom Track DF354/DF355 - NMEA $PNORBT3/$PNORBT4 + # Example: $PNORBT4,1.234,-1.234,1.234,23.4,12.34567,12.3*09 + + fields = ( + ('Time (Trigger)', 'dt1', float), + ('Time (NMEA)', 'dt2', float), + ('Speed of Sound', 'sound_speed', float), + ('Direction', 'dir', float), + ('Figure of Merit', 'fom', float), + ('Vertical Distance', 'dist', float), + ) + +class NORBT7(NOR): + # Bottom Track DF356/DF357 - NMEA $PNORBT6/$PNORBT7 + # Example: $PNORBT7,1452244916.7508,1.234,-1.234,0.1234,0.1234,0.1234,12.34,23.45,23.45,23.45,23.45*39 + + fields = ( + ('Ping Time', 'timestamp', lambda x: datetime.utcfromtimestamp(float(x))), + ('Time (Trigger)', 'dt1', float), + ('Time (NMEA)', 'dt2', float), + ('Velocity X', 'vx', float), + ('Velocity Y', 'vy', float), + ('Velocity Z', 'vz', float), + ('Figure of Merit', 'fom', float), + ('Vertical Distance Beam 1', 'd1', float), + ('Vertical Distance Beam 2', 'd2', float), + ('Vertical Distance Beam 3', 'd3', float), + ('Vertical Distance Beam 4', 'd4', float), + ) + +class NORBT9(NOR): + # Bottom Track DF358/DF359 - NMEA $PNORBT8/$PNORBT9 + # Example: $PNORBT9,1452244916.7508,1.234,-1.234,0.1234,0.1234,0.1234,12.34,23.45,23.45,23.45,23.45,23.4,1567.8,1.2,12.3,0x000FFFFF*1E + + fields = ( + ('Ping Time', 'timestamp', lambda x: datetime.utcfromtimestamp(float(x))), + ('Time (Trigger)', 'dt1', float), + ('Time (NMEA)', 'dt2', float), + ('Velocity X', 'vx', float), + ('Velocity Y', 'vy', float), + ('Velocity Z', 'vz', float), + ('Figure of Merit', 'fom', float), + ('Vertical Distance Beam 1', 'd1', float), + ('Vertical Distance Beam 2', 'd2', float), + ('Vertical Distance Beam 3', 'd3', float), + ('Vertical Distance Beam 4', 'd4', float), + ('Battery Voltage', 'battery_voltage', float), + ('Speed of Sound', 'sound_speed', float), + ('Pressure', 'pressure', float), + ('Temperature', 'temp', float), + ('Status ', 'stat'), + ) + +################################################################## +## ## +## DVL Water Track ASCII formats ## +## ## +## Invalid estimates of Velocity are set to set to -32.768. ## +## Invalid estimates of Range are set to 0.0. ## +## Invalid estimates of FOM are set to 10.0 ## +################################################################## + +class NORWT4(NOR): + # Water Track DF404/DF405 - NMEA $PNORWT3/$PNORWT4 + # Example: $PNORWT4,1.2345,-1.2345,1.234,23.4,12.34,12.3*1C + + fields = ( + ('Time Trigger ', 'dt1', float), + ('Time NMEA', 'dt2', float), + ('Speed of sound', 'sound_speed', float), + ('Direction', 'dir', float), + ('Figure of Merit', 'fom', float), + ('Vertical Distance', 'dist', float), + ) + +class NORWT7(NOR): + # Water Track DF406/DF407 - NMEA $PNORWT6/$PNORWT7 + # Example: $PNORWT7,1452244916.7508,1.234,-1.234,0.1234,0.1234,0.1234,12.34,23.45,23.45,23.45,23.45*2C + + fields = ( + ('Ping Time', 'timestamp', lambda x: datetime.utcfromtimestamp(float(x))), + ('Time (Trigger)', 'dt1', float), + ('Time (NMEA)', 'dt2', float), + ('Velocity X', 'vx', float), + ('Velocity Y', 'vy', float), + ('Velocity Z', 'vz', float), + ('Figure of Merit', 'fom', float), + ('Vertical Distance Beam 1', 'd1', float), + ('Vertical Distance Beam 2', 'd2', float), + ('Vertical Distance Beam 3', 'd3', float), + ('Vertical Distance Beam 4', 'd4', float), + ) + +class NORWT9(NOR): + # Water Track DF408/DF409 - NMEA $PNORWT8/$PNORWT9 + # Example: $PNORWT9,1452244916.7508,1.234,-1.234,0.1234,0.1234,0.1234,12.34,23.45,23.45,23.45,23.45,23.4,1567.8,1.2,12.3,0x000FFFFF*0B + + fields = ( + ('Ping Time', 'timestamp', lambda x: datetime.utcfromtimestamp(float(x))), + ('Time (Trigger)', 'dt1', float), + ('Time (NMEA)', 'dt2', float), + ('Velocity X', 'vx', float), + ('Velocity Y', 'vy', float), + ('Velocity Z', 'vz', float), + ('Figure of Merit', 'fom', float), + ('Vertical Distance Beam 1', 'd1', float), + ('Vertical Distance Beam 2', 'd2', float), + ('Vertical Distance Beam 3', 'd3', float), + ('Vertical Distance Beam 4', 'd4', float), + ('Battery Voltage', 'battery_voltage', float), + ('Speed of Sound', 'sound_speed', float), + ('Pressure', 'pressure', float), + ('Temperature', 'temp', float), + ('Status ', 'stat'), + ) + +################################################################## +## ## +## DVL Current Profile ASCII formats ## +## ## +################################################################## + +class NORI1(NOR): + # Information Data DF101/DF102 - NMEA Format 1 and 2 + # Example: $PNORI1,4,123456,3,30,1.00,5.00,BEAM*5B + + fields = ( + ('Instrument type', 'it', int), + ('Head ID', 'sn', int), + ('Number of Beams', 'nb', int), + ('Number of Cells', 'nc', int), + ('Blanking Distance', 'bd', float), + ('Cell Size', 'cs', float), + ('Coordinate System', 'cy', str), + ) + +class NORS1(NOR, nmea_utils.DatetimeFix): + # Sensors Data DF101/DF102 - NMEA Format 1 and 2 + # Example: $PNORS1,161109,132455,0,34000034,23.9,1500.0,123.4,0.02,45.6,0.02,23.4,0.02,123.456,0.02,24.56*51 + + fields = ( + ('Date', 'datestamp', nmea_utils.datestamp), + ('Time', 'timestamp', nmea_utils.timestamp), + ('Error Code', 'ec', int), + ('Status Code', 'sc'), + ('Battery Voltage', 'battery_voltage', float), + ('Speed of Sound', 'sound_speed', float), + ('Heading', 'heading', float), + ('Heading Std. Dev.', 'heading_std', float), + ('Pitch', 'pitch', float), + ('Pitch Std. Dev.', 'pitch_std', float), + ('Roll', 'roll', float), + ('Roll Std. Dev.', 'roll_std', float), + ('Pressure', 'pressure', float), + ('Pressure Std. Dev.', 'pressure_std', float), + ('Temperature', 'temp', float), + ) + + +class NORS4(NOR, nmea_utils.DatetimeFix): + # Sensors Data DF103/DF104 + # Example: $PNORS4,23.6,1530.2,0.0,0.0,0.0,0.000,23.30*66 + + fields = ( + ('Battery Voltage', 'battery_voltage', float), + ('Speed of Sound', 'sound_speed', float), + ('Heading', 'heading', float), + ('Pitch', 'pitch', float), + ('Roll', 'roll', float), + ('Pressure', 'pressure', float), + ('Temperature', 'temp', float), + ) + + +class NORC1(NOR, nmea_utils.DatetimeFix): + # Current Data DF101/DF102 - NMEA Format 1 and 2 + # Example: $PNORC1,083013,132455,3,11.0,0.332,0.332,0.332,78.9,78.9,78.9,78,78,78*46 + + fields = ( + ('Date', 'datestamp', nmea_utils.datestamp), + ('Time', 'timestamp', nmea_utils.timestamp), + ('Cell Number', 'cn', int), + ('Cell Position', 'cp', float), + ('Velocity X', 'vx', float), + ('Velocity Y', 'vy', float), + ('Velocity Z', 'vz', float), + ('Velocity Z2', 'vz2', float), + ('Amplitude Beam 1', 'amp1', float), + ('Amplitude Beam 2', 'amp2', float), + ('Amplitude Beam 3', 'amp3', float), + ('Amplitude Beam 4', 'amp4', float), + ('Correlation Beam 1', 'r1', int), + ('Correlation Beam 2', 'r2', int), + ('Correlation Beam 3', 'r3', int), + ('Correlation Beam 4', 'r4', int), + ('Correlation Beam 4', 'r5', int), + ) + + +class NORC4(NOR, nmea_utils.DatetimeFix): + # Current Data DF103/DF104 + # Example: $PNORC4,1.5,1.395,227.1,32,32*7A + + fields = ( + ('Cell Position', 'cp', float), + ('Speed', 'sp', float), + ('Direction', 'dir', float), + ('Correlation', 'r', int), + ('Amplitude', 'amp', int), + ) + + +class NORH4(NOR, nmea_utils.DatetimeFix): + # Header Data DF103/DF104 + # Example: $PNORH4,161109,143459,0,204C0002*38 + + fields = ( + ('Date', 'datestamp', nmea_utils.datestamp), + ('Time', 'timestamp', nmea_utils.timestamp), + ('Error Code', 'ec', int), + ('Status Code', 'sc'), + ) diff --git a/pynmea2/types/proprietary/ubx.py b/pynmea2/types/proprietary/ubx.py index f9743af..7df0e26 100644 --- a/pynmea2/types/proprietary/ubx.py +++ b/pynmea2/types/proprietary/ubx.py @@ -14,15 +14,13 @@ def __new__(_cls, manufacturer, data): cls = _cls.sentence_types.get(name, _cls) return super(UBX, cls).__new__(cls) - def __init__(self, manufacturer, data): - self.sentence_type = manufacturer + data[1] - super(UBX, self).__init__(manufacturer, data[2:]) - class UBX00(UBX, LatLonFix): """ Lat/Long Position Data """ fields = ( + ("Blank", "_blank"), + ("UBX Type", "ubx_type"), ("Timestamp (UTC)", "timestamp", timestamp), ("Latitude", "lat"), ("Latitude Direction", "lat_dir"), @@ -41,7 +39,6 @@ class UBX00(UBX, LatLonFix): ("Time Dilution of Precision", "tdop"), ("Number of Satellites Used", "num_svs"), ("Reserved", "reserved") - ) @@ -49,6 +46,8 @@ class UBX03(UBX): """ Satellite Status """ fields = ( + ("Blank", "_blank"), + ("UBX Type", "ubx_type"), ("Number of GNSS Satellites Tracked", "num_sv", int), ) @@ -61,6 +60,8 @@ class UBX04(UBX): """ Time and Day Clock Information """ fields = ( + ("Blank", "_blank"), + ("UBX Type", "ubx_type"), ("UTC Time", "time", timestamp), ("UTC Date", "date", datestamp), ("UTC Time of Week", "utc_tow"), diff --git a/pynmea2/types/proprietary/vtx.py b/pynmea2/types/proprietary/vtx.py new file mode 100644 index 0000000..0e6c3c2 --- /dev/null +++ b/pynmea2/types/proprietary/vtx.py @@ -0,0 +1,84 @@ +# Vectronix Moskito TI (LRF) + +from decimal import Decimal + +from ... import nmea +from ...nmea_utils import * + +class VTX(nmea.ProprietarySentence): + sentence_types = {} + + def __new__(_cls, manufacturer, data): + name = manufacturer + data[1] + cls = _cls.sentence_types.get(name, _cls) + return super(VTX, cls).__new__(cls) + + def __init__(self, manufacturer, data): + self.sentence_type = manufacturer + data[0] + super(VTX, self).__init__(manufacturer, data) + + +class VTX0002(VTX): + """ Vectronix measurement: laser distance and angles (degrees) with declination + """ + fields = ( + ("Message Placeholder", "mplaceholder"), + ("Subtype", "subtype"), + ("Measurement ID", "measurement_id", int), + ("Distance (meters)", "dist", float), + ("Distance unit", "dist_unit"), + ("Direction (degrees)", "direction", float), + ("Direction unit", "direction_unit"), + ("Vertical angle (degrees)", "va", float), + ("Magnetic declination (degrees)", "decl", float), + ("Magnetic declination ref (E/W)", "decl_ref") + ) + + +class VTX0000(VTX): + """ Vectronix raw measurement: laser distance and angles (radians) without declination + """ + fields = ( + ("Message Placeholder", "mplaceholder"), + ("Subtype", "subtype"), + ("Distance (meters)", "dist", float), + ("Distance unit", "dist_unit"), + ("Direction (radians)", "direction", float), + ("Roll angle (radians)", "roll", float), + ("Vertical angle (radians)", "va", float), + ("Angular units type", "angle_units") + ) + + +class VTX0020(VTX, LatLonFix): + """ Vectronix self location: lat, long, altitude + """ + fields = ( + ("Message Placeholder", "mplaceholder"), + ("Subtype", "subtype"), + ("Measurement ID", "measurement_id", int), + ('Latitude', 'lat'), + ('Latitude Direction', 'lat_dir'), + ('Longitude', 'lon'), + ('Longitude Direction', 'lon_dir'), + ('Altitude above WGS84 ellipsoid, meters', 'altitude', float), + ('Altitude units', 'altitude_units') + ) + + +class VTX0012(VTX, LatLonFix): + """ Vectronix target location: lat, long, altitude, gain + """ + fields = ( + ("Message Placeholder", "mplaceholder"), + ("Subtype", "subtype"), + ("Measurement ID", "measurement_id", int), + ('Latitude', 'lat'), + ('Latitude Direction', 'lat_dir'), + ('Longitude', 'lon'), + ('Longitude Direction', 'lon_dir'), + ('Altitude above WGS84 ellipsoid, meters', 'altitude', float), + ('Altitude units', 'altitude_units'), + ('Gain (meters)', 'gain', float), + ('Gain units', 'gain_units') + ) diff --git a/pynmea2/types/talker.py b/pynmea2/types/talker.py index 32fe727..6b56687 100644 --- a/pynmea2/types/talker.py +++ b/pynmea2/types/talker.py @@ -43,6 +43,19 @@ class ALM(TalkerSentence): ) +class ALR(TalkerSentence): + """ Set alarm state + $--ALR,hhmmss.ss,xxx,A,A,c--c*hh + """ + fields = ( + ('Time of alarm condition change, UTC', 'timestamp', timestamp), + ('Unique alarm number (identifier) at alarm source', 'alarm_num'), + ('Alarm condition (A=threshold exceeded, V=not exceeded)', 'alarm_con'), + ('Alarm\'s acknowledge state (A=acknowledged, V=unacknowledged)', 'alarm_state'), + ('Alarm\'s description text', 'description'), + ) + + class APA(TalkerSentence): """ Autopilot Sentence "A" """ @@ -181,6 +194,26 @@ class GNS(TalkerSentence, LatLonFix): ('Differential reference station ID', 'diferential'), ) +class GRS(TalkerSentence): + """ Order of satellites will match those in the last GSA + """ + fields = ( + ('Timestamp', 'timestamp', timestamp), + ('Residuals mode', 'residuals_mode', int), + ('SV 01 Residual (m)', 'sv_res_01', float), + ('SV 02 Residual (m)', 'sv_res_02', float), + ('SV 03 Residual (m)', 'sv_res_03', float), + ('SV 04 Residual (m)', 'sv_res_04', float), + ('SV 05 Residual (m)', 'sv_res_05', float), + ('SV 06 Residual (m)', 'sv_res_06', float), + ('SV 07 Residual (m)', 'sv_res_07', float), + ('SV 08 Residual (m)', 'sv_res_08', float), + ('SV 09 Residual (m)', 'sv_res_09', float), + ('SV 10 Residual (m)', 'sv_res_10', float), + ('SV 11 Residual (m)', 'sv_res_11', float), + ('SV 12 Residual (m)', 'sv_res_12', float), + ) + class BWW(TalkerSentence): """ Bearing, Waypoint to Waypoint """ @@ -263,6 +296,21 @@ class GSV(TalkerSentence): ) # 00-99 dB +class HBT(TalkerSentence): + """ Heartbeat supervision sentence + Format: $--HBT,<1>,<2>,<3>*hh + e.g. $AIHBT,30,A,5*0D + <1> Configured repeat interval + <2> Equipment status + <3> Sequential sentence identifier + """ + fields = ( + ("Configured repeat interval", "interval", float), + ("Equipment status", "eq_status"), + ("Sequential sentence identifier", "seq_sent_iden", int), + ) + + class HDG(TalkerSentence): """ NMEA 0183 standard Heading, Deviation and Variation Format: $HCHDG,<1>,<2>,<3>,<4>,<5>*hh @@ -324,7 +372,7 @@ class RMB(TalkerSentence, ValidStatusFix): ("Arrival Alarm", "arrival_alarm"), ) # A = Arrived, V = Not arrived -class RMC(TalkerSentence, ValidStatusFix, LatLonFix, DatetimeFix): +class RMC(TalkerSentence, ValidRMCStatusFix, LatLonFix, DatetimeFix): """ Recommended Minimum Specific GPS/TRANSIT Data """ fields = ( @@ -339,6 +387,8 @@ class RMC(TalkerSentence, ValidStatusFix, LatLonFix, DatetimeFix): ("Datestamp", "datestamp", datestamp), ("Magnetic Variation", "mag_variation"), ("Magnetic Variation Direction", "mag_var_dir"), + ("Mode Indicator", "mode_indicator"), + ("Navigational Status", "nav_status"), ) class RTE(TalkerSentence): @@ -485,7 +535,7 @@ class XTE(TalkerSentence): ) -class ZDA(TalkerSentence): +class ZDA(TalkerSentence, DatetimeFix): fields = ( ("Timestamp", "timestamp", timestamp), # hhmmss.ss = UTC ("Day", "day", int), # 01 to 31 @@ -504,9 +554,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) @@ -1015,4 +1065,36 @@ class ALK(TalkerSentence,SeaTalk): ("Data Byte 7", "data_byte7"), ("Data Byte 8", "data_byte8"), ("Data Byte 9", "data_byte9") - ) \ No newline at end of file + ) + +# Implemented by Davis Chappins for FLARM traffic +#PFLAU: Operating status and priority intruder and obstacle data +class LAU(TalkerSentence): + fields = ( + ("RX","RX"), + ("TX","TX"), + ("GPS","GPS"), + ("Power","Power"), + ("AlarmLevel","AlarmLevel"), + ("RelativeBearing","RelativeBearing"), + ("AlarmType","AlarmType"), + ("RelativeVertial","RelativeVertical"), + ("RelativeDistance","RelativeDistance"), + + ) + +#PFLAA: Data on other moving objects around +class LAA(TalkerSentence): + fields = ( + ("AlarmLevel","AlarmLevel"), + ("RelativeNorth","RelativeNorth"), + ("RelativeEast","RelativeEast"), + ("RelativeVertical","RelativeVertical"), + ("ID-Type","ID-Type"), + ("ID","ID"), + ("Track","Track"), + ("TurnRate","TurnRate"), + ("GroundSpeed","GroundSpeed"), + ("ClimbRate","ClimbRate"), + ("Type","Type"), + ) diff --git a/setup.py b/setup.py index ade345a..facd391 100644 --- a/setup.py +++ b/setup.py @@ -23,9 +23,12 @@ 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: Implementation :: PyPy', 'Topic :: Scientific/Engineering :: GIS', 'Topic :: Software Development :: Libraries :: Python Modules', diff --git a/test/test_ash.py b/test/test_ash.py index b33261e..b7a9425 100644 --- a/test/test_ash.py +++ b/test/test_ash.py @@ -14,16 +14,17 @@ def test_ashrltn(): def test_ashratt(): - data = '$PASHR,130533.620,0.311,T,-80.467,-1.395,,0.066,0.067,0.215,2,3*0B' + data = '$PASHR,130533.620,0.311,T,-80.467,-1.395,0.25,0.066,0.067,0.215,2,3*12' msg = pynmea2.parse(data) assert type(msg) == pynmea2.ash.ASHRATT - assert msg.data == ['R', '130533.620', '0.311', 'T', '-80.467', '-1.395', '', '0.066', '0.067', '0.215', '2', '3'] + 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 assert msg.pitch == -1.395 + assert msg.heave == 0.25 assert msg.roll_accuracy == 0.066 assert msg.pitch_accuracy == 0.067 assert msg.heading_accuracy == 0.215 @@ -32,6 +33,13 @@ def test_ashratt(): assert msg.render() == data +def test_ashratt_with_2_vs_3_decimal_timestamp(): + msg_3 = pynmea2.parse('$PASHR,130533.620,0.311,T,-80.467,-1.395,,0.066,0.067,0.215,2,3*0B') + msg_2 = pynmea2.parse('$PASHR,130533.62,0.311,T,-80.467,-1.395,,0.066,0.067,0.215,2,3*3B') + + assert msg_3.timestamp == msg_2.timestamp + + def test_ash_undefined(): ''' Test that non-ATT messages still fall back to the generic ASH type diff --git a/test/test_file.py b/test/test_file.py index 003de25..0858e38 100644 --- a/test/test_file.py +++ b/test/test_file.py @@ -16,6 +16,7 @@ $GPGLL,4040.018,N,07808.022,W,181039.576,V*39 $GPRMC,181040.576,V,4133.618,N,07725.034,W,96.8,44.47,250915,,E*7F""" + def test_file(): nmeafile = pynmea2.NMEAFile(StringIO(TEST_DATA)) diff --git a/test/test_nor.py b/test/test_nor.py new file mode 100644 index 0000000..2c020b5 --- /dev/null +++ b/test/test_nor.py @@ -0,0 +1,261 @@ +import datetime + +import pynmea2 + + +def test_norbt0(): + data = '$PNORBT0,1,040721,131335.3341,23.961,-48.122,-32.76800,10.00000,0.00,0x00000000*48' + msg = pynmea2.parse(data) + assert type(msg) == pynmea2.nor.NORBT0 + assert msg.manufacturer == 'NOR' + 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, tzinfo=datetime.timezone.utc) + assert msg.dt1 == 23.961 + assert msg.dt2 == -48.122 + assert msg.bv == -32.76800 + assert msg.fom == 10.00000 + assert msg.dist == 0.00 + assert msg.stat == '0x00000000' + assert msg.render() == data + + +def test_norbt4(): + data = '$PNORBT4,1.234,-1.234,1.234,23.4,12.34567,12.3*3D' + msg = pynmea2.parse(data) + assert type(msg) == pynmea2.nor.NORBT4 + assert msg.manufacturer == 'NOR' + assert msg.sentence_type == 'NORBT4' + assert msg.dt1 == 1.234 + assert msg.dt2 == -1.234 + assert msg.sound_speed == 1.234 + assert msg.dir == 23.4 + assert msg.fom == 12.34567 + assert msg.dist == 12.3 + assert msg.render() == data + + +def test_norbt7(): + data = '$PNORBT7,1452244916.7508,1.234,-1.234,0.1234,0.1234,0.1234,12.34,23.45,23.45,23.45,23.45*39' + msg = pynmea2.parse(data) + assert type(msg) == pynmea2.nor.NORBT7 + assert msg.manufacturer == 'NOR' + assert msg.sentence_type == 'NORBT7' + assert msg.timestamp == datetime.datetime(2016, 1, 8, 9, 21, 56, 750800) + assert msg.dt1 == 1.234 + assert msg.dt2 == -1.234 + assert msg.vx == 0.1234 + assert msg.vy == 0.1234 + assert msg.vz == 0.1234 + assert msg.fom == 12.34 + assert msg.d1 == 23.45 + assert msg.d2 == 23.45 + assert msg.d3 == 23.45 + assert msg.d4 == 23.45 + assert msg.render() == data + + +def test_norbt9(): + data = '$PNORBT9,1452244916.7508,1.234,-1.234,0.1234,0.1234,0.1234,12.34,23.45,23.45,23.45,23.45,23.4,1567.8,1.2,12.3,0x000FFFFF*1E' + msg = pynmea2.parse(data) + assert type(msg) == pynmea2.nor.NORBT9 + assert msg.manufacturer == 'NOR' + assert msg.sentence_type == 'NORBT9' + assert msg.timestamp == datetime.datetime(2016, 1, 8, 9, 21, 56, 750800) + assert msg.dt1 == 1.234 + assert msg.dt2 == -1.234 + assert msg.vx == 0.1234 + assert msg.vy == 0.1234 + assert msg.vz == 0.1234 + assert msg.fom == 12.34 + assert msg.d1 == 23.45 + assert msg.d2 == 23.45 + assert msg.d3 == 23.45 + assert msg.d4 == 23.45 + assert msg.battery_voltage == 23.4 + assert msg.sound_speed == 1567.8 + assert msg.pressure == 1.2 + assert msg.temp == 12.3 + assert msg.stat == '0x000FFFFF' + assert msg.render() == data + + +def test_norwt4(): + data = '$PNORWT4,1.2345,-1.2345,1.234,23.4,12.34,12.3*1C' + msg = pynmea2.parse(data) + assert type(msg) == pynmea2.nor.NORWT4 + assert msg.manufacturer == 'NOR' + assert msg.sentence_type == 'NORWT4' + assert msg.dt1 == 1.2345 + assert msg.dt2 == -1.2345 + assert msg.sound_speed == 1.234 + assert msg.dir == 23.4 + assert msg.fom == 12.34 + assert msg.dist == 12.3 + assert msg.render() == data + + +def test_norwt7(): + data = '$PNORWT7,1452244916.7508,1.234,-1.234,0.1234,0.1234,0.1234,12.34,23.45,23.45,23.45,23.45*2C' + msg = pynmea2.parse(data) + assert type(msg) == pynmea2.nor.NORWT7 + assert msg.manufacturer == 'NOR' + assert msg.sentence_type == 'NORWT7' + assert msg.timestamp == datetime.datetime(2016, 1, 8, 9, 21, 56, 750800) + assert msg.dt1 == 1.234 + assert msg.dt2 == -1.234 + assert msg.vx == 0.1234 + assert msg.vy == 0.1234 + assert msg.vz == 0.1234 + assert msg.fom == 12.34 + assert msg.d1 == 23.45 + assert msg.d2 == 23.45 + assert msg.d3 == 23.45 + assert msg.d4 == 23.45 + assert msg.render() == data + + +def test_norwt9(): + data = '$PNORWT9,1452244916.7508,1.234,-1.234,0.1234,0.1234,0.1234,12.34,23.45,23.45,23.45,23.45,23.4,1567.8,1.2,12.3,0x000FFFFF*0B' + msg = pynmea2.parse(data) + assert type(msg) == pynmea2.nor.NORWT9 + assert msg.manufacturer == 'NOR' + assert msg.sentence_type == 'NORWT9' + assert msg.timestamp == datetime.datetime(2016, 1, 8, 9, 21, 56, 750800) + assert msg.dt1 == 1.234 + assert msg.dt2 == -1.234 + assert msg.vx == 0.1234 + assert msg.vy == 0.1234 + assert msg.vz == 0.1234 + assert msg.fom == 12.34 + assert msg.d1 == 23.45 + assert msg.d2 == 23.45 + assert msg.d3 == 23.45 + assert msg.d4 == 23.45 + assert msg.battery_voltage == 23.4 + assert msg.sound_speed == 1567.8 + assert msg.pressure == 1.2 + assert msg.temp == 12.3 + assert msg.stat == '0x000FFFFF' + assert msg.render() == data + + +def test_nori1(): + data = '$PNORI1,4,123456,3,30,1.00,5.00,BEAM*5B' + msg = pynmea2.parse(data) + assert type(msg) == pynmea2.nor.NORI1 + assert msg.manufacturer == 'NOR' + assert msg.sentence_type == 'NORI1' + assert msg.it == 4 + assert msg.sn == 123456 + assert msg.nb == 3 + assert msg.nc == 30 + assert msg.bd == 1.00 + assert msg.cs == 5.00 + assert msg.cy == 'BEAM' + assert msg.render() == data + + +def test_nors1(): + data = '$PNORS1,161109,132455,0,34000034,23.9,1500.0,123.4,0.02,45.6,0.02,23.4,0.02,123.456,0.02,24.56*51' + msg = pynmea2.parse(data) + assert type(msg) == pynmea2.nor.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, tzinfo=datetime.timezone.utc) + assert msg.ec == 0 + assert msg.sc == '34000034' + assert msg.battery_voltage == 23.9 + assert msg.sound_speed == 1500.0 + assert msg.heading == 123.4 + assert msg.heading_std == 0.02 + assert msg.pitch == 45.6 + assert msg.pitch_std == 0.02 + assert msg.roll == 23.4 + assert msg.roll_std == 0.02 + assert msg.pressure == 123.456 + assert msg.pressure_std == 0.02 + assert msg.temp == 24.56 + assert msg.render() == data + + +def test_nors4(): + data = '$PNORS4,23.6,1530.2,0.0,0.0,0.0,0.000,23.30*66' + msg = pynmea2.parse(data) + assert type(msg) == pynmea2.nor.NORS4 + assert msg.manufacturer == 'NOR' + assert msg.sentence_type == 'NORS4' + assert msg.battery_voltage == 23.6 + assert msg.sound_speed == 1530.2 + assert msg.heading == 0.0 + assert msg.pitch == 0.0 + assert msg.roll == 0.0 + assert msg.pressure == 0.0 + assert msg.temp == 23.30 + assert msg.render() == data + + +def test_norc1(): + data = '$PNORC1,161109,132455,3,11.0,0.332,0.332,0.332,0.332,78.9,78.9,78.9,78.9,78,78,78,78*56' + msg = pynmea2.parse(data) + 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, tzinfo=datetime.timezone.utc) + assert msg.cn == 3 + assert msg.cp == 11.0 + assert msg.vx == 0.332 + assert msg.vy == 0.332 + assert msg.vz == 0.332 + assert msg.vz2 == 0.332 + assert msg.amp1 == 78.9 + assert msg.amp2 == 78.9 + assert msg.amp3 == 78.9 + assert msg.amp4 == 78.9 + assert msg.r1 == 78 + assert msg.r2 == 78 + assert msg.r3 == 78 + assert msg.r4 == 78 + assert msg.render() == data + + +def test_norc4(): + data = '$PNORC4,1.5,1.395,227.1,32,32*7A' + msg = pynmea2.parse(data) + assert type(msg) == pynmea2.nor.NORC4 + assert msg.manufacturer == 'NOR' + assert msg.sentence_type == 'NORC4' + assert msg.cp == 1.5 + assert msg.sp == 1.395 + assert msg.dir == 227.1 + assert msg.r == 32 + assert msg.amp == 32 + assert msg.render() == data + + +def test_norh4(): + data = '$PNORH4,161109,143459,0,204C0002*38' + msg = pynmea2.parse(data) + assert type(msg) == pynmea2.nor.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, tzinfo=datetime.timezone.utc) + assert msg.ec == 0 + assert msg.sc == '204C0002' + assert msg.render() == data + + +def test_nor_undefined(): + ''' + Test that non-NOR messages still fall back to the generic NOR type + ''' + data = '$PNORTT3,XYZ,123' + msg = pynmea2.parse(data) + assert type(msg) == pynmea2.nor.NOR + assert msg.manufacturer == 'NOR' + assert msg.sentence_type == 'NORTT3' + assert msg.data == ['XYZ', '123'] + assert msg.render(checksum=False) == data diff --git a/test/test_proprietary.py b/test/test_proprietary.py index 15f7c11..ce6aea3 100644 --- a/test/test_proprietary.py +++ b/test/test_proprietary.py @@ -105,10 +105,11 @@ def test_srf(): data = '$PSRF999,0,1200,8,1,1' msg = pynmea2.parse(data) assert type(msg) == pynmea2.srf.SRF + assert msg.render(checksum=False) == data def test_grm(): - data = ' $PGRME,15.0,M,45.0,M,25.0,M*1C' + data = '$PGRME,15.0,M,45.0,M,25.0,M*1C' msg = pynmea2.parse(data) assert type(msg) == pynmea2.grm.GRME assert msg.sentence_type == 'GRME' @@ -118,6 +119,8 @@ def test_grm(): assert msg.vpe_unit == 'M' assert msg.osepe == 25.0 assert msg.osepe_unit == 'M' + assert msg.render() == data + def test_tnl(): data = '$PTNL,BPQ,224445.06,021207,3723.09383914,N,12200.32620132,W,EHT-5.923,M,5*60' @@ -126,15 +129,19 @@ def test_tnl(): assert msg.datestamp == datetime.date(2007,12,2) assert msg.latitude == 37.384897319 assert msg.longitude == -122.00543668866666 + assert msg.render() == data def test_ubx00(): data = '$PUBX,00,074440.00,4703.74203,N,00736.82976,E,576.991,D3,2.0,2.0,0.091,0.00,-0.032,,0.76,1.05,0.65,14,0,0*70' msg = pynmea2.parse(data) assert type(msg) == pynmea2.ubx.UBX00 - assert msg.timestamp == datetime.time(7, 44, 40) + assert msg.identifier() == 'PUBX' + assert msg.ubx_type == '00' + 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 def test_ubx03(): @@ -142,6 +149,7 @@ def test_ubx03(): msg = pynmea2.parse(data) assert type(msg) == pynmea2.ubx.UBX03 assert msg.num_sv == 20 + assert msg.render() == data def test_ubx04(): @@ -149,8 +157,9 @@ 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 def test_create(): @@ -171,3 +180,149 @@ def test_unknown_sentence(): assert msg.manufacturer == 'ZZZ' assert msg.data == ['ABC', '1', '2', '3'] assert msg.render(checksum=False, dollar=False) == data + + +def test_proprietary_VTX_0002(): + # A sample proprietary sentence from a Vectronix device (laser distance) + data = "$PVTX,0002,181330,00005.22,M,262.518,T,-01.967,09.358,W*7E" + msg = pynmea2.parse(data) + assert msg.manufacturer == 'VTX' + assert msg.dist == 5.22 + assert msg.direction == 262.518 + assert msg.va == -1.967 + assert msg.render() == data + + +def test_proprietary_VTX_0012(): + # A sample proprietary sentence from a Vectronix device (target position) + data = "$PVTX,0012,177750,3348.5861,N,10048.5861,W,00045.2,M,038.8,M*22" + msg = pynmea2.parse(data) + assert msg.manufacturer == 'VTX' + assert msg.latitude == 33.80976833333333 + assert msg.longitude == -100.80976833333334 + assert msg.altitude == 45.2 + assert msg.gain == 38.8 + assert msg.render() == data + + +def test_proprietary_GRMW(): + # A sample proprietary Garmin Waypoint sentence, generated by DIREWOLF + data = "$PGRMW,AC7FD-1,,000A,AC7FD local DIGI U=12.5V|T=23.9C*1A" + msg = pynmea2.parse(data) + assert msg.manufacturer == 'GRM' + assert msg.wname == 'AC7FD-1' + assert msg.altitude == None + assert msg.symbol == '000A' + assert msg.comment == 'AC7FD local DIGI U=12.5V|T=23.9C' + + +def test_proprietary_MGNWPL(): + # A sample proprietary Magellan Waypoint sentence, generated by DIREWOLF + data = "$PMGNWPL,4531.7900,N,12253.4800,W,,M,AC7FD-1,AC7FD local DIGI U=12.5V|T=23.9C,c*46" + msg = pynmea2.parse(data) + assert msg.manufacturer == 'MGN' + assert msg.lat =='4531.7900' + assert msg.lat_dir == 'N' + assert msg.lon == '12253.4800' + assert msg.lon_dir == 'W' + assert msg.altitude == None + assert msg.altitude_unit == 'M' + assert msg.wname == 'AC7FD-1' + assert msg.comment == 'AC7FD local DIGI U=12.5V|T=23.9C' + assert msg.icon == 'c' + assert msg.latitude == 45.529833333333336 + assert msg.longitude == -122.89133333333334 + + +def test_KWDWPL(): + # A sample proprietary Kenwood Waypoint sentence, generated by DIREWOLF + 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, tzinfo=datetime.timezone.utc) + assert msg.status == 'V' + assert msg.is_valid == False + assert msg.lat == '4531.7900' + assert msg.lat_dir == 'N' + assert msg.lon == '12253.4800' + assert msg.lon_dir == 'W' + 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, tzinfo=datetime.timezone.utc) + assert msg.altitude == None + assert msg.wname == 'AC7FD-1' + assert msg.ts == '/-' + assert msg.latitude == 45.529833333333336 + assert msg.longitude == -122.89133333333334 + +def test_PKNDS(): + # A sample proprietary Kenwood sentence used for GPS data communications in NEXEDGE Digital + data = "$PKNDS,114400,A,4954.1450,N,11923.6043,W,001.4,356.8,130223,19.20,W00,U00002,207,00,*2E" + msg = pynmea2.parse(data) + assert msg.manufacturer == "KND" + assert msg.timestamp == datetime.time(11, 44, 00, tzinfo=datetime.timezone.utc) + assert msg.status == 'A' + assert msg.is_valid == True + assert msg.lat == '4954.1450' + assert msg.lat_dir == 'N' + assert msg.lon == '11923.6043' + assert msg.lon_dir == 'W' + assert msg.datestamp == datetime.date(2023, 2, 13) + assert msg.datetime == datetime.datetime(2023, 2, 13, 11, 44, 00, tzinfo=datetime.timezone.utc) + assert msg.senderid == 'U00002' + assert msg.senderstatus == 207 + assert msg.latitude == 49.90241666666667 + assert msg.longitude == -119.393405 + +def test_PKNSH(): + # A sample proprietary Kenwood sentence used for GPS data communications in NEXEDGE Digital + data = "$PKNSH,4954.1450,N,11923.6043,W,114400,A,U00002,*44" + msg = pynmea2.parse(data) + assert msg.manufacturer == "KNS" + assert msg.timestamp == datetime.time(11, 44, 00, tzinfo=datetime.timezone.utc) + assert msg.status == 'A' + assert msg.is_valid == True + assert msg.lat == '4954.1450' + assert msg.lat_dir == 'N' + assert msg.lon == '11923.6043' + assert msg.lon_dir == 'W' + assert msg.senderid == 'U00002' + assert msg.latitude == 49.90241666666667 + assert msg.longitude == -119.393405 + +def test_PKLDS(): + # A sample proprietary Kenwood sentence used for GPS data communications in FleetSync II signaling + data = "$PKLDS,122434,A,4954.1474,N,11923.6044,W,001.1,194.9,130223,19.20,W00,100,1001,80,00,*60" + msg = pynmea2.parse(data) + assert msg.manufacturer == "KLD" + assert msg.timestamp == datetime.time(12, 24, 34, tzinfo=datetime.timezone.utc) + assert msg.status == 'A' + assert msg.is_valid == True + assert msg.lat == '4954.1474' + assert msg.lat_dir == 'N' + assert msg.lon == '11923.6044' + assert msg.lon_dir == 'W' + assert msg.datestamp == datetime.date(2023, 2, 13) + assert msg.datetime == datetime.datetime(2023, 2, 13, 12, 24, 34, tzinfo=datetime.timezone.utc) + assert msg.senderid == '1001' + assert msg.fleet == 100 + assert msg.latitude == 49.902456666666666 + assert msg.longitude == -119.39340666666666 + +def test_PKLSH(): + # A sample proprietary Kenwood sentence used for GPS data communications in FleetSync II signaling + data = "$PKLSH,4954.1474,N,11923.6044,W,122434,A,100,1001,*3F" + msg = pynmea2.parse(data) + assert msg.manufacturer == "KLS" + assert msg.timestamp == datetime.time(12, 24, 34, tzinfo=datetime.timezone.utc) + assert msg.status == 'A' + assert msg.is_valid == True + assert msg.lat == '4954.1474' + assert msg.lat_dir == 'N' + assert msg.lon == '11923.6044' + assert msg.lon_dir == 'W' + assert msg.senderid == '1001' + assert msg.fleet == 100 + assert msg.latitude == 49.902456666666666 + assert msg.longitude == -119.39340666666666 diff --git a/test/test_pynmea.py b/test/test_pynmea.py index 941aa7b..0b12fcb 100644 --- a/test/test_pynmea.py +++ b/test/test_pynmea.py @@ -5,7 +5,7 @@ def test_version(): - version = '1.12.0' + version = '1.19.0' assert pynmea2.version == version assert pynmea2.__version__ == version @@ -87,12 +87,14 @@ def test_nmea_util(): assert pynmea2.nmea_utils.dm_to_sd('0') == 0. assert pynmea2.nmea_utils.dm_to_sd('12108.1') == 121.135 + def test_missing_latlon(): data = '$GPGGA,201716.684,,,,,0,00,,,M,0.0,M,,0000*5F' msg = pynmea2.parse(data) print(msg) assert msg.latitude == 0. + def test_query(): data = 'CCGPQ,GGA' msg = pynmea2.parse(data) @@ -100,6 +102,9 @@ def test_query(): assert msg.talker == 'CC' assert msg.listener == 'GP' assert msg.sentence_type == 'GGA' + msg = pynmea2.QuerySentence('CC', 'GP', 'GGA') + assert msg.render() == '$CCGPQ,GGA*2B' + def test_slash(): with pytest.raises(pynmea2.nmea.ParseError): diff --git a/test/test_stream.py b/test/test_stream.py index 40eac47..24f4f07 100644 --- a/test/test_stream.py +++ b/test/test_stream.py @@ -31,6 +31,15 @@ def test_stream(): assert len(list(sr.next())) == 0 +def test_iter(): + sr = pynmea2.NMEAStreamReader(StringIO(DATA)) + for batch in sr: + for msg in batch: + assert isinstance(msg, pynmea2.GGA) + break + break + + def test_raise_errors(): sr = pynmea2.NMEAStreamReader(errors='raise') assert list(sr.next('foobar')) == [] diff --git a/test/test_types.py b/test/test_types.py index f5dc72d..22f4123 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,15 +114,85 @@ 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 +def test_RMC_valid(): + '''The RMC mode indicator and navigation status values are optional. + Test that when supplied the whole message must be valid. When not supplied + 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 + + https://orolia.com/manuals/VSP/Content/NC_and_SS/Com/Topics/APPENDIX/NMEA_RMCmess.htm + + for more information about the RMC Message additions. + ''' + msgs = [ + # Original + '$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', + '$GPRMC,123519.00,A,4807.038,N,01131.000,E,,,230394,,,*1F', + '$GPRMC,123519.00,V,4807.038,N,01131.000,E,,,230394,,,S*5B', + '$GPRMC,123519.00,V,4807.038,N,01131.000,E,,,230394,,,N*46', + '$GPRMC,123519.00,V,4807.038,N,01131.000,E,,,230394,,,*08', + '$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', + '$GPRMC,123519.00,A,4807.038,N,01131.000,E,,,230394,,,S,*60', + '$GPRMC,123519.00,A,4807.038,N,01131.000,E,,,230394,,,N,A*3C', + '$GPRMC,123519.00,A,4807.038,N,01131.000,E,,,230394,,,N,V*2B', + '$GPRMC,123519.00,A,4807.038,N,01131.000,E,,,230394,,,N,*7D', + '$GPRMC,123519.00,A,4807.038,N,01131.000,E,,,230394,,,,A*72', + '$GPRMC,123519.00,A,4807.038,N,01131.000,E,,,230394,,,,V*65', + '$GPRMC,123519.00,A,4807.038,N,01131.000,E,,,230394,,,,*33', + '$GPRMC,123519.00,V,4807.038,N,01131.000,E,,,230394,,,S,A*36', + '$GPRMC,123519.00,V,4807.038,N,01131.000,E,,,230394,,,S,V*21', + '$GPRMC,123519.00,V,4807.038,N,01131.000,E,,,230394,,,S,*77', + '$GPRMC,123519.00,V,4807.038,N,01131.000,E,,,230394,,,N,A*2B', + '$GPRMC,123519.00,V,4807.038,N,01131.000,E,,,230394,,,N,V*3C', + '$GPRMC,123519.00,V,4807.038,N,01131.000,E,,,230394,,,N,*6A', + '$GPRMC,123519.00,V,4807.038,N,01131.000,E,,,230394,,,,A*65', + '$GPRMC,123519.00,V,4807.038,N,01131.000,E,,,230394,,,,V*72', + '$GPRMC,123519.00,V,4807.038,N,01131.000,E,,,230394,,,,*24', + '$GPRMC,123519.00,,4807.038,N,01131.000,E,,,230394,,,S,A*60', + '$GPRMC,123519.00,,4807.038,N,01131.000,E,,,230394,,,S,V*77', + '$GPRMC,123519.00,,4807.038,N,01131.000,E,,,230394,,,S,*21', + '$GPRMC,123519.00,,4807.038,N,01131.000,E,,,230394,,,N,A*7D', + '$GPRMC,123519.00,,4807.038,N,01131.000,E,,,230394,,,N,V*6A', + '$GPRMC,123519.00,,4807.038,N,01131.000,E,,,230394,,,N,*3C', + '$GPRMC,123519.00,,4807.038,N,01131.000,E,,,230394,,,,A*33', + '$GPRMC,123519.00,,4807.038,N,01131.000,E,,,230394,,,,V*24', + '$GPRMC,123519.00,,4807.038,N,01131.000,E,,,230394,,,,*72', + ] + + # only the first of each section is valid + expected = [False] * 39 + expected[0] = True + expected[3] = True + expected[12] = True + + for i, msg in enumerate(msgs): + parsed = pynmea2.parse(msg) + assert expected[i] == parsed.is_valid + + def test_TXT(): data = '$GNTXT,01,01,02,ROM BASE 2.01 (75331) Oct 29 2013 13:28:17*44' msg = pynmea2.parse(data) @@ -134,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" @@ -220,3 +292,42 @@ def test_STALK_unidentified_command(): assert msg.render() == data assert msg.command_name == 'Unknown Command' +def test_GRS(): + data = "$GNGRS,162047.00,1,0.6,0.1,-16.6,-0.8,-0.1,0.5,,,,,,*41" + msg = pynmea2.parse(data) + assert msg.render() == data + assert msg.talker == 'GN' + assert msg.sentence_type == 'GRS' + assert msg.residuals_mode == 1 + assert msg.sv_res_01 == 0.6 + assert msg.sv_res_02 == 0.1 + assert msg.sv_res_03 == -16.6 + assert msg.sv_res_04 == -0.8 + assert msg.sv_res_05 == -0.1 + assert msg.sv_res_06 == 0.5 + assert msg.sv_res_07 == None + + +def test_HBT(): + data = "$AIHBT,30,A,1*09" + msg = pynmea2.parse(data) + assert msg.render() == data + assert isinstance(msg, pynmea2.HBT) + assert msg.talker == 'AI' + assert msg.sentence_type == 'HBT' + assert msg.interval == 30 + assert msg.eq_status == 'A' + assert msg.seq_sent_iden == 1 + + +def test_ALR(): + data = "$AIALR,,006,V,V,AIS:general failure*1A" + msg = pynmea2.parse(data) + assert msg.render() == data + assert isinstance(msg, pynmea2.ALR) + assert msg.talker == 'AI' + assert msg.sentence_type == 'ALR' + assert msg.alarm_num == '006' + assert msg.alarm_con == 'V' + assert msg.alarm_state == 'V' + assert msg.description == 'AIS:general failure' \ No newline at end of file diff --git a/test/test_utils.py b/test/test_utils.py new file mode 100644 index 0000000..cb2f4fa --- /dev/null +++ b/test/test_utils.py @@ -0,0 +1,17 @@ +import pytest +import pynmea2 +import pynmea2.nmea_utils + + +def test_GGA(): + data = "$GPGGA,184353.07,1929.045,S,02410.506,E,1,04,2.6,100.00,M,-33.9,M,,0000*6D" + msg = pynmea2.parse(data) + assert msg.latitude == -19.484083333333334 + assert msg.longitude == 24.1751 + assert msg.is_valid == True + +def test_latlon(): + data = "$GPGGA,161405.680,37.352387,N,121.953086,W,1,10,0.01,-110.342552,M,0.000000,M,,0000,*4E" + msg = pynmea2.parse(data) + with pytest.raises(ValueError): + x = msg.latitude