Skip to content

Commit

Permalink
Merge pull request #58 from semuconsulting/RC-1.0.37
Browse files Browse the repository at this point in the history
Rc 1.0.37
  • Loading branch information
semuadmin committed Jun 4, 2024
2 parents cca0fc1 + 1feb847 commit 32a10fa
Show file tree
Hide file tree
Showing 10 changed files with 128 additions and 53 deletions.
14 changes: 5 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,10 @@ Individual input NMEA messages can then be read using the `NMEAReader.read()` fu

The constructor accepts the following optional keyword arguments:


* `nmeaonly`: True = raise error if stream contains non-NMEA data, False = ignore non-NMEA data (default)
* `validate`: bitfield validation flags (can be used in combination):
- `VALCKSUM` (0x01) = validate checksum (default)
- `VALMSGID` (0x02) = validate msgId (i.e. raise error if unknown NMEA message is received)
* `msgmode`: 0 = GET (default, i.e. output _from_ receiver), 1 = SET (i.e. input _to_ receiver), 2 = POLL (i.e. query _to_ receiver in anticipation of response back)

* `nmeaonly`: True = raise error if stream contains non-NMEA data, False = ignore non-NMEA data (default)
* `validate`: validation flags `VALCKSUM` (0x01) = validate checksum (default), `VALMSGID` (0x02) = validate msgId (i.e. raise error if unknown NMEA message is received)
* `quitonerror`: `ERR_IGNORE` (0) = ignore errors, `ERR_LOG` (1) = log continue, `ERR_RAISE` (2) = (re)raise (1)

Examples:

Expand Down Expand Up @@ -154,10 +151,9 @@ Attributes within repeating groups are parsed with a two-digit suffix (svid_01,

The `parse()` function accepts the following optional keyword arguments:

* `validate`: bitfield validation flags (can be used in combination):
- `VALCKSUM` (0x01) = validate checksum (default)
- `VALMSGID` (0x02) = validate msgId (i.e. raise error if unknown NMEA message is received)
* `msgmode`: 0 = GET (default), 1 = SET, 2 = POLL
* `validate`: validation flags `VALCKSUM` (0x01) = validate checksum (default), `VALMSGID` (0x02) = validate msgId (i.e. raise error if unknown NMEA message is received)
* `quitonerror`: `ERR_IGNORE` (0) = ignore errors, `ERR_LOG` (1) = log continue, `ERR_RAISE` (2) = (re)raise (1)

Example:

Expand Down
7 changes: 7 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# pynmeagps Release Notes

### RELEASE 1.0.37

ENHANCEMENTS:

1. Correct `planar()` helper function.
1. Internal logging & exception handling enhancements.

### RELEASE 1.0.36

ENHANCEMENTS:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ disable = """

[tool.pytest.ini_options]
minversion = "7.0"
addopts = "--cov --cov-report html --cov-fail-under 95"
addopts = "--cov --cov-report html --cov-fail-under 98"
pythonpath = ["src"]

[tool.coverage.run]
Expand Down
2 changes: 1 addition & 1 deletion src/pynmeagps/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@
:license: BSD 3-Clause
"""

__version__ = "1.0.36"
__version__ = "1.0.37"
7 changes: 4 additions & 3 deletions src/pynmeagps/nmeahelpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -517,9 +517,10 @@ def planar(
:rtype: float
"""

x = (lon2 - lon1) * cos(lat1) * pi * radius / 180
y = (lat2 - lat1) * pi * radius / 180
dist = sqrt(x * x + y * y)
phi1, lambda1, phi2, lambda2 = [c * pi / 180 for c in (lat1, lon1, lat2, lon2)]
dlambda = (lambda2 - lambda1) * cos(phi1)
dphi = phi2 - phi1
dist = radius * sqrt(dlambda * dlambda + dphi * dphi)

return dist

Expand Down
79 changes: 47 additions & 32 deletions src/pynmeagps/nmeareader.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
:license: BSD 3-Clause
"""

from logging import getLogger
from socket import socket

import pynmeagps.exceptions as nme
Expand Down Expand Up @@ -55,10 +56,11 @@ def __init__(
"""Constructor.
:param stream stream: input data stream (e.g. Serial or binary File)
:param int msgmode: 0 = GET (default), 1 = SET, 2 = POLL
:param int validate: bitfield validation flags - VALCKSUM (default), VALMSGID
:param int msgmode: 0=GET, 1=SET, 2=POLL (0)
:param int validate: validation flags - VALNONE (0), VALCKSUM (1), VALMSGID (2) (1)
:param bool nmeaonly: True = error on non-NMEA data, False = ignore non-NMEA data
:param int quitonerror: 0 = ignore, 1 = log and continue, 2 = (re)raise (1)
:param int quitonerror: ERR_IGNORE (0) = ignore errors, ERR_LOG (1) = log continue,
ERR_RAISE (2) = (re)raise (1)
:param int bufsize: socket recv buffer size (4096)
:param object errorhandler: error handling object or function (None)
:raises: NMEAParseError (if mode is invalid)
Expand All @@ -78,6 +80,7 @@ def __init__(
self._nmea_only = nmeaonly
self._validate = validate
self._mode = msgmode
self._logger = getLogger(__name__)

def __iter__(self):
"""Iterator."""
Expand Down Expand Up @@ -113,8 +116,8 @@ def read(self) -> tuple:
raw_data = None
parsed_data = None

try:
while parsing: # loop until end of valid NMEA message or EOF
while parsing: # loop until end of valid NMEA message or EOF
try:
byte1 = self._read_bytes(1) # read 1st byte
if byte1 != b"\x24": # not NMEA, discard and continue
continue
Expand All @@ -124,24 +127,26 @@ def read(self) -> tuple:
byten = self._read_line() # NMEA protocol is CRLF terminated
raw_data = bytehdr + byten
parsed_data = self.parse(
raw_data, validate=self._validate, msgmode=self._mode
raw_data,
msgmode=self._mode,
validate=self._validate,
)
parsing = False
else: # it's not a NMEA message (UBX or something else)
if self._nmea_only: # raise error and quit
raise nme.NMEAParseError(f"Unknown data header {bytehdr}.")

except EOFError:
return (None, None)
except (
nme.NMEAMessageError,
nme.NMEATypeError,
nme.NMEAParseError,
nme.NMEAStreamError,
) as err:
if self._quitonerror:
self._do_error(str(err))
parsed_data = str(err)
raise nme.NMEAParseError(f"Unknown protocol header {bytehdr}.")

except EOFError:
return (None, None)
except (
nme.NMEAMessageError,
nme.NMEATypeError,
nme.NMEAParseError,
nme.NMEAStreamError,
) as err:
if self._quitonerror:
self._do_error(err)
continue

return (raw_data, parsed_data)

Expand All @@ -156,38 +161,48 @@ def _read_bytes(self, size: int) -> bytes:
"""

data = self._stream.read(size)
if len(data) < size: # EOF
raise EOFError()
if len(data) == 0: # EOF
raise EOFError() # pragma: no cover
if 0 < len(data) < size: # truncated stream
raise nme.NMEAStreamError( # pragma: no cover
"Serial stream terminated unexpectedly. "
f"{size} bytes requested, {len(data)} bytes returned."
)
return data

def _read_line(self) -> bytes:
"""
Read until end of line (CRLF).
Read bytes until LF (0x0a) terminator.
:return: bytes
:rtype: bytes
:raises: EOFError if stream ends prematurely
"""

data = self._stream.readline() # NMEA protocol is CRLF terminated
if data[-1:] != b"\x0a": # EOF
raise EOFError()
data = self._stream.readline() # NMEA protocol is CRLF-terminated
if len(data) == 0: # EOF
raise EOFError() # pragma: no cover
if data[-1:] != b"\x0a": # truncated stream
raise nme.NMEAStreamError( # pragma: no cover
"Serial stream terminated unexpectedly. "
f"Line requested, {len(data)} bytes returned."
)
return data

def _do_error(self, err: str):
def _do_error(self, err: Exception):
"""
Handle error.
:param str err: error message
:raises: UBXParseError if quitonerror = 2
:param Exception err: error message
:raises: Exception if quitonerror = 2
"""

if self._quitonerror == ERR_RAISE:
raise nme.NMEAParseError(err)
raise err from err
if self._quitonerror == ERR_LOG:
# pass to error handler if there is one
if self._errorhandler is None:
print(err)
self._logger.error(err)
else:
self._errorhandler(err)

Expand All @@ -212,11 +227,11 @@ def parse(
Parse NMEA byte stream to NMEAMessage object.
:param bytes message: bytes message to parse
:param int msgmode: 0 = GET (default), 1 = SET, 2 = POLL
:param int msgmode: 0=GET, 1=SET, 2=POLL (0)
:param int validate: 1 VALCKSUM (default), 2 VALMSGID (can be OR'd)
:return: NMEAMessage object (or None if unknown message and VALMSGID is not set)
:rtype: NMEAMessage
:raises: NMEAParseError (if data stream contains invalid data or unknown message type)
:raises: Exception (if data stream contains invalid data or unknown message type)
"""

Expand Down
16 changes: 16 additions & 0 deletions tests/pygpsdata-BADHDR.log
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
$GPTXT,01,01,02,HW UBX-G70xx 00070000 *77
$GPTXT,01,01,02,ROM CORE 1.00 (59842) Jun 27 2012 17:43:52*59
$GPTXT,01,01,02,PROTVER 14.00*1E
$GPTXT,01,01,02,ANTSUPERV=AC SD PDoS SR*20
$GPTXT,01,01,02,ANTSTATUS=OK*3B
$&bTXT,01,01,02,LLC FFFFFFFF-FFFFFFFD-FFFFFFFF-FFFFFFFF-FFFFFFF9*53
$GPRMC,102929.00,A,5327.04024,N,00214.41560,W,0.273,,070321,,,A*62
$GPVTG,,T,,M,0.273,N,0.506,K,A*26
$GPGGA,102929.00,5327.04024,N,00214.41560,W,1,08,1.16,36.3,M,48.5,M,,*7E
$GPGSA,A,3,17,15,10,24,20,12,19,23,,,,,2.36,1.16,2.05*09
$GPGSV,4,1,15,01,06,015,,10,30,290,27,12,42,207,26,13,19,141,23*7C
$GPGSV,4,2,15,14,07,049,21,15,45,171,27,17,32,065,22,19,33,095,25*77
$GPGSV,4,3,15,20,21,251,31,21,04,355,,23,28,252,33,24,88,273,36*70
$GPGSV,4,4,15,25,05,223,,28,14,049,26,32,10,313,16*4C
$GPGLL,5327.04024,N,00214.41560,W,102929.00,A,A*7A
$GPRMC,102930.00,A,5327.04033,N,00214.41550,W,0.099,,070321,,,A*69
10 changes: 9 additions & 1 deletion tests/test_parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,15 @@
"""

import unittest
from pynmeagps import NMEAReader, NMEAParseError, SET, VALCKSUM, VALMSGID
from pynmeagps import (
NMEAReader,
NMEAMessageError,
NMEAParseError,
ERR_RAISE,
SET,
VALCKSUM,
VALMSGID,
)


class ParseTest(unittest.TestCase):
Expand Down
6 changes: 3 additions & 3 deletions tests/test_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -470,13 +470,13 @@ def testhaversine(self):
def testplanar(self): # test planar
res = planar(53, 2, 53.000001, 2.000001)
# print(res)
self.assertAlmostEqual(res, 0.15113412625873954, 7)
self.assertAlmostEqual(res, 0.12992378701316823, 7)
res = planar(53, 2, 53.00001, 2.00001)
# print(res)
self.assertAlmostEqual(res, 1.5113412648256797, 7)
self.assertAlmostEqual(res, 1.299237873027932, 7)
res = planar(53, 2, 53.0001, 2.0001)
# print(res)
self.assertAlmostEqual(res, 15.113412645895695, 7)
self.assertAlmostEqual(res, 12.992378723687828, 7)

def testbearing(self):
res = bearing(51.23, -2.41, 53.205, -2.34)
Expand Down
38 changes: 35 additions & 3 deletions tests/test_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@
import os
import sys
import unittest
from logging import ERROR

from pynmeagps import (
NMEAReader,
NMEAParseError,
NMEATypeError,
VALCKSUM,
ERR_RAISE,
ERR_IGNORE,
Expand Down Expand Up @@ -210,7 +212,7 @@ def testMIXED(
def testMIXED2(
self,
): # stream of mixed NMEA & UBX data with nmea_only set to TRUE - should be rejected
EXPECTED_ERROR = "Unknown data header b'$\\x11'"
EXPECTED_ERROR = "Unknown protocol header b'$\\x11'"
with open(os.path.join(DIRNAME, "pygpsdata-mixed.log"), "rb") as stream:
with self.assertRaises(NMEAParseError) as context:
i = 0
Expand Down Expand Up @@ -321,7 +323,7 @@ def testNMEAITERATE_ERR3(
def testNMEAFOO1(self): # stream containing invalid attribute type
EXPECTED_ERROR = "Unknown attribute type Z2"
with open(os.path.join(DIRNAME, "pygpsdata-nmeafoo1.log"), "rb") as stream:
with self.assertRaises(NMEAParseError) as context:
with self.assertRaises(NMEATypeError) as context:
i = 0
raw = 0
nmr = NMEAReader(
Expand All @@ -336,7 +338,7 @@ def testNMEAFOO1(self): # stream containing invalid attribute type
def testNMEAFOO2(self): # stream containing invalid value for attribute type
EXPECTED_ERROR = "Incorrect type for attribute spd in msgID RMC"
with open(os.path.join(DIRNAME, "pygpsdata-nmeafoo2.log"), "rb") as stream:
with self.assertRaises(NMEAParseError) as context:
with self.assertRaises(NMEATypeError) as context:
i = 0
raw = 0
nmr = NMEAReader(stream, nmeaonly=False, quitonerror=ERR_RAISE)
Expand Down Expand Up @@ -442,6 +444,36 @@ def testNMEAKENWOOD(self): # test proprietary Kenwood messages
i += 1
self.assertEqual(i, 5)

def testBADHDR_FAIL(self): # invalid header in data with quitonerror = 2
EXPECTED_ERROR = "Unknown protocol header b'$&'."
with self.assertRaises(NMEAParseError) as context:
i = 0
with open(os.path.join(DIRNAME, "pygpsdata-BADHDR.log"), "rb") as stream:
ubr = NMEAReader(stream, quitonerror=ERR_RAISE, nmeaonly=True)
for _, _ in ubr:
i += 1
self.assertTrue(EXPECTED_ERROR in str(context.exception))

def testBADHDR_LOG(self): # invalid header in data with quitonerror = 1
i = 0
with self.assertLogs(level=ERROR) as log:
with open(os.path.join(DIRNAME, "pygpsdata-BADHDR.log"), "rb") as stream:
ubr = NMEAReader(stream, quitonerror=ERR_LOG, nmeaonly=True)
for raw, parsed in ubr:
i += 1
self.assertEqual(
["ERROR:pynmeagps.nmeareader:Unknown protocol header b'$&'."],
log.output,
)

def testBADHDR_IGNORE(self): # invalid header in data with quitonerror = 0
i = 0
with open(os.path.join(DIRNAME, "pygpsdata-BADHDR.log"), "rb") as stream:
ubr = NMEAReader(stream, quitonerror=ERR_IGNORE, nmeaonly=True)
for raw, parsed in ubr:
i += 1
self.assertEqual(i, 15)


if __name__ == "__main__":
# import sys;sys.argv = ['', 'Test.testName']
Expand Down

0 comments on commit 32a10fa

Please sign in to comment.