SELKIELogger  1.0.0
SLMessages.py
Go to the documentation of this file.
1 # Copyright (C) 2023 Swansea University
2 #
3 # This file is part of the SELKIELogger suite of tools.
4 #
5 # SELKIELogger is free software: you can redistribute it and/or modify it
6 # under the terms of the GNU General Public License as published by the Free
7 # Software Foundation, either version 3 of the License, or (at your option)
8 # any later version.
9 #
10 # SELKIELogger is distributed in the hope that it will be useful, but WITHOUT
11 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12 # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
13 # more details.
14 #
15 # You should have received a copy of the GNU General Public License along
16 # with this SELKIELogger product.
17 # If not, see <http://www.gnu.org/licenses/>.
18 
19 import msgpack
20 import logging
21 from warnings import warn
22 
23 
24 
25 
26 class IDs:
27  """! Mirror ID numbers from base/sources.h"""
28 
29 
30  SLSOURCE_LOCAL = 0x00
31 
32  SLSOURCE_CONV = 0x01
33 
34  SLSOURCE_TIMER = 0x02
35 
36  SLSOURCE_TEST1 = 0x05
37 
38  SLSOURCE_TEST2 = 0x06
39 
40  SLSOURCE_TEST3 = 0x07
41 
42  SLSOURCE_GPS = 0x10
43 
44  SLSOURCE_ADC = 0x20
45 
46  SLSOURCE_NMEA = 0x30
47 
48  SLSOURCE_N2K = 0x38
49 
50  SLSOURCE_I2C = 0x40
51 
52  SLSOURCE_EXT = 0x60
53 
54  SLSOURCE_MQTT = 0x68
55 
56  SLSOURCE_MP = 0x70
57 
58 
59  SLCHAN_NAME = 0x00
60 
61  SLCHAN_MAP = 0x01
62 
63  SLCHAN_TSTAMP = 0x02
64 
65  SLCHAN_RAW = 0x03
66 
67  SLCHAN_LOG_INFO = 0x7D
68 
69  SLCHAN_LOG_WARN = 0x7E
70 
71  SLCHAN_LOG_ERR = 0x7F
72 
73 
74 
75 class SLMessage:
76  """!
77  Represent messages stored by the Logger program and generated by devices
78  with compatible firmware.
79 
80  Messages are 4 element MessagePacked arrays, with the first element a
81  constant 0x55, second and third elements identifying the source and message
82  IDs and the final element containing the data for that message.
83  """
84 
85 
86  __slots__ = ["SourceID", "ChannelID", "Data"]
87 
88  def __init__(self, sourceID, channelID, data):
89  """!
90  Create message with specified source, channel and data.
91 
92  Note that the C library and utilities only support a limited range of
93  types within these messages. Check the library documentation for
94  details.
95 
96  @param sourceID Message Source
97  @param channelID Message Channel
98  @param data Message value / data
99 
100  @sa library/base/sources.h
101  """
102  try:
103  assert 0 <= sourceID and sourceID < 128
104  assert 0 <= channelID and channelID < 128
105  except:
106  logging.error(
107  f"Invalid source/channel values: Source: {sourceID}, Channel: {channelID}, Data: {data}"
108  )
109  raise ValueError("Invalid source/channel values")
110 
111 
112  self.SourceIDSourceID = sourceID
113 
114  self.ChannelIDChannelID = channelID
115 
116  self.DataData = data
117 
118  @classmethod
119  def unpack(cl, data):
120  """!
121  Unpack raw messagepack bytes or list into SLMessage.
122  If a list is provided, it's expected to correspond to each member of
123  the raw array, i.e. [0x55, sourceID, channelID, data]
124 
125  @param cl Class to be created (classmethod)
126  @param data Raw bytes or a list of values.
127  @returns New message instance
128  """
129  if isinstance(data, bytes):
130  data = msgpack.unpackb(data)
131  if not isinstance(data, list) or len(data) != 4:
132  raise ValueError("Bad message")
133  assert data[0] == 0x55
134  return cl(data[1], data[2], data[3])
135 
136  def pack(self):
137  """!
138  Return packed binary representation of message
139  @returns Bytes representing message in messagepack form
140  """
141  return msgpack.packb([0x55, self.SourceIDSourceID, self.ChannelIDChannelID, self.DataData])
142 
143  def __repr__(self):
144  """!
145  Represent class as packed binary data
146  @todo Replace with standards compliant repr
147  @returns Message, as bytes
148  """
149  return self.packpack()
150 
151  def __str__(self):
152  """! @returns printable representation of message"""
153  return f"{self.SourceID:02x}\t{self.ChannelID:02x}\t{str(self.Data)}"
154 
155 
157  """!
158  Software message source
159 
160  Provide a framework for creating valid messages from within Python code.
161  Although this class enforces channel names and provides support for the
162  standard messages (except timestamps), no restriction is placed on the data
163  types used in data messages. See separate documentation for the library to
164  check compatibility.
165 
166  @sa library/base/sources.h
167  """
168 
169  def __init__(
170  self, sourceID, name="PythonDL", dataChannels=1, dataChannelNames=None
171  ):
172  """!
173  Any valid source must have a source ID, name and a list of named data channels
174  @param sourceID Valid data source ID number - @see IDs
175  @param name Source Name (Default: PythonDL)
176  @param dataChannels Number of data channels to be created (ID 3+)
177  @param dataChannelNames Names for data channels (First entry = Channel 3)
178  """
179 
180  self.SourceIDSourceID = int(sourceID)
181 
182  self.NameName = str(name)
183 
184  self.ChannelMapChannelMap = ["Name", "Channels", "Timestamp"]
185 
186  if self.SourceIDSourceID > 127 or self.SourceIDSourceID <= 0:
187  raise ValueError("Invalid Source ID")
188 
189  if dataChannelNames is None:
190  dataChannelNames = [f"Data{x+1}" for x in range(dataChannels)]
191 
192  if len(dataChannelNames) != dataChannels:
193  raise ValueError(
194  f"Inconsistent number of channels specified: Expected {dataChannels}, got {len(dataChannels)}"
195  )
196 
197  if len(dataChannelNames) > 0:
198  self.ChannelMapChannelMap.extend(dataChannelNames)
199 
200  def IDMessage(self):
201  """! @returns Source name message (Channel 0)"""
202  return SLMessage(self.SourceIDSourceID, 0, self.NameName)
203 
204  def ChannelsMessage(self):
205  """! @returns Channel name map message (Channel 1)"""
206  return SLMessage(self.SourceIDSourceID, 1, self.ChannelMapChannelMap)
207 
208  def InfoMessage(self, message):
209  """!
210  @returns INFO level log message
211  @param message Message text
212  """
213  return SLMessage(self.SourceIDSourceID, 125, str(message))
214 
215  def WarningMessage(self, message):
216  """!
217  @returns WARNING level log message
218  @param message Message text
219  """
220  return SLMessage(self.SourceIDSourceID, 126, str(message))
221 
222  def ErrorMessage(self, message):
223  """!
224  @returns ERROR level log message
225  @param message Message text
226  """
227  return SLMessage(self.SourceIDSourceID, 127, str(message))
228 
229  def TimestampMessage(self):
230  """!
231  Placeholder for timestamp message (Channel 2)
232 
233  Sources should provide a timestamp periodically to allow messages
234  generated at a particular time to be grouped.
235 
236  Not implemented here
237  @returns N/A - Throws NotImplemented exception
238  """
239  raise NotImplemented
240 
241  def DataMessage(self, channelID, data):
242  """!
243  @returns Message representing data from this source
244  @param channelID Channel ID (Must correspond to a map entry)
245  @param data Message data
246  """
247  assert channelID < len(self.ChannelMapChannelMap)
248  return SLMessage(self.SourceIDSourceID, channelID, data)
249 
250 
252  """!
253  Source and channel map data
254  """
255 
256 
257  __slots__ = ["_s", "_log"]
258 
259  class Source:
260  """! Represent sources being tracked"""
261 
262 
263  __slots__ = ["id", "name", "channels", "lastTimestamp"]
264 
265  def __init__(self, number, name=None, channels=None, lastTimestamp=None):
266  """!
267  Tracked source must be identified by name
268  @param number SourceID
269  @param name Source Name
270  @param channels Channel Map
271  @param lastTimestamp Last source timestamp received
272  """
273 
274  self.idid = number
275  if name:
276 
277  self.namename = f"[{number:02x}]"
278  self.namename = str(name)
279  if channels:
280 
281  self.channelschannels = [str(x) for x in channels]
282  else:
283 
284  self.channelschannels = ["Name", "Channels", "Timestamp"]
285 
286  if lastTimestamp:
287 
288  self.lastTimestamplastTimestamp = lastTimestamp
289  else:
290 
291  self.lastTimestamplastTimestamp = 0
292 
293  def __getitem__(self, ch):
294  """!
295  Support subscripted access to channel names
296  @param ch Channel ID
297  @returns String representing channel name
298  """
299  if ch < len(self.channelschannels):
300  return str(self.channelschannels[ch])
301  elif ch == 125:
302  return "Information"
303  elif ch == 126:
304  return "Warning"
305  elif ch == 127:
306  return "Error"
307  else:
308  return f"[{ch:02x}]"
309 
310  def __iter__(self):
311  """!
312  Allow iteration over channel list, e.g.
313  ```
314  for channel in source:
315  print(channel)
316  ```
317  @returns Iterator over channel list
318  """
319  return iter(self.channelschannels)
320 
321  def __str__(self):
322  """!
323  Represent source by its name
324  @returns Source name
325  """
326  return self.namename
327 
328  class Channel:
329  """! Represents a single channel"""
330 
331 
332  __slots__ = ["name"]
333 
334  def __init__(self, name):
335  """!
336  @param name Channel Name
337  """
338 
339  self.namename = name
340 
341  def __repr__(self):
342  """!
343  @todo Replace with more python compliant function
344  @returns Channel name
345  """
346  return self.namename
347 
348  # Back to the main SLChannelMap
349 
350  def __init__(self):
351  """! Initialise blank map"""
352 
353  self._s_s = dict()
354 
355  self._log_log = logging.getLogger(__name__)
356 
357  def __getitem__(self, ix):
358  """!
359  Support subscripted access to sources
360  @param ix Source ID
361  @returns Source object
362  """
363  if not isinstance(ix, int):
364  ix = int(ix, 0)
365  return self._s_s[ix]
366 
367  def __iter__(self):
368  """! @returns Iterator over sources"""
369  return iter(self._s_s)
370 
371  def __next__(self):
372  """! @returns next() source"""
373  return next(self._s_s)
374 
375  def to_dict(self):
376  """! @returns Dictionary representation of map"""
377  return {x: self._s_s[x].channels for x in self._s_s}
378 
379  def GetSourceName(self, source):
380  """!
381  @param source Source ID
382  @returns Formatted name for source ID
383  """
384  try:
385  if isinstance(source, str):
386  source = int(source, base=0)
387  else:
388  source = int(source)
389  except Exception as e:
390  self._log_log.error(str(e))
391  if source in self._s_s:
392  return self._s_s[source].name
393  else:
394  return f"[0x{source:02x}]"
395 
396  def GetChannelName(self, source, channel):
397  """!
398  @returns Formatted channel name
399  @param source Source ID
400  @param channel Channel ID
401  """
402  try:
403  if isinstance(source, str):
404  channel = int(channel, base=0)
405  else:
406  channel = int(channel)
407  except Exception as e:
408  self._log_log.error(str(e))
409 
410  if self.SourceExistsSourceExists(source):
411  if channel < len(self._s_s[source].channels):
412  return self._s_s[source].channels[channel]
413  elif channel == 125:
414  return "Information"
415  elif channel == 126:
416  return "Warning"
417  elif channel == 127:
418  return "Error"
419  else:
420  return f"[0x{channel:02x}]"
421 
422  def NewSource(self, source, name=None):
423  """!
424  Create or update source
425  @param source Source ID
426  @param name Source Name
427  @returns None
428  """
429  if self.SourceExistsSourceExists(source):
430  self._log_log.error(
431  f"Source 0x{source:02x} already exists (as {self.GetSourceName(source)})"
432  )
433  self._s_s[source] = self.SourceSource(source, name)
434 
435  def SourceExists(self, source):
436  """!
437  @param source Source ID
438  @returns True if source already known
439  """
440  return source in self._s_s
441 
442  def ChannelExists(self, source, channel):
443  """!
444  Special cases default channels that must always exist (SLCHAN_NAME,
445  SLCHAN_MAP, SLCHAN_TSTAMP, SLCHAN_LOG_INFO, SLCHAN_LOG_WARN,
446  SLCHAN_LOG_ERR), then checks for the existence of others.
447 
448  @param source Source ID
449  @param channel Channel ID
450  @returns True if channel exists in specified source
451  """
452  if channel in [0, 1, 2, 125, 126, 127]:
453  return True
454 
455  if self.SourceExistsSourceExists(source):
456  return channel < len(self._s_s[source].channels)
457  return False
458 
459  def SetSourceName(self, source, name):
460  """!
461  Update source name, creating source if required.
462  @param source SourceID
463  @param name SourceName
464  @returns None
465  """
466  if not self.SourceExistsSourceExists(source):
467  self.NewSourceNewSource(source, name)
468  else:
469  self._s_s[source].name = name
470 
471  def SetChannelNames(self, source, channels):
472  """!
473  Update channel map for a source, creating if required
474  @param source SourceID
475  @param channels List of channel names
476  @returns None
477  """
478  if not self.SourceExistsSourceExists(source):
479  self.NewSourceNewSource(source)
480 
481  self._s_s[source].channels = channels
482 
483  def UpdateTimestamp(self, source, timestamp):
484  """!
485  Update last timestamp value for a source, creating source if required.
486  @param source Source ID
487  @param timestamp Timestamp value
488  @returns None
489  """
490  if not self.SourceExistsSourceExists(source):
491  self.NewSourceNewSource(source)
492  self._s_s[source].lastTimestamp = int(timestamp)
493 
494 
496  """!
497  Parse incoming messages.
498 
499  Maintains an internal mapping of channel and source names to provide prettier output.
500 
501  Expects data to be provided one message at a time to .Process(), and will
502  return data as a dict, printable string or as an SLMessage().
503  """
504 
505  def __init__(self, msglogger=None):
506  """!
507  Initialise message sink
508 
509  Optionally accepts a logging object for any error, warning, or
510  information messages encountered in the source data.
511  @param msglogger Logger object for messages extracted from data
512  """
513 
514  self._sm_sm = SLChannelMap()
515 
516 
517  self._log_log = logging.getLogger(__name__)
518  logging.addLevelName(5, "DDebug")
519 
520 
521  self._msglog_msglog = msglogger
522 
523  if self._msglog_msglog is None:
524  self._log_log.warn("No message logger specified")
525  self._msglog_msglog = logging.getLogger(f"{__name__}.msg")
526 
527  def SourceMap(self):
528  """! @returns Source/Channel map"""
529  return self._sm_sm
530 
531  def FormatMessage(self, msg):
532  """!
533  Pretty print a message
534  @param msg Message object
535  @returns Formatted Message as string
536  """
537  return f"{self._sm.GetSourceName(msg.SourceID)}\t{self._sm.GetChannelName(msg.SourceID, msg.ChannelID)}\t{msg.Data}"
538 
539  def Process(self, message, output="dict", allMessages=False):
540  """!
541  Process an incoming message
542 
543  Accepts SLMessage object or bytes that can be unpacked into one.
544 
545  If a valid message is decoded, internal data structures are updated to
546  provide channel and source names for message formatting and tracking of
547  the last timestamp seen from each source. Any data messages are
548  returned as a string or as a dictionary.
549 
550  @param message Message (or bytes) to be decoded
551  @param output Output format for each message: String, dict, raw (SLMessage)
552  @param allMessages Output messages normally used internally
553  @return Message in selected format
554  """
555  if output not in [None, "string", "dict", "raw"]:
556  raise ValueError("Bad output type")
557 
558  if not isinstance(message, SLMessage):
559  try:
560  message = SLMessage.unpack(message)
561  except ValueError:
562  self._log_log.warning("Bad message encountered, skipping")
563  self._log_log.debug(repr(message))
564  return
565 
566  if not self._sm_sm.SourceExists(message.SourceID):
567  self._sm_sm.NewSource(message.SourceID)
568 
569  suppressOutput = False
570  if message.ChannelID == 0:
571  self._log_log.log(
572  5,
573  f"New name for {self._sm.GetSourceName(message.SourceID)}: {message.Data}",
574  )
575  self._sm_sm.SetSourceName(message.SourceID, message.Data)
576  suppressOutput = True
577  elif message.ChannelID == 1:
578  self._log_log.log(
579  5,
580  f"New channels for {self._sm.GetSourceName(message.SourceID)}: {message.Data}",
581  )
582  self._sm_sm.SetChannelNames(message.SourceID, message.Data)
583  suppressOutput = True
584  elif message.ChannelID == 2:
585  self._log_log.log(
586  5,
587  f"New update time for {self._sm.GetSourceName(message.SourceID)}: {message.Data}",
588  )
589  self._sm_sm.UpdateTimestamp(message.SourceID, message.Data)
590  suppressOutput = True
591  elif message.ChannelID == 125:
592  self._msglog_msglog.info(self.FormatMessageFormatMessage(message))
593  suppressOutput = True
594  elif message.ChannelID == 126:
595  self._msglog_msglog.warning(self.FormatMessageFormatMessage(message))
596  suppressOutput = True
597  elif message.ChannelID == 127:
598  self._msglog_msglog.error(self.FormatMessageFormatMessage(message))
599  suppressOutput = True
600 
601  if allMessages or (not suppressOutput):
602  if output == "dict":
603  return {
604  "sourceID": message.SourceID,
605  "sourceName": self._sm_sm.GetSourceName(message.SourceID),
606  "channelID": message.ChannelID,
607  "channelName": self._sm_sm.GetChannelName(
608  message.SourceID, message.ChannelID
609  ),
610  "data": message.Data,
611  }
612  elif output == "string":
613  return self.FormatMessageFormatMessage(message)
614  elif output == "raw":
615  return message
616  else:
617  return
Mirror ID numbers from base/sources.h.
Definition: SLMessages.py:26
Represent sources being tracked.
Definition: SLMessages.py:259
def __str__(self)
Represent source by its name.
Definition: SLMessages.py:321
def __init__(self, number, name=None, channels=None, lastTimestamp=None)
Tracked source must be identified by name.
Definition: SLMessages.py:265
def __getitem__(self, ch)
Support subscripted access to channel names.
Definition: SLMessages.py:293
def __iter__(self)
Allow iteration over channel list, e.g.
Definition: SLMessages.py:310
Source and channel map data.
Definition: SLMessages.py:251
def SetChannelNames(self, source, channels)
Update channel map for a source, creating if required.
Definition: SLMessages.py:471
def NewSource(self, source, name=None)
Create or update source.
Definition: SLMessages.py:422
def SetSourceName(self, source, name)
Update source name, creating source if required.
Definition: SLMessages.py:459
def ChannelExists(self, source, channel)
Special cases default channels that must always exist (SLCHAN_NAME, SLCHAN_MAP, SLCHAN_TSTAMP,...
Definition: SLMessages.py:442
_s
Dictionary of sources, keyed by ID.
Definition: SLMessages.py:353
def UpdateTimestamp(self, source, timestamp)
Update last timestamp value for a source, creating source if required.
Definition: SLMessages.py:483
def __init__(self)
Initialise blank map.
Definition: SLMessages.py:350
def GetChannelName(self, source, channel)
Definition: SLMessages.py:396
_log
Logger object for later use.
Definition: SLMessages.py:355
def __getitem__(self, ix)
Support subscripted access to sources.
Definition: SLMessages.py:357
Parse incoming messages.
Definition: SLMessages.py:495
def __init__(self, msglogger=None)
Initialise message sink.
Definition: SLMessages.py:505
def FormatMessage(self, msg)
Pretty print a message.
Definition: SLMessages.py:531
def Process(self, message, output="dict", allMessages=False)
Process an incoming message.
Definition: SLMessages.py:539
_sm
Channel Map to be filled from input data.
Definition: SLMessages.py:514
_msglog
Logger for messages extracted from data.
Definition: SLMessages.py:521
ChannelMap
Channel Map: Names for all data channels.
Definition: SLMessages.py:184
SourceID
ID number for this source -.
Definition: SLMessages.py:180
def __init__(self, sourceID, name="PythonDL", dataChannels=1, dataChannelNames=None)
Any valid source must have a source ID, name and a list of named data channels.
Definition: SLMessages.py:171
def TimestampMessage(self)
Placeholder for timestamp message (Channel 2)
Definition: SLMessages.py:229
def DataMessage(self, channelID, data)
Definition: SLMessages.py:241
Python representation of a logged message.
Definition: SLMessages.py:75
Data
Message value / embedded data.
Definition: SLMessages.py:116
def __repr__(self)
Represent class as packed binary data.
Definition: SLMessages.py:143
def __init__(self, sourceID, channelID, data)
Create message with specified source, channel and data.
Definition: SLMessages.py:88
def pack(self)
Return packed binary representation of message.
Definition: SLMessages.py:136
def unpack(cl, data)
Unpack raw messagepack bytes or list into SLMessage.
Definition: SLMessages.py:119