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
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.