Ticket #3926: positioning.diff

File positioning.diff, 75.2 KB (added by lvh, 7 years ago)
  • twisted/positioning/base.py

    === added directory 'twisted/positioning'
    === added file 'twisted/positioning/__init__.py'
    === added file 'twisted/positioning/base.py'
     
     1# -*- encoding: utf-8 -*-
     2# -*- test-case-name: twisted.positioning.test.test_base -*-
     3# Copyright (c) 2009 Twisted Matrix Laboratories.
     4# See LICENSE for details.
     5"""
     6Generic positioning base classes.
     7"""
     8from zope.interface import implements
     9from twisted.positioning import ipositioning
     10
     11MPS_PER_KNOT = 0.5144444444444444
     12MPS_PER_KPH = 0.27777777777777777
     13METERS_PER_FOOT = 0.3048
     14
     15
     16class BasePositioningReceiver(object):
     17    implements(ipositioning.IPositioningReceiver)
     18    def headingReceived(self, heading):
     19        """
     20        Implements C{IPositioningReceiver.headingReceived} stub.
     21        """
     22
     23
     24    def speedReceived(self, speed):
     25        """
     26        Implements C{IPositioningReceiver.speedReceived} stub.
     27        """
     28
     29
     30    def climbReceived(self, climb):
     31        """
     32        Implements C{IPositioningReceiver.climbReceived} stub.
     33        """
     34
     35
     36    def positionReceived(self, latitude, longitude):
     37        """
     38        Implements C{IPositioningReceiver.positionReceived} stub.
     39        """
     40
     41
     42    def positioningErrorReceived(self, positioningError):
     43        """
     44        Implements C{IPositioningReceiver.positioningErrorReceived} stub.
     45        """
     46
     47
     48    def altitudeReceived(self, altitude):
     49        """
     50        Implements C{IPositioningReceiver.altitudeReceived} stub.
     51        """
     52
     53       
     54    def beaconInformationReceived(self, beaconInformation):
     55        """
     56        Implements C{IPositioningReceiver.beaconInformationReceived} stub.
     57        """
     58
     59
     60
     61class InvalidSentence(Exception):
     62    """
     63    An exception raised when a sentence is invalid.
     64    """
     65
     66
     67
     68class InvalidChecksum(Exception):
     69    """
     70    An exception raised when the checksum of a sentence is invalid.
     71    """
     72
     73
     74class Heading(object):
     75    """
     76    The heading of a mobile object.
     77
     78    @ivar heading: The heading of a mobile object. C{None} if unknown.
     79    @type heading: C{float} or C{NoneType}
     80    @ivar variation: The (optional) variation.
     81        The sign of the variation is positive for variations towards the east
     82        (clockwise from north), and negative for variations towards the west
     83        (counterclockwise from north).
     84        If the variation is unknown or not applicable, this is C{None}.
     85    @type variation: C{float} or C{NoneType}.
     86    """
     87    def __init__(self, heading=None, variation=None):
     88        """
     89        Initializes a heading with an optional variation.
     90        """
     91        self.heading = heading
     92        self.variation = variation
     93
     94        if self.heading is not None and not 0 <= self.heading < 360:
     95            raise ValueError("Bad heading (%s)" % (self.heading,))
     96        if self.variation is not None and not -180 < self.variation < 180:
     97            raise ValueError("Bad variation (%s)" % (self.variation,))
     98
     99       
     100    #@staticmethod
     101    def _clipAngle(angle):
     102        """
     103        TODO: document
     104        """
     105        while angle <= 0:
     106            angle += 360
     107
     108        while angle >= 360:
     109            angle -= 360
     110
     111        return angle
     112
     113   
     114    _clipAngle = staticmethod(_clipAngle)
     115   
     116
     117    def _getCorrectedHeading(self):
     118        """
     119        Corrects the heading by the given variation. This is sometimes known as
     120        the true heading.
     121
     122        @return: The heading, corrected by the variation. If the variation is
     123            unknown, returns None.
     124        @rtype: C{float} or C{NoneType}
     125        """
     126        if self.variation is None:
     127            return None
     128
     129        return self._clipAngle(self.heading - self.variation)
     130       
     131
     132    correctedHeading=property(fget=_getCorrectedHeading, doc=
     133        """
     134        Returns the heading, corrected for variation.
     135
     136        If the variation is None, returns None.
     137
     138        This calculates the raw heading minus the variation and then coerces
     139        the value to something between 0 and 360.
     140
     141        @return The corrected heading. If it is unknown or not applicable to
     142            this heading object, returns C{None}.
     143        @rtype: C{float} or C{NoneType}
     144        """)
     145
     146
     147    def __float__(self):
     148        """
     149        Returns this heading as a float.
     150
     151        @return: The float value of this heading.
     152        @rtype: C{float}
     153        """
     154        return self.heading
     155
     156
     157    def __imul__(self, factor):
     158        """
     159        Multiplies the variation in place.
     160
     161        Note that this multiplies the variation, not the actual heading
     162        itself. This is because multiplying the angle generally has no meaning,
     163        wheras multiplying the variation is commonly useful (for example, for
     164        setting the sign of the variation).
     165       
     166        @param factor: The factor to multiply with.
     167        @type factor: C{float}
     168
     169        @return: The mutated C{self}.
     170        @post: The variation will be equal to the current value, multiplied by
     171            the parameter value.
     172        """
     173        self.variation *= factor
     174        return self
     175
     176   
     177    def __repr__(self):
     178        """
     179        Returns a debugging representation of a heading object.
     180        """
     181        return "<Heading (%s, var: %s)>" % (self.heading, self.variation)
     182
     183
     184    def __eq__(self, other):
     185        """
     186        Compares two heading objects for equality.
     187        """
     188        return self.heading == other.heading and \
     189               self.variation == other.variation
     190
     191
     192
     193class Altitude(object):
     194    """
     195    An altitude.
     196
     197    @ivar altitude: The altitude represented by this object, in meters.
     198    @type altitude: C{float}
     199
     200    @ivar altitudeInFeet: As above, but expressed in feet.
     201    @type altitudeInFeet: C{float}
     202
     203    The value in meters is the default. For ease of use, this value is used
     204    when converting this object to a float:
     205
     206    >>> altitude = Altitude(100) # 100 meters
     207    >>> float(altitude)
     208    100.0
     209    >>> "The altitude is %.1f meters." % (altitude,)
     210    'The altitude is 100.0 meters.'
     211    """
     212    def __init__(self, altitude):
     213        """
     214        Initializes an altitude.
     215
     216        @param altitude: The altitude in meters.
     217        @type altitude: C{float}
     218        """
     219        self.altitude = float(altitude)
     220
     221
     222    def _getAltitudeInFeet(self):
     223        """
     224        Gets the altitude of this altitude in feet.
     225        """
     226        return self.altitude / METERS_PER_FOOT
     227
     228
     229    inFeet = property(fget = _getAltitudeInFeet, doc=
     230        """
     231        Returns the altitude represented by this object expressed in feet.
     232
     233        @return: The altitude represented by this object expressed in feet.
     234        @rtype: C{float}
     235        """)
     236
     237
     238    def __float__(self):
     239        """
     240        Returns the altitude represented by this object expressed in meters.
     241        """
     242        return self.altitude
     243
     244
     245    def __eq__(self, other):
     246        """
     247        Compares two altitudes for equality
     248
     249        @param other: The Altitude to compare to.
     250        @return: C{True} if the other altitude is equal to this altitude,
     251            C{False} otherwise.
     252        @rtype: C{bool}
     253        """
     254        return self.altitude == other.altitude
     255
     256
     257    def __repr__(self):
     258        """
     259        Returns a string representation of this Altitude object.
     260        """
     261        return "<Altitude (%s m)>" % (self.altitude,)
     262
     263
     264
     265class Speed(object):
     266    """
     267    An object representing the speed of a mobile object.
     268
     269    @ivar speed: The speed that this object represents, in meters per second.
     270    @type speed: C{float}
     271
     272    @ivar speedInKnots: Same as above, but in knots.
     273    @type speedInKnots: C{float}
     274
     275    When converted to a float, this object will represent the speed in meters
     276    per second.
     277    """
     278    def __init__(self, speed):
     279        """
     280        Initializes a speed.
     281
     282        @param speed: The speed that this object represents, expressed in
     283            meters per second.
     284o        @type speed: C{float}
     285
     286        If the provided speed is smaller than zero, raises ValueError.
     287
     288        >>> Speed(-1.0)
     289        Traceback (most recent call last):
     290            ...
     291        ValueError: negative speed: -1.0
     292        """
     293        if speed < 0:
     294            raise ValueError("negative speed: %r" % (speed,))
     295
     296        self.speed = float(speed)
     297
     298
     299    def _getSpeedInKnots(self):
     300        return self.speed / MPS_PER_KNOT
     301
     302
     303    speedInKnots = property(fget=_getSpeedInKnots, doc=
     304        """
     305        Gets the speed represented by this object, expressed in knots.
     306        """)
     307
     308
     309    def __eq__(self, other):
     310        """
     311        Compares two speeds for equality.
     312        """
     313        return self.speed == other.speed
     314
     315
     316    def __float__(self):
     317        """
     318        Returns speed that this object represents, in meters per second.
     319        """
     320        return self.speed
     321
     322
     323    def __repr__(self):
     324        """
     325        Returns a string representation of this Speed object.
     326        """
     327        return "<Speed (%s m/s)>" % round(self.speed, 2)
     328
     329
     330
     331class Coordinate(object):
     332    """
     333    A coordinate.
     334
     335    @ivar angle: The value of the coordinate in decimal degrees, with the usual
     336        rules for sign (northern and eastern hemispheres are positive, southern
     337        and western hemispheres are negative).
     338    @type angle: C{float}
     339
     340    @ivar type: An optional description of the type of coordinate
     341        this object. Should be one of ("latitude", "longitude").
     342    @type type: C{str}
     343    """
     344    def __init__(self, angle, type=None):
     345        """
     346        Initializes a coordinate.
     347
     348        @param angle: The angle of this coordinate in decimal degrees. The
     349            hemisphere is determined by the sign (north and east are positive).
     350        @type angle: C{float}
     351
     352        @param type: One of ("latitude", "longitude"). Used to return
     353            hemisphere names.
     354        @type type: C{str}
     355        """
     356        self.angle = angle
     357        self.type = type
     358
     359
     360    def _getDMS(self):
     361        """
     362        Gets the angle of this coordinate as a degrees, minutes, seconds tuple.
     363        """
     364        degrees = abs(int(self.angle))
     365        fractionalDegrees = abs(self.angle - int(self.angle))
     366        decimalMinutes = 60 * fractionalDegrees
     367
     368        minutes = int(decimalMinutes)
     369        fractionalMinutes = decimalMinutes - int(decimalMinutes)
     370        decimalSeconds = 60 * fractionalMinutes
     371
     372        return degrees, minutes, int(round(decimalSeconds))
     373
     374
     375    inDegreesMinutesSeconds = property(fget=_getDMS, doc=
     376        """
     377        Returns the angle of this coordinate in degrees, minutes, seconds.
     378        """)
     379
     380
     381    def _getHemisphere(self):
     382        """
     383        Gets the hemisphere of this coordinate (one of ('N', 'E', 'S', 'W')).
     384        """
     385        # REVIEW: switch inner and outer jmps, then use lookup dict? :-)
     386        # (Or something evil like abusing indexing with a boolean (True == 1))
     387        if self.type == "latitude":
     388            if self.angle > 0:
     389                return "N"
     390            else:
     391                return "S"
     392        elif self.type == "longitude":
     393            if self.angle > 0:
     394                return 'E'
     395            else:
     396                return "W"
     397        else:
     398            raise ValueError("Unknown coordinate type %s" % (self.type,))
     399
     400
     401    hemisphere = property(fget=_getHemisphere, doc=
     402        """
     403        The hemisphere of this coordinate (one of ('N', 'E', 'S', 'W')).
     404
     405        The hemisphere is determined from the coordinate type (latitude or
     406        longitude) and signage of the angle in decimal degrees.
     407
     408        >>> Coordinate(1.0, "latitude").hemisphere
     409        'N'
     410        >>> Coordinate(-1.0, "latitude").hemisphere
     411        'S'
     412        >>> Coordinate(1.0, "longitude").hemisphere
     413        'E'
     414        >>> Coordinate(-1.0, "longitude").hemisphere
     415        'W'
     416        """)
     417
     418
     419    def setSign(self, sign):
     420        """
     421        Sets the sign of this coordinate.
     422
     423        @param sign: The sign to set, 1 for positive, -1 for negative signs.
     424        @type sign: C{int}
     425        """
     426        if sign not in (-1, 1):
     427            raise ValueError("bad sign (got %s, expected -1 or 1)" % sign)
     428
     429        self.angle = sign * abs(self.angle)
     430
     431
     432    def __eq__(self, other):
     433        """
     434        Compares two coordinates for equality.
     435        """
     436        return self.type == other.type \
     437            and self.angle == other.angle
     438
     439
     440    def __imul__(self, factor):
     441        """
     442        Multiplies this coordinate in place.
     443
     444        This is particularly useful for setting the sign of a coordinate
     445        (multiplying by -1 or 1).
     446       
     447        @param factor: The factor to multiply with.
     448        @type factor: numeric
     449
     450        @return: The mutated C{self}.
     451        @post: The coordinate will be equal to the current value, multiplied by
     452            the parameter value.
     453        """
     454        self.angle *= factor
     455        return self
     456
     457   
     458    def __repr__(self):
     459        """
     460        Returns a string representation of this coordinate.
     461
     462        @return: The string representation.
     463        @rtype: C{str}
     464        """
     465        if self.type in ('latitude', 'longitude'):
     466            coordinateType = self.type.title()
     467        else:
     468            coordinateType = "Coordinate of unknown type %s" % self.type
     469
     470        return "<%s (%s degrees)>" % (coordinateType, round(self.angle, 2),)
     471
     472
     473
     474class PositioningError(object):
     475    """
     476    Positioning error information.
     477    """
     478
     479    ALLOWABLE_TRESHOLD = 0.01
     480    def __init__(self, pdop=None, hdop=None, vdop=None, testInvariant=False):
     481        """
     482        Initializes a positioning error object.
     483
     484        @param pdop: The position dilution of precision.
     485        @type pdop: C{float}
     486        @param hdop: The horizontal dilution of precision.
     487        @type hdop: C{float}
     488        @param vdop: The vertical dilution of precision.
     489        @type vdop: C{float}
     490        @param testInvariant: Flag to test if the DOP invariant is valid or
     491            not. If C{True}, the invariant (PDOP = (HDOP**2 + VDOP**2)*.5) is
     492            checked at every mutation. By default, this is false, because the
     493            vast majority of DOP-providing devices ignore this invariant.
     494        @type testInvariant: c{bool}
     495        """
     496        self._pdop = pdop
     497        self._hdop = hdop
     498        self._vdop = vdop
     499
     500        self._testInvariant = testInvariant
     501        self._testDilutionOfPositionInvariant()
     502
     503
     504    def _testDilutionOfPositionInvariant(self):
     505        """
     506        Tests if this positioning error object satisfies the dilution of
     507        position invariant (PDOP = (HDOP**2 + VDOP**2)*.5), unless the
     508        C{self._testInvariant} instance variable is C{False}.
     509
     510        @return: C{None} if the invariant was not satisifed or not tested.
     511        @raises: C{ValueError} if the invariant was not satisfied.
     512        """
     513        if not self._testInvariant:
     514            return
     515
     516        for x in (self.pdop, self.hdop, self.vdop):
     517            if x is None:
     518                return
     519
     520        delta = abs(self.pdop - (self.hdop**2 + self.vdop**2)**.5)
     521        if delta > self.ALLOWABLE_TRESHOLD:
     522            raise ValueError("invalid combination of dilutions of precision: "
     523                             "position: %s, horizontal: %s, vertical: %s"
     524                             % (self.pdop, self.hdop, self.vdop))
     525
     526
     527    DOP_EXPRESSIONS = {
     528        '_pdop': lambda self: (self._hdop**2 + self._vdop**2)**.5,
     529        '_hdop': lambda self: (self._pdop**2 - self._vdop**2)**.5,
     530        '_vdop': lambda self: (self._pdop**2 - self._hdop**2)**.5,
     531    }
     532
     533
     534    def _getDOP(self, dopType):
     535        """
     536        Gets a particular dilution of position value
     537        """
     538        attributeName = "_" + dopType
     539       
     540        if getattr(self, attributeName) is not None:
     541            # known
     542            return getattr(self, attributeName)
     543
     544
     545        # REVIEW: perhaps we should replace this with a simple try/except?
     546        others = (dop for dop in self.DOP_EXPRESSIONS if dop != attributeName)
     547        for dop in others:
     548            if getattr(self, dop) is None:
     549                # At least one other DOP is None, can't calculate the last one.
     550                return None
     551
     552        # !known && calculable
     553        return self.DOP_EXPRESSIONS[attributeName](self)
     554
     555
     556    def _setDOP(self, dopType, value):
     557        """
     558        Sets a particular dilution of position value.
     559
     560        @param dopType: The type of dilution of position to set. One of
     561            ('pdop', 'hdop', 'vdop').
     562        @type dopType: C{str}
     563
     564        @param value: The value to set the dilution of position type to.
     565        @type value: C{float}
     566        """
     567        attributeName = "_" + dopType
     568        setattr(self, attributeName, float(value))
     569        self._testDilutionOfPositionInvariant()
     570
     571
     572    pdop = property(
     573        fget=lambda self: self._getDOP('pdop'),
     574        fset=lambda self, value: self._setDOP('pdop', value),
     575        doc="""
     576        Returns (or calculates, if not directly available) the position
     577        dilution of precision.
     578
     579        @return: The position dilution of precision.
     580        @rtype: C{float} or C{NoneType} if unknown
     581        """)
     582
     583
     584    hdop = property(
     585        fget=lambda self: self._getDOP('hdop'),
     586        fset=lambda self, value: self._setDOP('hdop', value),
     587        doc="""
     588        Returns (or calculates, if not directly available) the horizontal
     589        dilution of precision.
     590
     591        @return: The horizontal dilution of precision.
     592        @rtype: C{float} or C{NoneType} if unknown
     593        """)
     594
     595
     596    vdop = property(
     597        fget=lambda self: self._getDOP('vdop'),
     598        fset=lambda self, value: self._setDOP('vdop', value),
     599        doc="""
     600        Returns (or calculates, if not directly available) the vertical
     601        dilution of precision.
     602
     603        @return: The vertical dilution of precision.
     604        @rtype: C{float} or C{NoneType} if unknown
     605        """)
     606
     607    def __eq__(self, other):
     608        """
     609        Compares two PositionErrors for equality.
     610
     611        @return C{True} if the two positioning errors are equal (all the
     612           relevant attributes are equal), C{False} otherwise.
     613        @rtype: C{bool}
     614        """
     615        return self.pdop == other.pdop \
     616            and self.hdop == other.hdop \
     617            and self.vdop == other.vdop
     618
     619
     620    def __repr__(self):
     621        """
     622        Returns a debugging representation of a positioning error.
     623        """
     624        return "<PositioningError (pdop: %s, hdop: %s, vdop: %s)>" \
     625            % (self.pdop, self.hdop, self.vdop)
     626
     627
     628
     629class BeaconInformation(object):
     630    """
     631    Information about positioning beacons (a generalized term for the reference
     632    objects that help you determine your position, such as satellites or cell
     633    towers).
     634
     635    @ivar beacons: A set of visible beacons. Note that visible beacons are not
     636        necessarily used in acquiring a postioning fix.
     637    @type beacons: C{set} of C{IPositioningBeacon}
     638
     639    TODO: document seen/used ivars
     640   
     641    This object may be iterated to yield these beacons.
     642    """
     643    def __init__(self, seen=0, used=0, beacons=None):
     644        """
     645        Initializes a beacon information object.
     646
     647        @ivar beacons: A collection of beacons in this beacon information
     648            object.
     649        @type beacons: iterable
     650        """
     651        self.seen = int(seen)
     652        self.used = int(used)
     653        self.beacons = set(beacons or [])
     654
     655
     656    def _getUsedBeacons(self):
     657        return (x for x in self.beacons.value if x.isUsed)
     658
     659
     660    usedBeacons = property(fget=_getUsedBeacons, doc=
     661        """
     662        Yields the used beacons in this BeaconInformation object.
     663
     664        This is different from BeaconInformation.__iter__ because it only
     665        yields beacons that are actually used in obtaining the fix.
     666        """)
     667
     668
     669    def __iter__(self):
     670        """
     671        Yields the beacons in this beacon information object.
     672        """
     673        for beacon in self.beacons:
     674            yield beacon
     675
     676
     677    def __repr__(self):
     678        """
     679        Returns a string representation of this beacon information object.
     680
     681        @return: The string representation.
     682        @rtype: C{str}
     683        """
     684        beaconReprs = "".join(repr(beacon) for beacon in self.beacons)
     685        return "<BeaconInformation {%s}>" % beaconReprs
     686
     687
     688
     689class PositioningBeacon(object):
     690    """
     691    A positioning beacon.
     692
     693    @ivar identifier: The unqiue identifier for this satellite. This is usually
     694        an integer. For GPS, this is also known as the PRN.
     695    @type identifier: Pretty much anything that can be used as a unique
     696        identifier. Depends on the implementation.
     697    @ivar isUsed: True if the satellite is currently being used to acchieve a
     698        fix, False if it is not currently being used, None if unknown.
     699    @type isUsed: c{bool} or C{None}
     700    """
     701    def __init__(self, identifier, isUsed=None):
     702        self.identifier = identifier
     703        self.isUsed = isUsed
     704
     705
     706    def __hash__(self):
     707        """
     708        Returns the identifier for this beacon.
     709        """
     710        return self.identifier
     711
     712
     713    def _usedRepr(self):
     714        """
     715        Returns a single character representation of the status of this
     716        satellite in terms of being used for attaining a positioning fix.
     717
     718        @return: One of ("Y", "N", "?") depending on the status of the
     719            satellite.
     720        @rval: C{str}
     721        """
     722        return {True: "Y", False: "N", None: "?"}[self.isUsed]       
     723   
     724
     725    def __repr__(self):
     726        """
     727        Returns a string representation of this beacon.
     728
     729        @return: The string representation.
     730        @rtype: C{str}
     731        """
     732        return "<Beacon (identifier: %s, used: %s)>" \
     733            % (self.identifier, self._usedRepr())
     734
     735
     736class Satellite(PositioningBeacon):
     737    """
     738    A satellite.
     739
     740    @ivar azimuth: The azimuth of the satellite. This is the heading (positive
     741        angle relative to true north) where the satellite appears to be to the
     742        device.
     743    @ivar elevation: The (positive) angle above the horizon where this
     744        satellite appears to be to the device.
     745    @ivar snr: The signal to noise ratio of the signal coming from this
     746        satellite.
     747    """
     748    def __init__(self, identifier, azimuth, elevation, snr, isUsed=None):
     749        """
     750        Initializes a satellite object.
     751
     752        @param azimuth: The azimuth of the satellite (see instance variable
     753            documentation).
     754        @type azimuth: C{float}
     755        @param elevation: The elevation of the satellite (see instance variable
     756            documentation).
     757        @type elevation: C{float}
     758        @param snr: The signal to noise ratio of the connection to this
     759            satellite (see instance variable documentation).
     760        @type snr: C{float}
     761
     762        """
     763        super(Satellite, self).__init__(int(identifier), isUsed)
     764
     765        # TODO: remove these float casts, make default arguments None
     766        self.azimuth = float(azimuth)
     767        self.elevation = float(elevation)
     768        self.signalToNoiseRatio = float(snr)
     769
     770
     771    def __repr__(self):
     772        """
     773        Returns a string representation of this Satellite.
     774
     775        @return: The string representation.
     776        @rtype: C{str}
     777        """
     778        return "<Satellite (%s), azimuth: %s, elevation: %s, used: %s>" \
     779            % (self.identifier, self.azimuth, self.elevation, self._usedRepr())
  • twisted/positioning/ipositioning.py

    === added file 'twisted/positioning/ipositioning.py'
     
     1# -*- encoding: utf-8 -*-
     2# Copyright (c) 2009 Twisted Matrix Laboratories.
     3# See LICENSE for details.
     4"""
     5Positioning interface.
     6"""
     7from zope.interface import Interface
     8
     9
     10class IPositioningReceiver(Interface):
     11    """
     12    An interface for positioning providers.
     13    """
     14    def positionReceived(latitude, longitude):
     15        """
     16        Method called when a position is received.
     17
     18        @param latitude: The latitude of the received position.
     19        @type latitude: C{Coordinate}
     20        @param longitude: The longitude of the received position.
     21        @type longitude: C{Coordinate}
     22        """
     23
     24
     25    def positioningErrorReceived(positioningError):
     26        """
     27        Method called when positioning error is received.
     28
     29        @param positioningError: The positioning error.
     30        TODO: Create and document type.
     31        """
     32
     33    def timeReceived(time, date):
     34        """
     35        Method called when time and date information arrives.
     36
     37        @param time: The time (in UTC unless otherwise specified).
     38        @type time: C{datetime.time}
     39        @param date: The date.
     40        @type date: C{datetime.date}
     41        """
     42
     43    def headingReceived(heading):
     44        """
     45        Method called when a true heading is received.
     46
     47        @param heading: The heading.
     48        @type heading: C{Heading}
     49        """
     50
     51
     52    def altitudeReceived(altitude):
     53        """
     54        Method called when an altitude is received.
     55
     56        @param altitude: The altitude.
     57        @type altitude: C{twisted.positioning.base.Altitude}
     58        """
     59
     60
     61    def speedReceived(speed):
     62        """
     63        Method called when the speed is received.
     64
     65        @param speed: The speed of a mobile object.
     66        @type speed: C{twisted.positioning.base.Speed}
     67        """
     68
     69
     70    def climbReceived(climb):
     71        """
     72        Method called when the climb is received.
     73
     74        @param climb: The climb of the mobile object.
     75        TODO: Create and document type.
     76        """
     77
     78    def beaconInformationReceived(beaconInformation):
     79        """
     80        Method called when positioning beacon information is received.
     81
     82        @param beaconInformation: The beacon information.
     83        @type C{twisted.positioning.base.BeaconInformation}
     84        """
     85
     86
     87class INMEAReceiver(Interface):
     88    """
     89    An object that can receive NMEA data.
     90    """
     91    def sentenceReceived(sentence):
     92        """
     93        Method called when a sentence is received.
     94
     95        @param sentence: The received NMEA sentence.
     96        @type C{twisted.positioning.nmea.NMEASentence}
     97        """
  • twisted/positioning/nmea.py

    === added file 'twisted/positioning/nmea.py'
     
     1# -*- encoding: utf-8 -*-
     2# -*- test-case-name: twisted.positioning.test.test_nmea -*-
     3# Copyright (c) 2009 Twisted Matrix Laboratories.
     4# See LICENSE for details.
     5"""
     6Classes for using NMEAProtocol sentences.
     7"""
     8import itertools
     9import operator
     10import datetime
     11from zope.interface import implements
     12
     13import twisted.protocols.basic
     14from twisted.positioning import base, ipositioning
     15
     16
     17class NMEAProtocol(twisted.protocols.basic.LineReceiver):
     18    """
     19    A protocol that parses and verifies the checksum of an NMEAProtocol sentence, and
     20    delegates to a receiver.
     21
     22    Responsibilities:
     23        - receiving lines (which are hopefully sentences)
     24        - verifying their checksum
     25        - unpacking them (mapping of sentence element keys to their values)
     26        - creating C{NMEASentence} objects
     27        - passing them to the receiver.
     28    """
     29    def __init__(self, receiver):
     30        """
     31        Initializes an receiver for NMEAProtocol sentences.
     32
     33        @param receiver: A receiver for NMEAProtocol sentence objects.
     34        @type receiver: L{INMEAReceiver}
     35        """
     36        self.receiver = receiver
     37
     38
     39    METHOD_PREFIX = "nmea_"
     40
     41
     42    def lineReceived(self, rawSentence):
     43        """
     44        Parses the data from the sentence and validates the checksum.
     45
     46        @param rawSentence: The raw positioning sentence.
     47        @type rawSentence: C{str} (bytestring encoded in ascii)
     48        """
     49        sentence = rawSentence.strip()
     50
     51        self.validateChecksum(sentence)
     52        splitSentence = self.splitSentence(sentence)
     53
     54        sentenceType, contents = splitSentence[0], splitSentence[1:]
     55
     56        keys = self.SENTENCE_CONTENTS.get(sentenceType, None)
     57        callback = getattr(self, self.METHOD_PREFIX + sentenceType, None)
     58
     59        if keys is None or callback is None:
     60            raise ValueError("unknown sentence type %s" % sentenceType)
     61
     62        sentenceData = {"type": sentenceType}
     63        for key, value in itertools.izip(keys, contents):
     64            if key is not None and value != "":
     65                sentenceData[key] = value
     66
     67        sentence = NMEASentence(sentenceData)
     68
     69        callback(sentence)
     70
     71        if self.receiver is not None:
     72            self.receiver.sentenceReceived(sentence)
     73
     74
     75    #@staticmethod
     76    def validateChecksum(sentence):
     77        """
     78        Validates the checksum of an NMEAProtocol sentence.
     79
     80        Does nothing (except implicitly return None, of course) on sentences
     81        with valid checksums.
     82
     83        >>> NMEASentence.validateChecksum(
     84        ... '$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47'
     85        ... )
     86
     87        Same thing on sentences missing a checksum:
     88
     89        >>> NMEASentence.validateChecksum(
     90        ... '$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*')
     91
     92        Will raise an exception on sentences with a missing checksum:
     93
     94        >>> NMEASentence.validateChecksum(
     95        ... '$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*46'
     96        ... )
     97        Traceback (most recent call last):
     98            ...
     99        InvalidChecksum: 71 != 70
     100        """
     101        if sentence[-3] == '*': # sentence has a checksum
     102            reference, source = int(sentence[-2:], 16), sentence[1:-3]
     103            computed = reduce(operator.xor, (ord(x) for x in source))
     104            if computed != reference:
     105                raise base.InvalidChecksum("%02x != %02x"
     106                                           % (computed, reference))
     107
     108           
     109    validateChecksum = staticmethod(validateChecksum)
     110
     111
     112    #@staticmethod
     113    def splitSentence(sentence):
     114        """
     115        Returns the split version of the sentence, minus header and checksum.
     116
     117        >>> NMEASentence.splitSentence("$GPGGA,spam,eggs*00")
     118        ['GPGGA', 'spam', 'eggs']
     119        """
     120        if sentence[-3] == "*": # sentence with checksum
     121            return sentence[1:-3].split(',')
     122        elif sentence[-1] == "*": # sentence without checksum
     123            return sentence[1:-1].split(',')
     124        else:
     125            raise base.InvalidSentence("malformed sentence %s" % sentence)
     126
     127
     128    splitSentence = staticmethod(splitSentence)
     129
     130
     131    def nmea_GPGGA(self, sentence):
     132        """
     133        Callback called when a GGA sentence is received.
     134        """
     135
     136
     137    def nmea_GPRMC(self, sentence):
     138        """
     139        Callback called when an RMC sentence is received.
     140        """
     141
     142
     143    def nmea_GPGSV(self, sentence):
     144        """
     145        Callback called when a GSV sentence is received.
     146        """
     147
     148
     149    def nmea_GPGLL(self, sentence):
     150        """
     151        Callback called when a GGL sentence is received.
     152        """
     153
     154
     155    def nmea_GPHDT(self, sentence):
     156        """
     157        Callback called when an HDT sentence is received.
     158        """
     159
     160
     161    def nmea_GPGSA(self, sentence):
     162        """
     163        Callback called when a GSA sentence is received.
     164        """
     165
     166
     167
     168    SENTENCE_CONTENTS = {
     169        'GPGGA': [
     170            'timestamp',
     171
     172            'latitudeFloat',
     173            'latitudeHemisphere',
     174            'longitudeFloat',
     175            'longitudeHemisphere',
     176
     177            'validGGA',
     178            'numberOfSatellitesSeen',
     179            'horizontalDilutionOfPrecision',
     180
     181            'altitude',
     182            'altitudeUnits',
     183            'heightOfGeoidAboveWGS84',
     184            'heightOfGeoidAboveWGS84Units',
     185
     186            # TODO: DGPS information
     187        ],
     188
     189        'GPRMC': [
     190            'timestamp',
     191
     192            'validRMC',
     193
     194            'latitudeFloat',
     195            'latitudeHemisphere',
     196            'longitudeFloat',
     197            'longitudeHemisphere',
     198
     199            'speedInKnots',
     200
     201            'trueHeading',
     202
     203            'datestamp',
     204
     205            'magneticVariation',
     206            'magneticVariationDirection',
     207        ],
     208
     209        'GPGSV': [
     210            'numberOfGSVSentences',
     211            'GSVSentenceIndex',
     212
     213            'numberOfSatellitesSeen',
     214
     215            'satellitePRN_0',
     216            'elevation_0',
     217            'azimuth_0',
     218            'signalToNoiseRatio_0',
     219
     220            'satellitePRN_1',
     221            'elevation_1',
     222            'azimuth_1',
     223            'signalToNoiseRatio_1',
     224
     225            'satellitePRN_2',
     226            'elevation_2',
     227            'azimuth_2',
     228            'signalToNoiseRatio_2',
     229
     230            'satellitePRN_3',
     231            'elevation_3',
     232            'azimuth_3',
     233            'signalToNoiseRatio_3',
     234        ],
     235
     236        'GPGLL': [
     237            'latitudeFloat',
     238            'latitudeHemisphere',
     239            'longitudeFloat',
     240            'longitudeHemisphere',
     241            'timestamp',
     242            'validGLL',
     243        ],
     244
     245        'GPHDT': [
     246            'trueHeading',
     247        ],
     248
     249        'GPTRF': [
     250            'datestamp',
     251            'timestamp',
     252
     253            'latitudeFloat',
     254            'latitudeHemisphere',
     255            'longitudeFloat',
     256            'longitudeHemisphere',
     257
     258            # TODO: actually use these:
     259            'elevation',
     260            'numberOfIterations',
     261            'numberOfDopplerIntervals',
     262            'updateDistanceInNauticalMiles',
     263            'satellitePRN',
     264        ],
     265
     266        'GPGSA': [
     267            None, # like GPRMCMode
     268            None, # like GPGGAFixQuality
     269
     270            'usedSatellitePRN_0',
     271            'usedSatellitePRN_1',
     272            'usedSatellitePRN_2',
     273            'usedSatellitePRN_3',
     274            'usedSatellitePRN_4',
     275            'usedSatellitePRN_5',
     276            'usedSatellitePRN_6',
     277            'usedSatellitePRN_7',
     278            'usedSatellitePRN_8',
     279            'usedSatellitePRN_9',
     280            'usedSatellitePRN_10',
     281            'usedSatellitePRN_11',
     282
     283            'positionDilutionOfPrecision',
     284            'horizontalDilutionOfPrecision',
     285            'verticalDilutionOfPrecision',
     286        ]
     287    }
     288
     289
     290
     291class NMEASentence(object):
     292    """
     293    An object representing an NMEAProtocol sentence.
     294
     295    The attributes of this objects are raw NMEAProtocol representations, which
     296    are bytestrings encoded in ASCII.
     297
     298    @ivar type: The sentence type ("GPGGA", "GPGSV"...).
     299
     300    This object contains the raw NMEAProtocol representations in a sentence.
     301    Not all of these necessarily have to be present in the sentence. Missing
     302    attributes are None when accessed.
     303
     304    @ivar timestamp: An NMEAProtocol timestamp. ("123456" -> 12:34:56Z)
     305
     306    @ivar latitudeFloat: The NMEAProtocol angular representation of a latitude
     307        (for example: "1234.567" -> 12 degrees, 34.567 minutes).
     308    @ivar latitudeHemisphere: The NMEAProtocol representation of a latitudinal
     309        hemisphere ("N" or "S").
     310    @ivar longitudeFloat: The NMEAProtocol angular representation of a
     311        longitude. See C{latitudeFloat} for an example.
     312    @ivar longitudeHemisphere: The NMEAProtocol representation of a
     313        longitudinal hemisphere ("E" or "W").
     314
     315    TODO: finish documenting these attributes
     316
     317    """
     318    def __init__(self, sentenceData):
     319        """
     320        Initializes an NMEAProtocol sentence from parsed sentence data.
     321        """
     322        super(NMEASentence, self).__init__()
     323        self._sentenceData = sentenceData
     324
     325    ## REVIEW: would properties like this be okay? Does that mean we can remove
     326    ## the ivar docs in the class docstring? Would a bunch of getters, like:
     327    ##
     328    ##     def _getLatitudeFloat(self):
     329    ##         return self._sentenceData['latitudeFloat']
     330    ##
     331    ## and then property(fget=_getLatitudeFloat) be preferable? That seems like
     332    ## a lot of repetitive code.
     333    latitudeFloat = property(
     334        fget=lambda self: self._sentenceData['latitudeFloat'],
     335        doc="""
     336        The NMEAProtocol angular representation of a latitude.
     337
     338        For example: "1234.567" -> 12 degrees, 34.567 minutes.
     339        """)
     340
     341    def __getattr__(self, name):
     342        """
     343        Gets an attribute from the internal sentence dictionary.
     344        """
     345        # TODO: remove
     346        return self._sentenceData.get(name, None)
     347
     348
     349    def _isFirstGSVSentence(self):
     350        """
     351        Tests if this current GSV sentence is the first one in a sequence.
     352        """
     353        return self.GSVSentenceIndex == "1"
     354
     355
     356    def _isLastGSVSentence(self):
     357        """
     358        Tests if this current GSV sentence is the final one in a sequence.
     359        """
     360        return self.GSVSentenceIndex == self.numberOfGSVSentences
     361
     362
     363    keys = property(fget=lambda self: iter(self._sentenceData), doc=
     364        """
     365        Returns an iterator that iterates over the names of attributes present
     366        in this sentence.
     367        """)
     368
     369
     370    def __repr__(self):
     371        """
     372        Returns a textual representation of this NMEA sentence.
     373
     374        Note that this object represents a sentence that has already been
     375        parsed -- this method does not return the raw serialized NMEA sentence.
     376        """
     377        return "<NMEASentence {%s}>" % (repr(self._sentenceData),)
     378
     379   
     380
     381MODE_AUTO, MODE_MANUAL = 'A', 'M'
     382RMC_INVALID, RMC_VALID = 'V', 'A'
     383GLL_INVALID, GLL_VALID = 'V', 'A'
     384GGA_INVALID, GGA_GPS_FIX, GGA_DGPS_FIX = 1, 2, 3
     385
     386
     387
     388class NMEAAdapter(object):
     389    """
     390    An adapter from NMEAProtocol receivers to positioning receivers.
     391
     392    @cvar DATESTAMP_HANDLING: Way to handle dates. One of (C{'intelligent'}
     393        (default, if the last two digits are greater than the intelligent data
     394        threshold,  assumes twentieth century, otherwise assumes twenty-first
     395        century.), C{'19xx'} (assumes dates start with '19'), or C{'20xx'}
     396        (assumes dates start with '20').
     397    @type DATESTAMP_HANDLING: C{str}
     398
     399    @cvar INTELLIGENT_DATE_THRESHOLD: The threshold that determines which
     400        century we guess a year is in. If the year value in a sentence is above
     401        this value, assumes the 20th century (19xx), otherwise assumes the
     402        twenty-first century (20xx).
     403    @type INTELLIGENT_DATE_THRESHOLD: C{int}
     404    """
     405    implements(ipositioning.INMEAReceiver)
     406
     407   
     408    def __init__(self, positioningReceiver):
     409        """
     410        Initializes a new NMEA adapter.
     411
     412        @param positioningReceiver: The receiver for positioning sentences.
     413        @type positioningReceiver: C{ipositioning.IPositioningReceiver}
     414        """
     415        self._state = {}
     416        self._sentenceData = {}
     417        self._receiver = positioningReceiver
     418
     419
     420    def _fixTimestamp(self):
     421        """
     422        Turns the NMEAProtocol timestamp notation into a datetime.time object.
     423        The time in this object is expressed as Zulu time.
     424        """
     425        timestamp = self.currentSentence.timestamp.split('.')[0]
     426        timeObject = datetime.datetime.strptime(timestamp, '%H%M%S').time()
     427        self._sentenceData['time'] = timeObject
     428
     429
     430    DATESTAMP_HANDLING = 'intelligent'
     431    INTELLIGENT_DATE_THRESHOLD = 80
     432
     433   
     434    def _fixDatestamp(self):
     435        """
     436        Turns an NMEA datestamp format into a Python datetime.date object.
     437        """
     438        datestamp = self.currentSentence.datestamp
     439
     440        day, month, year = [int(ordinalString) for ordinalString in
     441                            (datestamp[0:2], datestamp[2:4], datestamp[4:6])]
     442
     443        # REVIEW: this can be optimized into a dict lookup + call, if I could
     444        # have ternaries in 2.3 to do the intelligent datestamp handling.
     445        if self.DATESTAMP_HANDLING == 'intelligent':
     446            if year > self.INTELLIGENT_DATE_THRESHOLD:
     447                year = int('19%02d' % year)
     448            else:
     449                year = int('20%02d' % year)
     450
     451        elif self.DATESTAMP_HANDLING == '20xx':
     452            year = int('20%02d' % year)
     453
     454        elif self.DATESTAMP_HANDLING == '19xx':
     455            year = int('19%02d' % year)
     456
     457        else:
     458            raise ValueError("unknown datestamp handling method (%s)"
     459                             % (self.DATESTAMP_HANDLING,))
     460
     461        self._sentenceData['date'] = datetime.date(year, month, day)
     462
     463
     464    def _fixCoordinateFloat(self, coordinate):
     465        """
     466        Turns the NMEAProtocol coordinate format into Python float.
     467
     468        @param coordinate: The coordinate type: 'latitude' or 'longitude'.
     469        @type coordinate: C{str}
     470        """
     471        nmeaCoordinate = getattr(self.currentSentence, coordinate + 'Float')
     472        left, right = nmeaCoordinate.split('.')
     473        degrees, minutes = int(left[:-2]), float("%s.%s" % (left[-2:], right))
     474       
     475        self._sentenceData[coordinate] = base.Coordinate(degrees + minutes/60,
     476                                                         coordinate)
     477
     478       
     479    ALLOWED_HEMISPHERE_LETTERS = {
     480        "latitude": "NS",
     481        'longitude': "EW",
     482        "magneticVariation": "EW",
     483    }
     484
     485   
     486    def _fixHemisphereSign(self, coordinate, sentenceDataKey=None):
     487        """
     488        Fixes the sign for a hemisphere.
     489
     490        @param coordinate: Coordinate type (latitude, longitude or
     491            magneticVariation).
     492        @type coordinate: C{str}
     493
     494        This method must be called after the magnitude for the thing it
     495        determines the sign of has been set. This is done by the following
     496        functions:
     497
     498            - C{self.FIXERS['magneticVariation']}
     499            - C{self.FIXERS['latitudeFloat']}
     500            - C{self.FIXERS['longitudeFloat']}
     501        """
     502        sentenceDataKey = sentenceDataKey or coordinate
     503        sign = self._getHemisphereSign(coordinate)
     504        self._sentenceData[sentenceDataKey] *= sign
     505
     506
     507    COORDINATE_SIGNS = {
     508        'N': 1,
     509        'E': 1,
     510        'S': -1,
     511        'W': -1   
     512    }
     513
     514   
     515    def _getHemisphereSign(self, coordinate):
     516        """
     517        Returns the hemisphere sign for a given coordinate.
     518
     519        @param coordinate: Coordinate type (latitude, longitude or
     520            magneticVariation).
     521        @type coordinate: C{str}
     522        """
     523        if coordinate in ('latitude', 'longitude'):
     524            hemisphereKey = coordinate + 'Hemisphere'
     525        else:
     526            hemisphereKey = coordinate + 'Direction'
     527
     528        hemisphere = getattr(self.currentSentence, hemisphereKey)
     529
     530        try:
     531            return self.COORDINATE_SIGNS[hemisphere.upper()]
     532        except KeyError:
     533            raise ValueError("bad hemisphere/direction: %s" % hemisphere)
     534           
     535
     536    def _convert(self, sourceKey, converter=float, destinationKey=None):
     537        """
     538        A simple conversion fix.
     539        """
     540        currentValue = getattr(self.currentSentence, sourceKey)
     541
     542        if destinationKey is None:
     543            destinationKey = sourceKey
     544
     545        self._sentenceData[destinationKey] = converter(currentValue)
     546
     547
     548
     549    STATEFUL_UPDATE = {
     550        # sentenceKey: (stateKey, factory, attributeName, converter),
     551        'trueHeading':
     552            ('heading', base.Heading, 'heading', float),
     553        'magneticVariation':
     554            ('heading', base.Heading, 'variation', float),
     555
     556        'horizontalDilutionOfPrecision':
     557            ('positioningError', base.PositioningError, 'hdop', float),
     558        'verticalDilutionOfPrecision':
     559            ('positioningError', base.PositioningError, 'vdop', float),
     560        'positionDilutionOfPrecision':
     561            ('positioningError', base.PositioningError, 'pdop', float),
     562
     563    }
     564
     565   
     566    def _statefulUpdate(self, sentenceKey):
     567        """
     568        Does a stateful update of a particular positioning attribute.
     569
     570        @param stateKey: The name of the key in the sentence attributes, the
     571            adapter state, and the NMEAAdapter.STATEFUL_UPDATE dict.
     572        @type stateKey: C{str}
     573        """
     574        stateKey, factory, attributeName, converter \
     575            = self.STATEFUL_UPDATE[sentenceKey]
     576
     577        if stateKey not in self._sentenceData:
     578            self._sentenceData[stateKey] = self._state.get(stateKey, factory())
     579
     580        newValue = converter(getattr(self.currentSentence, sentenceKey))
     581        setattr(self._sentenceData[stateKey], attributeName, newValue)
     582           
     583       
     584    ACCEPTABLE_UNITS = frozenset(['M'])
     585    UNIT_CONVERTERS = {
     586        'N': lambda inKnots: base.Speed(float(inKnots) * base.MPS_PER_KNOT),
     587        'K': lambda inKPH: base.Speed(float(inKPH) * base.MPS_PER_KPH),
     588    }
     589
     590
     591    def _fixUnits(self, unitKey=None, valueKey=None, sourceKey=None, unit=None):
     592        """
     593        Fixes the units of a certain value.
     594
     595        At least one of C{unit}, C{unitKey} must be provided.
     596
     597        If the C{valueKey} is not provided, will attempt to strip "Units" from
     598        the C{unitKey}.
     599
     600        If the C{sourceKey} is not provided. will store the new data in the
     601        same place as the C{valueKey}.
     602        """
     603        unit = unit or getattr(self.currentSentence, unitKey)
     604        valueKey = valueKey or unitKey.strip('Units')
     605        sourceKey = sourceKey or valueKey
     606
     607        if unit not in self.ACCEPTABLE_UNITS:
     608            converter = self.UNIT_CONVERTERS[unit]
     609            currentValue = getattr(self.currentSentence, sourceKey)
     610            self._sentenceData[valueKey] = converter(currentValue)
     611
     612
     613    GSV_KEYS = "satellitePRN", "azimuth", "elevation", "signalToNoiseRatio"
     614
     615
     616    def _fixGSV(self):
     617        """
     618        Parses partial visible satellite information from a GSV sentence.
     619        """
     620        # To anyone who knows NMEA, this method's name should raise a chuckle's
     621        # worth of schadenfreude. 'Fix' GSV? Hah! Ludicrous.
     622        self._sentenceData['_partialBeaconInformation'] = base.BeaconInformation()
     623
     624        for index in range(4):
     625            prn, azimuth, elevation, snr = \
     626                [getattr(self.currentSentence, "%s_%s" % (gsvKey, index))
     627                 for gsvKey in self.GSV_KEYS]
     628
     629            if prn is None or snr is None:
     630                continue # continue not break, to accomodate for some bad gpses
     631
     632            satellite = base.Satellite(prn, azimuth, elevation, snr)
     633            self._sentenceData['_partialBeaconInformation'].beacons.add(satellite)
     634
     635
     636    def _fixBeacons(self, predicate):
     637        """
     638        TODO: document
     639       
     640        @param predicate: One of C{"seen"} or C{"used"}.
     641        @type predicate: C{str}
     642        """
     643        ## TODO: refactor mercilessly to a statefulUpdate
     644        if "beaconInformation" not in self._state:
     645            self._state["beaconInformation"] = base.BeaconInformation()
     646
     647        # TODO: Write tests for this!!! -- lvh
     648        informationKey = predicate.lower()
     649        sentenceKey = "beacons" + predicate.title()
     650
     651        setattr(self._state["beaconInformation"], informationKey,
     652                int(getattr(self.currentSentence, sentenceKey)))
     653
     654
     655    def _fixGSA(self):
     656        """
     657        Extracts the information regarding which satellites were used in
     658        obtaining the GPS fix from a GSA sentence.
     659
     660        @pre: A GSA sentence was fired.
     661        @post: The current sentence data (C{self._sentenceData} will contain a
     662            set of the currently used PRNs (under the key C{_usedPRNs}.
     663        """
     664        self._sentenceData['_usedPRNs'] = set()
     665        for key in ("usedSatellitePRN_%d" % x for x in range(12)):
     666            prn = getattr(self.currentSentence, key, None)
     667            if prn is not None:
     668                self._sentenceData['_usedPRNs'].add(int(prn))
     669
     670
     671               
     672    SENTENCE_INVALIDITY = {
     673        "RMC": RMC_INVALID,
     674        "GGA": GGA_INVALID,
     675        "GLL": GLL_INVALID,
     676    }
     677
     678
     679    def _validate(self, sentenceType):
     680        """
     681        Tests if a sentence contains a valid fix.
     682
     683        Some sentences (GGA, RMC...) contain information on the validity of the
     684        fix.
     685        """
     686        invalidValue = self.SENTENCE_INVALIDITY[sentenceType]
     687        thisValue = getattr(self.currentSentence, "valid" + sentenceType)
     688
     689        if thisValue == invalidValue:
     690            raise base.InvalidSentence("bad %s validity" % sentenceType)
     691
     692
     693    SPECIFIC_SENTENCE_FIXES = {
     694        'GPGSV': _fixGSV,
     695        'GPGSA': _fixGSA,
     696    }
     697
     698
     699    def _sentenceSpecificFix(self):
     700        """
     701        Executes a fix for a specific type of sentence.
     702        """
     703        fixer = self.SPECIFIC_SENTENCE_FIXES.get(self.currentSentence.type)
     704        if fixer is not None:
     705            fixer(self)
     706
     707
     708    FIXERS = {
     709        'type':
     710            lambda self: self._sentenceSpecificFix(),
     711
     712        'timestamp':
     713            lambda self: self._fixTimestamp(),
     714        'datestamp':
     715            lambda self: self._fixDatestamp(),
     716
     717        'latitudeFloat':
     718            lambda self: self._fixCoordinateFloat(coordinate='latitude'),
     719        'latitudeHemisphere':
     720            lambda self: self._fixHemisphereSign('latitude'),
     721        'longitudeFloat':
     722            lambda self: self._fixCoordinateFloat(coordinate='longitude'),
     723        'longitudeHemisphere':
     724            lambda self: self._fixHemisphereSign('longitude'),
     725
     726        'altitude':
     727            lambda self: self._convert('altitude',
     728                                       converter=base.Altitude),
     729        'altitudeUnits':
     730            lambda self: self._fixUnits(unitKey='altitudeUnits'),
     731
     732        'heightOfGeoidAboveWGS84':
     733            lambda self: self._convert('heightOfGeoidAboveWGS84',
     734                                       converter=base.Altitude),
     735        'heightOfGeoidAboveWGS84Units':
     736            lambda self: self._fixUnits(
     737                unitKey='heightOfGeoidAboveWGS84Units'),
     738
     739        'trueHeading':
     740            lambda self: self._statefulUpdate('trueHeading'),
     741        'magneticVariation':
     742            lambda self: self._statefulUpdate('magneticVariation'),
     743
     744        'magneticVariationDirection':
     745            lambda self: self._fixHemisphereSign('magneticVariation',
     746                                                 'heading'),
     747
     748        'speedInKnots':
     749            lambda self: self._fixUnits(valueKey='speed',
     750                                        sourceKey='speedInKnots',
     751                                        unit='N'),
     752
     753        'validGGA':
     754            lambda self: self._validate('GGA'),
     755        'validRMC':
     756            lambda self: self._validate('RMC'),
     757        'validGLL':
     758            lambda self: self._validate('GLL'),
     759
     760        'numberOfBeaconsUsed':
     761            lambda self: self._fixBeacons('used'),
     762        'numberOfBeaconsSeen':
     763            lambda self: self._fixBeacons('seen'),
     764
     765        'positionDilutionOfPrecision':
     766            lambda self: self._statefulUpdate('positionDilutionOfPrecision'),
     767        'horizontalDilutionOfPrecision':
     768            lambda self: self._statefulUpdate('horizontalDilutionOfPrecision'),
     769        'verticalDilutionOfPrecision':
     770            lambda self: self._statefulUpdate('verticalDilutionOfPrecision'),
     771    }
     772
     773
     774    def clear(self):
     775        """
     776        Resets this adapter.
     777
     778        This will empty the adapter state and the current sentence data.
     779        """
     780        self._state = {}
     781        self._sentenceData = {}
     782
     783
     784    def sentenceReceived(self, sentence):
     785        """
     786        Called when a sentence is received.
     787
     788        Will clean the received NMEAProtocol sentence up, and then update the
     789        adapter's state, followed by firing the callbacks.
     790
     791        If the received sentence was invalid, the state will be cleared.
     792
     793        @param sentence: The sentence that is received.
     794        @type sentence: C{NMEASentence}
     795        """
     796        self.currentSentence = sentence
     797
     798        try:
     799            self._cleanCurrentSentence()
     800            self._updateSentence()
     801            self._fireSentenceCallbacks()
     802        except base.InvalidSentence:
     803            self.clear()
     804
     805
     806    def _cleanCurrentSentence(self):
     807        """
     808        Cleans the current sentence.
     809        """
     810        for key in sorted(self.currentSentence.keys):
     811            fixer = self.FIXERS.get(key, None)
     812
     813            if fixer is not None:
     814                fixer(self)
     815
     816
     817    def _updateSentence(self):
     818        """
     819        Updates the current state with the new information from the sentence.
     820        """
     821        self._updateBeaconInformation()
     822        self._state.update(self._sentenceData)
     823
     824
     825    def _updateBeaconInformation(self):
     826        """
     827        Updates existing beacon information state with new data.
     828        """
     829        new = self._sentenceData.get('_partialBeaconInformation')
     830        if new is None:
     831            return
     832
     833        usedPRNs = self._state.get('_usedPRNs') \
     834            or self._sentenceData.get('_usedPRNs')
     835        if usedPRNs is not None:
     836            for beacon in new.beacons:
     837                beacon.isUsed = (beacon.identifier in usedPRNs)
     838
     839        old = self._state.get('_partialBeaconInformation')
     840        if old is not None:
     841            new.beacons.update(old.beacons)
     842               
     843        if self.currentSentence._isLastGSVSentence():
     844            del self._state['_partialBeaconInformation']
     845            bi = self._sentenceData.pop('_partialBeaconInformation')
     846            self._sentenceData['beaconInformation'] = bi
     847
     848
     849    def _fireSentenceCallbacks(self):
     850        """
     851        Fires sentence callbacks for the current sentence.
     852
     853        A callback will only fire if all of the keys it requires are present in
     854        the current state and at least one such field was altered in the
     855        current sentence.
     856
     857        The callbacks will only be fired with data from C{self._state}.
     858        """
     859        for callbackName, requiredFields in self.REQUIRED_CALLBACK_FIELDS.items():
     860            callback = getattr(self._receiver, callbackName, None)
     861
     862            if callback is None:
     863                continue
     864
     865            kwargs = {}
     866            atLeastOnePresentInSentence = False
     867
     868            try:
     869                for field in requiredFields:
     870                    if field in self._sentenceData:
     871                        atLeastOnePresentInSentence = True
     872                        kwargs[field] = self._state[field]
     873            except KeyError:
     874                continue
     875
     876            if atLeastOnePresentInSentence:
     877                callback(**kwargs)
     878
     879
     880NMEAAdapter.REQUIRED_CALLBACK_FIELDS = dict((name, method.positional)
     881     for name, method
     882     in ipositioning.IPositioningReceiver.namesAndDescriptions())
  • twisted/positioning/test/test_nmea.py

    === added directory 'twisted/positioning/test'
    === added file 'twisted/positioning/test/__init__.py'
    === added file 'twisted/positioning/test/test_nmea.py'
     
     1# -*- encoding: utf-8 -*-
     2# Copyright (c) 2009 Twisted Matrix Laboratories.
     3# See LICENSE for details.
     4"""
     5Test cases for using NMEA sentences.
     6"""
     7import datetime
     8from zope.interface import implements
     9
     10from twisted.positioning import base, nmea, ipositioning
     11from twisted.trial.unittest import TestCase
     12
     13class NMEATestReceiver(object):
     14    implements(ipositioning.INMEAReceiver)
     15
     16    def __init__(self):
     17        self.clear()
     18
     19
     20    def clear(self):
     21        self.receivedSentence = None
     22
     23
     24    def sentenceReceived(self, sentence):
     25        self.receivedSentence = sentence
     26
     27
     28
     29class CallbackTestNMEAProtocol(nmea.NMEAProtocol):
     30    """
     31    A class that tests that the correct callbacks are called.
     32    """
     33    def __init__(self):
     34        nmea.NMEAProtocol.__init__(self, None)
     35
     36        for sentenceType in nmea.NMEAProtocol.SENTENCE_CONTENTS:
     37            self._createCallback(sentenceType)
     38
     39        self.clear()
     40
     41
     42    def clear(self):
     43        self.sentenceReceived = None
     44        self.called = {}
     45
     46
     47    SENTENCE_TYPES = [x for x in nmea.NMEAProtocol.SENTENCE_CONTENTS]
     48
     49    def _createCallback(self, sentenceType):
     50        """
     51        Creates a callback for an NMEA sentence.
     52        """
     53        def callback(sentence):
     54            self.sentenceReceived = sentence
     55            self.called[sentenceType] = True
     56
     57        setattr(self, "nmea_" + sentenceType, callback)
     58
     59
     60
     61class NMEATests(TestCase):
     62    def setUp(self):
     63        self.callbackProtocol = CallbackTestNMEAProtocol()
     64
     65        self.receiver = NMEATestReceiver()
     66        self.receiverProtocol = nmea.NMEAProtocol(self.receiver)
     67
     68
     69    def test_callbacksCalled(self):
     70        """
     71        Tests that the correct callbacks fire, and that *only* those fire.
     72        """
     73        sentencesByType = {
     74            "GPGGA": [
     75                "$GPGGA*56",
     76            ],
     77
     78            "GPRMC": [
     79                "$GPRMC*4b",
     80            ],
     81
     82            "GPGSV": [
     83                "$GPGSV*55",
     84            ],
     85
     86            "GPGLL": [
     87                "$GPGLL*50",
     88            ],
     89
     90            "GPHDT": [
     91                "$GPHDT*4f",
     92            ],
     93
     94            "GPGSA": [
     95                "$GPGSA*42",
     96            ],
     97
     98        }
     99
     100        for calledSentenceType in sentencesByType:
     101            for sentence in sentencesByType[calledSentenceType]:
     102                self.callbackProtocol.lineReceived(sentence)
     103                called = self.callbackProtocol.called
     104
     105                for sentenceType in CallbackTestNMEAProtocol.SENTENCE_TYPES:
     106                    self.assertEquals(sentenceType == calledSentenceType,
     107                                      called.get(sentenceType, False))
     108
     109                self.callbackProtocol.clear()
     110
     111                self.receiverProtocol.lineReceived(sentence)
     112                self.assertTrue(self.receiver.receivedSentence)
     113
     114
     115
     116    def test_validateGoodChecksum(self):
     117        """
     118        Tests checkum validation for good or missing checksums.
     119        """
     120        sentences = [
     121          '$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47',
     122          '$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*',
     123        ]
     124
     125        for sentence in sentences:
     126            nmea.NMEAProtocol.validateChecksum(sentence)
     127
     128
     129    def test_validateBadChecksum(self):
     130        """
     131        Tests checksum validation on bad checksums.
     132        """
     133        sentences = [
     134          '$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*46',
     135        ]
     136
     137        for sentence in sentences:
     138            self.assertRaises(base.InvalidChecksum,
     139                              nmea.NMEAProtocol.validateChecksum, sentence)
     140
     141
     142    def test_GSVFirstSequence(self):
     143        """
     144        Tests if the last sentence in a GSV sequence is correctly identified.
     145        """
     146        string = '$GPGSV,3,1,11,22,42,067,42,24,14,311,43,27,05,244,00,,,,*4F'
     147        self.callbackProtocol.lineReceived(string)
     148        sentence = self.callbackProtocol.sentenceReceived
     149
     150        self.assertTrue(sentence._isFirstGSVSentence())
     151        self.assertFalse(sentence._isLastGSVSentence())
     152
     153
     154    def test_GSVLastSentence(self):
     155        """
     156        Tests if the last sentence in a GSV sequence is correctly identified.
     157        """
     158        string = '$GPGSV,3,3,11,22,42,067,42,24,14,311,43,27,05,244,00,,,,*4D'
     159        self.callbackProtocol.lineReceived(string)
     160        sentence = self.callbackProtocol.sentenceReceived
     161
     162        self.assertFalse(sentence._isFirstGSVSentence())
     163        self.assertTrue(sentence._isLastGSVSentence())
     164
     165
     166    def test_parsing(self):
     167        """
     168        Tests the parsing of a few sentences.
     169        """
     170        sentences = {
     171        # Full GPRMC sentence.
     172        '$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A': {
     173             'type': 'GPRMC',
     174             'latitudeFloat': '4807.038',
     175             'latitudeHemisphere': 'N',
     176             'longitudeFloat': '01131.000',
     177             'longitudeHemisphere': 'E',
     178             'magneticVariation': '003.1',
     179             'magneticVariationDirection': 'W',
     180             'speedInKnots': '022.4',
     181             'timestamp': '123519',
     182             'datestamp': '230394',
     183             'trueHeading': '084.4',
     184             'validRMC': 'A',
     185        },
     186
     187        # Full GPGGA sentence.
     188        '$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47': {
     189            'type': 'GPGGA',
     190
     191            'altitude': '545.4',
     192            'altitudeUnits': 'M',
     193            'heightOfGeoidAboveWGS84': '46.9',
     194            'heightOfGeoidAboveWGS84Units': 'M',
     195
     196            'horizontalDilutionOfPrecision': '0.9',
     197
     198            'latitudeFloat': '4807.038',
     199            'latitudeHemisphere': 'N',
     200            'longitudeFloat': '01131.000',
     201            'longitudeHemisphere': 'E',
     202
     203            'numberOfSatellitesSeen': '08',
     204            'timestamp': '123519',
     205            'validGGA': '1',
     206        },
     207
     208        # Partial GPGLL sentence.
     209        '$GPGLL,3751.65,S,14507.36,E*77': {
     210            'type': 'GPGLL',
     211
     212            'latitudeFloat': '3751.65',
     213            'latitudeHemisphere': 'S',
     214            'longitudeFloat': '14507.36',
     215            'longitudeHemisphere': 'E',
     216        },
     217
     218        # Full GPGLL sentence.
     219        '$GPGLL,4916.45,N,12311.12,W,225444,A*31': {
     220            'type': 'GPGLL',
     221
     222            'latitudeFloat': '4916.45',
     223            'latitudeHemisphere': 'N',
     224            'longitudeFloat': '12311.12',
     225            'longitudeHemisphere': 'W',
     226
     227            'timestamp': '225444',
     228            'validGLL': 'A',
     229        },
     230
     231        # Full GPGSV sentence.
     232        '$GPGSV,3,1,11,03,03,111,00,04,15,270,00,06,01,010,00,13,06,292,00*74': {
     233            'type': 'GPGSV',
     234            'GSVSentenceIndex': '1',
     235            'numberOfGSVSentences': '3',
     236            'numberOfSatellitesSeen': '11',
     237
     238            'azimuth_0': '111',
     239            'azimuth_1': '270',
     240            'azimuth_2': '010',
     241            'azimuth_3': '292',
     242
     243            'elevation_0': '03',
     244            'elevation_1': '15',
     245            'elevation_2': '01',
     246            'elevation_3': '06',
     247
     248            'satellitePRN_0': '03',
     249            'satellitePRN_1': '04',
     250            'satellitePRN_2': '06',
     251            'satellitePRN_3': '13',
     252
     253            'signalToNoiseRatio_0': '00',
     254            'signalToNoiseRatio_1': '00',
     255            'signalToNoiseRatio_2': '00',
     256            'signalToNoiseRatio_3': '00',
     257        },
     258
     259        # Partially empty GSV sentence support.
     260        '$GPGSV,3,3,11,22,42,067,42,24,14,311,43,27,05,244,00,,,,*4D': {
     261            'type': 'GPGSV',
     262            'GSVSentenceIndex': '3',
     263            'numberOfGSVSentences': '3',
     264            'numberOfSatellitesSeen': '11',
     265
     266            'azimuth_0': '067',
     267            'azimuth_1': '311',
     268            'azimuth_2': '244',
     269
     270            'elevation_0': '42',
     271            'elevation_1': '14',
     272            'elevation_2': '05',
     273
     274            'satellitePRN_0': '22',
     275            'satellitePRN_1': '24',
     276            'satellitePRN_2': '27',
     277
     278            'signalToNoiseRatio_0': '42',
     279            'signalToNoiseRatio_1': '43',
     280            'signalToNoiseRatio_2': '00',
     281        },
     282
     283        # Full HDT sentence.
     284        '$GPHDT,038.005,T*3B': {
     285            'type': 'GPHDT',
     286            'trueHeading': '038.005',
     287        },
     288
     289        # Full TRG sentence.
     290        # TODO: fill
     291
     292        # Typical GPGSA sentence.
     293        '$GPGSA,A,3,19,28,14,18,27,22,31,39,,,,,1.7,1.0,1.3*34': {
     294            'type': 'GPGSA',
     295
     296            'usedSatellitePRN_0': '19',
     297            'usedSatellitePRN_1': '28',
     298            'usedSatellitePRN_2': '14',
     299            'usedSatellitePRN_3': '18',
     300            'usedSatellitePRN_4': '27',
     301            'usedSatellitePRN_5': '22',
     302            'usedSatellitePRN_6': '31',
     303            'usedSatellitePRN_7': '39',
     304
     305            'positionDilutionOfPrecision': '1.7',
     306            'horizontalDilutionOfPrecision': '1.0',
     307            'verticalDilutionOfPrecision': '1.3',
     308        },
     309
     310
     311        }
     312
     313        for sentence, expected in sentences.iteritems():
     314            self.callbackProtocol.lineReceived(sentence)
     315            received = self.callbackProtocol.sentenceReceived
     316            self.assertEquals(expected, received._sentenceData)
     317            self.callbackProtocol.clear()
     318
     319
     320
     321class NMEAAdapterConverterTests(TestCase):
     322    """
     323    Tests for the converters on an NMEA adapter.
     324    """
     325    def setUp(self):
     326        self.adapter = nmea.NMEAAdapter(None)
     327
     328
     329    def test_fixTimestamp(self):
     330        self._genericFixerTest(
     331            {'timestamp': '123456'}, # 12:34:56Z
     332            {'time': datetime.time(12, 34, 56)})
     333
     334
     335    def test_fixBrokenTimestamp(self):
     336        self._genericFixerRaisingTest(
     337            {'timestamp': '993456'}, ValueError)
     338        self._genericFixerRaisingTest(
     339            {'timestamp': '129956'}, ValueError)
     340        self._genericFixerRaisingTest(
     341            {'timestamp': '123499'}, ValueError)
     342
     343
     344    def test_fixDatestamp_intelligent(self):
     345        self._genericFixerTest(
     346            {'datestamp': '010199'},
     347            {'date': datetime.date(1999, 1, 1)})
     348
     349        self._genericFixerTest(
     350            {'datestamp': '010109'},
     351            {'date': datetime.date(2009, 1, 1)})
     352
     353
     354    def test_fixDatestamp_19xx(self):
     355        self.adapter.DATESTAMP_HANDLING = '19xx'
     356
     357        self._genericFixerTest(
     358            {'datestamp': '010199'},
     359            {'date': datetime.date(1999, 1, 1)})
     360
     361        self._genericFixerTest(
     362            {'datestamp': '010109'},
     363            {'date': datetime.date(1909, 1, 1)})
     364
     365
     366    def test_fixDatestamp_20xx(self):
     367        self.adapter.DATESTAMP_HANDLING = '20xx'
     368
     369        self._genericFixerTest(
     370            {'datestamp': '010199'},
     371            {'date': datetime.date(2099, 1, 1)})
     372
     373        self._genericFixerTest(
     374            {'datestamp': '010109'},
     375            {'date': datetime.date(2009, 1, 1)})
     376
     377
     378    def test_fixBrokenDatestamp(self):
     379        self._genericFixerRaisingTest({'datestamp': '123456'}, ValueError)
     380
     381
     382    def test_coordinate_north(self):
     383        self._genericFixerTest(
     384            {'latitudeFloat': '1030.000', 'latitudeHemisphere': 'N'},
     385            {'latitude': base.Coordinate(10+30.000/60, 'latitude')})
     386
     387       
     388    def test_coordinate_south(self):
     389        self._genericFixerTest(
     390            {'latitudeFloat': '4512.145', 'latitudeHemisphere': 'S'},
     391            {'latitude': base.Coordinate(-(45 + 12.145/60), 'latitude')})
     392
     393
     394    def test_coordinate_east(self):
     395        self._genericFixerTest(
     396            {'longitudeFloat': '5331.513', 'longitudeHemisphere': 'E'},
     397            {'longitude': base.Coordinate(53 + 31.513/60, 'longitude')})
     398
     399       
     400    def test_coordinate_west(self):
     401        self._genericFixerTest(
     402            {'longitudeFloat': '1245.120', 'longitudeHemisphere': 'W'},
     403            {'longitude': base.Coordinate(-(12 + 45.12/60), 'longitude')})
     404
     405
     406    def test_fixHemisphereSignBadHemispheres(self):
     407        self._genericFixerRaisingTest({'longitudeHemisphere': 'Q'},
     408                                      ValueError)
     409
     410
     411    def test_fixAltitude(self):
     412        self._genericFixerTest(
     413            {'altitude': '545.4'},
     414            {'altitude': base.Altitude(545.4)})
     415
     416        self._genericFixerTest(
     417            {'heightOfGeoidAboveWGS84': '46.9'},
     418            {'heightOfGeoidAboveWGS84': base.Altitude(46.9)})
     419
     420
     421    def test_validation(self):
     422        """
     423        Tests validation of sentences.
     424
     425        Invalid sentences will cause the state to be cleared. The altitude is
     426        added so we have junk data (that will hopefully be removed, since the
     427        GPS is telling us that this data is invalid).
     428        """
     429        self._genericFixerTest(
     430            {'altitude': '545.4', 'validGGA': nmea.GGA_GPS_FIX},
     431            {'altitude': base.Altitude(545.4)})
     432        self._genericFixerTest(
     433            {'altitude': '545.4', 'validGGA': nmea.GGA_INVALID},
     434            {})
     435
     436        self._genericFixerTest(
     437            {'altitude': '545.4', 'validGLL': nmea.GLL_VALID},
     438            {'altitude': base.Altitude(545.4)})
     439        self._genericFixerTest(
     440            {'altitude': '545.4', 'validGLL': nmea.GLL_INVALID},
     441            {})
     442
     443
     444    def test_speedInKnots(self):
     445        self._genericFixerTest(
     446            {'speedInKnots': '10'},
     447            {'speed': base.Speed(10 * base.MPS_PER_KNOT)})
     448
     449
     450    def test_magneticVariation_west(self):
     451        self._genericFixerTest(
     452            {'magneticVariation': '1.34', 'magneticVariationDirection': 'W'},
     453            {'heading': base.Heading(variation=-1.34)})
     454
     455       
     456    def test_magneticVariation_east(self):
     457        self._genericFixerTest(
     458            {'magneticVariation': '1.34', 'magneticVariationDirection': 'E'},
     459            {'heading': base.Heading(variation=1.34)})
     460
     461
     462    def test_headingPlusMagneticVariation(self):
     463
     464        self._genericFixerTest(
     465            {'trueHeading': '123.12',
     466             'magneticVariation': '1.34', 'magneticVariationDirection': 'E'},
     467            {'heading': base.Heading(123.12, variation=1.34)})
     468
     469
     470    def test_positioningError(self):
     471        self._genericFixerTest(
     472            {'horizontalDilutionOfPrecision': '11'},
     473            {'positioningError': base.PositioningError(hdop=11.)})
     474
     475
     476    def test_positioningError_mixing(self):
     477        self._genericFixerTest(
     478            {'positionDilutionOfPrecision': '1',
     479             'horizontalDilutionOfPrecision': '1',
     480             'verticalDilutionOfPrecision': '1'},
     481            {'positioningError': base.PositioningError(pdop=1., hdop=1., vdop=1.)})
     482
     483
     484    def _genericFixerTest(self, sentenceData, expected):
     485        sentence = nmea.NMEASentence(sentenceData)
     486        self.adapter.sentenceReceived(sentence)
     487        self.assertEquals(self.adapter._state, expected)
     488        self.adapter.clear()
     489
     490
     491    def _genericFixerRaisingTest(self, sentenceData, exceptionClass):
     492        sentence = nmea.NMEASentence(sentenceData)
     493        self.assertRaises(exceptionClass,
     494        self.adapter.sentenceReceived, sentence)
     495        self.adapter.clear()
     496
     497
     498class MockNMEAReceiver(base.BasePositioningReceiver):
     499    """
     500    A mock NMEA receiver.
     501    """
     502    def __init__(self):
     503        self.clear()
     504
     505        for methodName in ipositioning.IPositioningReceiver:
     506            setattr(self, methodName, self._callbackForName(methodName))
     507
     508
     509    def clear(self):
     510        self.called = {}
     511
     512
     513    def _callbackForName(self, name):
     514        def setter(**_):
     515            self.called[name] = True
     516        return setter
     517
     518
     519
     520class NMEAReceiverTest(TestCase):
     521    """
     522    Tests for NMEAReceivers.
     523    """
     524    def setUp(self):
     525        self.receiver = MockNMEAReceiver()
     526        self.adapter = nmea.NMEAAdapter(self.receiver)
     527        self.protocol = nmea.NMEAProtocol(self.adapter)
     528
     529
     530    def test_positioningErrorUpdateAcrossStates(self):
     531        """
     532        Tests that the positioning error is updated across multiple states.
     533        """
     534        sentences = [
     535        '$GPGSA,A,3,19,28,14,18,27,22,31,39,,,,,1.7,1.0,1.3*34',
     536        '$GPGSV,3,1,11,19,03,111,00,04,15,270,00,06,01,010,00,31,06,292,00*7f',
     537        '$GPGSV,3,2,11,28,25,170,00,14,57,208,39,18,67,296,40,39,40,246,00*7b',
     538        '$GPGSV,3,3,11,22,42,067,42,27,14,311,43,27,05,244,00,,,,*4e',
     539        ]
     540        callbacksFired = set(['positioningErrorReceived',
     541                              'beaconInformationReceived'])
     542
     543        self._genericSentenceReceivedTest(sentences, callbacksFired)
     544
     545
     546    def test_GGASentences(self):
     547        sentences = [
     548        '$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47',
     549        ]
     550        callbacksFired = set(['positionReceived',
     551                              'positioningErrorReceived',
     552                              'altitudeReceived',
     553                              'timeReceived'])
     554        self._genericSentenceReceivedTest(sentences, callbacksFired)
     555
     556
     557    def test_RMCSentences(self):
     558        sentences = [
     559        '$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A',
     560        ]
     561        callbacksFired = set(['headingReceived',
     562                              'speedReceived',
     563                              'positionReceived',
     564                              'timeReceived'])
     565
     566        self._genericSentenceReceivedTest(sentences, callbacksFired)
     567
     568
     569    def test_GSVSentences_incompleteSequence(self):
     570        """
     571        Verifies that an incomplete sequence of GSV sentences does not fire.
     572        """
     573        sentences=[
     574        '$GPGSV,3,1,11,03,03,111,00,04,15,270,00,06,01,010,00,13,06,292,00*74',
     575        ]
     576
     577        self._genericSentenceReceivedTest(sentences, set([]))
     578
     579
     580    def test_GSVSentences(self):
     581        """
     582        Verifies that a complete sequence of GSV sentences fires.
     583        """
     584        sentences=[
     585        '$GPGSV,3,1,11,03,03,111,00,04,15,270,00,06,01,010,00,13,06,292,00*74',
     586        '$GPGSV,3,2,11,14,25,170,00,16,57,208,39,18,67,296,40,19,40,246,00*74',
     587        '$GPGSV,3,3,11,22,42,067,42,24,14,311,43,27,05,244,00,,,,*4D',
     588        ]
     589
     590        callbacksFired = set(['beaconInformationReceived'])
     591
     592        lambda self: self.assertNotIn(
     593            '_partialBeaconInformation', self.adapter._state)
     594
     595
     596        self._genericSentenceReceivedTest(sentences, callbacksFired,
     597            beforeClearCondition=lambda self: self.assertNotIn(
     598                '_partialBeaconInformation', self.adapter._state))
     599
     600
     601
     602    def test_GLLSentences(self):
     603        sentences=[
     604            '$GPGLL,3751.65,S,14507.36,E*77',
     605            '$GPGLL,4916.45,N,12311.12,W,225444,A*31',
     606        ]
     607        callbacksFired = set(['positionReceived', 'timeReceived'])
     608
     609        self._genericSentenceReceivedTest(sentences, callbacksFired)
     610
     611
     612    def test_HDTSentences(self):
     613        sentences=[
     614            "$GPHDT,038.005,T*3B",
     615        ]
     616        callbacksFired = set(['headingReceived'])
     617
     618        self._genericSentenceReceivedTest(sentences, callbacksFired)
     619
     620
     621    def test_mixedSentences(self):
     622        sentences = [
     623        '$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A',
     624        '$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47',
     625        ]
     626        callbacksFired = set(['altitudeReceived',
     627                              'speedReceived',
     628                              'positionReceived',
     629                              'positioningErrorReceived',
     630                              'timeReceived',
     631                              'headingReceived'])
     632        self._genericSentenceReceivedTest(sentences, callbacksFired)
     633
     634
     635    def test_mixesSentences_withBeaconInformationAndVisibility(self):
     636        sentences = [
     637        '$GPGSA,A,3,16,4,13,18,27,22,31,39,,,,,1.7,1.0,1.3*',
     638        '$GPGSV,3,1,11,03,03,111,00,04,15,270,00,06,01,010,00,13,06,292,00*74',
     639        '$GPGSV,3,2,11,14,25,170,00,16,57,208,39,18,67,296,40,19,40,246,00*74',
     640        '$GPGSV,3,3,11,22,42,067,42,24,14,311,43,27,05,244,00,,,,*4D',
     641        '$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A',
     642        '$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47',
     643        '$GPGLL,4916.45,N,12311.12,W,225444,A*31',
     644        ]
     645        callbacksFired = set(['headingReceived',
     646                              'beaconInformationReceived',
     647                              'speedReceived',
     648                              'positionReceived',
     649                              'timeReceived',
     650                              'altitudeReceived',
     651                              'positioningErrorReceived'])
     652        self._genericSentenceReceivedTest(sentences, callbacksFired)
     653
     654
     655    def _genericSentenceReceivedTest(self, sentences, callbacksFired,
     656                                     beforeClearCondition=lambda _: None):
     657        for sentence in sentences:
     658            self.protocol.lineReceived(sentence)
     659
     660        self.assertEquals(set(self.receiver.called.keys()), callbacksFired)
     661
     662        beforeClearCondition(self)
     663
     664        self.receiver.clear()
     665        self.adapter.clear()