openrvdas.logger.utils.nmea_parser

Tools for parsing.

  1#!/usr/bin/env python3
  2
  3"""Tools for parsing.
  4"""
  5
  6import glob
  7import logging
  8import re
  9
 10from logger.utils import read_config  # noqa: E402
 11from logger.utils.das_record import DASRecord  # noqa: E402
 12from logger.utils.timestamp import timestamp, TIME_FORMAT  # noqa: E402
 13
 14DEFAULT_MESSAGE_PATH = 'local/message/*.yaml'
 15DEFAULT_SENSOR_PATH = 'local/sensor/*.yaml'
 16DEFAULT_SENSOR_MODEL_PATH = 'local/sensor_model/*.yaml'
 17
 18RAW_FIELDS_RE = '(?P<raw_fields>[^*]+)'
 19CHECKSUM_RE = r'(?:\*(?P<checksum>[0-9A-F]{2}))?'
 20NMEA_RE = re.compile(RAW_FIELDS_RE + CHECKSUM_RE)
 21
 22
 23class NMEAParser:
 24    ############################
 25    def __init__(self, message_path=DEFAULT_MESSAGE_PATH,
 26                 sensor_path=DEFAULT_SENSOR_PATH,
 27                 sensor_model_path=DEFAULT_SENSOR_MODEL_PATH,
 28                 time_format=None):
 29        self.messages = self._read_definitions(message_path)
 30        self.sensor_models = self._read_definitions(sensor_model_path)
 31        self.sensors = self._read_definitions(sensor_path)
 32        self.time_format = time_format or TIME_FORMAT
 33
 34    ############################
 35    def parse_record(self, nmea_record):
 36        """Receive an id-prefixed, timestamped NMEA record."""
 37        if not nmea_record:
 38            return None
 39        if not isinstance(nmea_record, str):
 40            logging.info('Record is not NMEA string: "%s"', nmea_record)
 41            return None
 42        try:
 43            (data_id, raw_ts, message) = nmea_record.strip().split(maxsplit=2)
 44            ts = timestamp(raw_ts, time_format=self.time_format)
 45        except ValueError:
 46            logging.info('Record not in <data_id> <timestamp> <NMEA> format: "%s"',
 47                         nmea_record)
 48            return None
 49
 50        # Figure out what kind of message we're expecting, based on data_id
 51        sensor = self.sensors.get(data_id)
 52        if not sensor:
 53            logging.error('Unrecognized data_id ("%s") in record: %s',
 54                          data_id, nmea_record)
 55            return None
 56
 57        model_name = sensor.get('model')
 58        if not model_name:
 59            logging.error('No "model" for sensor %s', sensor)
 60            return None
 61
 62        # If something goes wrong during parsing, we'll get a ValueError
 63        try:
 64            (fields, message_type) = self.parse_nmea(sensor_model_name=model_name,
 65                                                     message=message)
 66        except ValueError as e:
 67            logging.error(str(e))
 68            return None
 69
 70        # Finally, convert field values to variable names specific to sensor
 71        sensor_fields = sensor.get('fields')
 72        if not sensor_fields:
 73            logging.error('No "fields" definition found for sensor %s', data_id)
 74            return None
 75
 76        named_fields = {}
 77        for field_name in fields:
 78            var_name = sensor_fields.get(field_name)
 79            if var_name:
 80                named_fields[var_name] = fields[field_name]
 81
 82        record = DASRecord(data_id=data_id, message_type=message_type,
 83                           timestamp=ts, fields=named_fields)
 84        logging.debug('created DASRecord: %s', str(record))
 85        return record
 86
 87    ############################
 88    def parse_nmea(self, sensor_model_name, message):
 89        """Parse a raw NMEA message; raise ValueError if there are problems."""
 90        # Break the message into an optional message_type, the aggregated
 91        # raw fields and an optional checksum.
 92        match = NMEA_RE.match(message)
 93        if not match:
 94            raise ValueError('Can\'t parse NMEA record: "%s"' % message)
 95
 96        # Parse out the optional checksum from the raw fields. The first
 97        # field may be a message type, but we'll deal with that later.
 98        raw_fields = match.group('raw_fields')
 99        checksum = match.group('checksum')
100
101        logging.debug('Parsed "%s"', message)
102        logging.debug('raw_fields "%s"', raw_fields)
103        logging.debug('checksum "%s"', checksum)
104
105        # Proper NMEA uses commas to delimit fields, but some serial
106        # instruments use spaces or other characters (Gravimeter, for
107        # example, uses both spaces and ':'). Look up sensor model and see
108        # if we have a non-default delimiter defined.
109        sensor_model = self.sensor_models.get(sensor_model_name)
110        if not sensor_model:
111            raise ValueError('No sensor_model  matching "%s"' % sensor_model_name)
112
113        field_delimiter = sensor_model.get('field_delimiter', ',')
114        fields = re.split(field_delimiter, raw_fields)
115        logging.debug('fields "%s"', fields)
116
117        # We need to find what fields are defined by this sensor model. If
118        # the top-level sensor_model definition has 'fields', then it's
119        # easy, and we're done.
120        message_type = ''
121        definition_base = sensor_model
122
123        # If we don't have 'fields' defined at the top level, it means
124        # that this sensor can emit multiple types of messages, which we
125        # expect to find under a 'messages' key. We will count on the
126        # first element of our field list to tell us which of those
127        # messages we've got.
128        while 'fields' not in definition_base:
129            logging.debug('Iterating with message_type "%s", looking for messages '
130                          'in definition_base: %s', message_type, definition_base)
131
132            # If there's no 'fields', there ought to be a 'messages'
133            sensor_messages = definition_base.get('messages')
134            if not sensor_messages:
135                raise ValueError('Sensor model %s must have either "fields" or '
136                                 '"messages" definition.' % sensor_model_name)
137
138            # We count on the first field in the field list to tell us which
139            # message out of our dictionary of messages we actually have.
140            element = fields.pop(0)
141            message_type = message_type + '-' + element if message_type else element
142
143            definition = sensor_messages.get(element)
144            if not definition:
145                raise ValueError('Message "%s" is not one defined by model %s (%s)'
146                                 % (message_type, sensor_model_name, sensor_messages))
147
148            # The value of the 'definition' can be one of two things: 1) a
149            # string referring us to a message definition in self.messages,
150            # or 2) a dictionary that contains the message definition. Note
151            # that the message definition may either directly contain a
152            # 'fields' key or may have a 'messages' key defining
153            # sub-message_types, requiring us to iterate.
154
155            # If it's a str, it's a reference into the definitions we've
156            # previously loaded into self.messages. Look it up, make that
157            # our new definition_base and loop.
158            if isinstance(definition, str):
159                definition_base = self.messages.get(definition)
160                logging.debug('Definition is reference to message "%s"; loaded: %s',
161                              definition, definition_base)
162                if not definition_base:
163                    raise ValueError('Message definition "%s" (%s) not found for %s'
164                                     % (definition, message_type, sensor_model_name))
165
166            # If 'definition' is a dict, make that our new definition_base,
167            # tack the element onto our message_type and iterate.
168            elif isinstance(definition, dict):
169                definition_base = definition
170
171            # If definition is neither dict nor str, something's wrong
172            else:
173                raise ValueError('Bad definition for %s (%s)'
174                                 % (message_type, sensor_model_name))
175
176        # End of while loop. If we're here, we darned well ought to have
177        # field_definitions in our definition_base. Get them and make sure
178        # they line up with the number of fields we have left.
179        field_definitions = definition_base.get('fields')
180        if len(fields) != len(field_definitions):
181            raise ValueError('Sensor model "%s": %s # of fields (%s) != '
182                             '# field definitions (%s): "%s" != "%s"' % (
183                                 sensor_model_name, message_type,
184                                 len(fields), len(field_definitions),
185                                 fields, [f[0] for f in field_definitions]))
186
187        # If still okay, map field values to their definitions
188        field_values = {}
189        for i in range(len(fields)):
190            (name, data_type) = field_definitions[i]
191            field_values[name] = self._convert(fields[i], data_type)
192        return (field_values, message_type)
193
194    ############################
195    def _convert(self, value, data_type):
196        if value == '':
197            return None
198        if not data_type:
199            return value
200        elif data_type == 'int':
201            return int(value)
202        elif data_type == 'float':
203            return float(value)
204        elif data_type == 'str':
205            return str(value)
206        else:
207            raise ValueError('Unknown data type in field definition: "%s"' % data_type)
208
209    ############################
210    def _read_definitions(self, filespec_paths):
211        definitions = {}
212        for filespec in filespec_paths.split(','):
213            logging.debug('reading definitions from %s', filespec)
214            for filename in glob.glob(filespec):
215                new_defs = read_config.read_config(filename)
216                for key in new_defs:
217                    if key in definitions:
218                        logging.warning('Duplicate definition for key "%s" found in %s',
219                                        key, filename)
220                    definitions[key] = new_defs[key]
221        return definitions
DEFAULT_MESSAGE_PATH = 'local/message/*.yaml'
DEFAULT_SENSOR_PATH = 'local/sensor/*.yaml'
DEFAULT_SENSOR_MODEL_PATH = 'local/sensor_model/*.yaml'
RAW_FIELDS_RE = '(?P<raw_fields>[^*]+)'
CHECKSUM_RE = '(?:\\*(?P<checksum>[0-9A-F]{2}))?'
NMEA_RE = re.compile('(?P<raw_fields>[^*]+)(?:\\*(?P<checksum>[0-9A-F]{2}))?')
class NMEAParser:
 24class NMEAParser:
 25    ############################
 26    def __init__(self, message_path=DEFAULT_MESSAGE_PATH,
 27                 sensor_path=DEFAULT_SENSOR_PATH,
 28                 sensor_model_path=DEFAULT_SENSOR_MODEL_PATH,
 29                 time_format=None):
 30        self.messages = self._read_definitions(message_path)
 31        self.sensor_models = self._read_definitions(sensor_model_path)
 32        self.sensors = self._read_definitions(sensor_path)
 33        self.time_format = time_format or TIME_FORMAT
 34
 35    ############################
 36    def parse_record(self, nmea_record):
 37        """Receive an id-prefixed, timestamped NMEA record."""
 38        if not nmea_record:
 39            return None
 40        if not isinstance(nmea_record, str):
 41            logging.info('Record is not NMEA string: "%s"', nmea_record)
 42            return None
 43        try:
 44            (data_id, raw_ts, message) = nmea_record.strip().split(maxsplit=2)
 45            ts = timestamp(raw_ts, time_format=self.time_format)
 46        except ValueError:
 47            logging.info('Record not in <data_id> <timestamp> <NMEA> format: "%s"',
 48                         nmea_record)
 49            return None
 50
 51        # Figure out what kind of message we're expecting, based on data_id
 52        sensor = self.sensors.get(data_id)
 53        if not sensor:
 54            logging.error('Unrecognized data_id ("%s") in record: %s',
 55                          data_id, nmea_record)
 56            return None
 57
 58        model_name = sensor.get('model')
 59        if not model_name:
 60            logging.error('No "model" for sensor %s', sensor)
 61            return None
 62
 63        # If something goes wrong during parsing, we'll get a ValueError
 64        try:
 65            (fields, message_type) = self.parse_nmea(sensor_model_name=model_name,
 66                                                     message=message)
 67        except ValueError as e:
 68            logging.error(str(e))
 69            return None
 70
 71        # Finally, convert field values to variable names specific to sensor
 72        sensor_fields = sensor.get('fields')
 73        if not sensor_fields:
 74            logging.error('No "fields" definition found for sensor %s', data_id)
 75            return None
 76
 77        named_fields = {}
 78        for field_name in fields:
 79            var_name = sensor_fields.get(field_name)
 80            if var_name:
 81                named_fields[var_name] = fields[field_name]
 82
 83        record = DASRecord(data_id=data_id, message_type=message_type,
 84                           timestamp=ts, fields=named_fields)
 85        logging.debug('created DASRecord: %s', str(record))
 86        return record
 87
 88    ############################
 89    def parse_nmea(self, sensor_model_name, message):
 90        """Parse a raw NMEA message; raise ValueError if there are problems."""
 91        # Break the message into an optional message_type, the aggregated
 92        # raw fields and an optional checksum.
 93        match = NMEA_RE.match(message)
 94        if not match:
 95            raise ValueError('Can\'t parse NMEA record: "%s"' % message)
 96
 97        # Parse out the optional checksum from the raw fields. The first
 98        # field may be a message type, but we'll deal with that later.
 99        raw_fields = match.group('raw_fields')
100        checksum = match.group('checksum')
101
102        logging.debug('Parsed "%s"', message)
103        logging.debug('raw_fields "%s"', raw_fields)
104        logging.debug('checksum "%s"', checksum)
105
106        # Proper NMEA uses commas to delimit fields, but some serial
107        # instruments use spaces or other characters (Gravimeter, for
108        # example, uses both spaces and ':'). Look up sensor model and see
109        # if we have a non-default delimiter defined.
110        sensor_model = self.sensor_models.get(sensor_model_name)
111        if not sensor_model:
112            raise ValueError('No sensor_model  matching "%s"' % sensor_model_name)
113
114        field_delimiter = sensor_model.get('field_delimiter', ',')
115        fields = re.split(field_delimiter, raw_fields)
116        logging.debug('fields "%s"', fields)
117
118        # We need to find what fields are defined by this sensor model. If
119        # the top-level sensor_model definition has 'fields', then it's
120        # easy, and we're done.
121        message_type = ''
122        definition_base = sensor_model
123
124        # If we don't have 'fields' defined at the top level, it means
125        # that this sensor can emit multiple types of messages, which we
126        # expect to find under a 'messages' key. We will count on the
127        # first element of our field list to tell us which of those
128        # messages we've got.
129        while 'fields' not in definition_base:
130            logging.debug('Iterating with message_type "%s", looking for messages '
131                          'in definition_base: %s', message_type, definition_base)
132
133            # If there's no 'fields', there ought to be a 'messages'
134            sensor_messages = definition_base.get('messages')
135            if not sensor_messages:
136                raise ValueError('Sensor model %s must have either "fields" or '
137                                 '"messages" definition.' % sensor_model_name)
138
139            # We count on the first field in the field list to tell us which
140            # message out of our dictionary of messages we actually have.
141            element = fields.pop(0)
142            message_type = message_type + '-' + element if message_type else element
143
144            definition = sensor_messages.get(element)
145            if not definition:
146                raise ValueError('Message "%s" is not one defined by model %s (%s)'
147                                 % (message_type, sensor_model_name, sensor_messages))
148
149            # The value of the 'definition' can be one of two things: 1) a
150            # string referring us to a message definition in self.messages,
151            # or 2) a dictionary that contains the message definition. Note
152            # that the message definition may either directly contain a
153            # 'fields' key or may have a 'messages' key defining
154            # sub-message_types, requiring us to iterate.
155
156            # If it's a str, it's a reference into the definitions we've
157            # previously loaded into self.messages. Look it up, make that
158            # our new definition_base and loop.
159            if isinstance(definition, str):
160                definition_base = self.messages.get(definition)
161                logging.debug('Definition is reference to message "%s"; loaded: %s',
162                              definition, definition_base)
163                if not definition_base:
164                    raise ValueError('Message definition "%s" (%s) not found for %s'
165                                     % (definition, message_type, sensor_model_name))
166
167            # If 'definition' is a dict, make that our new definition_base,
168            # tack the element onto our message_type and iterate.
169            elif isinstance(definition, dict):
170                definition_base = definition
171
172            # If definition is neither dict nor str, something's wrong
173            else:
174                raise ValueError('Bad definition for %s (%s)'
175                                 % (message_type, sensor_model_name))
176
177        # End of while loop. If we're here, we darned well ought to have
178        # field_definitions in our definition_base. Get them and make sure
179        # they line up with the number of fields we have left.
180        field_definitions = definition_base.get('fields')
181        if len(fields) != len(field_definitions):
182            raise ValueError('Sensor model "%s": %s # of fields (%s) != '
183                             '# field definitions (%s): "%s" != "%s"' % (
184                                 sensor_model_name, message_type,
185                                 len(fields), len(field_definitions),
186                                 fields, [f[0] for f in field_definitions]))
187
188        # If still okay, map field values to their definitions
189        field_values = {}
190        for i in range(len(fields)):
191            (name, data_type) = field_definitions[i]
192            field_values[name] = self._convert(fields[i], data_type)
193        return (field_values, message_type)
194
195    ############################
196    def _convert(self, value, data_type):
197        if value == '':
198            return None
199        if not data_type:
200            return value
201        elif data_type == 'int':
202            return int(value)
203        elif data_type == 'float':
204            return float(value)
205        elif data_type == 'str':
206            return str(value)
207        else:
208            raise ValueError('Unknown data type in field definition: "%s"' % data_type)
209
210    ############################
211    def _read_definitions(self, filespec_paths):
212        definitions = {}
213        for filespec in filespec_paths.split(','):
214            logging.debug('reading definitions from %s', filespec)
215            for filename in glob.glob(filespec):
216                new_defs = read_config.read_config(filename)
217                for key in new_defs:
218                    if key in definitions:
219                        logging.warning('Duplicate definition for key "%s" found in %s',
220                                        key, filename)
221                    definitions[key] = new_defs[key]
222        return definitions
NMEAParser( message_path='local/message/*.yaml', sensor_path='local/sensor/*.yaml', sensor_model_path='local/sensor_model/*.yaml', time_format=None)
26    def __init__(self, message_path=DEFAULT_MESSAGE_PATH,
27                 sensor_path=DEFAULT_SENSOR_PATH,
28                 sensor_model_path=DEFAULT_SENSOR_MODEL_PATH,
29                 time_format=None):
30        self.messages = self._read_definitions(message_path)
31        self.sensor_models = self._read_definitions(sensor_model_path)
32        self.sensors = self._read_definitions(sensor_path)
33        self.time_format = time_format or TIME_FORMAT
messages
sensor_models
sensors
time_format
def parse_record(self, nmea_record):
36    def parse_record(self, nmea_record):
37        """Receive an id-prefixed, timestamped NMEA record."""
38        if not nmea_record:
39            return None
40        if not isinstance(nmea_record, str):
41            logging.info('Record is not NMEA string: "%s"', nmea_record)
42            return None
43        try:
44            (data_id, raw_ts, message) = nmea_record.strip().split(maxsplit=2)
45            ts = timestamp(raw_ts, time_format=self.time_format)
46        except ValueError:
47            logging.info('Record not in <data_id> <timestamp> <NMEA> format: "%s"',
48                         nmea_record)
49            return None
50
51        # Figure out what kind of message we're expecting, based on data_id
52        sensor = self.sensors.get(data_id)
53        if not sensor:
54            logging.error('Unrecognized data_id ("%s") in record: %s',
55                          data_id, nmea_record)
56            return None
57
58        model_name = sensor.get('model')
59        if not model_name:
60            logging.error('No "model" for sensor %s', sensor)
61            return None
62
63        # If something goes wrong during parsing, we'll get a ValueError
64        try:
65            (fields, message_type) = self.parse_nmea(sensor_model_name=model_name,
66                                                     message=message)
67        except ValueError as e:
68            logging.error(str(e))
69            return None
70
71        # Finally, convert field values to variable names specific to sensor
72        sensor_fields = sensor.get('fields')
73        if not sensor_fields:
74            logging.error('No "fields" definition found for sensor %s', data_id)
75            return None
76
77        named_fields = {}
78        for field_name in fields:
79            var_name = sensor_fields.get(field_name)
80            if var_name:
81                named_fields[var_name] = fields[field_name]
82
83        record = DASRecord(data_id=data_id, message_type=message_type,
84                           timestamp=ts, fields=named_fields)
85        logging.debug('created DASRecord: %s', str(record))
86        return record

Receive an id-prefixed, timestamped NMEA record.

def parse_nmea(self, sensor_model_name, message):
 89    def parse_nmea(self, sensor_model_name, message):
 90        """Parse a raw NMEA message; raise ValueError if there are problems."""
 91        # Break the message into an optional message_type, the aggregated
 92        # raw fields and an optional checksum.
 93        match = NMEA_RE.match(message)
 94        if not match:
 95            raise ValueError('Can\'t parse NMEA record: "%s"' % message)
 96
 97        # Parse out the optional checksum from the raw fields. The first
 98        # field may be a message type, but we'll deal with that later.
 99        raw_fields = match.group('raw_fields')
100        checksum = match.group('checksum')
101
102        logging.debug('Parsed "%s"', message)
103        logging.debug('raw_fields "%s"', raw_fields)
104        logging.debug('checksum "%s"', checksum)
105
106        # Proper NMEA uses commas to delimit fields, but some serial
107        # instruments use spaces or other characters (Gravimeter, for
108        # example, uses both spaces and ':'). Look up sensor model and see
109        # if we have a non-default delimiter defined.
110        sensor_model = self.sensor_models.get(sensor_model_name)
111        if not sensor_model:
112            raise ValueError('No sensor_model  matching "%s"' % sensor_model_name)
113
114        field_delimiter = sensor_model.get('field_delimiter', ',')
115        fields = re.split(field_delimiter, raw_fields)
116        logging.debug('fields "%s"', fields)
117
118        # We need to find what fields are defined by this sensor model. If
119        # the top-level sensor_model definition has 'fields', then it's
120        # easy, and we're done.
121        message_type = ''
122        definition_base = sensor_model
123
124        # If we don't have 'fields' defined at the top level, it means
125        # that this sensor can emit multiple types of messages, which we
126        # expect to find under a 'messages' key. We will count on the
127        # first element of our field list to tell us which of those
128        # messages we've got.
129        while 'fields' not in definition_base:
130            logging.debug('Iterating with message_type "%s", looking for messages '
131                          'in definition_base: %s', message_type, definition_base)
132
133            # If there's no 'fields', there ought to be a 'messages'
134            sensor_messages = definition_base.get('messages')
135            if not sensor_messages:
136                raise ValueError('Sensor model %s must have either "fields" or '
137                                 '"messages" definition.' % sensor_model_name)
138
139            # We count on the first field in the field list to tell us which
140            # message out of our dictionary of messages we actually have.
141            element = fields.pop(0)
142            message_type = message_type + '-' + element if message_type else element
143
144            definition = sensor_messages.get(element)
145            if not definition:
146                raise ValueError('Message "%s" is not one defined by model %s (%s)'
147                                 % (message_type, sensor_model_name, sensor_messages))
148
149            # The value of the 'definition' can be one of two things: 1) a
150            # string referring us to a message definition in self.messages,
151            # or 2) a dictionary that contains the message definition. Note
152            # that the message definition may either directly contain a
153            # 'fields' key or may have a 'messages' key defining
154            # sub-message_types, requiring us to iterate.
155
156            # If it's a str, it's a reference into the definitions we've
157            # previously loaded into self.messages. Look it up, make that
158            # our new definition_base and loop.
159            if isinstance(definition, str):
160                definition_base = self.messages.get(definition)
161                logging.debug('Definition is reference to message "%s"; loaded: %s',
162                              definition, definition_base)
163                if not definition_base:
164                    raise ValueError('Message definition "%s" (%s) not found for %s'
165                                     % (definition, message_type, sensor_model_name))
166
167            # If 'definition' is a dict, make that our new definition_base,
168            # tack the element onto our message_type and iterate.
169            elif isinstance(definition, dict):
170                definition_base = definition
171
172            # If definition is neither dict nor str, something's wrong
173            else:
174                raise ValueError('Bad definition for %s (%s)'
175                                 % (message_type, sensor_model_name))
176
177        # End of while loop. If we're here, we darned well ought to have
178        # field_definitions in our definition_base. Get them and make sure
179        # they line up with the number of fields we have left.
180        field_definitions = definition_base.get('fields')
181        if len(fields) != len(field_definitions):
182            raise ValueError('Sensor model "%s": %s # of fields (%s) != '
183                             '# field definitions (%s): "%s" != "%s"' % (
184                                 sensor_model_name, message_type,
185                                 len(fields), len(field_definitions),
186                                 fields, [f[0] for f in field_definitions]))
187
188        # If still okay, map field values to their definitions
189        field_values = {}
190        for i in range(len(fields)):
191            (name, data_type) = field_definitions[i]
192            field_values[name] = self._convert(fields[i], data_type)
193        return (field_values, message_type)

Parse a raw NMEA message; raise ValueError if there are problems.