1267 lines
41 KiB
Python
1267 lines
41 KiB
Python
# 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)
|