%PDF- %PDF-
Direktori : /lib/python3/dist-packages/twisted/positioning/test/ |
Current File : //lib/python3/dist-packages/twisted/positioning/test/test_nmea.py |
# Copyright (c) 2009-2011 Twisted Matrix Laboratories. # See LICENSE for details. """ Test cases for using NMEA sentences. """ from __future__ import annotations import datetime from operator import attrgetter from typing import Callable, Iterable, TypedDict from zope.interface import implementer from constantly import NamedConstant from typing import Literal, Protocol from twisted.positioning import base, ipositioning, nmea from twisted.positioning.base import Angles from twisted.positioning.test.receiver import MockPositioningReceiver from twisted.trial.unittest import TestCase # Sample sentences GPGGA = b"$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47" GPRMC = b"$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A" GPGSA = b"$GPGSA,A,3,19,28,14,18,27,22,31,39,,,,,1.7,1.0,1.3*34" GPHDT = b"$GPHDT,038.005,T*3B" GPGLL = b"$GPGLL,4916.45,N,12311.12,W,225444,A*31" GPGLL_PARTIAL = b"$GPGLL,3751.65,S,14507.36,E*77" GPGSV_SINGLE = b"$GPGSV,1,1,11,03,03,111,00,04,15,270,00,06,01,010,00,,,,*4b" GPGSV_EMPTY_MIDDLE = b"$GPGSV,1,1,11,03,03,111,00,,,,,,,,,13,06,292,00*75" GPGSV_SEQ = ( GPGSV_FIRST, GPGSV_MIDDLE, GPGSV_LAST, ) = b""" $GPGSV,3,1,11,03,03,111,00,04,15,270,00,06,01,010,00,13,06,292,00*74 $GPGSV,3,2,11,14,25,170,00,16,57,208,39,18,67,296,40,19,40,246,00*74 $GPGSV,3,3,11,22,42,067,42,24,14,311,43,27,05,244,00,,,,*4D """.split() @implementer(ipositioning.INMEAReceiver) class NMEATestReceiver: """ An NMEA receiver for testing. Remembers the last sentence it has received. """ def __init__(self) -> None: self.clear() def clear(self) -> None: """ Forgets the received sentence (if any), by setting C{self.receivedSentence} to L{None}. """ self.receivedSentence: nmea.NMEASentence | None = None def sentenceReceived(self, sentence: nmea.NMEASentence) -> None: self.receivedSentence = sentence class CallbackTests(TestCase): """ Tests if the NMEA protocol correctly calls its sentence callback. @ivar protocol: The NMEA protocol under test. @type protocol: L{nmea.NMEAProtocol} @ivar sentenceTypes: The set of sentence types of all sentences the test's sentence callback function has been called with. @type sentenceTypes: C{set} """ def setUp(self) -> None: receiver = NMEATestReceiver() self.protocol = nmea.NMEAProtocol(receiver, self._sentenceCallback) self.sentenceTypes: set[str] = set() def _sentenceCallback(self, sentence: nmea.NMEASentence) -> None: """ Remembers that a sentence of this type was fired. """ self.sentenceTypes.add(sentence.type) def test_callbacksCalled(self) -> None: """ The correct callbacks fire, and that *only* those fire. """ sentencesByType = { "GPGGA": [b"$GPGGA*56"], "GPGLL": [b"$GPGLL*50"], "GPGSA": [b"$GPGSA*42"], "GPGSV": [b"$GPGSV*55"], "GPHDT": [b"$GPHDT*4f"], "GPRMC": [b"$GPRMC*4b"], } for sentenceType, sentences in sentencesByType.items(): for sentence in sentences: self.protocol.lineReceived(sentence) self.assertEqual(self.sentenceTypes, {sentenceType}) self.sentenceTypes.clear() class BrokenSentenceCallbackTests(TestCase): """ Tests for broken NMEA sentence callbacks. """ def setUp(self) -> None: receiver = NMEATestReceiver() self.protocol = nmea.NMEAProtocol(receiver, self._sentenceCallback) def _sentenceCallback(self, sentence: nmea.NMEASentence) -> None: """ Raises C{AttributeError}. """ raise AttributeError("ERROR!!!") def test_dontSwallowCallbackExceptions(self) -> None: """ An C{AttributeError} in the sentence callback of an C{NMEAProtocol} doesn't get swallowed. """ lineReceived = self.protocol.lineReceived self.assertRaises(AttributeError, lineReceived, b"$GPGGA*56") class SplitTests(TestCase): """ Checks splitting of NMEA sentences. """ def test_withChecksum(self) -> None: """ An NMEA sentence with a checksum gets split correctly. """ splitSentence = nmea._split(b"$GPGGA,spam,eggs*00") self.assertEqual(splitSentence, [b"GPGGA", b"spam", b"eggs"]) def test_noCheckum(self) -> None: """ An NMEA sentence without a checksum gets split correctly. """ splitSentence = nmea._split(b"$GPGGA,spam,eggs*") self.assertEqual(splitSentence, [b"GPGGA", b"spam", b"eggs"]) class ChecksumTests(TestCase): """ NMEA sentence checksum verification tests. """ def test_valid(self) -> None: """ Sentences with valid checksums get validated. """ nmea._validateChecksum(GPGGA) def test_missing(self) -> None: """ Sentences with missing checksums get validated. """ nmea._validateChecksum(GPGGA[:-2]) def test_invalid(self) -> None: """ Sentences with a bad checksum raise L{base.InvalidChecksum} when attempting to validate them. """ validate = nmea._validateChecksum bareSentence, checksum = GPGGA.split(b"*") badChecksum = b"%d" % (int(checksum, 16) + 1,) sentences = [bareSentence + b"*" + badChecksum] for s in sentences: self.assertRaises(base.InvalidChecksum, validate, s) class NMEAReceiverSetup: """ A mixin for tests that need an NMEA receiver (and a protocol attached to it). @ivar receiver: An NMEA receiver that remembers the last sentence. @type receiver: L{NMEATestReceiver} @ivar protocol: An NMEA protocol attached to the receiver. @type protocol: L{twisted.positioning.nmea.NMEAProtocol} """ def setUp(self) -> None: """ Sets up an NMEA receiver. """ self.receiver = NMEATestReceiver() self.protocol = nmea.NMEAProtocol(self.receiver) class GSVSequenceTests(NMEAReceiverSetup, TestCase): """ Tests for the interpretation of GSV sequences. """ def test_firstSentence(self) -> None: """ The first sentence in a GSV sequence is correctly identified. """ self.protocol.lineReceived(GPGSV_FIRST) sentence = self.receiver.receivedSentence assert sentence is not None self.assertTrue(sentence._isFirstGSVSentence()) self.assertFalse(sentence._isLastGSVSentence()) def test_middleSentence(self) -> None: """ A sentence in the middle of a GSV sequence is correctly identified (as being neither the last nor the first). """ self.protocol.lineReceived(GPGSV_MIDDLE) sentence = self.receiver.receivedSentence assert sentence is not None self.assertFalse(sentence._isFirstGSVSentence()) self.assertFalse(sentence._isLastGSVSentence()) def test_lastSentence(self) -> None: """ The last sentence in a GSV sequence is correctly identified. """ self.protocol.lineReceived(GPGSV_LAST) sentence = self.receiver.receivedSentence assert sentence is not None self.assertFalse(sentence._isFirstGSVSentence()) self.assertTrue(sentence._isLastGSVSentence()) class BogusSentenceTests(NMEAReceiverSetup, TestCase): """ Tests for verifying predictable failure for bogus NMEA sentences. """ def assertRaisesOnSentence( self, exceptionClass: type[Exception], sentence: str | bytes ) -> None: """ Asserts that the protocol raises C{exceptionClass} when it receives C{sentence}. @param exceptionClass: The exception class expected to be raised. @type exceptionClass: C{Exception} subclass @param sentence: The (bogus) NMEA sentence. @type sentence: C{str} """ self.assertRaises(exceptionClass, self.protocol.lineReceived, sentence) def test_raiseOnUnknownSentenceType(self) -> None: """ Receiving a well-formed sentence of unknown type raises C{ValueError}. """ self.assertRaisesOnSentence(ValueError, b"$GPBOGUS*5b") def test_raiseOnMalformedSentences(self) -> None: """ Receiving a malformed sentence raises L{base.InvalidSentence}. """ self.assertRaisesOnSentence(base.InvalidSentence, "GPBOGUS") class NMEASentenceTests(NMEAReceiverSetup, TestCase): """ Tests for L{nmea.NMEASentence} objects. """ def test_repr(self) -> None: """ The C{repr} of L{nmea.NMEASentence} objects is correct. """ sentencesWithExpectedRepr = [ ( GPGSA, "<NMEASentence (GPGSA) {" "dataMode: A, " "fixType: 3, " "horizontalDilutionOfPrecision: 1.0, " "positionDilutionOfPrecision: 1.7, " "usedSatellitePRN_0: 19, " "usedSatellitePRN_1: 28, " "usedSatellitePRN_2: 14, " "usedSatellitePRN_3: 18, " "usedSatellitePRN_4: 27, " "usedSatellitePRN_5: 22, " "usedSatellitePRN_6: 31, " "usedSatellitePRN_7: 39, " "verticalDilutionOfPrecision: 1.3" "}>", ), ] for sentence, expectedRepr in sentencesWithExpectedRepr: self.protocol.lineReceived(sentence) received = self.receiver.receivedSentence self.assertEqual(repr(received), expectedRepr) class ParsingTests(NMEAReceiverSetup, TestCase): """ Tests if raw NMEA sentences get parsed correctly. This doesn't really involve any interpretation, just turning ugly raw NMEA representations into objects that are more pleasant to work with. """ def _parserTest(self, sentence: bytes, expected: dict[str, str]) -> None: """ Passes a sentence to the protocol and gets the parsed sentence from the receiver. Then verifies that the parsed sentence contains the expected data. """ self.protocol.lineReceived(sentence) received = self.receiver.receivedSentence assert received is not None self.assertEqual(expected, received._sentenceData) def test_fullRMC(self) -> None: """ A full RMC sentence is correctly parsed. """ expected = { "type": "GPRMC", "latitudeFloat": "4807.038", "latitudeHemisphere": "N", "longitudeFloat": "01131.000", "longitudeHemisphere": "E", "magneticVariation": "003.1", "magneticVariationDirection": "W", "speedInKnots": "022.4", "timestamp": "123519", "datestamp": "230394", "trueHeading": "084.4", "dataMode": "A", } self._parserTest(GPRMC, expected) def test_fullGGA(self) -> None: """ A full GGA sentence is correctly parsed. """ expected = { "type": "GPGGA", "altitude": "545.4", "altitudeUnits": "M", "heightOfGeoidAboveWGS84": "46.9", "heightOfGeoidAboveWGS84Units": "M", "horizontalDilutionOfPrecision": "0.9", "latitudeFloat": "4807.038", "latitudeHemisphere": "N", "longitudeFloat": "01131.000", "longitudeHemisphere": "E", "numberOfSatellitesSeen": "08", "timestamp": "123519", "fixQuality": "1", } self._parserTest(GPGGA, expected) def test_fullGLL(self) -> None: """ A full GLL sentence is correctly parsed. """ expected = { "type": "GPGLL", "latitudeFloat": "4916.45", "latitudeHemisphere": "N", "longitudeFloat": "12311.12", "longitudeHemisphere": "W", "timestamp": "225444", "dataMode": "A", } self._parserTest(GPGLL, expected) def test_partialGLL(self) -> None: """ A partial GLL sentence is correctly parsed. """ expected = { "type": "GPGLL", "latitudeFloat": "3751.65", "latitudeHemisphere": "S", "longitudeFloat": "14507.36", "longitudeHemisphere": "E", } self._parserTest(GPGLL_PARTIAL, expected) def test_fullGSV(self) -> None: """ A full GSV sentence is correctly parsed. """ expected = { "type": "GPGSV", "GSVSentenceIndex": "1", "numberOfGSVSentences": "3", "numberOfSatellitesSeen": "11", "azimuth_0": "111", "azimuth_1": "270", "azimuth_2": "010", "azimuth_3": "292", "elevation_0": "03", "elevation_1": "15", "elevation_2": "01", "elevation_3": "06", "satellitePRN_0": "03", "satellitePRN_1": "04", "satellitePRN_2": "06", "satellitePRN_3": "13", "signalToNoiseRatio_0": "00", "signalToNoiseRatio_1": "00", "signalToNoiseRatio_2": "00", "signalToNoiseRatio_3": "00", } self._parserTest(GPGSV_FIRST, expected) def test_partialGSV(self) -> None: """ A partial GSV sentence is correctly parsed. """ expected = { "type": "GPGSV", "GSVSentenceIndex": "3", "numberOfGSVSentences": "3", "numberOfSatellitesSeen": "11", "azimuth_0": "067", "azimuth_1": "311", "azimuth_2": "244", "elevation_0": "42", "elevation_1": "14", "elevation_2": "05", "satellitePRN_0": "22", "satellitePRN_1": "24", "satellitePRN_2": "27", "signalToNoiseRatio_0": "42", "signalToNoiseRatio_1": "43", "signalToNoiseRatio_2": "00", } self._parserTest(GPGSV_LAST, expected) def test_fullHDT(self) -> None: """ A full HDT sentence is correctly parsed. """ expected = { "type": "GPHDT", "trueHeading": "038.005", } self._parserTest(GPHDT, expected) def test_typicalGSA(self) -> None: """ A typical GSA sentence is correctly parsed. """ expected = { "type": "GPGSA", "dataMode": "A", "fixType": "3", "usedSatellitePRN_0": "19", "usedSatellitePRN_1": "28", "usedSatellitePRN_2": "14", "usedSatellitePRN_3": "18", "usedSatellitePRN_4": "27", "usedSatellitePRN_5": "22", "usedSatellitePRN_6": "31", "usedSatellitePRN_7": "39", "positionDilutionOfPrecision": "1.7", "horizontalDilutionOfPrecision": "1.0", "verticalDilutionOfPrecision": "1.3", } self._parserTest(GPGSA, expected) class FixUnitsTests(TestCase): """ Tests for the generic unit fixing method, L{nmea.NMEAAdapter._fixUnits}. @ivar adapter: The NMEA adapter. @type adapter: L{nmea.NMEAAdapter} """ def setUp(self) -> None: self.adapter = nmea.NMEAAdapter(base.BasePositioningReceiver()) def test_noValueKey(self) -> None: """ Tests that when no C{valueKey} is provided, C{unitKey} is used, minus C{"Units"} at the end. """ class FakeSentence: """ A fake sentence that just has a "foo" attribute. """ def __init__(self) -> None: self.foo = 1 self.adapter.currentSentence = FakeSentence() self.adapter._fixUnits(unitKey="fooUnits", unit="N") self.assertNotEqual(self.adapter._sentenceData["foo"], 1) def test_unitKeyButNoUnit(self) -> None: """ Tests that if a unit key is provided but the unit isn't, the unit is automatically determined from the unit key. """ class FakeSentence: """ A fake sentence that just has "foo" and "fooUnits" attributes. """ def __init__(self) -> None: self.foo = 1 self.fooUnits = "N" self.adapter.currentSentence = FakeSentence() self.adapter._fixUnits(unitKey="fooUnits") self.assertNotEqual(self.adapter._sentenceData["foo"], 1) def test_noValueKeyAndNoUnitKey(self) -> None: """ Tests that when a unit is specified but neither C{valueKey} nor C{unitKey} is provided, C{ValueError} is raised. """ self.assertRaises(ValueError, self.adapter._fixUnits, unit="K") class _State(TypedDict, total=False): _time: datetime.time _date: datetime.date latitude: base.Coordinate longitude: base.Coordinate altitude: base.Altitude heightOfGeoidAboveWGS84: base.Altitude speed: base.Speed heading: base.Heading positionError: base.PositionError class _FixerTestMixinBase(Protocol): @property def adapter(self) -> nmea.NMEAAdapter: ... def assertEqual(self, a: object, b: object) -> object: ... def assertRaises( self, exception: type[Exception], f: Callable[[], object] ) -> object: ... class FixerTestMixin: """ Mixin for tests for the fixers on L{nmea.NMEAAdapter} that adapt from NMEA-specific notations to generic Python objects. @ivar adapter: The NMEA adapter. @type adapter: L{nmea.NMEAAdapter} """ def setUp(self) -> None: self.adapter = nmea.NMEAAdapter(base.BasePositioningReceiver()) def _fixerTest( self: _FixerTestMixinBase, sentenceData: dict[str, str], expected: _State | None = None, exceptionClass: type[Exception] | None = None, ) -> None: """ A generic adapter fixer test. Creates a sentence from the C{sentenceData} and sends that to the adapter. If C{exceptionClass} is not passed, this is assumed to work, and C{expected} is compared with the adapter's internal state. Otherwise, passing the sentence to the adapter is checked to raise C{exceptionClass}. @param sentenceData: Raw sentence content. @type sentenceData: C{dict} mapping C{str} to C{str} @param expected: The expected state of the adapter. @type expected: C{dict} or L{None} @param exceptionClass: The exception to be raised by the adapter. @type exceptionClass: subclass of C{Exception} """ sentence = nmea.NMEASentence(sentenceData) def receiveSentence() -> None: self.adapter.sentenceReceived(sentence) if exceptionClass is None: receiveSentence() self.assertEqual(self.adapter._state, expected) else: self.assertRaises(exceptionClass, receiveSentence) self.adapter.clear() class TimestampFixerTests(FixerTestMixin, TestCase): """ Tests conversion from NMEA timestamps to C{datetime.time} objects. """ def test_simple(self) -> None: """ A simple timestamp is converted correctly. """ data = {"timestamp": "123456"} # 12:34:56Z expected: _State = {"_time": datetime.time(12, 34, 56)} self._fixerTest(data, expected) def test_broken(self) -> None: """ A broken timestamp raises C{ValueError}. """ badTimestamps = "993456", "129956", "123499" for t in badTimestamps: self._fixerTest({"timestamp": t}, exceptionClass=ValueError) class DatestampFixerTests(FixerTestMixin, TestCase): def test_defaultYearThreshold(self) -> None: """ The default year threshold is 1980. """ self.assertEqual(self.adapter.yearThreshold, 1980) def test_beforeThreshold(self) -> None: """ Dates before the threshold are interpreted as being in the century after the threshold. (Since the threshold is the earliest possible date.) """ datestring, date = "010115", datetime.date(2015, 1, 1) self._fixerTest({"datestamp": datestring}, {"_date": date}) def test_afterThreshold(self) -> None: """ Dates after the threshold are interpreted as being in the same century as the threshold. """ datestring, date = "010195", datetime.date(1995, 1, 1) self._fixerTest({"datestamp": datestring}, {"_date": date}) def test_invalidMonth(self) -> None: """ A datestring with an invalid month (> 12) raises C{ValueError}. """ self._fixerTest({"datestamp": "011301"}, exceptionClass=ValueError) def test_invalidDay(self) -> None: """ A datestring with an invalid day (more days than there are in that month) raises C{ValueError}. """ self._fixerTest({"datestamp": "320101"}, exceptionClass=ValueError) self._fixerTest({"datestamp": "300201"}, exceptionClass=ValueError) def _nmeaFloat(degrees: int, minutes: float) -> str: """ Builds an NMEA float representation for a given angle in degrees and decimal minutes. @param degrees: The integer degrees for this angle. @type degrees: C{int} @param minutes: The decimal minutes value for this angle. @type minutes: C{float} @return: The NMEA float representation for this angle. @rtype: C{str} """ return "%i%0.3f" % (degrees, minutes) def _coordinateSign(hemisphere: str) -> Literal[1, -1]: """ Return the sign of a coordinate. This is C{1} if the coordinate is in the northern or eastern hemispheres, C{-1} otherwise. @param hemisphere: NMEA shorthand for the hemisphere. One of "NESW". @type hemisphere: C{str} @return: The sign of the coordinate value. @rtype: C{int} """ return 1 if hemisphere in "NE" else -1 def _coordinateType(hemisphere: str) -> NamedConstant: """ Return the type of a coordinate. This is L{Angles.LATITUDE} if the coordinate is in the northern or southern hemispheres, L{Angles.LONGITUDE} otherwise. @param hemisphere: NMEA shorthand for the hemisphere. One of "NESW". @type hemisphere: C{str} @return: The type of the coordinate (L{Angles.LATITUDE} or L{Angles.LONGITUDE}) """ return Angles.LATITUDE if hemisphere in "NS" else Angles.LONGITUDE class CoordinateFixerTests(FixerTestMixin, TestCase): """ Tests turning NMEA coordinate notations into something more pleasant. """ def test_north(self) -> None: """ NMEA coordinate representations in the northern hemisphere convert correctly. """ sentenceData = {"latitudeFloat": "1030.000", "latitudeHemisphere": "N"} state: _State = {"latitude": base.Coordinate(10.5, Angles.LATITUDE)} self._fixerTest(sentenceData, state) def test_south(self) -> None: """ NMEA coordinate representations in the southern hemisphere convert correctly. """ sentenceData = {"latitudeFloat": "1030.000", "latitudeHemisphere": "S"} state: _State = {"latitude": base.Coordinate(-10.5, Angles.LATITUDE)} self._fixerTest(sentenceData, state) def test_east(self) -> None: """ NMEA coordinate representations in the eastern hemisphere convert correctly. """ sentenceData = {"longitudeFloat": "1030.000", "longitudeHemisphere": "E"} state: _State = {"longitude": base.Coordinate(10.5, Angles.LONGITUDE)} self._fixerTest(sentenceData, state) def test_west(self) -> None: """ NMEA coordinate representations in the western hemisphere convert correctly. """ sentenceData = {"longitudeFloat": "1030.000", "longitudeHemisphere": "W"} state: _State = {"longitude": base.Coordinate(-10.5, Angles.LONGITUDE)} self._fixerTest(sentenceData, state) def test_badHemisphere(self) -> None: """ NMEA coordinate representations for nonexistent hemispheres raise C{ValueError} when you attempt to parse them. """ sentenceData = {"longitudeHemisphere": "Q"} self._fixerTest(sentenceData, exceptionClass=ValueError) def test_badHemisphereSign(self) -> None: """ NMEA coordinate repesentation parsing fails predictably when you pass nonexistent coordinate types (not latitude or longitude). """ getSign = lambda: self.adapter._getHemisphereSign("BOGUS_VALUE") self.assertRaises(ValueError, getSign) class AltitudeFixerTests(FixerTestMixin, TestCase): """ Tests that NMEA representations of altitudes are correctly converted. """ def test_fixAltitude(self) -> None: """ The NMEA representation of an altitude (above mean sea level) is correctly converted. """ key, value = "altitude", "545.4" altitude = base.Altitude(float(value)) self._fixerTest({key: value}, _State(altitude=altitude)) def test_heightOfGeoidAboveWGS84(self) -> None: """ The NMEA representation of an altitude of the geoid (above the WGS84 reference level) is correctly converted. """ key, value = "heightOfGeoidAboveWGS84", "46.9" altitude = base.Altitude(float(value)) self._fixerTest({key: value}, _State(heightOfGeoidAboveWGS84=altitude)) class SpeedFixerTests(FixerTestMixin, TestCase): """ Tests that NMEA representations of speeds are correctly converted. """ def test_speedInKnots(self) -> None: """ Speeds reported in knots correctly get converted to meters per second. """ key, value = "speedInKnots", "10" speed = base.Speed(float(value) * base.MPS_PER_KNOT) self._fixerTest({key: value}, _State(speed=speed)) class VariationFixerTests(FixerTestMixin, TestCase): """ Tests if the absolute values of magnetic variations on the heading and their sign get combined correctly, and if that value gets combined with a heading correctly. """ def test_west(self) -> None: """ Tests westward (negative) magnetic variation. """ variation, direction = "1.34", "W" heading = base.Heading.fromFloats(variationValue=-1 * float(variation)) sentenceData = { "magneticVariation": variation, "magneticVariationDirection": direction, } self._fixerTest(sentenceData, {"heading": heading}) def test_east(self) -> None: """ Tests eastward (positive) magnetic variation. """ variation, direction = "1.34", "E" heading = base.Heading.fromFloats(variationValue=float(variation)) sentenceData = { "magneticVariation": variation, "magneticVariationDirection": direction, } self._fixerTest(sentenceData, {"heading": heading}) def test_withHeading(self) -> None: """ Variation values get combined with headings correctly. """ trueHeading, variation, direction = "123.12", "1.34", "E" sentenceData = { "trueHeading": trueHeading, "magneticVariation": variation, "magneticVariationDirection": direction, } heading = base.Heading.fromFloats( float(trueHeading), variationValue=float(variation) ) self._fixerTest(sentenceData, {"heading": heading}) class PositionErrorFixerTests(FixerTestMixin, TestCase): """ Position errors in NMEA are passed as dilutions of precision (DOP). This is a measure relative to some specified value of the GPS device as its "reference" precision. Unfortunately, there are very few ways of figuring this out from just the device (sans manual). There are two basic DOP values: vertical and horizontal. HDOP tells you how precise your location is on the face of the earth (pretending it's flat, at least locally). VDOP tells you how precise your altitude is known. PDOP (position DOP) is a dependent value defined as the Euclidean norm of those two, and gives you a more generic "goodness of fix" value. """ def test_simple(self) -> None: self._fixerTest( {"horizontalDilutionOfPrecision": "11"}, {"positionError": base.PositionError(hdop=11.0)}, ) def test_mixing(self) -> None: pdop, hdop, vdop = "1", "1", "1" positionError = base.PositionError( pdop=float(pdop), hdop=float(hdop), vdop=float(vdop) ) sentenceData = { "positionDilutionOfPrecision": pdop, "horizontalDilutionOfPrecision": hdop, "verticalDilutionOfPrecision": vdop, } self._fixerTest(sentenceData, {"positionError": positionError}) class ValidFixTests(FixerTestMixin, TestCase): """ Tests that data reported from a valid fix is used. """ def test_GGA(self) -> None: """ GGA data with a valid fix is used. """ sentenceData = { "type": "GPGGA", "altitude": "545.4", "fixQuality": nmea.GPGGAFixQualities.GPS_FIX, } expectedState: _State = {"altitude": base.Altitude(545.4)} self._fixerTest(sentenceData, expectedState) def test_GLL(self) -> None: """ GLL data with a valid data mode is used. """ sentenceData = { "type": "GPGLL", "altitude": "545.4", "dataMode": nmea.GPGLLGPRMCFixQualities.ACTIVE, } expectedState: _State = {"altitude": base.Altitude(545.4)} self._fixerTest(sentenceData, expectedState) class InvalidFixTests(FixerTestMixin, TestCase): """ Tests that data being reported from a bad or incomplete fix isn't used. Although the specification dictates that GPSes shouldn't produce NMEA sentences with real-looking values for altitude or position in them unless they have at least some semblance of a GPS fix, this is widely ignored. """ def _invalidFixTest(self, sentenceData: dict[str, str]) -> None: """ Sentences with an invalid fix or data mode result in empty state (ie, the data isn't used). """ self._fixerTest(sentenceData, {}) def test_GGA(self) -> None: """ GGA sentence data is unused when there is no fix. """ sentenceData = { "type": "GPGGA", "altitude": "545.4", "fixQuality": nmea.GPGGAFixQualities.INVALID_FIX, } self._invalidFixTest(sentenceData) def test_GLL(self) -> None: """ GLL sentence data is unused when the data is flagged as void. """ sentenceData = { "type": "GPGLL", "altitude": "545.4", "dataMode": nmea.GPGLLGPRMCFixQualities.VOID, } self._invalidFixTest(sentenceData) def test_badGSADataMode(self) -> None: """ GSA sentence data is not used when there is no GPS fix, but the data mode claims the data is "active". Some GPSes do this, unfortunately, and that means you shouldn't use the data. """ sentenceData = { "type": "GPGSA", "altitude": "545.4", "dataMode": nmea.GPGLLGPRMCFixQualities.ACTIVE, "fixType": nmea.GPGSAFixTypes.GSA_NO_FIX, } self._invalidFixTest(sentenceData) def test_badGSAFixType(self) -> None: """ GSA sentence data is not used when the fix claims to be valid (albeit only 2D), but the data mode says the data is void. Some GPSes do this, unfortunately, and that means you shouldn't use the data. """ sentenceData = { "type": "GPGSA", "altitude": "545.4", "dataMode": nmea.GPGLLGPRMCFixQualities.VOID, "fixType": nmea.GPGSAFixTypes.GSA_2D_FIX, } self._invalidFixTest(sentenceData) def test_badGSADataModeAndFixType(self) -> None: """ GSA sentence data is not use when neither the fix nor the data mode is any good. """ sentenceData = { "type": "GPGSA", "altitude": "545.4", "dataMode": nmea.GPGLLGPRMCFixQualities.VOID, "fixType": nmea.GPGSAFixTypes.GSA_NO_FIX, } self._invalidFixTest(sentenceData) class NMEAReceiverTests(TestCase): """ Tests for the NMEA receiver. """ def setUp(self) -> None: self.receiver = MockPositioningReceiver() self.adapter = nmea.NMEAAdapter(self.receiver) self.protocol = nmea.NMEAProtocol(self.adapter) def test_onlyFireWhenCurrentSentenceHasNewInformation(self) -> None: """ If the current sentence does not contain any new fields for a particular callback, that callback is not called; even if all necessary information is still in the state from one or more previous messages. """ self.protocol.lineReceived(GPGGA) gpggaCallbacks = { "positionReceived", "positionErrorReceived", "altitudeReceived", } self.assertEqual(set(self.receiver.called.keys()), gpggaCallbacks) self.receiver.clear() self.assertNotEqual(self.adapter._state, {}) # GPHDT contains heading information but not position, # altitude or anything like that; but that information is # still in the state. self.protocol.lineReceived(GPHDT) gphdtCallbacks = {"headingReceived"} self.assertEqual(set(self.receiver.called.keys()), gphdtCallbacks) def _receiverTest( self, sentences: Iterable[bytes], expectedFired: Iterable[str] = (), extraTest: Callable[[], None] | None = None, ) -> None: """ A generic test for NMEA receiver behavior. @param sentences: The sequence of sentences to simulate receiving. @type sentences: iterable of C{str} @param expectedFired: The names of the callbacks expected to fire. @type expectedFired: iterable of C{str} @param extraTest: An optional extra test hook. @type extraTest: nullary callable """ for sentence in sentences: self.protocol.lineReceived(sentence) actuallyFired = self.receiver.called.keys() self.assertEqual(set(actuallyFired), set(expectedFired)) if extraTest is not None: extraTest() self.receiver.clear() self.adapter.clear() def test_positionErrorUpdateAcrossStates(self) -> None: """ The positioning error is updated across multiple states. """ sentences = [GPGSA] + GPGSV_SEQ callbacksFired = ["positionErrorReceived", "beaconInformationReceived"] def _getIdentifiers(beacons: Iterable[base.Satellite]) -> list[int]: return sorted(map(attrgetter("identifier"), beacons)) def checkBeaconInformation() -> None: beaconInformation = self.adapter._state["beaconInformation"] seenIdentifiers = _getIdentifiers(beaconInformation.seenBeacons) expected = [3, 4, 6, 13, 14, 16, 18, 19, 22, 24, 27] self.assertEqual(seenIdentifiers, expected) usedIdentifiers = _getIdentifiers(beaconInformation.usedBeacons) # These are not actually all the PRNs in the sample GPGSA: # only the ones also reported by the GPGSV sequence. This # is just because the sample data doesn't come from the # same reporting cycle of a GPS device. self.assertEqual(usedIdentifiers, [14, 18, 19, 22, 27]) self._receiverTest(sentences, callbacksFired, checkBeaconInformation) def test_emptyMiddleGSV(self) -> None: """ A GSV sentence with empty entries in any position does not mean that entries in subsequent positions of the same GSV sentence are ignored. """ sentences = [GPGSV_EMPTY_MIDDLE] callbacksFired = ["beaconInformationReceived"] def checkBeaconInformation() -> None: beaconInformation = self.adapter._state["beaconInformation"] seenBeacons = beaconInformation.seenBeacons self.assertEqual(len(seenBeacons), 2) self.assertIn(13, [b.identifier for b in seenBeacons]) self._receiverTest(sentences, callbacksFired, checkBeaconInformation) def test_GGASentences(self) -> None: """ A sequence of GGA sentences fires C{positionReceived}, C{positionErrorReceived} and C{altitudeReceived}. """ sentences = [GPGGA] callbacksFired = [ "positionReceived", "positionErrorReceived", "altitudeReceived", ] self._receiverTest(sentences, callbacksFired) def test_GGAWithDateInState(self) -> None: """ When receiving a GPGGA sentence and a date was already in the state, the new time (from the GPGGA sentence) is combined with that date. """ self.adapter._state["_date"] = datetime.date(2014, 1, 1) sentences = [GPGGA] callbacksFired = [ "positionReceived", "positionErrorReceived", "altitudeReceived", "timeReceived", ] self._receiverTest(sentences, callbacksFired) def test_RMCSentences(self) -> None: """ A sequence of RMC sentences fires C{positionReceived}, C{speedReceived}, C{headingReceived} and C{timeReceived}. """ sentences = [GPRMC] callbacksFired = [ "headingReceived", "speedReceived", "positionReceived", "timeReceived", ] self._receiverTest(sentences, callbacksFired) def test_GSVSentences(self) -> None: """ A complete sequence of GSV sentences fires C{beaconInformationReceived}. """ sentences = [GPGSV_FIRST, GPGSV_MIDDLE, GPGSV_LAST] callbacksFired = ["beaconInformationReceived"] def checkPartialInformation() -> None: self.assertNotIn("_partialBeaconInformation", self.adapter._state) self._receiverTest(sentences, callbacksFired, checkPartialInformation) def test_emptyMiddleEntriesGSVSequence(self) -> None: """ A complete sequence of GSV sentences with empty entries in the middle still fires C{beaconInformationReceived}. """ sentences = [GPGSV_EMPTY_MIDDLE] self._receiverTest(sentences, ["beaconInformationReceived"]) def test_incompleteGSVSequence(self) -> None: """ An incomplete sequence of GSV sentences does not fire any callbacks. """ sentences = [GPGSV_FIRST] self._receiverTest(sentences) def test_singleSentenceGSVSequence(self) -> None: """ The parser does not fail badly when the sequence consists of only one sentence (but is otherwise complete). """ sentences = [GPGSV_SINGLE] self._receiverTest(sentences, ["beaconInformationReceived"]) def test_GLLSentences(self) -> None: """ GLL sentences fire C{positionReceived}. """ sentences = [GPGLL_PARTIAL, GPGLL] self._receiverTest(sentences, ["positionReceived"]) def test_HDTSentences(self) -> None: """ HDT sentences fire C{headingReceived}. """ sentences = [GPHDT] self._receiverTest(sentences, ["headingReceived"]) def test_mixedSentences(self) -> None: """ A mix of sentences fires the correct callbacks. """ sentences = [GPRMC, GPGGA] callbacksFired = [ "altitudeReceived", "speedReceived", "positionReceived", "positionErrorReceived", "timeReceived", "headingReceived", ] def checkTime() -> None: expectedDateTime = datetime.datetime(1994, 3, 23, 12, 35, 19) self.assertEqual(self.adapter._state["time"], expectedDateTime) self._receiverTest(sentences, callbacksFired, checkTime) def test_lotsOfMixedSentences(self) -> None: """ Sends an entire gamut of sentences and verifies the appropriate callbacks fire. These are more than you'd expect from your average consumer GPS device. They have most of the important information, including beacon information and visibility. """ sentences = [GPGSA] + GPGSV_SEQ + [GPRMC, GPGGA, GPGLL] callbacksFired = [ "headingReceived", "beaconInformationReceived", "speedReceived", "positionReceived", "timeReceived", "altitudeReceived", "positionErrorReceived", ] self._receiverTest(sentences, callbacksFired)