diff options
Diffstat (limited to 'decoders/pjon')
-rw-r--r-- | decoders/pjon/__init__.py | 25 | ||||
-rw-r--r-- | decoders/pjon/pd.py | 517 |
2 files changed, 542 insertions, 0 deletions
diff --git a/decoders/pjon/__init__.py b/decoders/pjon/__init__.py new file mode 100644 index 0000000..579fb59 --- /dev/null +++ b/decoders/pjon/__init__.py @@ -0,0 +1,25 @@ +## +## This file is part of the libsigrokdecode project. +## +## Copyright (C) 2020 Gerhard Sittig <gerhard.sittig@gmx.net> +## +## This program is free software; you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation; either version 2 of the License, or +## (at your option) any later version. +## +## This program is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with this program; if not, see <http://www.gnu.org/licenses/>. +## + +''' +This protocol decoder interprets the PJON protocol on top of the PJDL +link layer (and potentially other link layers). +''' + +from .pd import Decoder diff --git a/decoders/pjon/pd.py b/decoders/pjon/pd.py new file mode 100644 index 0000000..cf03ef6 --- /dev/null +++ b/decoders/pjon/pd.py @@ -0,0 +1,517 @@ +## +## This file is part of the libsigrokdecode project. +## +## Copyright (C) 2020 Gerhard Sittig <gerhard.sittig@gmx.net> +## +## This program is free software; you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation; either version 2 of the License, or +## (at your option) any later version. +## +## This program is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with this program; if not, see <http://www.gnu.org/licenses/>. +## + +# See the https://www.pjon.org/ PJON project page and especially the +# https://www.pjon.org/PJON-protocol-specification-v3.2.php protocol +# specification, which can use different link layers. + +# TODO +# - Handle RX and TX identifiers in separate routines, including the +# optional bus identifers. Track those addresses, add formatters for +# them, and emit "communication relation" details. +# - Check for correct endianess in variable width fields. The spec says +# "network order", that's what this implementation uses. +# - Check for the correct order of optional fields (the spec is not as +# explicit on these details as I'd expect). +# - Check decoder's robustness, completeness, and correctness when more +# captures become available. Currently there are only few, which only +# cover minimal communication, and none of the protocol's flexibility. +# The decoder was essentially written based on the available docs, and +# then took some arbitrary choices and liberties to cope with real life +# data from an example setup. Strictly speaking this decoder violates +# the spec, and errs towards the usability side. + +import sigrokdecode as srd +import struct + +ANN_RX_INFO, ANN_HDR_CFG, ANN_PKT_LEN, ANN_META_CRC, ANN_TX_INFO, \ +ANN_SVC_ID, ANN_PKT_ID, ANN_ANON_DATA, ANN_PAYLOAD, ANN_END_CRC, \ +ANN_SYN_RSP, \ +ANN_RELATION, \ +ANN_WARN, \ + = range(13) + +def calc_crc8(data): + crc = 0 + for b in data: + crc ^= b + for i in range(8): + odd = crc % 2 + crc >>= 1 + if odd: + crc ^= 0x97 + return crc + +def calc_crc32(data): + crc = 0xffffffff + for b in data: + crc ^= b + for i in range(8): + odd = crc % 2 + crc >>= 1 + if odd: + crc ^= 0xedb88320 + crc ^= 0xffffffff + return crc + +class Decoder(srd.Decoder): + api_version = 3 + id = 'pjon' + name = 'PJON' + longname = 'PJON' + desc = 'The PJON protocol.' + license = 'gplv2+' + inputs = ['pjon-link'] + outputs = [] + tags = ['Embedded'] + annotations = ( + ('rx_info', 'Receiver ID'), + ('hdr_cfg', 'Header config'), + ('pkt_len', 'Packet length'), + ('meta_crc', 'Meta CRC'), + ('tx_info', 'Sender ID'), + ('port', 'Service ID'), + ('pkt_id', 'Packet ID'), + ('anon', 'Anonymous data'), + ('payload', 'Payload'), + ('end_crc', 'End CRC'), + ('syn_rsp', 'Sync response'), + ('relation', 'Relation'), + ('warning', 'Warning'), + ) + annotation_rows = ( + ('fields', 'Fields', ( + ANN_RX_INFO, ANN_HDR_CFG, ANN_PKT_LEN, ANN_META_CRC, ANN_TX_INFO, + ANN_SVC_ID, ANN_ANON_DATA, ANN_PAYLOAD, ANN_END_CRC, ANN_SYN_RSP, + )), + ('relations', 'Relations', (ANN_RELATION,)), + ('warnings', 'Warnings', (ANN_WARN,)), + ) + + def __init__(self): + self.reset() + + def reset(self): + self.reset_frame() + + def reset_frame(self): + self.frame_bytes = None + self.ack_bytes = None + self.ann_ss = None + self.ann_es = None + + def start(self): + self.out_ann = self.register(srd.OUTPUT_ANN) + + def putg(self, ss, es, ann, data): + self.put(ss, es, self.out_ann, [ann, data]) + + def frame_flush(self): + if not self.frame_bytes: + return + # TODO Emit "communication relation" details here? + + def handle_field_get_desc(self, idx = None): + '''Lookup description of a PJON frame field.''' + if not self.field_desc: + return None + if idx is None: + idx = self.field_desc_idx + if idx >= 0 and idx >= len(self.field_desc): + return None + if idx < 0 and abs(idx) > len(self.field_desc): + return None + desc = self.field_desc[idx] + return desc + + def handle_field_add_desc(self, fmt, hdl, cls = None): + '''Register description for a PJON frame field.''' + item = { + 'format': fmt, + 'width': struct.calcsize(fmt), + 'handler': hdl, + 'anncls': cls, + } + self.field_desc.append(item) + + def handle_field_seed_desc(self): + '''Seed list of PJON frame fields' descriptions.''' + + # At the start of a PJON frame, the layout of only two fields + # is known. Subsequent fields (their presence, and width) depend + # on the content of the header config field. + + self.field_desc = [] + self.handle_field_add_desc('<B', self.handle_field_rx_id, ANN_RX_INFO) + self.handle_field_add_desc('<B', self.handle_field_config, ANN_HDR_CFG) + + self.field_desc_idx = 0 + self.field_desc_got = 0 + + self.frame_rx_id = None + self.frame_is_broadcast = None + self.frame_tx_id = None + self.frame_payload = None + self.frame_has_ack = None + + def handle_field_rx_id(self, b): + '''Process receiver ID field of a PJON frame.''' + + b = b[0] + + # Track RX info for communication relation emission. + self.frame_rx_id = b + self.frame_is_broadcast = b == 0 + + # Provide text presentation, caller emits frame field annotation. + if b == 255: # "not assigned" + id_txt = 'NA' + elif b == 0: # "broadcast" + id_txt = 'BC' + else: # unicast + id_txt = '{:d}'.format(b) + texts = [ + 'RX_ID {}'.format(id_txt), + '{}'.format(id_txt), + ] + return texts + + def handle_field_config(self, b): + '''Process header config field of a PJON frame.''' + + # Caller provides a list of values. We want a single scalar. + b = b[0] + + # Get the config flags. + self.cfg_shared = b & (1 << 0) + self.cfg_tx_info = b & (1 << 1) + self.cfg_sync_ack = b & (1 << 2) + self.cfg_async_ack = b & (1 << 3) + self.cfg_port = b & (1 << 4) + self.cfg_crc32 = b & (1 << 5) + self.cfg_len16 = b & (1 << 6) + self.cfg_pkt_id = b & (1 << 7) + + # Get a textual presentation of the flags. + text = [] + text.append('pkt_id' if self.cfg_pkt_id else '-') # packet number + text.append('len16' if self.cfg_len16 else '-') # 16bit length not 8bit + text.append('crc32' if self.cfg_crc32 else '-') # 32bit CRC not 8bit + text.append('svc_id' if self.cfg_port else '-') # port aka service ID + text.append('ack_mode' if self.cfg_async_ack else '-') # async response + text.append('ack' if self.cfg_sync_ack else '-') # synchronous response + text.append('tx_info' if self.cfg_tx_info else '-') + text.append('bus_id' if self.cfg_shared else '-') # "shared" vs "local" + text = ' '.join(text) + bits = '{:08b}'.format(b) + texts = [ + 'CFG {:s}'.format(text), + 'CFG {}'.format(bits), + bits + ] + + # TODO Come up with the most appropriate phrases for this logic. + # Are separate instruction groups with repeated conditions more + # readable than one common block which registers fields _and_ + # updates the overhead size? Or is the latter preferrable due to + # easier maintenance and less potential for inconsistency? + + # Get the size of variable width fields, to calculate the size + # of the packet overhead (the part that is not the payload data). + # This lets us derive the payload length when we later receive + # the packet length. + u8_fmt = '>B' + u16_fmt = '>H' + u32_fmt = '>L' + len_fmt = u16_fmt if self.cfg_len16 else u8_fmt + crc_fmt = u32_fmt if self.cfg_crc32 else u8_fmt + self.cfg_overhead = 0 + self.cfg_overhead += struct.calcsize(u8_fmt) # receiver ID + self.cfg_overhead += struct.calcsize(u8_fmt) # header config + self.cfg_overhead += struct.calcsize(len_fmt) # packet length + self.cfg_overhead += struct.calcsize(u8_fmt) # initial CRC, always CRC8 + # TODO Check for completeness and correctness. + if self.cfg_shared: + self.cfg_overhead += struct.calcsize(u32_fmt) # receiver bus + if self.cfg_tx_info: + if self.cfg_shared: + self.cfg_overhead += struct.calcsize(u32_fmt) # sender bus + self.cfg_overhead += struct.calcsize(u8_fmt) # sender ID + if self.cfg_port: + self.cfg_overhead += struct.calcsize(u16_fmt) # service ID + if self.cfg_pkt_id: + self.cfg_overhead += struct.calcsize(u16_fmt) # packet ID + self.cfg_overhead += struct.calcsize(crc_fmt) # end CRC + + # Register more frame fields as we learn about their presence and + # format. Up to this point only receiver ID and header config were + # registered since their layout is fixed. + # + # Packet length and meta CRC are always present but can be of + # variable width. Optional fields follow the meta CRC and preceed + # the payload bytes. Notice that payload length isn't known here + # either, though its position is known already. The packet length + # is yet to get received. Subtracting the packet overhead from it + # (which depends on the header configuration) will provide that + # information. + # + # TODO Check for completeness and correctness. + # TODO Optionally fold overhead size arith and field registration + # into one block of instructions, to reduce the redundancy in the + # condition checks, and raise awareness for incomplete sequences + # during maintenance. + self.handle_field_add_desc(len_fmt, self.handle_field_pkt_len, ANN_PKT_LEN) + self.handle_field_add_desc(u8_fmt, self.handle_field_init_crc, ANN_META_CRC) + if self.cfg_shared: + self.handle_field_add_desc(u32_fmt, ['RX_BUS {:08x}', '{:08x}'], ANN_ANON_DATA) + if self.cfg_tx_info: + if self.cfg_shared: + self.handle_field_add_desc(u32_fmt, ['TX_BUS {:08x}', '{:08x}'], ANN_ANON_DATA) + self.handle_field_add_desc(u8_fmt, ['TX_ID {:d}', '{:d}'], ANN_ANON_DATA) + if self.cfg_port: + self.handle_field_add_desc(u16_fmt, ['PORT {:d}', '{:d}'], ANN_ANON_DATA) + if self.cfg_pkt_id: + self.handle_field_add_desc(u16_fmt, ['PKT {:04x}', '{:04x}'], ANN_ANON_DATA) + pl_fmt = '>{:d}B'.format(0) + self.handle_field_add_desc(pl_fmt, self.handle_field_payload, ANN_PAYLOAD) + self.handle_field_add_desc(crc_fmt, self.handle_field_end_crc, ANN_END_CRC) + + # Emit warning annotations for invalid flag combinations. + warn_texts = [] + wants_ack = self.cfg_sync_ack or self.cfg_async_ack + if wants_ack and not self.cfg_tx_info: + warn_texts.append('ACK request without TX info') + if wants_ack and self.frame_is_broadcast: + warn_texts.append('ACK request for broadcast') + if self.cfg_sync_ack and self.cfg_async_ack: + warn_texts.append('sync and async ACK request') + if self.cfg_len16 and not self.cfg_crc32: + warn_texts.append('extended length needs CRC32') + if warn_texts: + warn_texts = ', '.join(warn_texts) + self.putg(self.ann_ss, self.ann_es, ANN_WARN, [warn_texts]) + + # Have the caller emit the annotation for configuration data. + return texts + + def handle_field_pkt_len(self, b): + '''Process packet length field of a PJON frame.''' + + # Caller provides a list of values. We want a single scalar. + b = b[0] + + # The wire communicates the total packet length. Some of it is + # overhead (non-payload data), while its volume is variable in + # size (dpends on the header configuration). + # + # Derive the payload size from previously observed flags. Update + # the previously registered field description (the second last + # item in the list, before the end CRC). + + pkt_len = b + pl_len = b - self.cfg_overhead + warn_texts = [] + if pkt_len not in range(self.cfg_overhead, 65536): + warn_texts.append('suspicious packet length') + if pkt_len > 15 and not self.cfg_crc32: + warn_texts.append('length above 15 needs CRC32') + if pl_len < 1: + warn_texts.append('suspicious payload length') + pl_len = 0 + if warn_texts: + warn_texts = ', '.join(warn_texts) + self.putg(self.ann_ss, self.ann_es, ANN_WARN, [warn_texts]) + pl_fmt = '>{:d}B'.format(pl_len) + + desc = self.handle_field_get_desc(-2) + desc['format'] = pl_fmt + desc['width'] = struct.calcsize(pl_fmt) + + # Have the caller emit the annotation for the packet length. + # Provide information of different detail level for zooming. + texts = [ + 'LENGTH {:d} (PAYLOAD {:d})'.format(pkt_len, pl_len), + 'LEN {:d} (PL {:d})'.format(pkt_len, pl_len), + '{:d} ({:d})'.format(pkt_len, pl_len), + '{:d}'.format(pkt_len), + ] + return texts + + def handle_field_common_crc(self, have, is_meta): + '''Process a CRC field of a PJON frame.''' + + # CRC algorithm and width are configurable, and can differ + # across meta and end checksums in a frame's fields. + caption = 'META' if is_meta else 'END' + crc_len = 8 if is_meta else 32 if self.cfg_crc32 else 8 + crc_fmt = '{:08x}' if crc_len == 32 else '{:02x}' + have_text = crc_fmt.format(have) + + # Check received against expected checksum. Emit warnings. + warn_texts = [] + data = self.frame_bytes[:-1] + want = calc_crc32(data) if crc_len == 32 else calc_crc8(data) + if want != have: + want_text = crc_fmt.format(want) + warn_texts.append('CRC mismatch - want {} have {}'.format(want_text, have_text)) + if warn_texts: + warn_texts = ', '.join(warn_texts) + self.putg(self.ann_ss, self.ann_es, ANN_WARN, [warn_texts]) + + # Provide text representation for frame field, caller emits + # the annotation. + texts = [ + '{}_CRC {}'.format(caption, have_text), + 'CRC {}'.format(have_text), + have_text, + ] + return texts + + def handle_field_init_crc(self, b): + '''Process initial CRC (meta) field of a PJON frame.''' + # Caller provides a list of values. We want a single scalar. + b = b[0] + return self.handle_field_common_crc(b, True) + + def handle_field_end_crc(self, b): + '''Process end CRC (total frame) field of a PJON frame.''' + # Caller provides a list of values. We want a single scalar. + b = b[0] + return self.handle_field_common_crc(b, False) + + def handle_field_payload(self, b): + '''Process payload data field of a PJON frame.''' + + self.frame_payload = b[:] + + text = ' '.join(['{:02x}'.format(v) for v in b]) + texts = [ + 'PAYLOAD {}'.format(text), + text, + ] + return texts + + def handle_field_sync_resp(self, b): + '''Process synchronous response for a PJON frame.''' + + self.frame_has_ack = b + + texts = [ + 'ACK {:02x}'.format(b), + '{:02x}'.format(b), + ] + return texts + + def decode(self, ss, es, data): + ptype, pdata = data + + # Start frame bytes accumulation when FRAME_INIT is seen. Flush + # previously accumulated frame bytes when a new frame starts. + if ptype == 'FRAME_INIT': + self.frame_flush() + self.reset_frame() + self.frame_bytes = [] + self.handle_field_seed_desc() + return + + # Use IDLE as another (earlier) trigger to flush frames. Also + # trigger flushes on FRAME-DATA which mean that the link layer + # inspection has seen the end of a protocol frame. + # + # TODO Improve usability? Emit warnings for PJON frames where + # FRAME_DATA was seen but FRAME_INIT wasn't? So that users can + # become aware of broken frames. + if ptype in ('IDLE', 'FRAME_DATA'): + self.frame_flush() + self.reset_frame() + return + + # Switch from data bytes to response bytes when WAIT is seen. + if ptype == 'SYNC_RESP_WAIT': + self.ack_bytes = [] + self.ann_ss, self.ann_es = None, None + return + + # Accumulate data bytes as they arrive. Put them in the bucket + # which corresponds to its most recently seen leader. + if ptype == 'DATA_BYTE': + b = pdata + + # Are we collecting response bytes (ACK)? + if self.ack_bytes is not None: + if not self.ann_ss: + self.ann_ss = ss + self.ack_bytes.append(b) + self.ann_es = es + text = self.handle_field_sync_resp(b) + if text: + self.putg(self.ann_ss, self.ann_es, ANN_SYN_RSP, text) + self.ann_ss, self.ann_es = None, None + return + + # Are we collecting frame content? + if self.frame_bytes is not None: + if not self.ann_ss: + self.ann_ss = ss + self.frame_bytes.append(b) + self.ann_es = es + + # Has the field value become available yet? + desc = self.handle_field_get_desc() + if not desc: + return + width = desc.get('width', None) + if not width: + return + self.field_desc_got += 1 + if self.field_desc_got != width: + return + + # Grab most recent received field as a byte array. Get + # the values that it contains. + fmt = desc.get('format', '>B') + raw = bytearray(self.frame_bytes[-width:]) + values = struct.unpack(fmt, raw) + + # Process the value, and get its presentation. Can be + # mere formatting, or serious execution of logic. + hdl = desc.get('handler', '{!r}') + if isinstance(hdl, str): + text = [hdl.format(*values)] + elif isinstance(hdl, (list, tuple)): + text = [f.format(*values) for f in hdl] + elif hdl: + text = hdl(values) + cls = desc.get('anncls', ANN_ANON_DATA) + + # Emit annotation unless the handler routine already did. + if cls is not None and text: + self.putg(self.ann_ss, self.ann_es, cls, text) + self.ann_ss, self.ann_es = None, None + + # Advance scan position for to-get-received field. + self.field_desc_idx += 1 + self.field_desc_got = 0 + return + + # Unknown phase, not collecting. Not synced yet to the input? + return + + # Unknown or unhandled kind of link layer output. + return |