Repository: akolar/ogn-lib

Issue:
Rewrite parser engine in regex
Rewrite all parser functions that use string manipulation for parsing messages and replace them with regular expressions. 

Advantages include:

- more robust and extendable
- faster than the current implementation

Discussion:
codecov-io: # [Codecov](https://codecov.io/gh/akolar/ogn-lib/pull/9?src=pr&el=h1) Report
> Merging [#9](https://codecov.io/gh/akolar/ogn-lib/pull/9?src=pr&el=desc) into [develop](https://codecov.io/gh/akolar/ogn-lib/commit/a78134bedadbeba05ef3c4a514dd205fcd80300a?src=pr&el=desc) will **decrease** coverage by `0.57%`.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/akolar/ogn-lib/pull/9/graphs/tree.svg?height=150&width=650&src=pr&token=h0RKAqOWA9)](https://codecov.io/gh/akolar/ogn-lib/pull/9?src=pr&el=tree)

```diff
@@             Coverage Diff             @@
##           develop       #9      +/-   ##
===========================================
- Coverage    98.78%   98.21%   -0.58%     
===========================================
  Files            4        4              
  Lines          413      392      -21     
===========================================
- Hits           408      385      -23     
- Misses           5        7       +2
```


| [Impacted Files](https://codecov.io/gh/akolar/ogn-lib/pull/9?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [ogn\_lib/parser.py](https://codecov.io/gh/akolar/ogn-lib/pull/9/diff?src=pr&el=tree#diff-b2duX2xpYi9wYXJzZXIucHk=) | `99.17% <100%> (-0.83%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/akolar/ogn-lib/pull/9?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/akolar/ogn-lib/pull/9?src=pr&el=footer). Last update [a78134b...b6490ea](https://codecov.io/gh/akolar/ogn-lib/pull/9?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/ogn_lib/parser.py b/ogn_lib/parser.py
index 4373711..f43f6cb 100644
--- a/ogn_lib/parser.py
+++ b/ogn_lib/parser.py
@@ -1,6 +1,7 @@
 import collections
 import functools
 import logging
+import re
 from datetime import datetime, time, timedelta
 
 from ogn_lib import constants, exceptions
@@ -43,6 +44,8 @@ class ParserBase(type):
             for c in callsigns:
                 logger.debug('Setting %s as a parser for %s messages', name, c)
                 meta.parsers[c] = class_
+        elif callsigns is None:
+            pass
         else:
             raise TypeError('instance of __destto__ should be either a sequence'
                             'or a string; is {}'.format(type(callsigns)))
@@ -69,7 +72,7 @@ class ParserBase(type):
             _, body = raw_message.split('>', 1)
             destto, *_ = body.split(',', 1)
 
-            if 'TCPIP*' in body or ':>' in body or 'qAC' in body:  # server message
+            if 'TCPIP*' in body:
                 return ServerParser.parse_message(raw_message)
 
             try:
@@ -102,33 +105,75 @@ class Parser(metaclass=ParserBase):
     Base class for all parser classes.
 
     Implements parsing of APRS message header and calls the populates the data
-    with the values returned by the _parse_comment(comment) of the extending
-    class.
+    with the values returned by the _parse_protocol_specific(comment) of the
+    extending class.
     """
 
     __default__ = True
 
+    # TNC-2 formatted header (p. 84)
+    PATTERN_HEADER = re.compile('(?P<source>.{1,9})'
+                                '>(?P<destination>.{1,9}?)'
+                                '(,(?P<digipeaters>.{0,80}))'
+                                ':(?P<data>.*?)$')
+
+    # Lat/Long Position Report Format - with Timestamp (p. 32)
+    PATTERN_LOCATION = re.compile('(@|/)'
+                                  '(?P<time>\d{6}(z|h))'
+                                  '(?P<latitude>\d{4}\.\d{2}(N|S))'
+                                  '(/|\\\\|I)(?P<longitude>\d{5}\.\d{2}(E|W))')
+
+    PATTERN_COMMENT_COMON = re.compile('((?P<heading>\d{3})/(?P<speed>\d{3}))?'
+                                       '(/A=(?P<altitude>\d{6}))?'
+                                       '( (?P<protocol_specific>.*?))?$')
+
+    # Merged header and position
+    PATTERN_ALL = re.compile('(?P<source>.{1,9})>'
+                             '(?P<destination>.{1,9}?)'
+                             '(,(?P<digipeaters>.{0,81})):'
+                             '(@|/)'
+                             '(?P<time>\d{6}(z|h))'
+                             '(?P<latitude>\d{4}\.\d{2}(N|S))'
+                             '.(?P<longitude>\d{5}\.\d{2}(E|W))'
+                             '.((?P<heading>\d{3})/(?P<speed>\d{3}))?'
+                             '(/A=(?P<altitude>\d{6}))?'
+                             '( (?P<protocol_specific>.*?))?$')
+
     @classmethod
     def parse_message(cls, raw_message):
         """
-        Parses the fields of a raw APRS message to a dictionary.
+        Parses the fields of a raw APRS message to a dictionary. Returns
+        none if message could not be parsed.
 
         :param str raw_message: raw APRS message
-        :return: parsed message
-        :rtype: dict
+        :return: parsed message or None if the message failed to parse
+        :rtype: dict or None
+        :raises ogn_lib.exceptions.ParseError: if message cannot be parsed
+            using Parser.PATTERN_ALL
         """
 
-        from_, body = raw_message.split('>', 1)
-        header, *comment = body.split(' ', 1)
+        match = cls.PATTERN_ALL.match(raw_message)
+
+        if not match:
+            raise exceptions.ParseError('Message {} did not match {}'
+                                        .format(raw_message, cls.PATTERN_ALL))
 
         data = {
-            'from': from_,
-            'beacon_type': constants.BeaconType.aircraft_beacon
+            'from': match.group('source'),
+            'destto': match.group('destination'),
+            'beacon_type': constants.BeaconType.aircraft_beacon,
+            'timestamp': Parser._parse_timestamp(match.group('time')),
+            'latitude': Parser._parse_location(match.group('latitude')),
+            'longitude': Parser._parse_location(match.group('longitude')),
+            'altitude': Parser._parse_altitude(match.group('altitude'))
         }
-        data.update(Parser._parse_header(header))
+        data.update(Parser._parse_digipeaters(match.group('digipeaters')))
+        data.update(Parser._parse_heading_speed(match.group('heading'),
+                                                match.group('speed')))
 
-        if comment:
-            comment_data = cls._parse_comment(comment[0])
+        protocol_specific = match.group('protocol_specific')
+        if protocol_specific:
+            comment_data = cls._parse_protocol_specific(protocol_specific)
 
             try:
                 cls._update_data(data, comment_data['_update'])
@@ -142,117 +187,77 @@ class Parser(metaclass=ParserBase):
         return data
 
     @staticmethod
-    def _parse_header(header):
+    def _parse_digipeaters(digipeaters):
         """
-        Parses the APRS message header.
-
-        :param str header: APRS message between the '[callsign]>' and comment
-                           field
-        :param str pos_separator: separator for latitude and longitude
-        :param str attrs_separator: separator for attributes substring
-        :return: parsed header
-        :rtype: dict
-        """
-
-        col_idx = header.find(':')
-        origin = header[:col_idx]
-        position = header[col_idx + 2:]
+        Parses the content of the APRS digipeaters field and extracts the
+        information about the receiver and the relayer.
 
-        data = Parser._parse_origin(origin)
-        data.update(Parser._parse_position(position))
-
-        return data
-
-    @staticmethod
-    def _parse_origin(header):
-        """
-        Parses the destto, receiver and relayer field of the APRS message.
-        :param str header: APRS message between the '[callsign]>' and position
-                           information
-        :return: parsed origin part of the APRS message
+        :param str digipeaters: digipeaters string from the original message
+        :return: dictionary with the receiver and the relayer
         :rtype: dict
+        :raises ValueError: if digipeaters string is in invalid format
         """
 
-        fields = header.split(',')
+        fields = digipeaters.split(',')
 
-        if len(fields) == 3:  # standard message
+        if len(fields) == 2:  # standard message
             relayer = None
-        elif len(fields) == 4:  # relayed message
-            relayer = fields[1].strip('*')
+        elif len(fields) == 3:  # relayed message
+            relayer = fields[0].strip('*')
         else:
-            raise ValueError('Unknown header format: {}'.format(header))
-
-        data = {'destto': fields[0], 'receiver': fields[-1], 'relayer': relayer}
+            raise ValueError('Unknown digipeaters format: {}'
+                             .format(digipeaters))
 
-        return data
+        return {'receiver': fields[-1], 'relayer': relayer}
 
     @staticmethod
-    def _parse_position(pos_header):
+    def _parse_heading_speed(heading, speed):
         """
-        Parses the position information, timestamp, altitude, heading and
-        ground speed from an APRS message.
+        Parses and converts the heading and speed from the original message
+        to the appropriate units.
 
-        :param str pos_header: position part of the APRS header
-        :param str pos_separator: separator for latitude and longitude
-        :param str attrs_separator: separator for attributes substring
-        :return: parsed position part of the APRS message
+        :param str heading: heading string
+        :param str speed: speed string
+        :return: dictionary containing the heading and ground speed in m/s
         :rtype: dict
         """
 
-        timestamp = pos_header[0:7]
-        lat = pos_header[7:15]
-        lon = pos_header[16:25]
-        attrs = pos_header[26:]
+        if not heading or not speed:
+            return {}
 
-        data = {
-            'timestamp': Parser._parse_timestamp(timestamp),
-            'latitude': Parser._parse_location(lat),
-            'longitude': Parser._parse_location(lon),
-        }
-        data.update(Parser._parse_attributes(attrs))
-
-        return data
-
-    @staticmethod
-    def _parse_attributes(attributes):
-        """
-        Parses the APRS attributes for heading, ground speed and altitude.
+        hdg = int(heading)
+        gsp = int(speed)
 
-        :param str attributes: attributes part of the APRS message
-        :return: parsed attributes
-        :rtype: dict
-        """
-
-        attrs = attributes.split('/')
         data = {}
 
-        if attrs[-1].startswith('A='):  # has altitude
-            data['altitude'] = int(attrs[-1][2:]) * FEET_TO_METERS
-        else:
-            data['altitude'] = None
-
-        if len(attrs) > 1 and attributes[0] != '/':  # i.e., format is hdg/gsp/?
-            heading = int(attrs[0])
-            speed = int(attrs[1])
-        else:
-            heading = None
-            speed = None
-
-        if heading or speed:
-            data['heading'] = heading
-            data['ground_speed'] = speed * KNOTS_TO_MS
+        if hdg or gsp:
+            data['heading'] = hdg
+            data['ground_speed'] = gsp * KNOTS_TO_MS
         else:
             data['heading'] = None
             data['ground_speed'] = None
 
         return data
 
+    @staticmethod
+    def _parse_altitude(altitude_string):
+        """
+        Parses the altitude string and converts it from feet to meters.
+
+        :param altitude_string: the altitude string
+        :type altitude_string: str or None
+        :return: altitude in meters or None if altitude_str is not given
+        :rtype: float or None
+        """
+
+        return int(altitude_string) * FEET_TO_METERS if altitude_string else None
+
     @staticmethod
     def _parse_timestamp(timestamp_str):
         """
-        Parses the UTC timestamp of an APRS package.
+        Parses the timestamp of an APRS package.
 
-        :param timestamp_str: utc timestamp string in %H%M%S or %d%H%M format
+        :param str timestamp_str: timestamp string in %H%M%S or %d%H%M format
         :return: parsed timestamp
         :rtype: datetime.datetime
         """
@@ -318,6 +323,9 @@ class Parser(metaclass=ParserBase):
         :rtype: float
         """
 
+        if not location_str:
+            return None
+
         sphere = location_str[-1]
         offset = 2 if sphere in ('N', 'S') else 3
 
@@ -330,7 +338,7 @@ class Parser(metaclass=ParserBase):
         return location
 
     @staticmethod
-    def _parse_comment(comment):
+    def _parse_protocol_specific(comment):
         """
         Parses the comment string from APRS messages.
 
@@ -339,7 +347,7 @@ class Parser(metaclass=ParserBase):
         :rtype: dict
         """
 
-        logger.warn('Parser._parse_comment method not overriden')
+        logger.warn('Parser._parse_protocol_specific method not overriden')
         return {}
 
     @staticmethod
@@ -411,7 +419,7 @@ class APRS(Parser):
     FLAGS_ADDRESS_TYPE = 0b11
 
     @staticmethod
-    def _parse_comment(comment):
+    def _parse_protocol_specific(comment):
         """
         Parses the comment string from APRS messages.
 
@@ -510,7 +518,7 @@ class Naviter(Parser):
     FLAGS_ADDRESS_TYPE = 0b111111 << 4
 
     @staticmethod
-    def _parse_comment(comment):
+    def _parse_protocol_specific(comment):
         """
         Parses the comment string from Naviter's APRS messages.
 
@@ -570,77 +578,52 @@ class Naviter(Parser):
         }
 
 
-class ServerParser:
+class ServerParser(Parser):
     """
     Parser for server messages.
     """
 
-    @staticmethod
-    def parse_message(raw_message):
-        """
-        Passes a server message to an appropriate parser.
-
-        :param raw_message: APRS message
-        :return: parsed data
-        :rtype: dict
-        """
-
-        if 'CPU' in raw_message or ':>' in raw_message:
-            data = ServerParser.parse_status(raw_message)
-        else:
-            data = ServerParser.parse_beacon(raw_message)
+    __destto__ = None
 
-        data['raw'] = raw_message
-        return data
+    PATTERN_ALL = re.compile('(?P<source>.{1,9})>'
+                             '(?P<destination>.{1,9}?)'
+                             '(,(?P<digipeaters>.{0,81})):'
+                             '(@|/|>)'
+                             '(?P<time>\d{6}(z|h))'
+                             '((?P<latitude>\d{4}\.\d{2}(N|S))'
+                             '.(?P<longitude>\d{5}\.\d{2}(E|W)))?'
+                             '(.((?P<heading>\d{3})/(?P<speed>\d{3}))?'
+                             '(/A=(?P<altitude>\d{6}))?)?'
+                             '( (?P<protocol_specific>.*?))?$')
 
-    @staticmethod
-    def parse_beacon(raw_message):
+    @classmethod
+    def parse_message(cls, raw_message):
         """
-        Parses server beacon messages.
+        Parses the server message using the Parser.parse_message.
 
-        :param raw_message: APRS message
-        :return: parsed data
+        :param str raw_message: raw APRS message
+        :return: parsed message
         :rtype: dict
         """
 
-        from_, header = raw_message.split('>', 1)
-        position, *comment = header.split(' ', 1)
-
-        data = {
-            'from': from_,
-            'beacon_type': constants.BeaconType.server_beacon
-        }
+        data = super().parse_message(raw_message)
 
-        data.update(Parser._parse_header(position))
-
-        data['comment'] = comment[0] if comment else None
+        if 'comment' in data:
+            data['beacon_type'] = constants.BeaconType.server_status
+        else:
+            data['beacon_type'] = constants.BeaconType.server_beacon
 
         return data
 
     @staticmethod
-    def parse_status(raw_message):
+    def _parse_protocol_specific(comment):
         """
-        Parses server status messages.
+        Converts the comment field from the server status message to a format
+        expected by Parser.parse_message
 
-        :param raw_message: APRS message
-        :return: parsed data
+        :param str comment: status comment
+        :return: dictionary with the comment
         :rtype: dict
         """
 
-        from_, body = raw_message.split('>', 1)
-        header, comment = body.split(' ', 1)
-
-        sep_idx = header.find(':')
-        origin = header[:sep_idx]
-        timestamp = header[sep_idx + 2:sep_idx + 9]
-
-        data = {
-            'from': from_,
-            'beacon_type': constants.BeaconType.server_status
-        }
-
-        data.update(Parser._parse_origin(origin))
-        data['timestamp'] = Parser._parse_timestamp(timestamp)
-        data['comment'] = comment
-
-        return data
+        return {'comment': comment}


Test patch:
diff --git a/tests/messages.txt b/tests/messages.txt
new file mode 100644
index 0000000..72b0c93
--- /dev/null
+++ b/tests/messages.txt
@@ -0,0 +1,82 @@
+FLRDDA5BA>APRS,qAS,LFMX:/165829h4415.41N/00600.03E'342/049/A=005524 id0ADDA5BA -454fpm -1.1rot 8.8dB 0e +51.2kHz gps4x5
+ICA4B0E3A>APRS,qAS,Letzi:/165319h4711.75N\00802.59E^327/149/A=006498 id154B0E3A -3959fpm +0.5rot 9.0dB 0e -6.3kHz gps1x3
+FLRDDB091>APRS,qAS,Letzi:/165831h4740.04N/00806.01EX152/124/A=004881 id06DD8E80 +198fpm +0.0rot 6.5dB 13e +4.0kHz gps3x4
+FLRDDDD33>APRS,qAS,LFNF:/165341h4344.27N/00547.41E'/A=000886 id06DDDD33 +020fpm +0.0rot 20.8dB 0e -14.3kHz gps3x4
+FLRDDE026>APRS,qAS,LFNF:/165341h4358.58N/00553.89E'204/055/A=005048 id06DDE026 +257fpm +0.1rot 7.2dB 0e -0.8kHz gps4x7
+ICA484A9C>APRS,qAS,LFMX:/165341h4403.50N/00559.67E'/A=001460 id05484A9C +000fpm +0.0rot 18.0dB 0e +3.5kHz gps4x7
+OGNE95A16>APRS,qAS,Sylwek:/165641h5001.94N/01956.91E'270/004/A=000000 id07E95A16 +000fpm +0.1rot 37.8dB 0e -0.4kHz
+ZK-GSC>APRS,qAS,Omarama:/165202h4429.25S/16959.33E'/A=001407 id05C821EA +020fpm +0.0rot 16.8dB 0e -3.1kHz gps1x3 hear1084 hearB597 hearB598
+ICA3ECE59>APRS,qAS,GLDRTR:/171254h5144.78N/00616.67E'263/000/A=000075 id093D0930 +000fpm +0.0rot
+ICA3ECE59>APRS,qAS,GLDRTR:/171254h5144.78N/00616.67E'263/000/A=000075 id053ECE59
+Lachens>APRS,TCPIP*,qAC,GLIDERN2:/165334h4344.70NI00639.19E&/A=005435 v0.2.1 CPU:0.3 RAM:1764.4/2121.4MB NTP:2.8ms/+4.9ppm +47.0C RF:+0.70dB
+LFGU>APRS,TCPIP*,qAC,GLIDERN2:/165556h4907.63NI00706.41E&/A=000833 v0.2.0 CPU:0.9 RAM:281.3/458.9MB NTP:0.5ms/-19.1ppm +53.0C RF:+0.70dB
+LSGS>APRS,TCPIP*,qAC,GLIDERN1:/165345h4613.25NI00719.68E&/A=001581 CPU:0.7 RAM:247.9/456.4MB NTP:0.7ms/-11.4ppm +44.4C RF:+53+71.9ppm/+0.4dB
+WolvesSW>APRS,TCPIP*,qAC,GLIDERN2:/165343h5232.23NI00210.91W&/A=000377 CPU:1.5 RAM:159.9/458.7MB NTP:6.6ms/-36.7ppm +45.5C RF:+130-0.4ppm/-0.1dB
+Oxford>APRS,TCPIP*,qAC,GLIDERN1:/165533h5142.96NI00109.68W&/A=000380 v0.1.3 CPU:0.9 RAM:268.8/458.6MB NTP:0.5ms/-45.9ppm +60.5C RF:+55+2.9ppm/+1.54dB
+Salland>APRS,TCPIP*,qAC,GLIDERN2:/165426h5227.93NI00620.03E&/A=000049 v0.2.2 CPU:0.7 RAM:659.3/916.9MB NTP:2.5ms/-75.0ppm RF:+0.41dB
+LSGS>APRS,TCPIP*,qAC,GLIDERN1:/165345h4613.25NI00719.68E&/A=001581 CPU:0.7 RAM:247.9/456.4MB NTP:0.7ms/-11.4ppm +44.4C RF:+53+71.9ppm/+0.4dB
+Drenstein>APRS,TCPIP*,qAC,GLIDERN1:/165011h5147.51NI00744.45E&/A=000213 v0.2.2 CPU:0.8 RAM:695.7/4025.5MB NTP:16000.0ms/+0.0ppm +63.0C
+VITACURA1>APRS,TCPIP*,qAC,GLIDERN3:/042149h3322.81SI07034.95W&/A=002345 v0.2.5.ARM CPU:0.6 RAM:694.4/970.5MB NTP:0.8ms/-7.5ppm +54.8C RF:+0-0.2ppm/+3.81dB
+Arnsberg>APRS,TCPIP*,qAC,GLIDERN1:/042146h5123.04NI00803.77E&/A=000623 v0.2.5.ARM CPU:0.4 RAM:765.1/970.8MB NTP:0.4ms/-1.7ppm +62.3C RF:+27+1.1ppm/+3.17dB
+CNF3a>APRS,TCPIP*,qAC,GLIDERN3:/042143h4529.25NI07505.65W&/A=000259 v0.2.5.ARM CPU:0.6 RAM:514.6/970.8MB NTP:4.5ms/-1.5ppm +27.2C RF:+0-0.4ppm/+18.69dB
+VITACURA2>APRS,TCPIP*,qAC,GLIDERN3:/042136h3322.81SI07034.95W&/A=002345 v0.2.5.ARM CPU:0.3 RAM:695.0/970.5MB NTP:0.6ms/-5.7ppm +51.5C RF:+0-0.0ppm/+1.32dB
+ROBLE3>APRS,TCPIP*,qAC,GLIDERN4:/200022h3258.58SI07100.78W&/A=007229 Contact: achanes@manquehue.net, brito.felipe@gmail.com
+ALFALFAL>APRS,TCPIP*,qAC,GLIDERN4:/221830h3330.40SI07007.88W&/A=008659 Alfalfal Hidroelectric Plant, Club de Planeadores Vitacurs
+FLRDDEEF1>OGCAPT,qAS,CAPTURS:/062744h4845.03N/00230.46E'000/000
+FLRDDEEF1>OGCAPT,qAS,CAPTURS:/064243h4839.64N/00236.78E'000/085/A=000410
+FLRDDEEF1>OGCAPT,qAS,CAPTURS:/064548h4838.87N/00234.03E'000/042/A=000377
+FLRDDEEF1>OGCAPT,qAS,CAPTURS:/064847h4837.95N/00234.36E'000/000
+FLRDDEEF1>OGCAPT,qAS,CAPTURS:/065144h4837.56N/00233.80E'000/000
+FLRDDEEF1>OGCAPT,qAS,CAPTURS:/065511h4837.63N/00233.79E'000/000
+FLRDDEEF1>OGCAPT,qAS,CAPTURS:/070016h4837.63N/00233.77E'000/001/A=000360
+FLRDDEEF1>OGCAPT,qAS,CAPTURS:/070153h4837.62N/00233.77E'000/001/A=000344
+FLRDD89C9>OGFLR,qAS,LIDH:/115054h4543.22N/01132.84E'260/072/A=002542 !W10! id06DD89C9 +198fpm -0.8rot 7.0dB 0e +0.7kHz gps2x3
+FLRDD98C6>OGFLR,qAS,LIDH:/115054h4543.21N/01132.80E'255/074/A=002535 !W83! id0ADD98C6 +158fpm -1.8rot 10.5dB 0e -0.8kHz gps2x3 s6.09 h02
+ICAA8CBA8>OGFLR,qAS,MontCAIO:/231150z4512.12N\01059.03E^192/106/A=009519 !W20! id21A8CBA8 -039fpm +0.0rot 3.5dB 2e -8.7kHz gps1x2 s6.09 h43 rDF0267
+ICAA8CBA8>OGFLR,qAS,MontCAIO:/114949h4512.44N\01059.12E^190/106/A=009522 !W33! id21A8CBA8 -039fpm +0.1rot 4.5dB 1e -8.7kHz gps1x2 +14.3dBm
+ICA3D1C35>OGFLR,qAS,Padova:/094220h4552.41N/01202.28E'110/099/A=003982 !W96! id053D1C35 -1187fpm +0.0rot 0.8dB 2e +4.5kHz gps1x2 s6.09 h32 rDD09D0
+FLRDDE48A>OGLT24,qAS,LT24:/102606h4030.47N/00338.38W'000/018/A=002267 id25387 +000fpm GPS
+FLRDDE48A>OGLT24,qAS,LT24:/102608h4030.47N/00338.38W'044/018/A=002270 id25387 +000fpm GPS
+FLRDDE48A>OGLT24,qAS,LT24:/102611h4030.47N/00338.38W'108/000/A=002280 id25387 +001fpm GPS
+FLRDDE48A>OGLT24,qAS,LT24:/102612h4030.47N/00338.38W'000/000/A=002280 id25387 +000fpm GPS
+FLRDDE48A>OGLT24,qAS,LT24:/102615h4030.47N/00338.39W'224/003/A=002280 id25387 +000fpm GPS
+FLRDDE48A>OGLT24,qAS,LT24:/102616h4030.47N/00338.38W'028/003/A=002250 id25387 -009fpm GPS
+FLRDDE48A>OGLT24,qAS,LT24:/102621h4030.47N/00338.38W'142/001/A=002267 id25387 +001fpm GPS
+FLRDDE48A>OGLT24,qAS,LT24:/102628h4030.47N/00338.38W'034/000/A=002263 id25387 +000fpm GPS
+FLRDDE48A>OGLT24,qAS,LT24:/102717h4030.47N/00338.38W'000/000/A=002263 id25387 +000fpm GPS
+FLRDDE48A>OGLT24,qAS,LT24:/110453h4030.47N/00338.38W'154/004/A=002253 id25387 +000fpm GPS
+NAV042121>OGNAVI,qAS,NAVITER:/140648h4550.36N/01314.85E'090/152/A=001086 !W47! id0440042121 +000fpm +0.5rot
+NAV04220E>OGNAVI,qAS,NAVITER:/140748h4552.27N/01155.61E'090/012/A=006562 !W81! id044004220E +060fpm +1.2rot
+NAV07220E>OGNAVI,qAS,NAVITER:/125447h4557.77N/01220.19E'258/056/A=006562 !W76! id1C4007220E +180fpm +0.0rot
+FLRFFFFFF>OGNAVI,NAV07220E*,qAS,NAVITER:/092002h1000.00S/01000.00W'000/000/A=003281 !W00! id2820FFFFFF +300fpm +1.7rot
+MontCAIO>OGNSDR,TCPIP*,qAC,GLIDERN3:/132231h4427.84NI01009.60E&/A=004822
+Padova>OGNSDR,TCPIP*,qAC,GLIDERN1:/132326h4525.38NI01156.29E&/A=000069
+LIDH>OGNSDR,TCPIP*,qAC,GLIDERN1:/132447h4540.89NI01129.65E&/A=000328
+LZHL>OGNSDR,TCPIP*,qAC,GLIDERN3:/132457h4849.09NI01708.30E&/A=000528
+BELG>OGNSDR,TCPIP*,qAC,GLIDERN3:/132507h4509.60NI00919.20E&/A=000246
+Saleve>OGNSDR,TCPIP*,qAC,GLIDERN1:/132624h4607.70NI00610.41E&/A=004198 Antenna: chinese, on a pylon, 20 meter above ground
+OGN2FD00F>OGNTRK,qAS,LZHL:/093213h4848.78N/01708.32E'000/000/A=000538 !W12! id072FD00F -058fpm +0.0rot FL003.12 32.8dB 0e -0.8kHz gps3x5
+FLRDD9C70>OGNTRK,OGN2FD00F*,qAS,LZHL:/093214h4848.77N/01708.33E'000/000/A=000515 !W56! id06DD9C70 -019fpm +0.0rot 32.2dB 0e -0.8kHz gps2x3
+FLRDD9C70>OGNTRK,OGN2FD00F*,qAS,LZHL:/093021h4848.77N/01708.33E'000/000/A=000518 !W66! id06DD9C70 -019fpm +0.0rot 29.0dB 0e -0.8kHz gps2x3 s6.09 h03
+OGN03AF2A>OGNTRK,qAS,LZHL:/092912h4848.77N/01708.33E'000/000/A=000535 !W53! id0703AF2A +000fpm +0.0rot FL003.15 4.5dB 1e -0.1kHz gps4x5 -11.2dBm
+FLRDD9C70>OGNTRK,RELAY*,qAS,LZHL:/094124h4848.78N/01708.33E'000/000/A=000397 !W15! id06DD9C70 +099fpm +0.0rot 24.5dB 0e -1.4kHz gps10x15
+FLRDDDD78>OGSKYL,qAS,SKYLINES:/134403h4225.90N/00144.83E'000/000/A=008438 id2816 +000fpm
+FLRDDF944>OGSPID,qAS,SPIDER:/190930h3322.78S/07034.60W'000/000/A=002263 id300234010617040 +19dB LWE 3D
+FLRDDF944>OGSPID,qAS,SPIDER:/190930h3322.78S/07034.60W'000/000/A=002263 id300234010617040 +19dB LWE 3D
+FLRDDF944>OGSPID,qAS,SPIDER:/192430h3322.78S/07034.61W'000/000/A=002250 id300234010617040 +12dB LWE 3D
+FLRDDF944>OGSPID,qAS,SPIDER:/193930h3322.10S/07034.26W'273/027/A=004071 id300234010617040 +9dB LWE 3D
+FLRDDF944>OGSPID,qAS,SPIDER:/195430h3322.82S/07034.90W'000/000/A=002217 id300234010617040 +10dB LWE 3D
+FLRDDF944>OGSPID,qAS,SPIDER:/193930h3322.78S/07034.60W'348/000/A=002286 id300234010617040 +12dB LWE 3D
+FLRDDF944>OGSPID,qAS,SPIDER:/195430h3323.16S/07037.68W'302/034/A=003316 id300234010617040 +10dB LWE 3D
+FLRDDF944>OGSPID,qAS,SPIDER:/195430h3323.16S/07037.68W'302/034/A=003316 id300234010617040 +10dB LWE 3D
+FLRDDF944>OGSPID,qAS,SPIDER:/200930h3319.13S/07036.12W'128/031/A=005482 id300234010617040 +15dB LWE 3D
+FLRDDF944>OGSPID,qAS,SPIDER:/200930h3319.13S/07036.12W'128/031/A=005482 id300234010617040 +15dB LWE 3D
+FLRDDF944>OGSPID,qAS,SPIDER:/202430h3314.92S/07032.08W'138/032/A=006453 id300234010617040 +9dB LWE 3D
+FLRDDF944>OGSPID,qAS,SPIDER:/203930h3321.38S/07027.29W'104/034/A=006272 id300234010617040 +8dB LWE 3D
+FLRDDF944>OGSPID,qAS,SPIDER:/205430h3322.13S/07033.53W'296/031/A=003927 id300234010617040 +7dB LWE 3D
+FLRDDF944>OGSPID,qAS,SPIDER:/210930h3322.05S/07035.74W'165/030/A=005187 id300234010617040 +8dB LWE 3D
+FLRDDF944>OGSPID,qAS,SPIDER:/212430h3322.02S/07036.14W'281/028/A=004550 id300234010617040 +7dB LWE 3D
+FLRDDF944>OGSPID,qAS,SPIDER:/213930h3322.17S/07033.97W'332/028/A=003428 id300234010617040 +7dB LWE 3D
+ICA3E7540>OGSPOT,qAS,SPOT:/161427h1448.35S/04610.86W'000/000/A=008677 id0-2860357 SPOT3 GOOD
+ICA3E7540>OGSPOT,qAS,SPOT:/162923h1431.99S/04604.33W'000/000/A=006797 id0-2860357 SPOT3 GOOD
+ICA3E7540>OGSPOT,qAS,SPOT:/163421h1430.38S/04604.43W'000/000/A=007693 id0-2860357 SPOT3 GOOD
diff --git a/tests/test_parser.py b/tests/test_parser.py
index 87108d4..cb9b05c 100644
--- a/tests/test_parser.py
+++ b/tests/test_parser.py
@@ -1,8 +1,25 @@
+import os
 import pytest
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, time
 from ogn_lib import exceptions, parser, constants
 
 
+def get_messages(n_messages=float('inf')):
+    test_dir = os.path.dirname(os.path.realpath(__file__))
+
+    with open(os.path.join(test_dir, 'messages.txt'), 'r') as f:
+        lines = f.readlines()
+
+    messages = []
+    for i, l in enumerate(lines):
+        if i >= n_messages:
+            break
+
+        messages.append(l.strip())
+
+    return messages
+
+
 class TestParserBase:
 
     def test_new_no_id(self):
@@ -19,11 +36,20 @@ class TestParserBase:
 
     def test_new_multi_id(self):
         class Callsign(parser.Parser):
-            __destto__ = ['CALL1234', 'CALL4321']
+            __destto__ = ['CALL234', 'CALL4321']
 
-        assert 'CALL1234' in parser.ParserBase.parsers
+        assert 'CALL234' in parser.ParserBase.parsers
         assert 'CALL4321' in parser.ParserBase.parsers
 
+    def test_no_destto(self):
+        old = parser.ParserBase.parsers
+
+        class Anon(parser.Parser):
+            __destto__ = None
+
+        assert parser.ParserBase.parsers == old
+        assert 'Anon' not in parser.ParserBase.parsers
+
     def test_new_wrong_id(self):
         with pytest.raises(TypeError):
             class Callsign(parser.Parser):
@@ -82,151 +108,171 @@ class TestParserBase:
 
 
 class TestParser:
-    def test_parse_msg_from(self, mocker):
-        msg = 'FROM12345>payload'
-        with mocker.patch('ogn_lib.parser.Parser._parse_header', return_value={}):
-            data = parser.Parser.parse_message(msg)
-            assert data['from'] == 'FROM12345'
-            assert data['raw'] == msg
-            assert data['beacon_type'] is constants.BeaconType.aircraft_beacon
+    messages = get_messages()
+
+    message = ("FLRDDA5BA>APRS,qAS,LFMX:/165829h4415.41N/00600.03E'342/049/A="
+               "005524 id0ADDA5BA -454fpm -1.1rot 8.8dB 0e+51.2kHz gps4x5")
+
+    expected_matches = {
+        'source': 'FLRDDA5BA',
+        'destination': 'APRS',
+        'digipeaters': 'qAS,LFMX',
+        'time': '165829h',
+        'latitude': '4415.41N',
+        'longitude': '00600.03E',
+        'altitude': '005524',
+        'speed': '049',
+        'heading': '342'
+    }
+
+    def _test_matches_all(self, pattern):
+        for msg in self.messages:
+            match = pattern.search(msg)
+            if not match:
+                raise Exception('Message not matched: {}'.format(msg))
+
+    def _test_pattern_field(self, pattern, field):
+        match = pattern.search(self.message)
+        assert match.group(field) == self.expected_matches[field]
+
+    def test_pattern_header(self):
+        for field in ['source', 'destination', 'digipeaters']:
+            self._test_pattern_field(parser.Parser.PATTERN_HEADER, field)
+
+        match = parser.Parser.PATTERN_HEADER.match(self.message)
+        assert match.group('data')
+
+    def test_pattern_header_matches_all(self):
+        self._test_matches_all(parser.Parser.PATTERN_HEADER)
+
+    def test_pattern_location(self):
+        for field in ['time', 'latitude', 'longitude']:
+            self._test_pattern_field(parser.Parser.PATTERN_LOCATION, field)
+
+    def test_pattern_location_matches_all(self):
+        self._test_matches_all(parser.Parser.PATTERN_LOCATION)
+
+    def test_pattern_comment_common(self):
+        for field in ['heading', 'speed', 'altitude']:
+            self._test_pattern_field(parser.Parser.PATTERN_COMMENT_COMON, field)
+
+    def test_pattern_comment_common_matches_all(self):
+        self._test_matches_all(parser.Parser.PATTERN_COMMENT_COMON)
+
+    def test_pattern_all(self):
+        for k in self.expected_matches.keys():
+            self._test_pattern_field(parser.Parser.PATTERN_ALL, k)
+
+    def test_pattern_all_matches_all(self):
+        self._test_matches_all(parser.Parser.PATTERN_ALL)
+
+    def test_parse_msg_no_match(self):
+        with pytest.raises(exceptions.ParseError):
+            parser.Parser.parse_message('invalid message')
+
+    def test_parse_msg_calls(self, mocker):
+        mocker.spy(parser.Parser, '_parse_timestamp')
+        mocker.spy(parser.Parser, '_parse_location')
+        mocker.spy(parser.Parser, '_parse_altitude')
+        mocker.spy(parser.Parser, '_parse_digipeaters')
+        mocker.spy(parser.Parser, '_parse_heading_speed')
+        mocker.spy(parser.Parser, '_parse_protocol_specific')
+
+        parser.Parser.parse_message(
+            'FLRDD83BC>APRS,qAS,EDLF:/163148h5124.56N/00634.42E\''
+            '276/075/A=001551')
+
+        parser.Parser._parse_timestamp.assert_called_once_with('163148h')
+        assert parser.Parser._parse_location.call_count == 2
+        parser.Parser._parse_altitude.assert_called_once_with('001551')
+        parser.Parser._parse_digipeaters.assert_called_once_with('qAS,EDLF')
+        parser.Parser._parse_heading_speed.assert_called_once_with('276', '075')
+        parser.Parser._parse_protocol_specific.assert_not_called()
 
     def test_parse_msg(self, mocker):
-        with mocker.patch('ogn_lib.parser.Parser._parse_header',
-                          return_value={}):
-            with mocker.patch('ogn_lib.parser.Parser._parse_comment',
-                              return_value={}):
-
-                parser.Parser.parse_message(
-                    'FLRDD83BC>APRS,qAS,EDLF:/163148h5124.56N/00634.42E\''
-                    '276/075/A=001551')
-                parser.Parser._parse_comment.assert_not_called()
-                parser.Parser._parse_header.assert_called_once_with(
-                    'APRS,qAS,EDLF:/163148h5124.56N/00634.42E\'276/075/A=001551')
+        data = parser.Parser.parse_message(
+            'FLRDD83BC>APRS,qAS,EDLF:/163148h5124.56N/00634.42E\''
+            '276/075/A=001551')
+
+        assert data['from'] == 'FLRDD83BC'
+        assert data['destto'] == 'APRS'
 
     def test_parse_msg_full(self, mocker):
         msg = ('NAV07220E>OGNAVI,qAS,NAVITER:/125447h4557.77N/01220.19E\'258/'
                '056/A=006562 !W76! id1C4007220E +180fpm +0.0rot')
 
-        with mocker.patch('ogn_lib.parser.Parser._parse_comment',
-                          return_value={}):
-            parser.Parser.parse_message(msg)
-            parser.Parser._parse_comment.assert_called_once_with(
-                '!W76! id1C4007220E +180fpm +0.0rot')
+        mocker.spy(parser.Parser, '_parse_protocol_specific')
+        data = parser.Parser.parse_message(msg)
+        parser.Parser._parse_protocol_specific.assert_called_once_with(
+            '!W76! id1C4007220E +180fpm +0.0rot')
+
+        assert data['raw'] == msg
 
     def test_parse_msg_delete_update(self, mocker):
         msg = ('NAV07220E>OGNAVI,qAS,NAVITER:/125447h4557.77N/01220.19E\'258/'
                '056/A=006562 !W76! id1C4007220E +180fpm +0.0rot')
 
         data = {'_update': [{'target': 'key', 'function': lambda x: x}]}
-        with mocker.patch('ogn_lib.parser.Parser._parse_header',
-                          return_value={}):
-            with mocker.patch('ogn_lib.parser.Parser._parse_comment',
-                              return_value=data):
-                with mocker.patch('ogn_lib.parser.Parser._update_data'):
-                    parser.Parser.parse_message(msg)
-                    assert parser.Parser._update_data.call_count == 1
+
+        mocker.patch('ogn_lib.parser.Parser._parse_protocol_specific',
+                     return_value=data)
+        mocker.patch('ogn_lib.parser.Parser._update_data')
+
+        parser.Parser.parse_message(msg)
+        assert parser.Parser._update_data.call_count == 1
 
     def test_parse_msg_comment(self, mocker):
-        with mocker.patch('ogn_lib.parser.Parser._parse_header',
-                          return_value={}):
-            with mocker.patch('ogn_lib.parser.Parser._parse_comment',
-                              return_value={}):
-
-                parser.Parser.parse_message(
-                    'FLRDD83BC>APRS,qAS,EDLF:/163148h5124.56N/00634.42E\''
-                    '276/075/A=001551 [comment]')
-                parser.Parser._parse_comment.assert_called_once_with('[comment]')
-
-    def test_parse_header(self, mocker):
-        with mocker.patch('ogn_lib.parser.Parser._parse_origin',
-                          return_value={'a': 1}):
-            with mocker.patch('ogn_lib.parser.Parser._parse_position',
-                              return_value={'b': 2}):
-                data = parser.Parser._parse_header('origin:/position')
-
-                parser.Parser._parse_origin.assert_called_once_with('origin')
-                parser.Parser._parse_position.assert_called_once_with('position')
-
-                assert data == {'a': 1, 'b': 2}
-
-    def test_parse_header_separators(self, mocker):
-        with mocker.patch('ogn_lib.parser.Parser._parse_origin',
-                          return_value={'a': 1}):
-            with mocker.patch('ogn_lib.parser.Parser._parse_position',
-                              return_value={'b': 2}):
-                parser.Parser._parse_header('origin:/position')
-
-                parser.Parser._parse_position.assert_called_once_with('position')
-
-    def test_parse_origin(self):
-        data = parser.Parser._parse_origin('FMT-VERS,qAS,RECEIVER')
+        mocker.patch('ogn_lib.parser.Parser._parse_protocol_specific',
+                     return_value={'comment': True})
+
+        data = parser.Parser.parse_message(
+            'FLRDD83BC>APRS,qAS,EDLF:/163148h5124.56N/00634.42E\''
+            '276/075/A=001551 [comment]')
+        parser.Parser._parse_protocol_specific.assert_called_once_with('[comment]')
+
+        assert data['comment']
+
+    def test_parse_digipeaters(self):
+        data = parser.Parser._parse_digipeaters('qAS,RECEIVER')
         assert data == {
-            'destto': 'FMT-VERS',
             'relayer': None,
             'receiver': 'RECEIVER'
         }
 
-    def test_parse_origin_relayed(self):
-        data = parser.Parser._parse_origin('FMT-VERS,RELAYER*,qAS,RECEIVER')
+    def test_parse_digipeaters_relayed(self):
+        data = parser.Parser._parse_digipeaters('RELAYER*,qAS,RECEIVER')
         assert data == {
-            'destto': 'FMT-VERS',
             'relayer': 'RELAYER',
             'receiver': 'RECEIVER'
         }
 
-    def test_parse_origin_unknown_format(self):
+    def test_parse_digipeaters_unknown_format(self):
         with pytest.raises(ValueError):
-            parser.Parser._parse_origin('FMT-VERS,qAS')
-
-    def _parse_position_separator(self, mocker, p_separator, a_separator, msg):
-        c_time = datetime(2017, 1, 1, 1, 2, 3)
-        with mocker.patch('ogn_lib.parser.Parser._parse_timestamp',
-                          return_value=c_time):
-            with mocker.patch('ogn_lib.parser.Parser._parse_attributes',
-                              return_value={}):
+            parser.Parser._parse_digipeaters('qAS')
 
-                data = parser.Parser._parse_position(msg)
-
-                parser.Parser._parse_attributes.assert_called_once_with(
-                    '200/100/A=00042')
-
-        assert data['timestamp'] == c_time
-        assert data['latitude'] == 1
-        assert data['longitude'] == 2
-
-    def test_parse_position(self, mocker):
-        msg = '010203h0100.00N/00200.00E\'200/100/A=00042'
-        self._parse_position_separator(mocker, '/', '\'', msg)
-
-    def test_parse_position_alt_separator(self, mocker):
-        msg = '010203h0100.00NI00200.00E&200/100/A=00042'
-        self._parse_position_separator(mocker, 'I', '&', msg)
-
-    def test_parse_attrs(self):
-        pass
-
-    def test_parse_attrs_na_hdg_speed(self):
-        data = parser.Parser._parse_position(
-            '010203h0100.00N/00200.00\'000/000/A=00042')
+    def test_parse_heading_speed(self):
+        data = parser.Parser._parse_heading_speed('100', '050')
+        assert data['heading'] == 100
+        assert abs(data['ground_speed'] - 25.72) < 0.1
 
+    def test_parse_heading_speed_both_missing(self):
+        data = parser.Parser._parse_heading_speed('000', '000')
         assert data['heading'] is None
         assert data['ground_speed'] is None
 
-    def test_parse_attrs_no_hdg_speed(self):
-        data = parser.Parser._parse_position(
-            '010203h0100.00N/00200.00\'/A=00042')
+    def test_parse_heading_speed_null_input(self):
+        assert not parser.Parser._parse_heading_speed(None, '000')
+        assert not parser.Parser._parse_heading_speed('000', None)
+        assert not parser.Parser._parse_heading_speed(None, None)
 
-        assert data['heading'] is None
-        assert data['ground_speed'] is None
-        assert data['altitude'] == 12.8016
+    def test_parse_altitude(self):
+        assert abs(parser.Parser._parse_altitude('005000') - 1524) < 1
 
-    def test_parse_attrs_no_alt(self):
-        data = parser.Parser._parse_position(
-            '010203h0100.00N/00200.00\'001/002')
+    def test_parse_altitude_missing(self):
+        assert parser.Parser._parse_altitude(None) is None
 
-        assert data['heading'] == 1
-        assert abs(data['ground_speed'] - 1.028889) < 0.001
-        assert data['altitude'] is None
+    def test_parse_attrs(self):
+        pass
 
     def test_parse_timestamp_h(self, mocker):
         with mocker.patch('ogn_lib.parser.Parser._parse_time'):
@@ -270,12 +316,12 @@ class TestParser:
 
     def test_parse_location_value(self):
         val = parser.Parser._parse_location('0130.50N')
-        assert abs(val == 1.5083333) < 0.0001
+        assert abs(val - 1.5083333) < 0.0001
         val = parser.Parser._parse_location('01125.01W')
         assert abs(val - -11.416833) < 0.0001
 
-    def test_parse_comment(self):
-        assert parser.Parser._parse_comment("1 2 3 4") == {}
+    def test_parse_protocol_specific(self):
+        assert parser.Parser._parse_protocol_specific("1 2 3 4") == {}
 
     def test_get_location_update_func(self):
         fn = parser.Parser._get_location_update_func(0)
@@ -325,10 +371,10 @@ class TestParser:
 
 class TestAPRS:
 
-    def test_parse_comment(self):
+    def test_parse_protocol_specific(self):
         msg = ('!W12! id06DF0A52 +020fpm +0.0rot FL000.00 55.2dB 0e -6.2kHz'
                ' gps4x6 s6.01 h03 rDDACC4 +5.0dBm hearD7EA hearDA95')
-        data = parser.APRS._parse_comment(msg)
+        data = parser.APRS._parse_protocol_specific(msg)
         assert len(data['_update']) == 2
         assert (set(map(lambda x: x['target'], data['_update'])) ==
                 {'latitude', 'longitude'})
@@ -366,9 +412,9 @@ class TestAPRS:
 
 class TestNaviter:
 
-    def test_parse_comment(self):
+    def test_parse_protocol_specific(self):
         msg = '!W76! id1C4007220E +180fpm +0.0rot'
-        data = parser.Naviter._parse_comment(msg)
+        data = parser.Naviter._parse_protocol_specific(msg)
         assert len(data['_update']) == 2
         assert (set(map(lambda x: x['target'], data['_update'])) ==
                 {'latitude', 'longitude'})
@@ -397,66 +443,46 @@ class TestNaviter:
 class TestServerParser:
 
     def test_parse_message_beacon(self, mocker):
-        with mocker.patch('ogn_lib.parser.ServerParser.parse_beacon'):
-            msg = ('LKHS>APRS,TCPIP*,qAC,GLIDERN2:/211635h4902.45NI01429.51E&'
-                   '000/000/A=001689')
-
-            data = parser.ServerParser.parse_message(msg)
-            parser.ServerParser.parse_beacon.assert_called_once_with(msg)
-
-        assert data['raw']
+        msg = ('LKHS>APRS,TCPIP*,qAC,GLIDERN2:/211635h4902.45NI01429.51E&'
+               '000/000/A=001689')
+
+        data = parser.ServerParser.parse_message(msg)
+        assert data['from'] == 'LKHS'
+        assert data['destto'] == 'APRS'
+        assert data['timestamp'].time() == time(21, 16, 35)
+        assert data['latitude'] == 49.04083333333333
+        assert data['longitude'] == 14.491833333333334
+        assert not data['heading']
+        assert not data['ground_speed']
+        assert abs(data['altitude'] - 514.8) < 1
+        assert data['raw'] == msg
+        assert data['beacon_type'] == constants.BeaconType.server_beacon
+        assert 'comment' not in data
 
     def test_parse_message_status(self, mocker):
-        with mocker.patch('ogn_lib.parser.ServerParser.parse_status'):
-            msg = (
-                'LKHS>APRS,TCPIP*,qAC,GLIDERN2:/211635h v0.2.6.ARM CPU:0.2 '
-                'RAM:777.7/972.2MB NTP:3.1ms/-3.8ppm 4.902V 0.583A +33.6C 14/'
-                '16Acfts[1h] RF:+62-0.8ppm/+33.66dB/+19.4dB@10km[112619]/+25.0'
-                'dB@10km[8/15]')
-
-            data = parser.ServerParser.parse_message(msg)
-            parser.ServerParser.parse_status.assert_called_once_with(msg)
-
+        msg = (
+            'LKHS>APRS,TCPIP*,qAC,GLIDERN2:/211635h v0.2.6.ARM CPU:0.2 '
+            'RAM:777.7/972.2MB NTP:3.1ms/-3.8ppm 4.902V 0.583A +33.6C 14/'
+            '16Acfts[1h] RF:+62-0.8ppm/+33.66dB/+19.4dB@10km[112619]/+25.0'
+            'dB@10km[8/15]')
+
+        data = parser.ServerParser.parse_message(msg)
+        assert data['from'] == 'LKHS'
+        assert data['destto'] == 'APRS'
+        assert data['timestamp'].time() == time(21, 16, 35)
+        assert not data['latitude']
+        assert not data['longitude']
+        assert 'heading' not in data
+        assert 'ground_speed' not in data
+        assert not data['altitude']
+        assert data['raw'] == msg
+        assert data['beacon_type'] == constants.BeaconType.server_status
         assert data['raw']
-
-    def test_parse_beacon(self, mocker):
-        with mocker.patch('ogn_lib.parser.Parser._parse_header', return_value={}):
-            msg = ('LKHS>APRS,TCPIP*,qAC,GLIDERN2:/211635h4902.45NI01429.51E&'
-                   '000/000/A=001689')
-            data = parser.ServerParser.parse_beacon(msg)
-            parser.Parser._parse_header.assert_called_once_with(
-                'APRS,TCPIP*,qAC,GLIDERN2:/211635h4902.45NI01429.51E&000/000/'
-                'A=001689')
-
-        assert data['from']
-        assert data['beacon_type'] is constants.BeaconType.server_beacon
-        assert data['comment'] is None
+        assert data['comment'].startswith('v0.2.6')
 
     def test_parse_beacon_comment(self, mocker):
-        with mocker.patch('ogn_lib.parser.Parser._parse_header', return_value={}):
-            msg = ('LKHS>APRS,TCPIP*,qAC,GLIDERN2:/211635h4902.45NI01429.51E&'
-                   '000/000/A=001689 comment')
-            data = parser.ServerParser.parse_beacon(msg)
+        msg = ('LKHS>APRS,TCPIP*,qAC,GLIDERN2:/211635h4902.45NI01429.51E&'
+               '000/000/A=001689 comment')
+        data = parser.ServerParser.parse_message(msg)
 
         assert data['comment'] == 'comment'
-
-    def test_parse_status(self, mocker):
-        msg = (
-            'LKHS>APRS,TCPIP*,qAC,GLIDERN2:/211635h v0.2.6.ARM CPU:0.2 RAM:777'
-            '.7/972.2MB NTP:3.1ms/-3.8ppm 4.902V 0.583A +33.6C 14/16Acfts[1h]'
-            'RF:+62-0.8ppm/+33.66dB/+19.4dB@10km[112619]/+25.0dB@10km[8/15]')
-
-        with mocker.patch('ogn_lib.parser.Parser._parse_origin',
-                          return_value={'origin': 1}):
-            with mocker.patch('ogn_lib.parser.Parser._parse_timestamp',
-                              return_value='ts'):
-                data = parser.ServerParser.parse_status(msg)
-
-                parser.Parser._parse_timestamp.assert_called_once_with(
-                    '211635h')
-                parser.Parser._parse_origin.assert_called_once_with(
-                    'APRS,TCPIP*,qAC,GLIDERN2')
-
-        assert data['timestamp'] == 'ts'
-        assert data['beacon_type'] is constants.BeaconType.server_status
-        assert 'origin' in data