From 72611273ec18a0846d57950313bfe4e320950927 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 10 Jul 2020 17:19:20 +0200 Subject: [PATCH 01/23] Small fixes on devices and doc --- bof/knx/knxdevice.py | 2 +- bof/knx/knxnet.json | 2 +- docs/conf.py | 2 +- examples/cemi.py | 19 +++++++++++-------- examples/discover.py | 2 +- tests/test_knx_device.py | 2 +- 6 files changed, 16 insertions(+), 13 deletions(-) diff --git a/bof/knx/knxdevice.py b/bof/knx/knxdevice.py index d9c964f..effb01e 100644 --- a/bof/knx/knxdevice.py +++ b/bof/knx/knxdevice.py @@ -215,7 +215,7 @@ def knx_address(self, value:str): x = int("".join([str(x) for x in bitlist[:4]]), 2) y = int("".join([str(x) for x in bitlist[4:]]), 2) z = byte.to_int(value[1:]) - value = "{0}/{1}/{2}".format(x, y, z) + value = "{0}.{1}.{2}".format(x, y, z) self.__knx_address = value @property def port(self) -> str: diff --git a/bof/knx/knxnet.json b/bof/knx/knxnet.json index f613f33..aaee5d7 100644 --- a/bof/knx/knxnet.json +++ b/bof/knx/knxnet.json @@ -94,7 +94,7 @@ ], "CONNECT RESPONSE": [ {"name": "communication channel id", "type": "field", "size": 1}, - {"name": "status", "type": "field", "size": 1, "default": "00"}, + {"name": "status", "type": "field", "size": 1}, {"name": "data endpoint", "type": "HPAI"}, {"name": "connection response data block", "type": "CRI_CRD"} ], diff --git a/docs/conf.py b/docs/conf.py index 6bdd337..f913828 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -46,7 +46,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'nature' +# html_theme = 'nature' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, diff --git a/examples/cemi.py b/examples/cemi.py index db6aab5..4a5e02e 100644 --- a/examples/cemi.py +++ b/examples/cemi.py @@ -14,16 +14,17 @@ def connect_request(knxnet, connection_type): if connection_type == "Tunneling Connection": connectreq.body.connection_request_information.append(knx.KnxField(name="link layer", size=1, value=b"\x02")) connectreq.body.connection_request_information.append(knx.KnxField(name="reserved", size=1, value=b"\x00")) - print(connectreq) + # print(connectreq) connectresp = knxnet.send_receive(connectreq) knxnet.channel = connectresp.body.communication_channel_id.value + return connectresp def disconnect_request(knxnet): discoreq = knx.KnxFrame(type="DISCONNECT REQUEST") discoreq.body.communication_channel_id = knxnet.channel discoreq.body.control_endpoint.ip_address.value = byte.from_ipv4(knxnet.source[0]) discoreq.body.control_endpoint.port.value = byte.from_int(knxnet.source[1]) - knxnet.send(discoreq) + knxnet.send_receive(discoreq) def read_property(knxnet, sequence_counter, object_type, property_id): request = knx.KnxFrame(type="CONFIGURATION REQUEST", cemi="PropRead.req") @@ -33,18 +34,17 @@ def read_property(knxnet, sequence_counter, object_type, property_id): request.body.cemi.object_type.value = knxspecs.object_types[object_type] request.body.cemi.object_instance.value = 1 request.body.cemi.property_id.value = knxspecs.properties[object_type][property_id] - print(request) try: response = knxnet.send_receive(request) # ACK while (1): response = knxnet.receive() # PropRead.con if response.sid == "CONFIGURATION REQUEST": - print(response) # We tell the boiboite we received it ack = knx.KnxFrame(type="CONFIGURATION ACK") ack.body.communication_channel_id.value = knxnet.channel ack.body.sequence_counter.value = sequence_counter knxnet.send(ack) + return response except BOFNetworkError: pass #Timeout @@ -57,12 +57,15 @@ def read_property(knxnet, sequence_counter, object_type, property_id): knxnet.connect(argv[1], 3671) # Gather device information -connect_request(knxnet, "Device Management Connection") -read_property(knxnet, 0, "IP PARAMETER OBJECTS", "PID_ADDITIONAL_INDIVIDUAL_ADDRESSES") -read_property(knxnet, 1, "DEVICE", "PID_MANUFACTURER_ID") +connectresp = connect_request(knxnet, "Device Management Connection") +print(connectresp) +knx_addr = read_property(knxnet, 0, "IP PARAMETER OBJECTS", "PID_ADDITIONAL_INDIVIDUAL_ADDRESSES") +print("Device individual address: {0}".format(knx_addr.body.cemi.data)) +# read_property(knxnet, 1, "DEVICE", "PID_MANUFACTURER_ID") disconnect_request(knxnet) # Establish tunneling connection to read and write objects -connect_request(knxnet, "Tunneling Connection") +connectresp = connect_request(knxnet, "Tunneling Connection") +print("Device individual address: {0}".format(connectresp.body.connection_response_data_block.knx_address.value)) # TODO disconnect_request(knxnet) diff --git a/examples/discover.py b/examples/discover.py index c6c6818..376e01c 100644 --- a/examples/discover.py +++ b/examples/discover.py @@ -14,7 +14,7 @@ # print(frame) knxnet.send(frame) response = knxnet.receive() - # print(response) + print(response) device = knx.KnxDevice(response, ip_address=ip, port=port) print(device) except BOFNetworkError as bne: diff --git a/tests/test_knx_device.py b/tests/test_knx_device.py index 70845b9..de000b4 100644 --- a/tests/test_knx_device.py +++ b/tests/test_knx_device.py @@ -68,4 +68,4 @@ def tearDown(self): def test_01_knx_device_from_descrresp(self): device = knx.KnxDevice(self.connection.send_receive(knx.KnxFrame(type="DESCRIPTION_REQUEST")), ip_address=BOIBOITE) - self.assertEqual(device.knx_address, "15/15/255") + self.assertEqual(device.knx_address, "15.15.255") From 41a537f52f64bc43b43f65fdcb508636f83f7eac Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 10 Jul 2020 18:50:39 +0200 Subject: [PATCH 02/23] KnxFrame now inherits from BOFFrame, separation is in progress --- bof/__init__.py | 6 +++ bof/frame.py | 106 ++++++++++++++++++++++++++++++++++++++++++++ bof/knx/knxframe.py | 102 +++++++++++------------------------------- 3 files changed, 139 insertions(+), 75 deletions(-) create mode 100644 bof/frame.py diff --git a/bof/__init__.py b/bof/__init__.py index 687e2d4..55b8dc4 100644 --- a/bof/__init__.py +++ b/bof/__init__.py @@ -14,6 +14,11 @@ The content of this class should not be used directly, unless writing a new protocol submodule. Available from direct bof import (``import bof``) +:frame: + Generic frame representation as objects within BOF. Classes from this + submodule should be inherited by protocol implementations, but they should + not be used directly by the end user. + :byte: Set of functions for byte conversion and handling. Accessed via import of the byte submodule (``from bof import byte``). @@ -31,4 +36,5 @@ from .base import * from .network import * +from .frame import * from .byte import * diff --git a/bof/frame.py b/bof/frame.py new file mode 100644 index 0000000..ab50543 --- /dev/null +++ b/bof/frame.py @@ -0,0 +1,106 @@ +"""Global objects from frames representation on different protocols. +Classes in this module should be inherited by protocol implementations. + +We assume that a frame has the following structure: + +:Frame: The global frame structure as a ``BOFFrame`` object. +:Block: The frame contains one or more blocks as ``BOFBlock`` objects. +:Field: Each block contains one or more fieds (final byte arrays) as + ``BOFField`` objects. +:BitField: A field can be divided into subfields which are not on entire + bytes (ex: 4 bits long or 12 bit long). They are reprensented + as ``BOFBitField`` objects within a field. +""" + +from textwrap import indent + +class BOFBitField(object): + pass + +class BOFField(object): + pass + +class BOFBlock(object): + pass + +#-----------------------------------------------------------------------------# +# Network frames / datagram representation # +#-----------------------------------------------------------------------------# + +class BOFFrame(object): + """Object representation of a protocol-independent network frame. Protocol + implementations with the following properties should inherit this class from + frame representation; + - The frame is sent and receive as a byte array + - The frame contains a set of blocks + - The order of blocks is defined, blocks are named. + + :param blocks: A dictionary containing blocks. + + .. warning: We rely on Python 3.6+'s ordering by insertion. If you use an + older implementation of Python, blocks may not come in the + right order (and I don't think BOF would work anyway). + """ + _blocks:dict + + def __init__(self): + self._blocks = {} + + def __bytes__(self): + self.update() + return self.raw + + def __len__(self): + self.update() + return len(self.raw) + + def __iter__(self): + yield from self.fields + + def __str__(self): + display = ["{0} object: {1}".format(self.__class__.__name__, repr(self))] + for block in self._blocks: + display += ["[{0}]".format(block.upper())] + for attr in self._blocks[block].content: + display += [indent(str(attr), " ")] + return "\n".join(display) + + #-------------------------------------------------------------------------# + # Public # + #-------------------------------------------------------------------------# + + def update(self) -> None: + """Automatically update all fields corresponding to block lengths + (Key ``is_length: True`` in the JSON file and/or attribute + ``is_length`` == ``True`` in a ``BOFField`` object). + + If a block has been modified and its size has changed, we need the + total block length field (a lot of protocol use such fields) to match. + If the ``BOFField`` has the attribute ``fixed_value`` set to ``True`` + (it usually happens when the value of this field has been changed + manually), then the value is not updated automatically. + """ + for block in self._blocks.values(): + block.update() + + #-------------------------------------------------------------------------# + # Properties # + #-------------------------------------------------------------------------# + + @property + def raw(self) -> bytes: + """Builds the raw byte array by combining all blocks in frame.""" + self.update() + return b''.join([bytes(block) for block in self._blocks.values()]) + + @property + def fields(self) -> list: + """Returns the content of the frame as a list of final fields.""" + self.update() + return sum([block.fields for block in self._blocks.values()], []) + + @property + def attributes(self) -> list: + """Returns the name of the fields contained in the frame.""" + self.update() + return sum([block.attributes for block in self._blocks.values()], []) diff --git a/bof/knx/knxframe.py b/bof/knx/knxframe.py index f03bb24..b13a782 100644 --- a/bof/knx/knxframe.py +++ b/bof/knx/knxframe.py @@ -29,7 +29,8 @@ from textwrap import indent from ..base import BOFProgrammingError, load_json, to_property, log -from ..network import UDPField, UDPBlock +from ..frame import BOFFrame, BOFBlock, BOFField, BOFBitField +from ..network import UDPField, UDPBlock # TODO from .. import byte ############################################################################### @@ -649,7 +650,7 @@ def _add_property(self, name, pointer:object) -> None: # KNX frames / datagram representation # #-----------------------------------------------------------------------------# -class KnxFrame(object): +class KnxFrame(BOFFrame): """Object representation of a KNX message (frame) with methods to build and read KNX datagrams. @@ -674,8 +675,6 @@ class KnxFrame(object): **KNX Standard v2.1 03_08_02** """ __source:tuple - __header:KnxBlock - __body:KnxBlock __specs:KnxSpec def __init__(self, **kwargs): @@ -704,10 +703,11 @@ def __init__(self, **kwargs): :param source: Source address of a frame, as a tuple (ip;str, port:int) Only used is param `frame` is set. """ + super().__init__() # Empty frame (no parameter) self.__source = ("",0) - self.__header = KnxBlock(type="header") - self.__body = KnxBlock(name="body") + self._blocks["header"] = KnxBlock(type="header") + self._blocks["body"] = KnxBlock(name="body") self.__specs = KnxSpec() # Fill in the frame according to parameters if "source" in kwargs: @@ -724,29 +724,6 @@ def __init__(self, **kwargs): # Update total frame length in header self.update() - def __bytes__(self): - """Overload so that bytes(frame) returns the raw KnxFrame bytearray.""" - self.update() - return self.raw - - def __len__(self): - """Return the size of the block in total number of bytes.""" - self.update() - return len(self.raw) - - def __str__(self): - ret = ["{0} object: {1}".format(self.__class__.__name__, repr(self))] - ret += ["[HEADER]"] - for attr in self.header.content: - ret += [indent(str(attr), " ")] - ret += ["[BODY]"] - for attr in self.body.content: - ret += [indent(str(attr), " ")] - return "\n".join(ret) - - def __iter__(self): - yield from self.fields - #-------------------------------------------------------------------------# # Public # #-------------------------------------------------------------------------# @@ -789,14 +766,14 @@ def build_from_sid(self, sid, cemi:str=None, optional:bool=False) -> None: raise BOFProgrammingError("Service {0} does not exist.".format(sid)) else: raise BOFProgrammingError("Service id should be a string or a bytearray.") - self.__body.append(KnxBlock.factory(template=self.__specs.bodies[sid], + self._blocks["body"].append(KnxBlock.factory(template=self.__specs.bodies[sid], cemi=cemi, optional=optional)) # Add fields names as properties to body :) - for field in self.__body.fields: - self.__body._add_property(field.name, field) + for field in self._blocks["body"].fields: + self._blocks["body"]._add_property(field.name, field) if sid in self.__specs.service_identifiers.keys(): value = bytes.fromhex(self.__specs.service_identifiers[sid]["id"]) - self.__header.service_identifier._update_value(value) + self._blocks["header"].service_identifier._update_value(value) self.update() def build_from_frame(self, frame:bytes) -> None: @@ -815,16 +792,16 @@ def build_from_frame(self, frame:bytes) -> None: """ # HEADER - self.__header = KnxBlock(type="HEADER", name="header") - self.__header.fill(frame[:frame[0]]) + self._blocks["header"] = KnxBlock(type="HEADER", name="header") + self._blocks["header"].fill(frame[:frame[0]]) blocklist = None for service in self.__specs.service_identifiers: attributes = self.__specs.service_identifiers[service] - if bytes(self.__header.service_identifier) == bytes.fromhex(attributes["id"]): + if bytes(self._blocks["header"].service_identifier) == bytes.fromhex(attributes["id"]): blocklist = self.__specs.bodies[service] break if not blocklist: - raise BOFProgrammingError("Unknown service identifier ({0})".format(self.__header.service_identifier.value)) + raise BOFProgrammingError("Unknown service identifier ({0})".format(self._blocks["header"].service_identifier.value)) # BODY cursor = frame[0] # We start at index len(header) (== 6) for block in blocklist: @@ -846,7 +823,7 @@ def build_from_frame(self, frame:bytes) -> None: else: block_object.fill(frame[cursor:cursor+frame[cursor]]) cursor += frame[cursor] - self.__body.append(block_object) + self._blocks["body"].append(block_object) def remove(self, name:str) -> None: """Remove the block/field ``name`` from the header or body, as long as @@ -863,7 +840,7 @@ def remove(self, name:str) -> None: print([x for x in frame.attributes]) """ name = name.lower() - for block in [self.__header, self.__body]: + for block in [self._blocks["header"], self._blocks["body"]]: for item in block.attributes: if item == to_property(name): item = getattr(block, item) @@ -875,18 +852,14 @@ def remove(self, name:str) -> None: del item def update(self): - """Update all fields corresponding to block lengths. Ex: if a - block has been modified, the update will change the value of - the block length field to match (unless this field's ``fixed_value`` - boolean is set to True. + """Update all fields corresponding to block lengths. - For frames, the ``update()`` methods also update the ``total length`` + For KNX frames, the ``update()`` methods also update the ``total length`` field in header, which requires an additional operation. """ - self.__body.update() - self.__header.update() - if "total_length" in self.__header.attributes: - self.__header.total_length._update_value(byte.from_int(len(self.__header) + len(self.__body))) + super().update() + if "total_length" in self._blocks["header"].attributes: + self._blocks["header"].total_length._update_value(byte.from_int(len(self._blocks["header"]) + len(self._blocks["body"]))) #-------------------------------------------------------------------------# # Properties # @@ -894,33 +867,12 @@ def update(self): @property def header(self): - """Builds the raw byte set and returns it.""" self.update() - return self.__header - + return self._blocks["header"] @property def body(self): - """Builds the raw byte set and returns it.""" - self.update() - return self.__body - - @property - def raw(self): - """Builds the raw byte set and returns it.""" - self.update() - return bytes(self.__header) + bytes(self.__body) - - @property - def fields(self) -> list: - """Build an array with all the fields in header + body.""" - self.update() - return self.__header.fields + self.__body.fields - - @property - def attributes(self) -> list: - """Builds an array with the names of all attributes in header + body.""" self.update() - return self.__header.attributes + self.__body.attributes + return self._blocks["body"] @property def sid(self) -> str: @@ -929,15 +881,15 @@ def sid(self) -> str: """ for service in self.__specs.service_identifiers: attributes = self.__specs.service_identifiers[service] - if bytes(self.__header.service_identifier) == bytes.fromhex(attributes["id"]): + if bytes(self._blocks["header"].service_identifier) == bytes.fromhex(attributes["id"]): return service - return str(self.__header.service_identifier.value) + return str(self._blocks["header"].service_identifier.value) @property def cemi(self) -> str: """Return the type of cemi, if any.""" - if "cemi" in self.__body.attributes: + if "cemi" in self._blocks["body"].attributes: for cemi in self.__specs.cemis: - if bytes(self.__body.cemi.message_code) == bytes.fromhex(self.__specs.cemis[cemi]["id"]): + if bytes(self._blocks["body"].cemi.message_code) == bytes.fromhex(self.__specs.cemis[cemi]["id"]): return cemi return "" From a96f36b0760daf928297704355d4045c9d75a0e1 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 10 Jul 2020 19:21:19 +0200 Subject: [PATCH 03/23] Continued moving KNX frame content to BOF frame generic objects with unittests, marked things to review as TODO --- bof/frame.py | 41 +++++++++++++++++++++++++++++++++++ bof/knx/knxframe.py | 47 +++++++++++------------------------------ tests/test_knx_frame.py | 11 +++++++--- 3 files changed, 61 insertions(+), 38 deletions(-) diff --git a/bof/frame.py b/bof/frame.py index ab50543..b4f8697 100644 --- a/bof/frame.py +++ b/bof/frame.py @@ -14,6 +14,8 @@ from textwrap import indent +from .base import BOFProgrammingError, to_property + class BOFBitField(object): pass @@ -69,6 +71,45 @@ def __str__(self): # Public # #-------------------------------------------------------------------------# + def append(self, name, block) -> None: + """Appends a block to the list of blocks. Creates an attribute with + the same name in the class. + + :param name: Name of the block to append. + :param block: Block, must inherit from ``BOFBlock``. + :raises BOFProgrammingError: If block is not a subclass of + ``BOFBlock``. + """ + if not isinstance(block, BOFBlock): + raise BOFProgrammingError("Frame can only contain BOF blocks.") + self._blocks[name] = block + setattr(self, to_property(name), self._blocks[name]) + + def remove(self, name:str) -> None: + """Remove a block or feld according to its name from the frame. + + If several fields share the same name, only the first one is removed. + + :param name: Name of the field to remove. + :raises BOFprogrammingError: if there is no field with such name. + + Example:: + + frame.remove("control_endpoint") + print([x for x in frame.attributes]) + """ + name = name.lower() + for block in self._blocks.values(): + for item in block.attributes: + if item == to_property(name): + item = getattr(block, item) + if isinstance(item, BOFBlock): + for field in item.fields: + item.remove(to_property(field.name)) + delattr(block, to_property(field.name)) + delattr(block, to_property(name)) + del item + def update(self) -> None: """Automatically update all fields corresponding to block lengths (Key ``is_length: True`` in the JSON file and/or attribute diff --git a/bof/knx/knxframe.py b/bof/knx/knxframe.py index b13a782..459e5dd 100644 --- a/bof/knx/knxframe.py +++ b/bof/knx/knxframe.py @@ -38,8 +38,9 @@ ############################################################################### KNXSPECFILE = "knxnet.json" -KNXFIELDSEP = "," +KNXFIELDSEP = "," # TODO +# TODO class KnxSpec(object): """Singleton class for KnxSpec specification content usage. @@ -123,6 +124,7 @@ def clear(self): # KNX fields (byte or byte array) representation # #-----------------------------------------------------------------------------# +# TODO class KnxField(UDPField): """A ``KnxField`` is a set of raw bytes with a name, a size and a content (``value``). @@ -357,7 +359,8 @@ def _update_value(self, content) -> None: # KNX blocks (set of fields) representation # #-----------------------------------------------------------------------------# -class KnxBlock(UDPBlock): +# TODO +class KnxBlock(UDPBlock, BOFBlock): """A ``KnxBlock`` contains an ordered set of nested blocks and/or an ordered set of fields (``KnxField``) of one or more bytes. @@ -704,14 +707,11 @@ def __init__(self, **kwargs): Only used is param `frame` is set. """ super().__init__() - # Empty frame (no parameter) - self.__source = ("",0) + # We do not use BOFFrame.append() because we use properties (not attrs) self._blocks["header"] = KnxBlock(type="header") self._blocks["body"] = KnxBlock(name="body") - self.__specs = KnxSpec() - # Fill in the frame according to parameters - if "source" in kwargs: - self.__source = kwargs["source"] + self.__specs = KnxSpec() # TODO + self.__source = kwargs["source"] if "source" in kwargs else ("",0) if "type" in kwargs: cemi = kwargs["cemi"] if "cemi" in kwargs else None optional = kwargs["optional"] if "optional" in kwargs else False @@ -728,6 +728,7 @@ def __init__(self, **kwargs): # Public # #-------------------------------------------------------------------------# + # TODO def build_from_sid(self, sid, cemi:str=None, optional:bool=False) -> None: """Fill in the KnxFrame object according to a predefined frame format corresponding to a service identifier. The frame format (blocks @@ -776,6 +777,7 @@ def build_from_sid(self, sid, cemi:str=None, optional:bool=False) -> None: self._blocks["header"].service_identifier._update_value(value) self.update() + # TODO def build_from_frame(self, frame:bytes) -> None: """Fill in the KnxFrame object using a frame as a raw byte array. This method is used when receiving and parsing a file from a KNX object. @@ -825,32 +827,6 @@ def build_from_frame(self, frame:bytes) -> None: cursor += frame[cursor] self._blocks["body"].append(block_object) - def remove(self, name:str) -> None: - """Remove the block/field ``name`` from the header or body, as long as - name is in the frame's attributes. - - If several fields have the same name, only the first one is removed. - - :param name: Name of the field to remove. - :raises BOFProgrammingError: if there is no corresponding field. - - Example:: - - frame.remove("control_endpoint") - print([x for x in frame.attributes]) - """ - name = name.lower() - for block in [self._blocks["header"], self._blocks["body"]]: - for item in block.attributes: - if item == to_property(name): - item = getattr(block, item) - if isinstance(item, KnxBlock): - for field in item.fields: - item.remove(to_property(field.name)) - delattr(block, to_property(field.name)) - delattr(block, to_property(name)) - del item - def update(self): """Update all fields corresponding to block lengths. @@ -859,7 +835,8 @@ def update(self): """ super().update() if "total_length" in self._blocks["header"].attributes: - self._blocks["header"].total_length._update_value(byte.from_int(len(self._blocks["header"]) + len(self._blocks["body"]))) + total = sum([len(block) for block in self._blocks.values()]) + self._blocks["header"].total_length._update_value(byte.from_int(total)) #-------------------------------------------------------------------------# # Properties # diff --git a/tests/test_knx_frame.py b/tests/test_knx_frame.py index 44f386b..253e6f0 100644 --- a/tests/test_knx_frame.py +++ b/tests/test_knx_frame.py @@ -140,14 +140,19 @@ def test_02_knx_create_field_length(self): new_header.append(knx.KnxField(name="fuel", size=2, value=666)) new_header.update() self.assertEqual(bytes(new_header.gasoline), b'\x00\x00\x05') - def test_03_knx_remove_field_by_name(self): + def test_03_knx_append_field(self): + """Test the append method of a frame.""" + frame = knx.KnxFrame(type="DESCRIPTION REQUEST") + frame.append("toto", knx.KnxBlock(type="SERVICE_FAMILY")) + self.assertIn("version", frame.attributes) + def test_04_knx_remove_field_by_name(self): """Test that a field can be removed according to its name.""" frame = knx.KnxFrame(type="DESCRIPTION REQUEST") self.assertIn("ip_address", frame.body.attributes) frame.body.remove("ip_address") self.assertNotIn("ip_address", frame.body.attributes) self.assertEqual(bytes(frame.body), b'\x04\x01\x00\x00') - def test_04_knx_multiple_fields_same_name(self): + def test_05_knx_multiple_fields_same_name(self): """Test the behavior in case multiple fields have the same name.""" body = knx.KnxBlock() body.append(knx.KnxField(name="gasoline", size=1, value=1)) @@ -156,7 +161,7 @@ def test_04_knx_multiple_fields_same_name(self): self.assertEqual(bytes(body), b'\x01\x00\x15') body.remove("gasoline") self.assertEqual(bytes(body), b'\x00\x15') - def test_05_knx_blockception(self): + def test_06_knx_blockception(self): """Test that we can do blockception""" block = knx.KnxBlock(name="atoll") block.append(knx.KnxField(name="pom-")) From 7858de8cbd9583138e8d0f49a6e7176033970f95 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 15 Jul 2020 09:26:46 +0200 Subject: [PATCH 04/23] Extracted specification data management class from KNX --- bof/__init__.py | 1 + bof/base.py | 16 ------- bof/knx/knxframe.py | 82 +++++----------------------------- bof/spec.py | 106 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 87 deletions(-) create mode 100644 bof/spec.py diff --git a/bof/__init__.py b/bof/__init__.py index 55b8dc4..08f735d 100644 --- a/bof/__init__.py +++ b/bof/__init__.py @@ -38,3 +38,4 @@ from .network import * from .frame import * from .byte import * +from .spec import * diff --git a/bof/base.py b/bof/base.py index a0de863..c2f4e60 100644 --- a/bof/base.py +++ b/bof/base.py @@ -6,7 +6,6 @@ """ import logging -import json from datetime import datetime from re import sub @@ -102,21 +101,6 @@ def log(message:str, level:str="INFO") -> bool: logging.log(level, message) return _LOGGING_ENABLED -############################################################################### -# BOF JSON FILE HANDLING # -############################################################################### - -def load_json(filename:str) -> dict: - """Loads a JSON file and returns the associated dictionary. - - :raises BOFLibraryError: if the file cannot be opened. - """ - try: - with open(filename, 'r') as jsonfile: - return json.load(jsonfile) - except Exception as e: - raise BOFLibraryError("JSON File {0} cannot be used.".format(filename)) from None - ############################################################################### # STRING MANIPULATION # ############################################################################### diff --git a/bof/knx/knxframe.py b/bof/knx/knxframe.py index 459e5dd..d8dc135 100644 --- a/bof/knx/knxframe.py +++ b/bof/knx/knxframe.py @@ -28,93 +28,31 @@ from ipaddress import ip_address from textwrap import indent -from ..base import BOFProgrammingError, load_json, to_property, log +from ..base import BOFProgrammingError, to_property, log from ..frame import BOFFrame, BOFBlock, BOFField, BOFBitField +from ..spec import BOFSpec from ..network import UDPField, UDPBlock # TODO from .. import byte -############################################################################### +#-----------------------------------------------------------------------------# # KNX SPECIFICATION CONTENT # -############################################################################### +#-----------------------------------------------------------------------------# KNXSPECFILE = "knxnet.json" -KNXFIELDSEP = "," # TODO -# TODO -class KnxSpec(object): +class KnxSpec(BOFSpec): """Singleton class for KnxSpec specification content usage. - - Specification file is a JSON file with the following format:: - - { - "category1": [ - {"name": "1-1", "attr1": "attr1-1", "attr2": "attr1-1"}, - {"name": "1-2", "attr1": "attr1-2", "attr2": "attr1-2"} - ], - "category2": [ - {"name": "2-1", "type": "type1", "attr1": "attr2-1", "attr2": "attr2-1"}, - {"name": "2-2", "type": "type2", "attr1": "attr2-2", "attr2": "attr2-2"} - ], - } - - ``categories`` can be accessed from this object using attributes. Ex:: - - for template in KnxSpec().category1: - print(template.name) + Inherits ``BOFSpec``. The default specification is ``knxnet.json`` however the end user is free to modify this file (add categories, contents and attributes) or create a new file following this format. """ - __instance = None - __is_init = False - - def __new__(cls): - if cls.__instance is None: - cls.__instance = object.__new__(cls) - return cls.__instance def __init__(self, filepath:str=None): - """If filepath is not specified, we load the default file.""" - if not self.__is_init: - if filepath: - self.load(filepath) - else: - self.load(path.join(path.dirname(path.realpath(__file__)), "knxnet.json")) - self.__is_init = True - - def load(self, filepath): - """Loads the content of a JSON file and adds its categories as attributes - to this class. - - If a file was loaded previously, the content will be added to previously - added content, unless the ``clear()`` method is called first. - - :param filepath: Absolute path of a JSON file to load. - :raises BOFLibraryError: If file cannot be used as JSON spec file. - - Usage:: - - spec.load("knxpec_extention.json") - """ - content = load_json(filepath) - for key in content.keys(): - setattr(self, to_property(key), content[key]) - - def clear(self): - """Remove all content loaded in class KnxSpec previously, and associated - attributes. - - Usage:: - - KnxSpec - spec.clear() - spec.load("knxpec.json") - """ - # Wee need to save the dict first as it changes in the loop - attributes = list(self.__dict__.keys()).copy() - for key in attributes: - delattr(self, key) + if not filepath: + filepath = path.join(path.dirname(path.realpath(__file__)), KNXSPECFILE) + super().__init__(filepath) ############################################################################### # KNX FRAME CONTENT # @@ -124,6 +62,8 @@ def clear(self): # KNX fields (byte or byte array) representation # #-----------------------------------------------------------------------------# +KNXFIELDSEP = "," + # TODO class KnxField(UDPField): """A ``KnxField`` is a set of raw bytes with a name, a size and a content diff --git a/bof/spec.py b/bof/spec.py new file mode 100644 index 0000000..1daa5d9 --- /dev/null +++ b/bof/spec.py @@ -0,0 +1,106 @@ +"""BOF should not contain code that is bound to a specific version of a +protocol's specifications. Therefore, message structures, error codes, +block contents, field names, etc. should be written to an external JSON +file and called within the code. Data from the specification should then +be stored within a protocol's implementation in a ``BOFSpec`` object or +in a class inherited from ``BOFSpec``. + +A specification file is a JSON file with the following format:: + + { + "category1": [ + {"name": "1-1", "attr1": "attr1-1", "attr2": "attr1-1"}, + {"name": "1-2", "attr1": "attr1-2", "attr2": "attr1-2"} + ], + "category2": [ + {"name": "2-1", "type": "type1", "attr1": "attr2-1", "attr2": "attr2-1"}, + {"name": "2-2", "type": "type2", "attr1": "attr2-2", "attr2": "attr2-2"} + ], + } + +``categories`` can be accessed from this object using attributes. Ex:: + + for template in BOFSpec().category1: + print(template.name) +""" + +import json + +from .base import BOFLibraryError, to_property + +#-----------------------------------------------------------------------------# +# JSON file management functions # +#-----------------------------------------------------------------------------# + +def load_json(filename:str) -> dict: + """Loads a JSON file and returns the associated dictionary. + + :raises BOFLibraryError: if the file cannot be opened. + """ + try: + with open(filename, 'r') as jsonfile: + return json.load(jsonfile) + except Exception as e: + raise BOFLibraryError("JSON File {0} cannot be used.".format(filename)) #from None + +#-----------------------------------------------------------------------------# +# BOF Specification object # +#-----------------------------------------------------------------------------# + +class BOFSpec(object): + """Singleton containing the data related to a protocol's specification, + retrieved as a JSON file. The object should be instantiated whenever + protocol-specific data is required in an implementation, in order not to + bound the code to the specification too tightly. + """ + __instance = None + __is_init = False + + def __new__(cls): + if cls.__instance is None: + cls.__instance = object.__new__(cls) + return cls.__instance + + def __init__(self, filepath:str=None): + """Iniatialize the specification object with a JSON file. + If file is not specified, we create an empty instance which can be + filled later with the method ``load``. + + :param filepath: Absolute path to the JSON file. + """ + if not self.__is_init: + if filepath: + self.load(filepath) + self.__is_init = True + + def load(self, filepath): + """Loads the content of a JSON file and adds its categories as attributes + to this class. + + If a file was loaded previously, the content will be added to previously + added content, unless the ``clear()`` method is called first. + + :param filepath: Absolute path of a JSON file to load. + :raises BOFLibraryError: If file cannot be used as JSON spec file. + + Usage:: + + spec.load("knxnet.json") + """ + content = load_json(filepath) + for key in content.keys(): + setattr(self, to_property(key), content[key]) + + def clear(self): + """Remove all content loaded in class previously, and associated + attributes. + + Usage:: + + spec.clear() + spec.load("knxnet.json") + """ + # We need to save the dict first as it changes in the loop + attributes = list(self.__dict__.keys()).copy() + for key in attributes: + delattr(self, key) From f120238359d8abce3459191074ad13172929aaca Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 15 Jul 2020 10:37:33 +0200 Subject: [PATCH 05/23] BOFBlock object created from KNXBlock and UDPBlock. UDPBlock removed --- bof/frame.py | 178 +++++++++++++++++++++++++++++++++++++++++- bof/knx/knxframe.py | 185 +++++--------------------------------------- bof/network.py | 47 ----------- 3 files changed, 193 insertions(+), 217 deletions(-) diff --git a/bof/frame.py b/bof/frame.py index b4f8697..c84e6f8 100644 --- a/bof/frame.py +++ b/bof/frame.py @@ -16,18 +16,190 @@ from .base import BOFProgrammingError, to_property +############################################################################### +# Bit field representation within a field # +############################################################################### + class BOFBitField(object): pass +############################################################################### +# Field representation within a block # +############################################################################### + class BOFField(object): pass +############################################################################### +# Block representation within a frame # +############################################################################### + class BOFBlock(object): - pass + """A ``BOFBlock`` object represents a block (set of fields) within a + frame. It contains an ordered set of nested blocks and/or fields + (``BOFField``). + + Implementations should inherit this class for block management inside + frames. + + :param name: Name of the block, so that it can be referred to by its name. + It is also use to create an attribute in the parent block. + :param content: List of blocks, fields or both. + """ + _name:str + _content:list + + def __init__(self, **kwargs): + self.name = kwargs["name"] if "name" in kwargs else "" + self._content = [] + + def __bytes__(self): + return b''.join(bytes(item) for item in self._content) + + def __len__(self): + return len(bytes(self)) + + def __str__(self): + ret = ["{0}: {1}".format(self.__class__.__name__, self._name)] + for item in self._content: + ret += [indent(str(item), " ")] + return "\n".join(ret) + + def __iter__(self): + yield from self.fields + + #-------------------------------------------------------------------------# + # Public # + #-------------------------------------------------------------------------# + + def append(self, content) -> None: + """Appends a block, a field or a list of blocks and/fields to + current block's content. Adds the name of the block to the list + of current's block properties. Ex: if ``block.name`` is ``foo``, + it could be referred to as ``self.foo``. + + :param block: ``BOFBlock``, ``BOFField`` or a list of such objects. + + Example:: + + block = KnxBlock(name="atoll") + block.append(KnxField(name="pom")) + block.append(KnxBlock(name="galli")) + """ + if isinstance(content, BOFField) or isinstance(content, BOFBlock): + self._content.append(content) + # Add the name of the block as a property to this instance + if isinstance(content.name, list): + for subname in content.name: + setattr(self, to_property(subname), content.subfield[subname]) + setattr(self, to_property(" ".join(content.name)), content) + elif len(content.name) > 0: + setattr(self, to_property(content.name), content) + elif isinstance(content, list): + for item in content: + self.append(item) + self.update() + + def update(self): + """Update all fields corresponding to lengths. Ex: if a block has been + modified, the update will change the value of the block length field + to match (unless this field's ``fixed_value`` boolean is set to True. + + Example:: + + header.service_identifier.value = b"\x01\x02\x03" + header.update() + print(header.header_length.value) + """ + for item in self._content: + if isinstance(item, BOFBlock): + item.update() + elif isinstance(item, BOFField): + if item.is_length: + item._update_value(len(self)) + + def remove(self, name:str) -> None: + """Remove the field ``name`` from the block (or nested block). + If several fields have the same name, only the first one is removed. + + :param name: Name of the field to remove. + :raises BOFProgrammingError: if there is no corresponding field. + + Example:: + + body = knx.KnxBlock() + body.append(knx.KnxField(name="abitbol", size=30, value="monde de merde")) + body.append(knx.KnxField(name="francky", size=30, value="cest oit")) + body.remove("abitbol") + print([x.name for x in body.fields]) + """ + name = name.lower() + for item in self._content: + if isinstance(item, BOFBlock): + delattr(self, to_property(name)) + item.remove(name) + elif isinstance(item, BOFField): + if item.name == name or to_property(item.name) == name: + self._content.remove(item) + delattr(self, to_property(name)) + del(item) + break + + #-------------------------------------------------------------------------# + # Internal (should not be used by end users) # + #-------------------------------------------------------------------------# + + def _add_property(self, name, pointer:object) -> None: + """Add a property to the object using ``setattr``, should not be used + outside module. + + :param name: Property name (string or list if field has subfields) + :param pointer: The object the property refers to. + """ + if isinstance(name, list): + for subname in name: + setattr(self, to_property(subname), pointer.subfield[subname]) + elif len(name) > 0: + setattr(self, to_property(name), pointer) + + #-------------------------------------------------------------------------# + # Properties # + #-------------------------------------------------------------------------# + + @property + def name(self) -> str: + return self._name + @name.setter + def name(self, name:str): + if isinstance(name, str): + self._name = name.lower() + else: + raise BOFProgrammingError("Block name should be a string.") + + @property + def fields(self) -> list: + self.update() + fieldlist = [] + for item in self._content: + if isinstance(item, BOFBlock): + fieldlist += item.fields + elif isinstance(item, BOFField): + fieldlist.append(item) + return fieldlist + + @property + def attributes(self) -> list: + """Gives the list of attributes added to the block (field names).""" + self.update() + return [x for x in self.__dict__.keys() if not x.startswith("_")] + + @property + def content(self) -> list: + return self._content -#-----------------------------------------------------------------------------# +############################################################################### # Network frames / datagram representation # -#-----------------------------------------------------------------------------# +############################################################################### class BOFFrame(object): """Object representation of a protocol-independent network frame. Protocol diff --git a/bof/knx/knxframe.py b/bof/knx/knxframe.py index d8dc135..07577e9 100644 --- a/bof/knx/knxframe.py +++ b/bof/knx/knxframe.py @@ -2,8 +2,9 @@ KNX frame handling ------------------ -KNXnet/IP frames handling implementation, implementing ``bof.network``'s -``UDPBlock`` and ``UDPField`` classes. +KNXnet/IP frames handling implementation, implementing ``bof.frame``'s +``BOFSpec``, ``BOFFrame``, ``BOFBlock``, ``BOFField`` and ``BOFBitField`` +classes. A KNX frame (``KnxFrame``) is a byte array divided into a set of blocks. A frame always has the following format: @@ -31,12 +32,12 @@ from ..base import BOFProgrammingError, to_property, log from ..frame import BOFFrame, BOFBlock, BOFField, BOFBitField from ..spec import BOFSpec -from ..network import UDPField, UDPBlock # TODO +from ..network import UDPField # TODO from .. import byte -#-----------------------------------------------------------------------------# +############################################################################### # KNX SPECIFICATION CONTENT # -#-----------------------------------------------------------------------------# +############################################################################### KNXSPECFILE = "knxnet.json" @@ -65,7 +66,7 @@ def __init__(self, filepath:str=None): KNXFIELDSEP = "," # TODO -class KnxField(UDPField): +class KnxField(UDPField, BOFField): """A ``KnxField`` is a set of raw bytes with a name, a size and a content (``value``). @@ -299,13 +300,10 @@ def _update_value(self, content) -> None: # KNX blocks (set of fields) representation # #-----------------------------------------------------------------------------# -# TODO -class KnxBlock(UDPBlock, BOFBlock): +class KnxBlock(BOFBlock): + """Object representation of a KNX block. Inherits ``BOFBlock``. - """A ``KnxBlock`` contains an ordered set of nested blocks and/or - an ordered set of fields (``KnxField``) of one or more bytes. - - A block has the following properties: + A KNX block has the following properties: - According to **KNX Standard v2.1 03_08_02**, the first byte of the block should (but does not always) contain the length of the block. @@ -313,19 +311,14 @@ class KnxBlock(UDPBlock, BOFBlock): - A terminal ``KnxBlock`` only contains a set of ``KnxField``. - A ``KnxBlock`` can also contain a mix of blocks and fields. - :param name: Name of the block, so that it can be accessed by its name - using a property. - :param content: List of blocks, fields or both. - - Instantiate:: + Usage example:: descr_resp = KnxBlock(name="description response") descr_resp.append(KnxBlock(type="DIB_DEVICE_INFO")) descr_resp.append(KnxBlock(type="DIB_SUPP_SVC_FAMILIES")) """ - __name:str - __content:list + # TODO def __init__(self, **kwargs): """Initialize the ``KnxBlock`` with a mandatory name and optional arguments to fill in the block content list (with fields or nested @@ -341,8 +334,7 @@ def __init__(self, **kwargs): :param cemi: Type of block if this is a cemi structure. Cannot be used with ``type``. """ - self.name = kwargs["name"] if "name" in kwargs else "" - self.__content = [] + super().__init__(**kwargs) specs = KnxSpec() if "type" in kwargs: if not kwargs["type"].upper() in specs.blocktypes.keys(): @@ -356,29 +348,11 @@ def __init__(self, **kwargs): self.append(self.factory(template=specs.blocktypes[specs.cemis[kwargs["cemi"]]["type"]])) self.message_code.value = bytes.fromhex(specs.cemis[kwargs["cemi"]]["id"]) - def __bytes__(self): - raw = b'' - for item in self.__content: - raw += bytes(item) - return raw - - def __len__(self): - """Return the size of the block in total number of bytes.""" - return len(bytes(self)) - - def __str__(self): - ret = ["{0}: {1}".format(self.__class__.__name__, self.__name)] - for item in self.__content: - ret += [indent(str(item), " ")] - return "\n".join(ret) - - def __iter__(self): - yield from self.fields - #-------------------------------------------------------------------------# # Public # #-------------------------------------------------------------------------# + # TODO @classmethod def factory(cls, **kwargs) -> object: """Factory method to create a list of ``KnxBlock`` according to kwargs. @@ -403,6 +377,7 @@ def factory(cls, **kwargs) -> object: return cls(cemi=kwargs["cemi"], name="cEMI") return None + # TODO @classmethod def create_from_template(cls, template, cemi:str=None, optional:bool=False) -> list: """Creates a list of ``KnxBlock``-inherited object according to the @@ -445,6 +420,7 @@ def create_from_template(cls, template, cemi:str=None, optional:bool=False) -> l raise BOFProgrammingError("Unknown block type ({0})".format(template)) return blocklist + # TODO def fill(self, frame:bytes) -> bytes: """Fills in the fields in object with the content of the frame. @@ -463,132 +439,6 @@ def fill(self, frame:bytes) -> bytes: self.fields[-1].size = len(frame) - cursor self.fields[-1].value = frame[cursor:cursor+field.size] - def append(self, content) -> None: - """Appends a block, a field or a list of blocks and/fields to - current block's content. Adds the name of the block to the list - of current's block properties. Ex: if ``block.name`` is ``foo``, - it could be referred to as ``self.foo``. - - :param block: ``KnxBlock``, ``KnxField`` or a list of such objects. - - Example:: - - block = KnxBlock(name="atoll") - block.append(KnxField(name="pom")) - block.append(KnxBlock(name="galli")) - """ - if isinstance(content, KnxField) or isinstance(content, KnxBlock): - self.__content.append(content) - # Add the name of the block as a property to this instance - if isinstance(content.name, list): - for subname in content.name: - setattr(self, to_property(subname), content.subfield[subname]) - setattr(self, to_property(" ".join(content.name)), content) - elif len(content.name) > 0: - setattr(self, to_property(content.name), content) - elif isinstance(content, list): - for item in content: - self.append(item) - self.update() - - def update(self): - """Update all fields corresponding to lengths. Ex: if a block has been - modified, the update will change the value of the block length field - to match (unless this field's ``fixed_value`` boolean is set to True. - - Example:: - - header.service_identifier.value = b"\x01\x02\x03" - header.update() - print(header.header_length.value) - """ - for item in self.__content: - if isinstance(item, KnxBlock): - item.update() - elif isinstance(item, KnxField): - if item.is_length: - item._update_value(len(self)) - - def remove(self, name:str) -> None: - """Remove the field ``name`` from the block (or nested block). - If several fields have the same name, only the first one is removed. - - :param name: Name of the field to remove. - :raises BOFProgrammingError: if there is no corresponding field. - - Example:: - - body = knx.KnxBlock() - body.append(knx.KnxField(name="abitbol", size=30, value="monde de merde")) - body.append(knx.KnxField(name="francky", size=30, value="cest oit")) - body.remove("abitbol") - print([x.name for x in body.fields]) - """ - name = name.lower() - for item in self.__content: - if isinstance(item, KnxBlock): - delattr(self, to_property(name)) - item.remove(name) - elif isinstance(item, KnxField): - if item.name == name or to_property(item.name) == name: - self.__content.remove(item) - delattr(self, to_property(name)) - del(item) - break - - #-------------------------------------------------------------------------# - # Properties # - #-------------------------------------------------------------------------# - - @property - def name(self) -> str: - return self.__name - - @name.setter - def name(self, name:str): - if isinstance(name, str): - self.__name = name.lower() - else: - raise BOFProgrammingError("Block name should be a string.") - - @property - def fields(self) -> list: - self.update() - fieldlist = [] - for item in self.__content: - if isinstance(item, KnxBlock): - fieldlist += item.fields - elif isinstance(item, KnxField): - fieldlist.append(item) - return fieldlist - - @property - def attributes(self) -> list: - """Gives the list of attributes added to the block (field names).""" - self.update() - return [x for x in self.__dict__.keys() if not x.startswith("_KnxBlock__")] - - @property - def content(self) -> list: - return self.__content - - #-------------------------------------------------------------------------# - # Internal (should not be used by end users) # - #-------------------------------------------------------------------------# - - def _add_property(self, name, pointer:object) -> None: - """Add a property to the object using ``setattr``, should not be used - outside module. - - :param name: Property name (string or list if field has subfields) - :param pointer: The object the property refers to. - """ - if isinstance(name, list): - for subname in name: - setattr(self, to_property(subname), pointer.subfield[subname]) - elif len(name) > 0: - setattr(self, to_property(name), pointer) - #-----------------------------------------------------------------------------# # KNX frames / datagram representation # #-----------------------------------------------------------------------------# @@ -620,6 +470,7 @@ class KnxFrame(BOFFrame): __source:tuple __specs:KnxSpec + # TODO def __init__(self, **kwargs): """Initialize a KnxFrame object from various origins using values from keyword argument (kwargs). @@ -650,7 +501,7 @@ def __init__(self, **kwargs): # We do not use BOFFrame.append() because we use properties (not attrs) self._blocks["header"] = KnxBlock(type="header") self._blocks["body"] = KnxBlock(name="body") - self.__specs = KnxSpec() # TODO + self.__specs = KnxSpec() self.__source = kwargs["source"] if "source" in kwargs else ("",0) if "type" in kwargs: cemi = kwargs["cemi"] if "cemi" in kwargs else None diff --git a/bof/network.py b/bof/network.py index c895b09..a1ba13b 100644 --- a/bof/network.py +++ b/bof/network.py @@ -125,53 +125,6 @@ def size(self, size:int): self._size = size self._value = byte.resize(self._value, self._size) -class UDPBlock(object): - """Object representation of a UDP block (set of byte fields) inside a packet. - - Higher-level protocol implementations relying on UDP should inherit this - class for basic packet byte, field and block definition. - """ - _content:dict - - def __bytes__(self): - """:returns: the ``_content`` parameter as a bytearray.""" - content = [] - for field in list(self._content.values()): - content += [field.value[i:i+1] for i in range(field.size)] # Split by byte - return b''.join(content) - - def __str__(self): - """:returns: the bytearray built from the ``_content`` dictionary - converted to a string. - """ - return "{0}".format(bytes(self)) - - #-------------------------------------------------------------------------# - # Protected # - #-------------------------------------------------------------------------# - - def _field(self, value, size:int=1, fixed_size:bool=False, fixed_value:bool=False) -> bytes: - """Creates a UDP Field object from a set of attributes. - - :param value: The value of the field as bytes or as an int. - :param size: The size (in bytes) of the field as an integer. If the - size does not match with the size of the ``value``, it - may change automatically (unless ``fixed_size`` is True. - :param fixed_size: Bool to state if the size can be changed manually. - :param fixed_value: Bool to state if the value can be changed manually. - :raises BOFProgrammingError: If the UDP field cannot be created.""" - return UDPField(value, size, fixed_size, fixed_value) - - def _resize(self, field, size:int) -> None: - """Change size of a field and resize its value. If size is set, - we state that its value is now fixed and will not adapt if the value - of the field is changed. - """ - value = byte.resize(self._content[field].value, size) - self._content[field].fixed_size = True - self._content[field].size = size - self._content[field].value = value - #-----------------------------------------------------------------------------# # Protocol implementation # #-----------------------------------------------------------------------------# From 2b566e30da93843ce29493ae83c473fff48c8098 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 15 Jul 2020 12:08:44 +0200 Subject: [PATCH 06/23] KnxField moved to BOFField, UDPField removed. Subfield/bitfield not moved yet --- bof/frame.py | 150 +++++++++++++++++++++++++++++++++++++++++--- bof/knx/knxframe.py | 125 +++++------------------------------- bof/network.py | 97 ---------------------------- 3 files changed, 159 insertions(+), 213 deletions(-) diff --git a/bof/frame.py b/bof/frame.py index c84e6f8..b7acdb2 100644 --- a/bof/frame.py +++ b/bof/frame.py @@ -14,7 +14,8 @@ from textwrap import indent -from .base import BOFProgrammingError, to_property +from .base import BOFProgrammingError, to_property, log +from . import byte ############################################################################### # Bit field representation within a field # @@ -28,7 +29,142 @@ class BOFBitField(object): ############################################################################### class BOFField(object): - pass + """Object representation of a field within a block inside a frame. A field + is a set of raw bytes with (at least) a name, a size and a value. + + :param name: Name of the field. Is used to refer to the field and to create + an attribute in parent block. + :param size: Length of the field (in number of bytes) + :param value: Value stored in a field (in bytes) + :param is_length: Boolean stating if the field is a length. If ``True`` and + ``fixed_value`` is False, the value is updated when a + field in the parent block is changed and the block length + changes. + :param fixed_size: If ``size`` is modified by the end-user, this parameter + is set to ``True`` to prevent methods from automatically + updating it (manual mode). + :param fixed_value: If ``value`` is modified by the end-user, this parameter + is set to ``True`` to prevent methods from automatically + updating it (manual mode). + :param bitfields: Some field are not on one byte, and the best solution we + found is to store bit fields within byte fields... This + parameter should contain a list of ``BOFBitField`` objects + or None. + :param bitsizes: List storing the sizes (in bit) of bit fields within the + field. + """ + _name:str + _size:int + _value:bytes + _is_length:bool + _fixed_size:bool + _fixed_value:bool + _bitfields:list + _bitsizes:list + + def __init__(self, **kwargs): + self.name = kwargs["name"] if "name" in kwargs else "" + self._value = kwargs["value"] if "value" in kwargs else b'' + self._size = int(kwargs["size"]) if "size" in kwargs else max(1, byte.get_size(self._value)) + self._is_length = kwargs["is_length"] if "is_length" in kwargs else False + self._fixed_size = kwargs["fixed_size"] if "fixed_size" in kwargs else False + self._fixed_value = kwargs["fixed_value"] if "fixed_value" in kwargs else False + # From now on, _update_value must be used to modify values within the code + self._bitfields = None + self._bitsizes = None + + def __str__(self): + return "<{0}: {1} ({2}B)>".format(self._name, self.value, self.size) + + def __len__(self): + return len(self.value) + + def __bytes__(self): + return bytes(self.value) + + def __iter__(self): + for i in range(len(self.value)): + yield byte.from_int(self.value[i]) + + #-------------------------------------------------------------------------# + # Internal (should not be used by end users) # + #-------------------------------------------------------------------------# + + def _update_value(self, content) -> None: + """Use this method to update a value within the code, so that nothing + is changed if ``fixed_value`` is set to True. + + :param content: The content to set as a value.. + """ + if self._fixed_value: + log("Tried to modified field {0} but value is fixed.".format(self._name)) + return + self.value = content + self._fixed_value = False # The property changes this value, we switch back + + #--------------------------------------------------------------------------# + # Properties # + #--------------------------------------------------------------------------# + + @property + def name(self) -> str: + return self._name + @name.setter + def name(self, name:str) -> None: + if isinstance(name, str): + self._name = name.lower() + else: + raise BOFProgrammingError("Field name should be a string.") + + @property + def size(self) -> int: + return self._size + @size.setter + def size(self, size:int): + self._size = size + self._value = byte.resize(self._value, self._size) + + @property + def value(self) -> bytes: + if self._bitfields: + bit_list = [] + for bitfield in self._bitfields.values(): + bit_list += bitfield.value + return byte.from_bit_list(bit_list) + else: + return self._value + @value.setter + def value(self, content) -> None: + if isinstance(content, bytes): + self._value = byte.resize(content, self.size) + elif isinstance(content, int): + self._value = byte.from_int(content, size=self.size) + elif isinstance(content, str) and content.isdigit(): + self._value = bytes.fromhex(content) + self._value = byte.resize(self._value, self.size) + elif isinstance(content, str): + self._value = content.encode('utf-8') + else: + raise BOFProgrammingError("Field value should be bytes, str or int.") + # Bitfield management + if self._bitfields: + bit_list = byte.to_bit_list(self._value, size=sum(self._bitsizes)) + cursor = 0 + for bitfield in self._bitfields.values(): + bitfield.value = bit_list[cursor:cursor+bitfield.size] + cursor += bitfield.size + self._fixed_value = True + + @property + def is_length(self) -> bool: + return self._is_length + @is_length.setter + def is_length(self, value:bool) -> None: + self._is_length = value + + @property + def bitfield(self) -> dict: + return self._bitfields ############################################################################### # Block representation within a frame # @@ -90,8 +226,8 @@ def append(self, content) -> None: self._content.append(content) # Add the name of the block as a property to this instance if isinstance(content.name, list): - for subname in content.name: - setattr(self, to_property(subname), content.subfield[subname]) + for bitfield_name in content.name: + setattr(self, to_property(bitfield_name), content.bitfield[bitfield_name]) setattr(self, to_property(" ".join(content.name)), content) elif len(content.name) > 0: setattr(self, to_property(content.name), content) @@ -153,12 +289,12 @@ def _add_property(self, name, pointer:object) -> None: """Add a property to the object using ``setattr``, should not be used outside module. - :param name: Property name (string or list if field has subfields) + :param name: Property name (string or list if field has bit fields) :param pointer: The object the property refers to. """ if isinstance(name, list): - for subname in name: - setattr(self, to_property(subname), pointer.subfield[subname]) + for bitfield_name in name: + setattr(self, to_property(bitfield_name), pointer.bitfield[bitfield_name]) elif len(name) > 0: setattr(self, to_property(name), pointer) diff --git a/bof/knx/knxframe.py b/bof/knx/knxframe.py index 07577e9..2b029ef 100644 --- a/bof/knx/knxframe.py +++ b/bof/knx/knxframe.py @@ -32,7 +32,6 @@ from ..base import BOFProgrammingError, to_property, log from ..frame import BOFFrame, BOFBlock, BOFField, BOFBitField from ..spec import BOFSpec -from ..network import UDPField # TODO from .. import byte ############################################################################### @@ -66,7 +65,7 @@ def __init__(self, filepath:str=None): KNXFIELDSEP = "," # TODO -class KnxField(UDPField, BOFField): +class KnxField(BOFField): """A ``KnxField`` is a set of raw bytes with a name, a size and a content (``value``). @@ -146,10 +145,6 @@ def value(self, i): else: self.__value = byte.int_to_bit_list(i, size=self.size) - __name:str - __is_length:bool - __subsizes:list - __subfields:list def __init__(self, **kwargs): """Initialize the field according to a set of keyword arguments. @@ -159,16 +154,10 @@ def __init__(self, **kwargs): ``__set_subfields``). """ super().__init__(**kwargs) - # Inherited from UDPField - self._size = int(kwargs["size"]) if "size" in kwargs else self._size - # KnxField initialization - self.__name = kwargs["name"].lower() if "name" in kwargs else "" - self.__subfields = None # Case field is separate into bitfields (2B split in fields of 4b & 12b) - if KNXFIELDSEP in self.__name: - self.__name = [x.strip() for x in self.__name.split(KNXFIELDSEP)] # Now it's a table + if KNXFIELDSEP in self._name: + self._name = [x.strip() for x in self._name.split(KNXFIELDSEP)] # Now it's a table self.__set_subfields(**kwargs) - self.__is_length = kwargs["is_length"] if "is_length" in kwargs else False if "default" in kwargs: self._update_value(kwargs["default"]) elif "value" in kwargs: @@ -188,113 +177,31 @@ def __set_subfields(self, **kwargs): :raises BOFProgrammingError: If subsize is invalid. """ if "subsize" not in kwargs: - raise BOFProgrammingError("Fields with subfields shall have subsizes ({0})".format(self.__name)) - self.__subsizes = [int(x) for x in kwargs["subsize"].split(KNXFIELDSEP)] - if len(self.__subsizes) != len(self.__name): - raise BOFProgrammingError("Subfield names do not match subsizes ({0}).".format(self.__name)) - self.__subfields = {} - for i in range(len(self.__name)): - self.__subfields[self.__name[i]] = KnxField.KnxSubField(name=self.__name[i], size=self.__subsizes[i]) - - def __len__(self): - return len(self.value) - - def __bytes__(self): - return bytes(self.value) - - def __str__(self): - return "<{0}: {1} ({2}B)>".format(self.__name, self.value, self.size) - - def __iter__(self): - for i in range(len(self.value)): - yield byte.from_int(self.value[i]) + raise BOFProgrammingError("Fields with subfields shall have subsizes ({0})".format(self._name)) + self._bitsizes = [int(x) for x in kwargs["subsize"].split(KNXFIELDSEP)] + if len(self._bitsizes) != len(self._name): + raise BOFProgrammingError("Subfield names do not match subsizes ({0}).".format(self._name)) + self._bitfields = {} + for i in range(len(self._name)): + self._bitfields[self._name[i]] = KnxField.KnxSubField(name=self._name[i], size=self._bitsizes[i]) #-------------------------------------------------------------------------# # Properties # #-------------------------------------------------------------------------# - @property - def name(self) -> str: - return self.__name - @name.setter - def name(self, name:str) -> None: - if isinstance(name, str): - self.__name = name.lower() - else: - raise BOFProgrammingError("Field name should be a string.") - - @property - def subfield(self) -> dict: - return self.__subfields - @property def value(self) -> bytes: - if self.__subfields: - bit_list = [] - for subfield in self.__subfields.values(): - bit_list += subfield.value - return byte.from_bit_list(bit_list) - else: - return self._value + return super().value @value.setter def value(self, content) -> None: - """Set ``content`` to value according to 3 types of data: byte array, - integer or string representation of an IPv4 address. - - Sets ``fixed_value`` to True to avoid rechanging the value automatically - using length updated. - - Example:: - - field.value = "192.168.1.1" - """ - if isinstance(content, bytes): - self._value = byte.resize(content, self.size) - elif isinstance(content, str) and content.isdigit(): - self._value = bytes.fromhex(content) - self._value = byte.resize(self._value, self.size) - elif isinstance(content, str): - # Check if IPv4: + # Check if IPv4: + if isinstance(content, str): try: ip_address(content) - self._value = byte.from_ipv4(content) + content = byte.from_ipv4(content) except ValueError: - self._value = content.encode('utf-8') - elif isinstance(content, int): - self._value = byte.from_int(content, size=self.size) - else: - raise BOFProgrammingError("Field value should be bytes, str or int.") - self.fixed_value = True - # If value is changed but contains subfields, we have to change - # the subfield values too - if self.__subfields: - bit_list = byte.to_bit_list(self._value, size=sum(self.__subsizes)) - cursor = 0 - for subfield in self.__subfields.values(): - subfield.value = bit_list[cursor:cursor+subfield.size] - cursor += subfield.size - @property - def is_length(self) -> bool: - return self.__is_length - @is_length.setter - def is_length(self, value:bool) -> None: - self.__is_length = value - - #-------------------------------------------------------------------------# - # Internal (should not be used by end users) # - #-------------------------------------------------------------------------# - - def _update_value(self, content) -> None: - """Change the value according to automated updated from within the code - si that nothing is changed in ``fixed_value`` is set to True. - - :param content: A byte array, an integer, or an IPv4 string. - """ - if self.fixed_value: - log("Tried to modified field {0} but value is fixed.".format(self.__name)) - return - self.value = content - self.fixed_value = False # Property changes this value, we switch back + pass + super(KnxField, self.__class__).value.fset(self, content) #-----------------------------------------------------------------------------# # KNX blocks (set of fields) representation # diff --git a/bof/network.py b/bof/network.py index a1ba13b..836dbf6 100644 --- a/bof/network.py +++ b/bof/network.py @@ -32,103 +32,6 @@ # UDP # ############################################################################### -#-----------------------------------------------------------------------------# -# Packet structures # -#-----------------------------------------------------------------------------# - -class UDPField(object): - """Object representation of a UDP field inside a block. - - Contains a set of attributes useful for UDP fields building and - handling, they may not all be used depending on the type of field. - - :param size: The size of the field (number of bytes in the bytearray) - :param value: The content of the field (bytearray) - :param fixed_size: Set to ``True`` if the ``size`` should not be modified - automatically when changing the value. - :param fixed_value: Set to ``True`` if the ``value`` should not be - modified automatically inside the module. - - ``fixed_size`` and ``fixed_value`` parameters are set to True when the user - manually specified a value for them: this manual value should not be - overwritten by automated field updates. - """ - _size:int - _value:bytes - fixed_size:bool - fixed_value:bool - - def __init__(self, value=b'', size:int=1, fixed_size:bool=False, fixed_value:bool=False, **kwargs): - """Initialize a field, requires at least a value to store to the field. - - :param value: Value to store as byte or int - :param size: Size of the field (number of bytes) - :param fixed_size: Boolean to state if the size can or cannot be changed. - :param fixed_value: Boolean to state if the value can be modified. - :param kwargs: Not used here but subclasses may use more keyword - arguments. - """ - # We need to set this value first - self.fixed_size = fixed_size - self._size = max(size, byte.get_size(value)) if not self.fixed_size else size - self.fixed_value = False # Initialize to false before it fails x) - self._value = value # Call property setter - # We set this after we first set a value - self.fixed_value = fixed_value # Now we set the correct value - - def __str__(self): - return "<{0}: {1} ({2}b)>".format(type(self).__name__, self._value, self._size) - - #--------------------------------------------------------------------------# - # Public # - #--------------------------------------------------------------------------# - - def update(self, value): - """Update field value, only if ``fixed_value`` is set to False. - - :param value: Bytes or int (will be converted to bytes) to use as field - value. - - .. warning:: This method should be used mainly when automatically - changing field values and not be called directly. Please - use properties (getters) instead. - """ - if not self.fixed_value: - self._set_value(value) - - #--------------------------------------------------------------------------# - # Properties # - #--------------------------------------------------------------------------# - - @property - def value(self) -> bytes: - return self._value - @value.setter - def value(self, content) -> None: - if not self.fixed_value: - if isinstance(content, bytes): - self._value = byte.resize(content, self.size) - elif isinstance(content, str): - self._value = bytes.fromhex(content) - self._value = byte.resize(self._value, self.size) - elif isinstance(content, int): - self._value = byte.from_int(content, size=self.size) - else: - raise BOFProgrammingError("Field value should be bytes, str or int.") - - @property - def size(self) -> int: - return self._size - @size.setter - def size(self, size:int): - if not self.fixed_size: - self._size = size - self._value = byte.resize(self._value, self._size) - -#-----------------------------------------------------------------------------# -# Protocol implementation # -#-----------------------------------------------------------------------------# - class _UDP(asyncio.DatagramProtocol): """UDP protocol implementation interface from asyncio builtin UDP handler. Will be called from protocol implementation class. From b74a0f291c1d60dc2740d4eb3cbe4fd2e01ef3fb Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 15 Jul 2020 13:33:08 +0200 Subject: [PATCH 07/23] Extract bit fields from KNXSubfields to BOFBitField, Field export is OK --- bof/frame.py | 90 ++++++++++++++++++++++++++++++++++- bof/knx/knxframe.py | 113 +------------------------------------------- bof/knx/knxnet.json | 2 +- bof/spec.py | 6 +++ 4 files changed, 98 insertions(+), 113 deletions(-) diff --git a/bof/frame.py b/bof/frame.py index b7acdb2..931d3dd 100644 --- a/bof/frame.py +++ b/bof/frame.py @@ -15,6 +15,7 @@ from textwrap import indent from .base import BOFProgrammingError, to_property, log +from .spec import SEPARATOR from . import byte ############################################################################### @@ -22,7 +23,69 @@ ############################################################################### class BOFBitField(object): - pass + """As we don't know how to handle bit fields that are not at least one + byte-long, we create fields that are not complete bytes (ex: 4bits) + inside a ``BOFField``, represented as ``BOFBitField`` objects. + + For instance, a field of 4bits and one of 12bits are merged into one byte + field of 2 bytes (16bits). + + The use of bit fields involves changes to the definition of fields in a + JSON specification file. The name field is divided into a list, and the + keyword ``bitsizes`` is introduced.:: + + {"name": "field1, field2", "type": "field", "size": 2, "bitsizes": "4, 12"} + + The attribute ``bitsizes`` shall match the field list from ``name``. + Here, we indicate that the field is divided into 2 bit fields: + ``field1`` is 4 bits-long, ``field2`` is 12 bits long. When referring + to the field from anywhere else in the code, they should be treated as + independent fields. + + The use of BOFBitFields instead of BOFField should not be seen by the + end-user: Bit fields are referred to as normal properties named ``field1`` + and ``field2``, independent, that return values as bit lists. + + In a ``BOFField``, we then have a ``bitfields`` list that contains a + set of ``BOFBitField`` objects. The ``BOFField`` object has name + ``["field1", "field2"]`` (name is a list, that's how we know it has bit + fields). A property to refer to the main field, that returns the value of the + complete byte array, is created with a name such as ``field1_field2``. + + Finally, values are calculated in bits instead of bytes, the translation + between bit fields and byte array (when they are manipulated in frames) + shall not be the problem of the enduser:: + + >>> response.body.cemi.field1.value + [0, 0, 0, 1] + >>> response.body.cemi.field2.value + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1] + >>> response.body.cemi.field1_field2.value + b'\\x10\\x01' # Stands for 0001 0000 0000 0001 + """ + name:str + size:int + __value:list # Bit list + + def __init__(self, name:str, size:int, value=0): + self.name = name + self.size = size + self.value = value + + def __str__(self): + return "<{0}: {1} ({2}b)>".format(self.name, self.value, self.size) + + @property + def value(self) -> list: + return self.__value + @value.setter + def value(self, i): + """Change value, so far we only consider big endian.""" + if isinstance(i, list): + self.__value = i + else: + self.__value = byte.int_to_bit_list(i, size=self.size) + ############################################################################### # Field representation within a block # @@ -69,9 +132,34 @@ def __init__(self, **kwargs): self._is_length = kwargs["is_length"] if "is_length" in kwargs else False self._fixed_size = kwargs["fixed_size"] if "fixed_size" in kwargs else False self._fixed_value = kwargs["fixed_value"] if "fixed_value" in kwargs else False + self._set_bitfields(**kwargs) # From now on, _update_value must be used to modify values within the code + if "value" in kwargs: + self._update_value(kwargs["value"]) + elif "default" in kwargs: + self._update_value(kwargs["default"]) + else: + self._update_value(bytes(self._size)) + + def _set_bitfields(self, **kwargs): + """If the field contains bitfields (name has format ``name1, ``name2`` + and JSON definition of field contains ``bitsizes``, we set the + attributes for bit field management accordingly (list of bit fields and + size of each bit field. + """ self._bitfields = None self._bitsizes = None + if not SEPARATOR in self._name: + return + if "bitsizes" not in kwargs: + raise BOFProgrammingError("Fields with bit fields shall have bitsizes ({0}).".format(self._name)) + self._name = [x.strip() for x in self._name.split(SEPARATOR)] # Now it's a table + self._bitsizes = [int(x) for x in kwargs["bitsizes"].split(SEPARATOR)] + if len(self._bitsizes) != len(self._name): + raise BOFProgrammingError("Bitfield names do not match bitsizes ({0}).".format(self._name)) + self._bitfields = {} + for i in range(len(self._name)): + self._bitfields[self._name[i]] = BOFBitField(name=self._name[i], size=self._bitsizes[i]) def __str__(self): return "<{0}: {1} ({2}B)>".format(self._name, self.value, self.size) diff --git a/bof/knx/knxframe.py b/bof/knx/knxframe.py index 2b029ef..bfff8f7 100644 --- a/bof/knx/knxframe.py +++ b/bof/knx/knxframe.py @@ -67,123 +67,14 @@ def __init__(self, filepath:str=None): # TODO class KnxField(BOFField): """A ``KnxField`` is a set of raw bytes with a name, a size and a content - (``value``). - - :param name: Name of the field, to be referred to using a property. - :param size: Size of the field (number of bytes), from ``UDPField``. - :param value: Value contained in the field (in bytes), from ``UDPFIield``. - :param fixed_size: Set to ``True`` if the ``size`` should not be modified - automatically when changing the value (``UDPField``). - :param fixed_value: Set to ``True`` if the ``value`` should not be - modified automatically inside the module (``UDPField``). - :param is_length: This boolean states if the field is the length field of - the block. If True, this value is updated when a field - in the block changes (except if this field has arg - ``fixed_value`` set to True. - :param subsizes: If the field is in fact a merge of bit fields (a field - usually works only with bytes), this parameter states - the size in bits of subfields. + (``value``). Inherits ``BOFField``. Instantiate:: KnxField(name="header length", size=1, default="06") - As we don't know how to handle bit fields that are not at least one - byte-long, we can create fields that are not complete bytes (ex: 4bits) - inside a ``KnxField``. For instance, a field of 4bits and one of 12bits - are merged into one byte field of 2 bytes (16bits). - - ``KnxField`` definition in the JSON spec file has the following format - if such subfields exist:: - - {"name": "field1, field2", "type": "field", "size": 2, "subsize": "4, 12"} - - The new attribute ``subsize`` shall match the field list from name. - Here, we indicate that the field is divided into 2 bit fields: - ``field1`` is 4 bits-long, ``field2`` is 12 bits long. When referring - to the field from anywhere else in the code, they should be treated as - independent fields. - Subfield are referred to as normal properties named ``field1`` and ``field2`` - independently that return values as bit lists. - A property to refer to the main field, that returns the value of the complete - byte array, is created with a name such as ``field1_field2``:: - - >>> response.body.cemi.field1.value - [0, 0, 0, 1] - >>> response.body.cemi.field2.value - [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1] - >>> response.body.cemi.field1_field2.value - b'\\x10\\x01' # Stands for 0001 0000 0000 0001 - - In a ``KnxField``, we then have a ``subfields`` dictionary that contains a - set of ``KnxSubField`` objects, which is an inner class of ``KnxField``. - Values are calculated in bits instead of bytes, the translation between bit - fields and byte array (when they are manipulated in frames.) shall not be - the problem of the user. - **KNX Standard v2.1 03_08_02** """ - class KnxSubField(object): - """Special KNX subfield with bit list values instead of bytes.""" - name:str - size:int - __value:list - def __init__(self, name, size, value=0): - self.name = name - self.size = size - self.value = value - def __str__(self): - return "<{0}: {1} ({2}b)>".format(self.name, self.value, self.size) - @property - def value(self) -> list: - return self.__value - @value.setter - def value(self, i): - """Change value, so far we only consider big endian.""" - if isinstance(i, list): - self.__value = i - else: - self.__value = byte.int_to_bit_list(i, size=self.size) - - - def __init__(self, **kwargs): - """Initialize the field according to a set of keyword arguments. - - :raises BOFProgrammingError: If the field has subfields but their - definition is invalid (details in - ``__set_subfields``). - """ - super().__init__(**kwargs) - # Case field is separate into bitfields (2B split in fields of 4b & 12b) - if KNXFIELDSEP in self._name: - self._name = [x.strip() for x in self._name.split(KNXFIELDSEP)] # Now it's a table - self.__set_subfields(**kwargs) - if "default" in kwargs: - self._update_value(kwargs["default"]) - elif "value" in kwargs: - self._update_value(kwargs["value"]) - else: - self._update_value(bytes(self._size)) # Empty bytearray - - def __set_subfields(self, **kwargs): - """If field (byte) contains subfields (bit), we check that the name list - and the subsizes match and set the value accordingly using bit to byte - and byte to bit conversion fuctions. We use bit list instead to make it - easier (for slices). Item stored in subfield dictionary referred to as - a name which is called from the rest of the code and by the end user as a - property like any other regular field. - - :param subsize: Size list (in bits), as a string. - :raises BOFProgrammingError: If subsize is invalid. - """ - if "subsize" not in kwargs: - raise BOFProgrammingError("Fields with subfields shall have subsizes ({0})".format(self._name)) - self._bitsizes = [int(x) for x in kwargs["subsize"].split(KNXFIELDSEP)] - if len(self._bitsizes) != len(self._name): - raise BOFProgrammingError("Subfield names do not match subsizes ({0}).".format(self._name)) - self._bitfields = {} - for i in range(len(self._name)): - self._bitfields[self._name[i]] = KnxField.KnxSubField(name=self._name[i], size=self._bitsizes[i]) #-------------------------------------------------------------------------# # Properties # @@ -194,8 +85,8 @@ def value(self) -> bytes: return super().value @value.setter def value(self, content) -> None: - # Check if IPv4: if isinstance(content, str): + # Check if content is an IPv4 address (A.B.C.D): try: ip_address(content) content = byte.from_ipv4(content) diff --git a/bof/knx/knxnet.json b/bof/knx/knxnet.json index aaee5d7..fa4fdc3 100644 --- a/bof/knx/knxnet.json +++ b/bof/knx/knxnet.json @@ -66,7 +66,7 @@ {"name": "object type", "type": "field", "size": 2}, {"name": "object instance", "type": "field", "size": 1}, {"name": "property id", "type": "field", "size": 1}, - {"name": "number of elements, start index", "type": "field", "size": 2, "subsize": "4, 12"}, + {"name": "number of elements, start index", "type": "field", "size": 2, "bitsizes": "4, 12"}, {"name": "data", "type": "field", "size": 0} ] }, diff --git a/bof/spec.py b/bof/spec.py index 1daa5d9..1c190d3 100644 --- a/bof/spec.py +++ b/bof/spec.py @@ -28,6 +28,12 @@ from .base import BOFLibraryError, to_property +#-----------------------------------------------------------------------------# +# JSON file constants # +#-----------------------------------------------------------------------------# + +SEPARATOR = "," + #-----------------------------------------------------------------------------# # JSON file management functions # #-----------------------------------------------------------------------------# From 5a6859d829c3ae24cb43a808b6a363e540a2509a Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 15 Jul 2020 15:03:10 +0200 Subject: [PATCH 08/23] Prepare for frame, block and field create and parse --- bof/frame.py | 9 +++---- bof/knx/knxframe.py | 66 ++++++++++++++++++++------------------------- bof/knx/knxnet.json | 2 +- bof/spec.py | 16 ++++++----- 4 files changed, 44 insertions(+), 49 deletions(-) diff --git a/bof/frame.py b/bof/frame.py index 931d3dd..c01d98a 100644 --- a/bof/frame.py +++ b/bof/frame.py @@ -15,8 +15,7 @@ from textwrap import indent from .base import BOFProgrammingError, to_property, log -from .spec import SEPARATOR -from . import byte +from . import byte, spec ############################################################################### # Bit field representation within a field # @@ -149,12 +148,12 @@ def _set_bitfields(self, **kwargs): """ self._bitfields = None self._bitsizes = None - if not SEPARATOR in self._name: + if not spec.SEPARATOR in self._name: return if "bitsizes" not in kwargs: raise BOFProgrammingError("Fields with bit fields shall have bitsizes ({0}).".format(self._name)) - self._name = [x.strip() for x in self._name.split(SEPARATOR)] # Now it's a table - self._bitsizes = [int(x) for x in kwargs["bitsizes"].split(SEPARATOR)] + self._name = [x.strip() for x in self._name.split(spec.SEPARATOR)] # Now it's a table + self._bitsizes = [int(x) for x in kwargs["bitsizes"].split(spec.SEPARATOR)] if len(self._bitsizes) != len(self._name): raise BOFProgrammingError("Bitfield names do not match bitsizes ({0}).".format(self._name)) self._bitfields = {} diff --git a/bof/knx/knxframe.py b/bof/knx/knxframe.py index bfff8f7..1840a6f 100644 --- a/bof/knx/knxframe.py +++ b/bof/knx/knxframe.py @@ -62,9 +62,6 @@ def __init__(self, filepath:str=None): # KNX fields (byte or byte array) representation # #-----------------------------------------------------------------------------# -KNXFIELDSEP = "," - -# TODO class KnxField(BOFField): """A ``KnxField`` is a set of raw bytes with a name, a size and a content (``value``). Inherits ``BOFField``. @@ -135,15 +132,15 @@ def __init__(self, **kwargs): super().__init__(**kwargs) specs = KnxSpec() if "type" in kwargs: - if not kwargs["type"].upper() in specs.blocktypes.keys(): + if not kwargs["type"].upper() in specs.blocks.keys(): raise BOFProgrammingError("Unknown block type ({0})".format(kwargs["type"])) self.name = self.name if len(self.name) else kwargs["type"] - self.append(self.factory(template=specs.blocktypes[kwargs["type"].upper()])) + self.append(self.factory(template=specs.blocks[kwargs["type"].upper()])) elif "cemi" in kwargs: if not kwargs["cemi"] in specs.cemis.keys(): raise BOFProgrammingError("cEMI is unknown ({0})".format(kwargs["cemi"])) self.name = self.name if len(self.name) else "cemi" - self.append(self.factory(template=specs.blocktypes[specs.cemis[kwargs["cemi"]]["type"]])) + self.append(self.factory(template=specs.blocks[specs.cemis[kwargs["cemi"]]["type"]])) self.message_code.value = bytes.fromhex(specs.cemis[kwargs["cemi"]]["id"]) #-------------------------------------------------------------------------# @@ -193,7 +190,7 @@ def create_from_template(cls, template, cemi:str=None, optional:bool=False) -> l Example:: block = KnxBlock(name="new block") - block.append(KnxBlock.factory(template=KnxSpec().blocktypes["HPAI"])) + block.append(KnxBlock.factory(template=KnxSpec().blocks["HPAI"])) """ blocklist = [] specs = KnxSpec() @@ -209,9 +206,9 @@ def create_from_template(cls, template, cemi:str=None, optional:bool=False) -> l blocklist.append(KnxField(**template)) elif template["type"] == "cemi": blocklist.append(cls(cemi=cemi)) - elif template["type"] in specs.blocktypes.keys(): + elif template["type"] in specs.blocks.keys(): nestedblock = cls(name=template["name"]) - content = specs.blocktypes[template["type"]] + content = specs.blocks[template["type"]] nestedblock.append(cls.create_from_template(content, cemi, optional)) blocklist.append(nestedblock) else: @@ -251,8 +248,6 @@ class KnxFrame(BOFFrame): - The frame body contains one or more blocks and varies according to the type of KNX message (defined in header). - :param source: Source address of the frame with format tuple - ``(ip:str, port:int)``. :param raw: Raw byte array used to build a KnxFrame object. :param header: Frame header as a ``KnxBlock`` object. :param body: Frame body as a ``KnxBlock`` which can also contain a set @@ -265,9 +260,6 @@ class KnxFrame(BOFFrame): **KNX Standard v2.1 03_08_02** """ - __source:tuple - __specs:KnxSpec - # TODO def __init__(self, **kwargs): """Initialize a KnxFrame object from various origins using values from @@ -292,15 +284,12 @@ def __init__(self, **kwargs): :param optional: Boolean, set to True if we want to create a frame with optional fields (from spec). :param frame: Raw bytearray used to build a KnxFrame object. - :param source: Source address of a frame, as a tuple (ip;str, port:int) - Only used is param `frame` is set. """ super().__init__() # We do not use BOFFrame.append() because we use properties (not attrs) + specs = KnxSpec() self._blocks["header"] = KnxBlock(type="header") self._blocks["body"] = KnxBlock(name="body") - self.__specs = KnxSpec() - self.__source = kwargs["source"] if "source" in kwargs else ("",0) if "type" in kwargs: cemi = kwargs["cemi"] if "cemi" in kwargs else None optional = kwargs["optional"] if "optional" in kwargs else False @@ -308,8 +297,7 @@ def __init__(self, **kwargs): log("Created new frame from service identifier {0}".format(kwargs["type"])) elif "frame" in kwargs: self.build_from_frame(kwargs["frame"]) - log("Created new frame from byte array {0} (source: {1})".format(kwargs["frame"], - self.__source)) + log("Created new frame from byte array {0}.".format(kwargs["frame"])) # Update total frame length in header self.update() @@ -338,17 +326,18 @@ def build_from_sid(self, sid, cemi:str=None, optional:bool=False) -> None: frame.build_from_sid("DESCRIPTION REQUEST") """ # If sid is bytes, replace the id (as bytes) by the service name + specs = KnxSpec() if isinstance(sid, bytes): - for service in self.__specs.service_identifiers: - if bytes.fromhex(self.__specs.service_identifiers[service]["id"]) == sid: + for service in specs.service_identifiers: + if bytes.fromhex(specs.service_identifiers[service]["id"]) == sid: sid = service break # Now check that the service id exists and has an associated body if isinstance(sid, str): - if sid not in self.__specs.bodies: + if sid not in specs.bodies: # Try with underscores (Ex: DESCRIPTION_REQUEST) - if sid in [to_property(x) for x in self.__specs.bodies]: - for body in self.__specs.bodies: + if sid in [to_property(x) for x in specs.bodies]: + for body in specs.bodies: if sid == to_property(body): sid = body break @@ -356,13 +345,13 @@ def build_from_sid(self, sid, cemi:str=None, optional:bool=False) -> None: raise BOFProgrammingError("Service {0} does not exist.".format(sid)) else: raise BOFProgrammingError("Service id should be a string or a bytearray.") - self._blocks["body"].append(KnxBlock.factory(template=self.__specs.bodies[sid], + self._blocks["body"].append(KnxBlock.factory(template=specs.bodies[sid], cemi=cemi, optional=optional)) # Add fields names as properties to body :) for field in self._blocks["body"].fields: self._blocks["body"]._add_property(field.name, field) - if sid in self.__specs.service_identifiers.keys(): - value = bytes.fromhex(self.__specs.service_identifiers[sid]["id"]) + if sid in specs.service_identifiers.keys(): + value = bytes.fromhex(specs.service_identifiers[sid]["id"]) self._blocks["header"].service_identifier._update_value(value) self.update() @@ -382,14 +371,15 @@ def build_from_frame(self, frame:bytes) -> None: frame = KnxFrame(frame=data, source=address) """ + specs = KnxSpec() # HEADER self._blocks["header"] = KnxBlock(type="HEADER", name="header") self._blocks["header"].fill(frame[:frame[0]]) blocklist = None - for service in self.__specs.service_identifiers: - attributes = self.__specs.service_identifiers[service] + for service in specs.service_identifiers: + attributes = specs.service_identifiers[service] if bytes(self._blocks["header"].service_identifier) == bytes.fromhex(attributes["id"]): - blocklist = self.__specs.bodies[service] + blocklist = specs.bodies[service] break if not blocklist: raise BOFProgrammingError("Unknown service identifier ({0})".format(self._blocks["header"].service_identifier.value)) @@ -401,8 +391,8 @@ def build_from_frame(self, frame:bytes) -> None: # If block is a cemi, we need its type before creating the structure cemi = frame[cursor:cursor+1] if block["type"] == "cemi" else None if cemi: # We get the name instead of the code - for cemi_type in self.__specs.cemis: - attributes = self.__specs.cemis[cemi_type] + for cemi_type in specs.cemis: + attributes = specs.cemis[cemi_type] if cemi == bytes.fromhex(attributes["id"]): cemi = cemi_type break @@ -445,8 +435,9 @@ def sid(self) -> str: """Return the name associated to the frame's service identifier, or empty string if it is not set. """ - for service in self.__specs.service_identifiers: - attributes = self.__specs.service_identifiers[service] + specs = KnxSpec() + for service in specs.service_identifiers: + attributes = specs.service_identifiers[service] if bytes(self._blocks["header"].service_identifier) == bytes.fromhex(attributes["id"]): return service return str(self._blocks["header"].service_identifier.value) @@ -454,8 +445,9 @@ def sid(self) -> str: @property def cemi(self) -> str: """Return the type of cemi, if any.""" + specs = KnxSpec() if "cemi" in self._blocks["body"].attributes: - for cemi in self.__specs.cemis: - if bytes(self._blocks["body"].cemi.message_code) == bytes.fromhex(self.__specs.cemis[cemi]["id"]): + for cemi in specs.cemis: + if bytes(self._blocks["body"].cemi.message_code) == bytes.fromhex(specs.cemis[cemi]["id"]): return cemi return "" diff --git a/bof/knx/knxnet.json b/bof/knx/knxnet.json index fa4fdc3..8ef9f6e 100644 --- a/bof/knx/knxnet.json +++ b/bof/knx/knxnet.json @@ -19,7 +19,7 @@ "PropWrite.req": {"id": "F6", "type": "DP_cEMI"}, "PropWrite.con": {"id": "F5", "type": "DP_cEMI"} }, - "blocktypes": { + "blocks": { "HEADER": [ {"name": "header length", "type": "field", "size": 1, "is_length": true}, {"name": "protocol version", "type": "field", "size": 1, "default": "10"}, diff --git a/bof/spec.py b/bof/spec.py index 1c190d3..afdd2a6 100644 --- a/bof/spec.py +++ b/bof/spec.py @@ -28,15 +28,19 @@ from .base import BOFLibraryError, to_property +############################################################################### +# JSON specification file constants # +############################################################################### + #-----------------------------------------------------------------------------# -# JSON file constants # +# Global structure # #-----------------------------------------------------------------------------# SEPARATOR = "," -#-----------------------------------------------------------------------------# +############################################################################### # JSON file management functions # -#-----------------------------------------------------------------------------# +############################################################################### def load_json(filename:str) -> dict: """Loads a JSON file and returns the associated dictionary. @@ -47,11 +51,11 @@ def load_json(filename:str) -> dict: with open(filename, 'r') as jsonfile: return json.load(jsonfile) except Exception as e: - raise BOFLibraryError("JSON File {0} cannot be used.".format(filename)) #from None + raise BOFLibraryError("JSON File {0} cannot be used.".format(filename)) from None -#-----------------------------------------------------------------------------# +############################################################################### # BOF Specification object # -#-----------------------------------------------------------------------------# +############################################################################### class BOFSpec(object): """Singleton containing the data related to a protocol's specification, From 030575f4a7e9f2fcfdf1fa5d93c7c60c174b49a0 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 16 Jul 2020 11:55:14 +0200 Subject: [PATCH 09/23] Refactoring of frame create and parse in very slow progress --- bof/base.py | 2 +- bof/knx/knxframe.py | 89 +++++++++++++++++++++++++++++------------ bof/knx/knxnet.py | 2 +- tests/test_knx_frame.py | 17 ++++++++ 4 files changed, 82 insertions(+), 28 deletions(-) diff --git a/bof/base.py b/bof/base.py index c2f4e60..dc6de3e 100644 --- a/bof/base.py +++ b/bof/base.py @@ -107,4 +107,4 @@ def log(message:str, level:str="INFO") -> bool: def to_property(value:str) -> str: """Replace all non alphanumeric characters in a string with ``_``""" - return sub('[^0-9a-zA-Z]+', '_', value) + return sub('[^0-9a-zA-Z]+', '_', value.lower()) diff --git a/bof/knx/knxframe.py b/bof/knx/knxframe.py index 1840a6f..b7b595a 100644 --- a/bof/knx/knxframe.py +++ b/bof/knx/knxframe.py @@ -54,6 +54,56 @@ def __init__(self, filepath:str=None): filepath = path.join(path.dirname(path.realpath(__file__)), KNXSPECFILE) super().__init__(filepath) + #-------------------------------------------------------------------------# + # Public # + #-------------------------------------------------------------------------# + + def get_service_id(self, name:str) -> bytes: + """Returns the content of parameter ``id`` for a given service + identifier name in KNX spec JSON file. + """ + value = self.__get_dict_value(self.service_identifiers, name) + return bytes.fromhex(value["id"]) if value else None + + def get_service_name(self, sid:bytes) -> str: + """Returns the name of the service identifier with id ``sid``.""" + if isinstance(sid, bytes): + return self.__get_dict_key(self.service_identifiers, "id", sid) + if isinstance(sid, str): + sid = to_property(sid) + for service in self.service_identifiers: + if sid == to_property(service): + return service + return None + + def get_template_from_body(self, name:str) -> list: + """Returns a template associated to a body, as a list, or None.""" + return self.__get_dict_value(self.bodies, name) + + #-------------------------------------------------------------------------# + # Internals # + #-------------------------------------------------------------------------# + + def __get_dict_value(self, dictionary:dict, key:str) -> object: + """Return the value associated to a key from a given dictionary. Key + is insensitive, the value can have different types. Must be called + inside class only. + """ + key = to_property(key) + for entry in dictionary: + if to_property(entry) == key: + return dictionary[entry] + return None + + def __get_dict_key(self, dictionary:dict, inner_key:str, value:object) -> str: + """Return the key associated to a value from a given dictionary inside a + dictionary. Must be called inside class only. + """ + for entry in dictionary: + if bytes.fromhex(dictionary[entry][inner_key]) == value: + return entry + return None + ############################################################################### # KNX FRAME CONTENT # ############################################################################### @@ -283,7 +333,7 @@ def __init__(self, **kwargs): service identifier. :param optional: Boolean, set to True if we want to create a frame with optional fields (from spec). - :param frame: Raw bytearray used to build a KnxFrame object. + :param bytes: Raw bytearray used to build a KnxFrame object. """ super().__init__() # We do not use BOFFrame.append() because we use properties (not attrs) @@ -295,9 +345,9 @@ def __init__(self, **kwargs): optional = kwargs["optional"] if "optional" in kwargs else False self.build_from_sid(kwargs["type"], cemi, optional) log("Created new frame from service identifier {0}".format(kwargs["type"])) - elif "frame" in kwargs: - self.build_from_frame(kwargs["frame"]) - log("Created new frame from byte array {0}.".format(kwargs["frame"])) + elif "bytes" in kwargs: + self.build_from_frame(kwargs["bytes"]) + log("Created new frame from byte array {0}.".format(kwargs["bytes"])) # Update total frame length in header self.update() @@ -325,33 +375,20 @@ def build_from_sid(self, sid, cemi:str=None, optional:bool=False) -> None: frame = KnxFrame() frame.build_from_sid("DESCRIPTION REQUEST") """ - # If sid is bytes, replace the id (as bytes) by the service name - specs = KnxSpec() - if isinstance(sid, bytes): - for service in specs.service_identifiers: - if bytes.fromhex(specs.service_identifiers[service]["id"]) == sid: - sid = service - break - # Now check that the service id exists and has an associated body - if isinstance(sid, str): - if sid not in specs.bodies: - # Try with underscores (Ex: DESCRIPTION_REQUEST) - if sid in [to_property(x) for x in specs.bodies]: - for body in specs.bodies: - if sid == to_property(body): - sid = body - break - else: - raise BOFProgrammingError("Service {0} does not exist.".format(sid)) - else: + if not isinstance(sid, bytes) and not isinstance(sid, str): raise BOFProgrammingError("Service id should be a string or a bytearray.") - self._blocks["body"].append(KnxBlock.factory(template=specs.bodies[sid], + # Now check that the service id exists and has an associated body + spec = KnxSpec() + sid = spec.get_service_name(sid) + if not sid or sid not in spec.bodies: + raise BOFProgrammingError("Service {0} does not exist.".format(sid)) + self._blocks["body"].append(KnxBlock.factory(template=spec.bodies[sid], cemi=cemi, optional=optional)) # Add fields names as properties to body :) for field in self._blocks["body"].fields: self._blocks["body"]._add_property(field.name, field) - if sid in specs.service_identifiers.keys(): - value = bytes.fromhex(specs.service_identifiers[sid]["id"]) + if sid in spec.service_identifiers.keys(): + value = bytes.fromhex(spec.service_identifiers[sid]["id"]) self._blocks["header"].service_identifier._update_value(value) self.update() diff --git a/bof/knx/knxnet.py b/bof/knx/knxnet.py index c76a552..eaefa46 100644 --- a/bof/knx/knxnet.py +++ b/bof/knx/knxnet.py @@ -139,4 +139,4 @@ def receive(self, timeout:float=1.0) -> object: :returns: A parsed KnxFrame with the received frame's representation. """ data, address = super().receive(timeout) - return KnxFrame(frame=data, source=address) + return KnxFrame(bytes=data, source=address) diff --git a/tests/test_knx_frame.py b/tests/test_knx_frame.py index 253e6f0..69be48e 100644 --- a/tests/test_knx_frame.py +++ b/tests/test_knx_frame.py @@ -61,6 +61,23 @@ def test_07_knxframe_body_from_sid_update_realvalues(self): frame.body.ip_address.value = ip self.assertEqual(bytes(frame.body), b"\x08\x01\x7f\x00\x00\x01\x00\x00") +class Test01KnxSpecTesting(unittest.TestCase): + """Test class for KnxSpec public methods.""" + def test_01_get_service_id(self): + """Test that we can get a service identifier from its name""" + sid = knx.KnxSpec().get_service_id("description request") + self.assertEqual(sid, b"\x02\x03") + def test_02_get_service_name(self): + """Test that we can get the name of a service identifier from its id.""" + name = knx.KnxSpec().get_service_name(b"\x02\x03") + self.assertEqual(name, "DESCRIPTION REQUEST") + name = knx.KnxSpec().get_service_name("DESCRIPTION_REQUEST") + self.assertEqual(name, "DESCRIPTION REQUEST") + def test_03_get_template_from_body(self): + """Test that we can retrieve the frame template associated to a body name.""" + template = knx.KnxSpec().get_template_from_body("description request") + self.assertEqual(isinstance(template, list), True) + class Test02AdvancedKnxHeaderCrafting(unittest.TestCase): """Test class for advanced header fields handling and altering.""" def test_01_basic_knx_header_from_frame(self): From 784d3bd3f95461a7b43c2ff4b0d8af0bea72451f Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 16 Jul 2020 14:36:43 +0200 Subject: [PATCH 10/23] Refactor KnxFrames build_from_stuff methods, before further changes --- bof/knx/knxframe.py | 69 ++++++++++++++++------------------------- tests/test_knx_frame.py | 6 +++- 2 files changed, 32 insertions(+), 43 deletions(-) diff --git a/bof/knx/knxframe.py b/bof/knx/knxframe.py index b7b595a..4cbc1ee 100644 --- a/bof/knx/knxframe.py +++ b/bof/knx/knxframe.py @@ -76,10 +76,16 @@ def get_service_name(self, sid:bytes) -> str: return service return None - def get_template_from_body(self, name:str) -> list: + def get_body_template(self, name:str) -> list: """Returns a template associated to a body, as a list, or None.""" return self.__get_dict_value(self.bodies, name) + def get_cemi_name(self, cid:bytes) -> str: + """Returns the name of the cemi withid ``cid``.""" + if isinstance(cid, bytes): + return self.__get_dict_key(self.cemis, "id", cid) + return None + #-------------------------------------------------------------------------# # Internals # #-------------------------------------------------------------------------# @@ -336,8 +342,6 @@ def __init__(self, **kwargs): :param bytes: Raw bytearray used to build a KnxFrame object. """ super().__init__() - # We do not use BOFFrame.append() because we use properties (not attrs) - specs = KnxSpec() self._blocks["header"] = KnxBlock(type="header") self._blocks["body"] = KnxBlock(name="body") if "type" in kwargs: @@ -355,7 +359,6 @@ def __init__(self, **kwargs): # Public # #-------------------------------------------------------------------------# - # TODO def build_from_sid(self, sid, cemi:str=None, optional:bool=False) -> None: """Fill in the KnxFrame object according to a predefined frame format corresponding to a service identifier. The frame format (blocks @@ -377,19 +380,20 @@ def build_from_sid(self, sid, cemi:str=None, optional:bool=False) -> None: """ if not isinstance(sid, bytes) and not isinstance(sid, str): raise BOFProgrammingError("Service id should be a string or a bytearray.") - # Now check that the service id exists and has an associated body spec = KnxSpec() + # Get data associated service identifier sid = spec.get_service_name(sid) if not sid or sid not in spec.bodies: raise BOFProgrammingError("Service {0} does not exist.".format(sid)) - self._blocks["body"].append(KnxBlock.factory(template=spec.bodies[sid], - cemi=cemi, optional=optional)) + template = spec.get_body_template(sid) + # Create KnxBlock according to template + self._blocks["body"].append(KnxBlock.factory( + template=template, cemi=cemi, optional=optional)) # Add fields names as properties to body :) for field in self._blocks["body"].fields: self._blocks["body"]._add_property(field.name, field) - if sid in spec.service_identifiers.keys(): - value = bytes.fromhex(spec.service_identifiers[sid]["id"]) - self._blocks["header"].service_identifier._update_value(value) + # Update header + self._blocks["header"].service_identifier._update_value(spec.get_service_id(sid)) self.update() # TODO @@ -408,31 +412,21 @@ def build_from_frame(self, frame:bytes) -> None: frame = KnxFrame(frame=data, source=address) """ - specs = KnxSpec() - # HEADER - self._blocks["header"] = KnxBlock(type="HEADER", name="header") - self._blocks["header"].fill(frame[:frame[0]]) - blocklist = None - for service in specs.service_identifiers: - attributes = specs.service_identifiers[service] - if bytes(self._blocks["header"].service_identifier) == bytes.fromhex(attributes["id"]): - blocklist = specs.bodies[service] - break - if not blocklist: - raise BOFProgrammingError("Unknown service identifier ({0})".format(self._blocks["header"].service_identifier.value)) + spec = KnxSpec() + # Fill in the header and retrieve information about the frame. + header_length = frame[0] + self._blocks["header"].fill(frame[:header_length]) # TODO + sid = spec.get_service_name(self._blocks["header"].service_identifier.value) + template = spec.get_body_template(sid) + if not template: + raise BOFProgrammingError("Unknown service identifier ({0})".format(sid)) # BODY cursor = frame[0] # We start at index len(header) (== 6) - for block in blocklist: + for block in template: if cursor >= len(frame): break # If block is a cemi, we need its type before creating the structure - cemi = frame[cursor:cursor+1] if block["type"] == "cemi" else None - if cemi: # We get the name instead of the code - for cemi_type in specs.cemis: - attributes = specs.cemis[cemi_type] - if cemi == bytes.fromhex(attributes["id"]): - cemi = cemi_type - break + cemi = spec.get_cemi_name(frame[cursor:cursor+1]) if block["type"] == "cemi" else None # factory returns a list but we only expect one item block_object = KnxBlock.factory(template=block,cemi=cemi)[0] if isinstance(block_object, KnxField): @@ -472,19 +466,10 @@ def sid(self) -> str: """Return the name associated to the frame's service identifier, or empty string if it is not set. """ - specs = KnxSpec() - for service in specs.service_identifiers: - attributes = specs.service_identifiers[service] - if bytes(self._blocks["header"].service_identifier) == bytes.fromhex(attributes["id"]): - return service - return str(self._blocks["header"].service_identifier.value) + sid = KnxSpec().get_service_name(self._blocks["header"].service_identifier.value) + return sid if sid else str(self._blocks["header"].service_identifier.value) @property def cemi(self) -> str: """Return the type of cemi, if any.""" - specs = KnxSpec() - if "cemi" in self._blocks["body"].attributes: - for cemi in specs.cemis: - if bytes(self._blocks["body"].cemi.message_code) == bytes.fromhex(specs.cemis[cemi]["id"]): - return cemi - return "" + KnxSpec().get_cemi_name(self._blocks["body"].cemi.message_code) diff --git a/tests/test_knx_frame.py b/tests/test_knx_frame.py index 69be48e..48177df 100644 --- a/tests/test_knx_frame.py +++ b/tests/test_knx_frame.py @@ -75,8 +75,12 @@ def test_02_get_service_name(self): self.assertEqual(name, "DESCRIPTION REQUEST") def test_03_get_template_from_body(self): """Test that we can retrieve the frame template associated to a body name.""" - template = knx.KnxSpec().get_template_from_body("description request") + template = knx.KnxSpec().get_body_template("description request") self.assertEqual(isinstance(template, list), True) + def test_04_get_cemi_name(self): + """Test that we can retrieve the name of a cEMI from its message code.""" + cemi = knx.KnxSpec().get_cemi_name(b"\xfc") + self.assertEqual(cemi, "PropRead.req") class Test02AdvancedKnxHeaderCrafting(unittest.TestCase): """Test class for advanced header fields handling and altering.""" From 8b9ab14d4b9ad30c736b00566d4d60f68d70c861 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 16 Jul 2020 17:55:44 +0200 Subject: [PATCH 11/23] Beginning of refactoring frame parsing in KnxFrame. Tests not OK yet --- bof/knx/knxframe.py | 42 ++++++++++++++++++++++++++++-------------- bof/knx/knxnet.json | 9 +++++---- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/bof/knx/knxframe.py b/bof/knx/knxframe.py index 4cbc1ee..45a23bc 100644 --- a/bof/knx/knxframe.py +++ b/bof/knx/knxframe.py @@ -256,6 +256,8 @@ def create_from_template(cls, template, cemi:str=None, optional:bool=False) -> l elif isinstance(template, dict): if "optional" in template.keys() and template["optional"] == True and not optional: return blocklist + if "depends" in template.keys(): + return blocklist # TODO if not "type" in template or template["type"] == "block": blocklist.append(cls(**template)) elif template["type"] == "field": @@ -413,29 +415,41 @@ def build_from_frame(self, frame:bytes) -> None: """ spec = KnxSpec() + header = frame[:frame[0]] + body = frame[frame[0]:] # Fill in the header and retrieve information about the frame. - header_length = frame[0] - self._blocks["header"].fill(frame[:header_length]) # TODO + self._blocks["header"].fill(header) # TODO sid = spec.get_service_name(self._blocks["header"].service_identifier.value) template = spec.get_body_template(sid) if not template: raise BOFProgrammingError("Unknown service identifier ({0})".format(sid)) # BODY - cursor = frame[0] # We start at index len(header) (== 6) + cursor = 0 # We start at index len(header) (== 6) for block in template: - if cursor >= len(frame): + if cursor >= len(block): break - # If block is a cemi, we need its type before creating the structure - cemi = spec.get_cemi_name(frame[cursor:cursor+1]) if block["type"] == "cemi" else None - # factory returns a list but we only expect one item - block_object = KnxBlock.factory(template=block,cemi=cemi)[0] - if isinstance(block_object, KnxField): - block_object.value = frame[cursor:cursor+block_object.size] - cursor += block_object.size + if block["type"] == "field": + entry = KnxField(**block) + entry.value = body[cursor:cursor+entry.size] else: - block_object.fill(frame[cursor:cursor+frame[cursor]]) - cursor += frame[cursor] - self._blocks["body"].append(block_object) + entry = KnxBlock(**block, bytes=body[cursor:]) + self._blocks["body"].append(entry) + cursor += len(block) + + # for block in template: + # if cursor >= len(body): + # break + # # If block is a cemi, we need its type before creating the structure + # cemi = spec.get_cemi_name(body[cursor:cursor+1]) if block["type"] == "cemi" else None + # # factory returns a list but we only expect one item + # # block_object = KnxBlock.factory(template=block,cemi=cemi)[0] + # if isinstance(block_object, KnxField): + # block_object.value = body[cursor:cursor+block_object.size] + # cursor += block_object.size + # else: + # block_object.fill(body[cursor:cursor+frame[cursor]]) + # cursor += body[cursor] + # self._blocks["body"].append(block_object) def update(self): """Update all fields corresponding to block lengths. diff --git a/bof/knx/knxnet.json b/bof/knx/knxnet.json index 8ef9f6e..d93694b 100644 --- a/bof/knx/knxnet.json +++ b/bof/knx/knxnet.json @@ -56,10 +56,11 @@ "CRI_CRD": [ {"name": "structure length", "type": "field", "size": 1, "is_length": true}, {"name": "connection type code", "type": "field", "size": 1}, - {"name": "ip address", "type": "field", "size": 4, "optional": true}, - {"name": "port", "type": "field", "size": 2, "optional": true}, - {"name": "ip address 2", "type": "field", "size": 4, "optional": true}, - {"name": "port 2", "type": "field", "size": 2, "optional": true} + {"name": "ip address", "type": "field", "size": 4, "depends": "connection type code=03"}, + {"name": "port", "type": "field", "size": 2, "depends": "connection type code=03"}, + {"name": "ip address 2", "type": "field", "size": 4, "depends": "connection type code=03"}, + {"name": "port 2", "type": "field", "size": 2, "depends": "connection type code=03"}, + {"name": "knx_address", "type": "field", "size": 2, "depends": "connection type code=04"} ], "DP_cEMI": [ {"name": "message code", "type": "field", "size": 1}, From 9316e698bc04d53210402a859847cfb0d4669c6e Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 16 Jul 2020 18:20:54 +0200 Subject: [PATCH 12/23] User documentation update --- docs/usermanual.rst | 78 ++++++++++++++++++++++++++++----------------- 1 file changed, 48 insertions(+), 30 deletions(-) diff --git a/docs/usermanual.rst b/docs/usermanual.rst index 8408a03..010e85a 100644 --- a/docs/usermanual.rst +++ b/docs/usermanual.rst @@ -67,7 +67,7 @@ address (or on multiple KNX devices on an address range) and stores it to a >>> from bof import knx >>> device = knx.discover("192.168.1.10") >>> print(device) -KnxDevice: Name=boiboite, MAC=00:00:54:ff:ff:ff, IP=192.168.1.10:3671 KNX=15/15/255 +KnxDevice: Name=boiboite, MAC=00:00:54:ff:ff:ff, IP=192.168.1.10:3671 KNX=15.15.255 Send and receive packets ++++++++++++++++++++++++ @@ -125,6 +125,7 @@ The library has the following structure:: ../bof ├── base.py ├── byte.py + ├── frame.py ├── __init__.py ├── knx │   ├── __init__.py @@ -246,25 +247,42 @@ object is described in the next section. response = knxnet.receive() print(response) # Response is a KnxFrame object -KNX frames +BOF frames ---------- Frames are sent and received as byte arrays. They can be divided into a set of -blocks, which contain a set of fields of varying sizes. Conforming to the KNX -Standard v2.1, the header's structure never changes and the body's structure -varies according to the type of the frame given in the header's ``service -identifier`` field. For instance, the format of a ``DESCRIPTION REQUEST`` -message extracted from the specification has the following content. +blocks, which contain a set of fields of varying sizes. + +In BOF, frames, blocks and fields are represented as objects (classes). A frame +(``BOFFrame``) has a header and a body, both of them being blocks +(``BOFBlock``). A block contains a set of raw fields (``BOFField``) and/or +nested ``BOFBlock`` objects with a special structure. + +Implementations (so far, KNX) inherit from these objects to build their own +specification-defined frames. They are described in BOF in a JSON specification +file, containing the definition of message codes, block types and frame +structures. The class ``BOFSpec``, inherited in implementations, is a singleton +class to parse and store specification JSON files. See "Developer manual" for +more information (not available yet). + +KNX frames +---------- + +Conforming to the KNX Standard v2.1, the header's structure never changes and +the body's structure varies according to the type of the frame given in the +header's ``service identifier`` field. For instance, the format of a +``DESCRIPTION REQUEST`` message extracted from the specification has the +following content. .. figure:: images/knx_fields.png -In BOF, frames, blocks and fields are represented as objects (classes). A frame -(``KnxFrame``) has a header and a body, both of them being blocks -(``KnxBlock``). A block contains a set of raw fields (``KnxField``) and/or -nested ``KnxBlock`` objects with a special structure (ex: ``HPAI`` is a type of -block with fixed fields). Finally, a ``KnxField`` object has three main -attributes: a ``name``, a ``size`` (number of bytes) and a ``value`` (as a byte -array). +Frame, block and field objects inherit from ``BOFFrame``, ``BOFBlock`` and +``BOFField`` global structures. A frame (``KnxFrame``) has a header and a body, +both of them being blocks (``KnxBlock``). A block contains a set of raw fields +(``KnxField``) and/or nested ``KnxBlock`` objects with a special structure (ex: +``HPAI`` is a type of block with fixed fields). Finally, a ``KnxField`` object +has three main attributes: a ``name``, a ``size`` (number of bytes) and a +``value`` (as a byte array). Create frames +++++++++++++ @@ -283,21 +301,20 @@ constructor. From the specification """""""""""""""""""""" -The KNX standard describes a set of message types with different format. They -are described in BOF in a JSON specification file, containing the definition of -message codes, block types and frame structures. The KNX Standard has not been -fully implemented yet so there may be missing content, please refer to -`bof/knx/knxnet.json` to know what is currently supported. Obviously, the -specification file content can be changed or a frame can be built without -referring to the specification, we discuss it further in the "Advanced -usage" section (not available yet). +The KNX standard describes a set of message types with different +format. Specific predefined blocks and identifiers are also written to KNX +Specification's JSON file. It has not been fully implemented yet so there may be +missing content, please refer to `bof/knx/knxnet.json` to know what is currently +supported. Obviously, the specification file content can be changed or a frame +can be built without referring to the specification, we discuss it further in +the "Advanced usage" section (not available yet). .. code-block:: python frame = knx.KnxFrame(type="DESCRIPTION REQUEST") A ``KnxFrame`` object based on a frame with the ``DESCRIPTION REQUEST`` service -identifier (sid) will be built according to this portion of the `knxnet.json` +identifier (sid) will be built according to this portion of the ``knxnet.json`` specification file. .. code-block:: json @@ -331,21 +348,22 @@ It should then have the following pattern: .. figure:: images/bof_spec.png -In predefined frames, fields are empty except for fields with a default value or -fields that store a length, which is evaluated automatically. Some frames can be -sent as is to a remote server, such as ``DESCRIPTION REQUEST`` frames, but some -of them require to fill the empty fields (see `Modify frames`_ below). +In predefined frames, fields are empty except for optional fields, fields with a +default value or fields that store a length, which is evaluated automatically. +Some frames can be sent as is to a remote server, such as ``DESCRIPTION +REQUEST`` frames, but some of them require to fill the empty fields (see `Modify +frames`_ below). From a byte array """"""""""""""""" A KnxFrame object can be created by parsing a raw byte array. This is what -happens when receiving a frame from a remote server.x +happens when receiving a frame from a remote server. .. code-block:: python - frame_from_byte = - knx.KnxFrame(frame=b'\x06\x10\x02\x03\x00\x0e\x08\x01\x7f\x00\x00\x01\xbe\x6d') + data = b'\x06\x10\x02\x03\x00\x0e\x08\x01\x7f\x00\x00\x01\xbe\x6d' + frame_from_byte = knx.KnxFrame(bytes=data) received_frame = knxnet.receive() # received_frame is a KnxFrame object The format of the frame must be understood by BOF to be efficient (i.e. the From 8ce5cfbbede62e96e7bcfbd1a2a4aa3b2f6a6548 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 17 Jul 2020 11:57:55 +0200 Subject: [PATCH 13/23] NOT TESTED KnxFrame looks almost like expected, now we need to make the code work with it --- bof/knx/knxframe.py | 75 +++++++++++++++++++-------------------------- 1 file changed, 31 insertions(+), 44 deletions(-) diff --git a/bof/knx/knxframe.py b/bof/knx/knxframe.py index 45a23bc..8dfcbd6 100644 --- a/bof/knx/knxframe.py +++ b/bof/knx/knxframe.py @@ -349,10 +349,10 @@ def __init__(self, **kwargs): if "type" in kwargs: cemi = kwargs["cemi"] if "cemi" in kwargs else None optional = kwargs["optional"] if "optional" in kwargs else False - self.build_from_sid(kwargs["type"], cemi, optional) + self.format(kwargs["type"], cemi=cemi, optional=optional) log("Created new frame from service identifier {0}".format(kwargs["type"])) elif "bytes" in kwargs: - self.build_from_frame(kwargs["bytes"]) + self.fill(kwargs["bytes"]) log("Created new frame from byte array {0}.".format(kwargs["bytes"])) # Update total frame length in header self.update() @@ -361,17 +361,22 @@ def __init__(self, **kwargs): # Public # #-------------------------------------------------------------------------# - def build_from_sid(self, sid, cemi:str=None, optional:bool=False) -> None: + # TEST REQUIRED + def format(self, service, **kwargs) -> None: """Fill in the KnxFrame object according to a predefined frame format corresponding to a service identifier. The frame format (blocks and field) can be found or added in the KNX specification JSON file. - :param sid: Service identifier as a string (service name) or as a - byte array (normally on 2 bytes but, whatever). - :param cemi: Type of cEMI if the blocks associated to ``sid`` have - a cEMI field/structure. - :param optional: Boolean, set to True if we want to build the optional - blocks/fields as stated in the specs. + :param service_id: Service identifier as a string (service name) or as a + byte array (normally on 2 bytes but, whatever). + + Keyword arguments: + + :param *: Unrecognized params may be used later when creating blocks; + if the param name matches with a field required to define + how to build the rest of the block, its value will be used + to shape the block accordingly. + :raises BOFProgrammingError: If the service identifier cannot be found in given JSON file. @@ -380,39 +385,36 @@ def build_from_sid(self, sid, cemi:str=None, optional:bool=False) -> None: frame = KnxFrame() frame.build_from_sid("DESCRIPTION REQUEST") """ - if not isinstance(sid, bytes) and not isinstance(sid, str): + if not isinstance(service, bytes) and not isinstance(service, str): raise BOFProgrammingError("Service id should be a string or a bytearray.") spec = KnxSpec() # Get data associated service identifier - sid = spec.get_service_name(sid) - if not sid or sid not in spec.bodies: - raise BOFProgrammingError("Service {0} does not exist.".format(sid)) - template = spec.get_body_template(sid) + service_name = spec.get_service_name(service) + if not service_name or service_name not in spec.bodies: + raise BOFProgrammingError("Service {0} does not exist.".format(service_name)) + template = spec.get_body_template(service_name) # Create KnxBlock according to template self._blocks["body"].append(KnxBlock.factory( - template=template, cemi=cemi, optional=optional)) + template=template, **kwargs)) # Add fields names as properties to body :) for field in self._blocks["body"].fields: self._blocks["body"]._add_property(field.name, field) # Update header - self._blocks["header"].service_identifier._update_value(spec.get_service_id(sid)) + self._blocks["header"].service_identifier._update_value( + spec.get_service_id(service_name)) self.update() - # TODO - def build_from_frame(self, frame:bytes) -> None: + # TEST REQUIRED + def fill(self, frame:bytes) -> None: """Fill in the KnxFrame object using a frame as a raw byte array. This method is used when receiving and parsing a file from a KNX object. - The parsing relies on the block lengths sometimes stated in first byte - of each part (block) of the frame. - - :param frame: KNX frame as a byte array (or anything, whatever) + :param frame: KNX frame as a byte array Example:: data, address = knx_connection.receive() - frame = KnxFrame(frame=data, source=address) - + frame = KnxFrame(bytes=data) """ spec = KnxSpec() header = frame[:frame[0]] @@ -426,30 +428,15 @@ def build_from_frame(self, frame:bytes) -> None: # BODY cursor = 0 # We start at index len(header) (== 6) for block in template: - if cursor >= len(block): - break if block["type"] == "field": entry = KnxField(**block) - entry.value = body[cursor:cursor+entry.size] + entry.value = body[cursor:cursor+len(entry)] else: - entry = KnxBlock(**block, bytes=body[cursor:]) + entry = KnxBlock(bytes=body[cursor:], **block) self._blocks["body"].append(entry) - cursor += len(block) - - # for block in template: - # if cursor >= len(body): - # break - # # If block is a cemi, we need its type before creating the structure - # cemi = spec.get_cemi_name(body[cursor:cursor+1]) if block["type"] == "cemi" else None - # # factory returns a list but we only expect one item - # # block_object = KnxBlock.factory(template=block,cemi=cemi)[0] - # if isinstance(block_object, KnxField): - # block_object.value = body[cursor:cursor+block_object.size] - # cursor += block_object.size - # else: - # block_object.fill(body[cursor:cursor+frame[cursor]]) - # cursor += body[cursor] - # self._blocks["body"].append(block_object) + cursor += len(entry) + if cursor >= len(body): + break def update(self): """Update all fields corresponding to block lengths. From 2328d04803ed41964a25c16de941b9749a09c955 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 24 Jul 2020 17:21:53 +0200 Subject: [PATCH 14/23] Reworked JSON, test still won't pass but now it's worse than ever --- bof/frame.py | 21 ++++++ bof/knx/knxframe.py | 79 ++++++++++++++------ bof/knx/knxnet.json | 162 +++++++++++++++++++++++----------------- docs/conf.py | 4 +- tests/test_knx_frame.py | 8 +- 5 files changed, 175 insertions(+), 99 deletions(-) diff --git a/bof/frame.py b/bof/frame.py index c01d98a..3840c07 100644 --- a/bof/frame.py +++ b/bof/frame.py @@ -267,13 +267,18 @@ class BOFBlock(object): :param name: Name of the block, so that it can be referred to by its name. It is also use to create an attribute in the parent block. + :param parent: Parent frame, used when a field or a block depends on the + value of a field previously written to the frame. :param content: List of blocks, fields or both. """ _name:str _content:list + _parent:object + _spec:object def __init__(self, **kwargs): self.name = kwargs["name"] if "name" in kwargs else "" + self._parent = kwargs["parent"] if "parent" in kwargs else None self._content = [] def __bytes__(self): @@ -385,6 +390,22 @@ def _add_property(self, name, pointer:object) -> None: elif len(name) > 0: setattr(self, to_property(name), pointer) + def _get_depends_block(self, field:str): + """If the format of a block depends on the value of a field set + previously, we look for it and choose the appropriate format. + The closest field with such name is used. + + :param name: Name of the field to look for and extract value. + """ + field = to_property(field) + field_list = list(self._parent) + field_list.reverse() + for frame_field in field_list: + if field == to_property(frame_field.name): + block = self._spec.get_code_name(frame_field.name, frame_field.value) + return block + raise BOFProgrammingError("Field does not exist ({0}).".format(field)) + #-------------------------------------------------------------------------# # Properties # #-------------------------------------------------------------------------# diff --git a/bof/knx/knxframe.py b/bof/knx/knxframe.py index 8dfcbd6..6a67e9a 100644 --- a/bof/knx/knxframe.py +++ b/bof/knx/knxframe.py @@ -62,34 +62,57 @@ def get_service_id(self, name:str) -> bytes: """Returns the content of parameter ``id`` for a given service identifier name in KNX spec JSON file. """ - value = self.__get_dict_value(self.service_identifiers, name) - return bytes.fromhex(value["id"]) if value else None + return self.__get_code_id(self.codes["service identifier"], name) def get_service_name(self, sid:bytes) -> str: """Returns the name of the service identifier with id ``sid``.""" if isinstance(sid, bytes): - return self.__get_dict_key(self.service_identifiers, "id", sid) + return self.get_code_name("service identifier", sid) if isinstance(sid, str): sid = to_property(sid) - for service in self.service_identifiers: + for service in self.codes["service identifier"].values(): if sid == to_property(service): return service return None - def get_body_template(self, name:str) -> list: + def get_block_template(self, name:str) -> list: """Returns a template associated to a body, as a list, or None.""" - return self.__get_dict_value(self.bodies, name) + return self.__get_dict_value(self.blocks, name) + + def get_cemi_id(self, name:str) -> bytes: + """Returns the content of parameter ``id`` for a given service + identifier name in KNX spec JSON file. + """ + return self.__get_code_id(self.codes["message code"], name) def get_cemi_name(self, cid:bytes) -> str: - """Returns the name of the cemi withid ``cid``.""" + """Returns the name of the cemi with id ``cid``.""" if isinstance(cid, bytes): - return self.__get_dict_key(self.cemis, "id", cid) + return self.get_code_name("message code", cid) + if isinstance(cid, str): + cid = to_property(cid) + for cemi in self.codes["message code"].values(): + if cid == to_property(cemi): + return cemi + return None + + def get_code_name(self, dict_key:str, identifier:bytes) -> str: + for key in self.codes[dict_key]: + if identifier == bytes.fromhex(key): + return self.codes[dict_key][key] return None #-------------------------------------------------------------------------# # Internals # #-------------------------------------------------------------------------# + def __get_code_id(self, dictionary:dict, name:str) -> bytes: + name = to_property(name) + for key, value in dictionary.items(): + if name == to_property(value): + return bytes.fromhex(key) + return None + def __get_dict_value(self, dictionary:dict, key:str) -> object: """Return the value associated to a key from a given dictionary. Key is insensitive, the value can have different types. Must be called @@ -169,7 +192,6 @@ class KnxBlock(BOFBlock): descr_resp.append(KnxBlock(type="DIB_SUPP_SVC_FAMILIES")) """ - # TODO def __init__(self, **kwargs): """Initialize the ``KnxBlock`` with a mandatory name and optional arguments to fill in the block content list (with fields or nested @@ -186,19 +208,27 @@ def __init__(self, **kwargs): with ``type``. """ super().__init__(**kwargs) - specs = KnxSpec() + self._spec = KnxSpec() if "type" in kwargs: - if not kwargs["type"].upper() in specs.blocks.keys(): - raise BOFProgrammingError("Unknown block type ({0})".format(kwargs["type"])) + block_type = kwargs["type"] + if block_type.startswith("depends:"): + block_type = self._get_depends_block(block_type.split(":")[1]) + template = self._spec.get_block_template(block_type) + if not template: + raise BOFProgrammingError("Unknown block type ({0})".format(block_type)) self.name = self.name if len(self.name) else kwargs["type"] - self.append(self.factory(template=specs.blocks[kwargs["type"].upper()])) + self.append(self.factory(template=template)) + # TODO elif "cemi" in kwargs: - if not kwargs["cemi"] in specs.cemis.keys(): + cemi = self._spec.get_cemi_name(kwargs["cemi"]) + if not cemi: raise BOFProgrammingError("cEMI is unknown ({0})".format(kwargs["cemi"])) self.name = self.name if len(self.name) else "cemi" - self.append(self.factory(template=specs.blocks[specs.cemis[kwargs["cemi"]]["type"]])) - self.message_code.value = bytes.fromhex(specs.cemis[kwargs["cemi"]]["id"]) + self.append(self.factory(template=self._spec.blocks["DP_cEMI"])) # TODO + self.message_code.value = self._spec.get_cemi_id(cemi) + # TMP location + #-------------------------------------------------------------------------# # Public # #-------------------------------------------------------------------------# @@ -256,8 +286,6 @@ def create_from_template(cls, template, cemi:str=None, optional:bool=False) -> l elif isinstance(template, dict): if "optional" in template.keys() and template["optional"] == True and not optional: return blocklist - if "depends" in template.keys(): - return blocklist # TODO if not "type" in template or template["type"] == "block": blocklist.append(cls(**template)) elif template["type"] == "field": @@ -344,8 +372,13 @@ def __init__(self, **kwargs): :param bytes: Raw bytearray used to build a KnxFrame object. """ super().__init__() - self._blocks["header"] = KnxBlock(type="header") - self._blocks["body"] = KnxBlock(name="body") + spec = KnxSpec() + sid = spec.get_service_id(kwargs["type"]) if "type" in kwargs else None + for block in spec.frame: + self._blocks[block["name"]] = KnxBlock(**block, parent=self) + if block["name"] == "header" and sid: + self._blocks[block["name"]].service_identifier.value = sid + #TODO if "type" in kwargs: cemi = kwargs["cemi"] if "cemi" in kwargs else None optional = kwargs["optional"] if "optional" in kwargs else False @@ -390,9 +423,9 @@ def format(self, service, **kwargs) -> None: spec = KnxSpec() # Get data associated service identifier service_name = spec.get_service_name(service) - if not service_name or service_name not in spec.bodies: + if not service_name or service_name not in spec.blocks: raise BOFProgrammingError("Service {0} does not exist.".format(service_name)) - template = spec.get_body_template(service_name) + template = spec.get_block_template(service_name) # Create KnxBlock according to template self._blocks["body"].append(KnxBlock.factory( template=template, **kwargs)) @@ -422,7 +455,7 @@ def fill(self, frame:bytes) -> None: # Fill in the header and retrieve information about the frame. self._blocks["header"].fill(header) # TODO sid = spec.get_service_name(self._blocks["header"].service_identifier.value) - template = spec.get_body_template(sid) + template = spec.get_block_template(sid) if not template: raise BOFProgrammingError("Unknown service identifier ({0})".format(sid)) # BODY diff --git a/bof/knx/knxnet.json b/bof/knx/knxnet.json index d93694b..e63bcfa 100644 --- a/bof/knx/knxnet.json +++ b/bof/knx/knxnet.json @@ -1,24 +1,8 @@ { - "service identifiers": { - "SEARCH REQUEST": {"id": "0201"}, - "SEARCH RESPONSE": {"id": "0202"}, - "DESCRIPTION REQUEST": {"id": "0203"}, - "DESCRIPTION RESPONSE": {"id": "0204"}, - "CONNECT REQUEST": {"id": "0205"}, - "CONNECT RESPONSE": {"id": "0206"}, - "CONNECTIONSTATE REQUEST": {"id": "0207"}, - "CONNECTIONSTATE RESPONSE": {"id": "0208"}, - "DISCONNECT REQUEST": {"id": "0209"}, - "DISCONNECT RESPONSE": {"id": "020A"}, - "CONFIGURATION REQUEST": {"id": "0310"}, - "CONFIGURATION ACK": {"id": "0311"} - }, - "cemis": { - "PropRead.req": {"id": "FC", "type": "DP_cEMI"}, - "PropRead.con": {"id": "FB", "type": "DP_cEMI"}, - "PropWrite.req": {"id": "F6", "type": "DP_cEMI"}, - "PropWrite.con": {"id": "F5", "type": "DP_cEMI"} - }, + "frame": [ + {"name": "header", "type": "HEADER"}, + {"name": "body", "type": "depends:service identifier"} + ], "blocks": { "HEADER": [ {"name": "header length", "type": "field", "size": 1, "is_length": true}, @@ -26,52 +10,6 @@ {"name": "service identifier", "type": "field", "size": 2}, {"name": "total length", "type": "field", "size": 2} ], - "HPAI": [ - {"name": "structure length", "type": "field", "size": 1, "is_length": true}, - {"name": "host protocol code", "type": "field", "size": 1, "default": "01"}, - {"name": "ip address", "type": "field", "size": 4}, - {"name": "port", "type": "field", "size": 2} - ], - "DIB_DEVICE_INFO": [ - {"name": "structure length", "type": "field", "size": 1, "is_length": true}, - {"name": "description type code", "type": "field", "size": 1, "default": "01"}, - {"name": "knx medium", "type": "field", "size": 1}, - {"name": "device status", "type": "field", "size": 1}, - {"name": "knx individual address", "type": "field", "size": 2}, - {"name": "project installation identifier", "type": "field", "size": 2}, - {"name": "knx serial number", "type": "field", "size": 6}, - {"name": "multicast address", "type": "field", "size": 4}, - {"name": "mac address", "type": "field", "size": 6}, - {"name": "friendly name", "type": "field", "size": 30} - ], - "DIB_SUPP_SVC_FAMILIES": [ - {"name": "structure length", "type": "field", "size": 1, "is_length": true}, - {"name": "description type code", "type": "field", "size": 1, "default": "02"}, - {"name": "service family", "type": "SERVICE_FAMILY", "size": 2, "repeat": true} - ], - "SERVICE_FAMILY": [ - {"name": "id", "type": "field", "size": 1}, - {"name": "version", "type": "field", "size": 1} - ], - "CRI_CRD": [ - {"name": "structure length", "type": "field", "size": 1, "is_length": true}, - {"name": "connection type code", "type": "field", "size": 1}, - {"name": "ip address", "type": "field", "size": 4, "depends": "connection type code=03"}, - {"name": "port", "type": "field", "size": 2, "depends": "connection type code=03"}, - {"name": "ip address 2", "type": "field", "size": 4, "depends": "connection type code=03"}, - {"name": "port 2", "type": "field", "size": 2, "depends": "connection type code=03"}, - {"name": "knx_address", "type": "field", "size": 2, "depends": "connection type code=04"} - ], - "DP_cEMI": [ - {"name": "message code", "type": "field", "size": 1}, - {"name": "object type", "type": "field", "size": 2}, - {"name": "object instance", "type": "field", "size": 1}, - {"name": "property id", "type": "field", "size": 1}, - {"name": "number of elements, start index", "type": "field", "size": 2, "bitsizes": "4, 12"}, - {"name": "data", "type": "field", "size": 0} - ] - }, - "bodies": { "SEARCH REQUEST": [ {"name": "discovery endpoint", "type": "HPAI"} ], @@ -122,18 +60,102 @@ {"name": "communication channel id", "type": "field", "size": 1, "default": "01"}, {"name": "sequence counter", "type": "field", "size": 1}, {"name": "reserved", "type": "field", "size": 1}, - {"name": "cEMI", "type": "cemi"} + {"name": "cEMI", "type": "CEMI"} ], "CONFIGURATION ACK": [ {"name": "structure length", "type": "field", "size": 1, "is_length": true}, {"name": "communication channel id", "type": "field", "size": 1, "default": "01"}, {"name": "sequence counter", "type": "field", "size": 1}, {"name": "status", "type": "field", "size": 1} + ], + "HPAI": [ + {"name": "structure length", "type": "field", "size": 1, "is_length": true}, + {"name": "host protocol code", "type": "field", "size": 1, "default": "01"}, + {"name": "ip address", "type": "field", "size": 4}, + {"name": "port", "type": "field", "size": 2} + ], + "DIB_DEVICE_INFO": [ + {"name": "structure length", "type": "field", "size": 1, "is_length": true}, + {"name": "description type code", "type": "field", "size": 1, "default": "01"}, + {"name": "knx medium", "type": "field", "size": 1}, + {"name": "device status", "type": "field", "size": 1}, + {"name": "knx individual address", "type": "field", "size": 2}, + {"name": "project installation identifier", "type": "field", "size": 2}, + {"name": "knx serial number", "type": "field", "size": 6}, + {"name": "multicast address", "type": "field", "size": 4}, + {"name": "mac address", "type": "field", "size": 6}, + {"name": "friendly name", "type": "field", "size": 30} + ], + "DIB_SUPP_SVC_FAMILIES": [ + {"name": "structure length", "type": "field", "size": 1, "is_length": true}, + {"name": "description type code", "type": "field", "size": 1, "default": "02"}, + {"name": "service family", "type": "SERVICE_FAMILY", "size": 2, "repeat": true} + ], + "SERVICE_FAMILY": [ + {"name": "id", "type": "field", "size": 1}, + {"name": "version", "type": "field", "size": 1} + ], + "CRI_CRD": [ + {"name": "structure length", "type": "field", "size": 1, "is_length": true}, + {"name": "connection type code", "type": "field", "size": 1}, + {"name": "connection data", "type": "depends:connection type code"} + ], + "DEVICE MANAGEMENT CONNECTION": [ + {"name": "ip address", "type": "field", "size": 4}, + {"name": "port", "type": "field", "size": 2} + ], + "TUNNELING CONNECTION": [ + {"name": "knx address", "type": "field", "size": 2} + ], + "CEMI": [ + {"name": "message code", "type": "field", "size": 1}, + {"name": "cemi data", "type": "depends:message code"} + ], + "DP_cEMI": [ + {"name": "object type", "type": "field", "size": 2}, + {"name": "object instance", "type": "field", "size": 1}, + {"name": "property id", "type": "field", "size": 1}, + {"name": "number of elements, start index", "type": "field", "size": 2, "bitsizes": "4, 12"}, + {"name": "data", "type": "field", "size": 0} + ], + "PropRead.req": [ + {"name": "PropRead.req", "type": "DP_cEMI"} + ], + "PropRead.con": [ + {"name": "PropRead.con", "type": "DP_cEMI"} + ], + "PropWrite.req": [ + {"name": "PropWrite.req", "type": "DP_cEMI"} + ], + "PropWrite.con": [ + {"name": "PropWrite.con", "type": "DP_cEMI"} ] }, - "connection types": { - "Device Management Connection": "03", - "Tunneling Connection": "04" + "codes" : { + "service identifier": { + "0201": "SEARCH REQUEST", + "0202": "SEARCH RESPONSE", + "0203": "DESCRIPTION REQUEST", + "0204": "DESCRIPTION RESPONSE", + "0205": "CONNECT REQUEST", + "0206": "CONNECT RESPONSE", + "0207": "CONNECTIONSTATE REQUEST", + "0208": "CONNECTIONSTATE RESPONSE", + "0209": "DISCONNECT REQUEST", + "020A": "DISCONNECT RESPONSE", + "0310": "CONFIGURATION REQUEST", + "0311": "CONFIGURATION RESPONSE" + }, + "message code": { + "FC": "PropRead.req", + "FB": "PropRead.con", + "F6": "PropWrite.req", + "F5": "PropWrite.con" + }, + "connection type code": { + "03": "DEVICE MANAGEMENT CONNECTION", + "04": "TUNNELING CONNECTION" + } }, "object types": { "DEVICE": 0, diff --git a/docs/conf.py b/docs/conf.py index f913828..9549f62 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -30,7 +30,7 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ['sphinx.ext.autodoc'] +extensions = ["sphinx.ext.autodoc", "sphinx_rtd_theme"] # Add any paths that contain templates here, relative to this directory. # templates_path = ['_templates'] @@ -46,7 +46,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -# html_theme = 'nature' +html_theme = 'sphinx_rtd_theme' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, diff --git a/tests/test_knx_frame.py b/tests/test_knx_frame.py index 48177df..e46e8c3 100644 --- a/tests/test_knx_frame.py +++ b/tests/test_knx_frame.py @@ -69,13 +69,13 @@ def test_01_get_service_id(self): self.assertEqual(sid, b"\x02\x03") def test_02_get_service_name(self): """Test that we can get the name of a service identifier from its id.""" - name = knx.KnxSpec().get_service_name(b"\x02\x03") + name = knx.KnxSpec().get_service_name(b"\x02\x03") self.assertEqual(name, "DESCRIPTION REQUEST") name = knx.KnxSpec().get_service_name("DESCRIPTION_REQUEST") self.assertEqual(name, "DESCRIPTION REQUEST") def test_03_get_template_from_body(self): """Test that we can retrieve the frame template associated to a body name.""" - template = knx.KnxSpec().get_body_template("description request") + template = knx.KnxSpec().get_block_template("description request") self.assertEqual(isinstance(template, list), True) def test_04_get_cemi_name(self): """Test that we can retrieve the name of a cEMI from its message code.""" @@ -196,12 +196,12 @@ class Test04DIBSpecificationClass(unittest.TestCase): """Test class for specification class building from JSON file.""" def test_01_knx_spec_instantiate(self): spec = knx.KnxSpec() - self.assertEqual(list(spec.service_identifiers.keys())[0], "SEARCH REQUEST") + self.assertEqual(list(spec.codes["service identifier"].values())[0], "SEARCH REQUEST") def test_01_knx_spec_clear(self): spec = knx.KnxSpec() spec.clear() with self.assertRaises(AttributeError): - print(spec.service_identifiers) + print(spec.codes.service_identifier) class Test05DIBBlockFromSpec(unittest.TestCase): """Test class for blocks with dib types (from a service identifier).""" From 6bb674f8247741bf1038c05a7f8fc683f966f41e Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 27 Jul 2020 14:49:26 +0200 Subject: [PATCH 15/23] Tests still not ok, but depends almost work --- bof/frame.py | 34 +++++++- bof/knx/knxframe.py | 160 +++++++++++++++++++++----------------- bof/knx/knxnet.json | 4 +- tests/test_knx_connect.py | 2 +- tests/test_knx_device.py | 6 +- tests/test_knx_frame.py | 4 +- 6 files changed, 127 insertions(+), 83 deletions(-) diff --git a/bof/frame.py b/bof/frame.py index 3840c07..2cfc6ba 100644 --- a/bof/frame.py +++ b/bof/frame.py @@ -276,10 +276,30 @@ class BOFBlock(object): _parent:object _spec:object - def __init__(self, **kwargs): + @classmethod + def factory(cls, template) -> object: + raise NotImplementedError("Factory should be instantiated in subclasses.") + + def __init__(self, defaults:dict=None, **kwargs): self.name = kwargs["name"] if "name" in kwargs else "" self._parent = kwargs["parent"] if "parent" in kwargs else None self._content = [] + # Without a type, the block remains empty + if not "type" in kwargs or kwargs["type"] == "block": + return + # Now we extract the final type of block from the arguments + block_type = kwargs["type"] + if block_type.startswith("depends:"): + field_name = to_property(block_type.split(":")[1]) + block_type = self._get_depends_block(field_name, defaults) + # We extract the block's content according to its type + template = self._spec.get_block_template(block_type) + if not template: + raise BOFProgrammingError("Unknown block type ({0})".format(block_type)) + # And we fill the block according to its content + template = [template] if not isinstance(template, list) else template + for item in template: + self.append(self.factory(item, defaults=defaults, parent=self)) def __bytes__(self): return b''.join(bytes(item) for item in self._content) @@ -390,21 +410,29 @@ def _add_property(self, name, pointer:object) -> None: elif len(name) > 0: setattr(self, to_property(name), pointer) - def _get_depends_block(self, field:str): + def _get_depends_block(self, field:str, defaults:dict=None): """If the format of a block depends on the value of a field set previously, we look for it and choose the appropriate format. The closest field with such name is used. :param name: Name of the field to look for and extract value. + :raises BOFProgrammingError: If specified field was not found. """ field = to_property(field) + # First search in default values list + if defaults: + for key in defaults: + if field == to_property(key): + block = self._spec.get_code_name(key, defaults[key]) + return block + # Then we look for it in parent values field_list = list(self._parent) field_list.reverse() for frame_field in field_list: if field == to_property(frame_field.name): block = self._spec.get_code_name(frame_field.name, frame_field.value) return block - raise BOFProgrammingError("Field does not exist ({0}).".format(field)) + raise BOFProgrammingError("Field nout found ({0}).".format(field)) #-------------------------------------------------------------------------# # Properties # diff --git a/bof/knx/knxframe.py b/bof/knx/knxframe.py index 6a67e9a..2f28dc8 100644 --- a/bof/knx/knxframe.py +++ b/bof/knx/knxframe.py @@ -96,11 +96,29 @@ def get_cemi_name(self, cid:bytes) -> str: return cemi return None + def get_connection_id(self, name:str) -> bytes: + """Returns the content of parameter ``id`` for a given service + identifier name in KNX spec JSON file. + """ + return self.__get_code_id(self.codes["connection type code"], name) + + def get_connection_name(self, cid:bytes) -> str: + """Returns the name of the cemi with id ``cid``.""" + if isinstance(cid, bytes): + return self.get_code_name("connection type code", cid) + if isinstance(cid, str): + cid = to_property(cid) + for connect in self.codes["connection type code"].values(): + if cid == to_property(connect): + return connect + return None + def get_code_name(self, dict_key:str, identifier:bytes) -> str: for key in self.codes[dict_key]: if identifier == bytes.fromhex(key): return self.codes[dict_key][key] - return None + raise BOFProgrammingError("Association not found for {0} ({1})".format( + dict_key, identifier)) #-------------------------------------------------------------------------# # Internals # @@ -192,71 +210,56 @@ class KnxBlock(BOFBlock): descr_resp.append(KnxBlock(type="DIB_SUPP_SVC_FAMILIES")) """ + @classmethod + def factory(cls, template, **kwargs) -> object: + if "type" in template and template["type"] == "field": + return KnxField(**template) + return cls(**template, **kwargs) + def __init__(self, **kwargs): """Initialize the ``KnxBlock`` with a mandatory name and optional arguments to fill in the block content list (with fields or nested blocks). - A ``KnxBlock`` can be pre-filled according to a type or to a cEMI - block as defined in the specification file. + From the specification file, the KnxBlock takes as argument a "block" + line, such as:: - Available keyword arguments: + {"name": "control endpoint", "type": "HPAI"}, - :param name: String to refer to the block using a property. - :param type: Type of block. Cannot be used with ``cemi``. - :param cemi: Type of block if this is a cemi structure. Cannot be used - with ``type``. + Optional keyword arguments can be given to force values of fields + to depend on to create a field (ex: message code) """ - super().__init__(**kwargs) self._spec = KnxSpec() - if "type" in kwargs: - block_type = kwargs["type"] - if block_type.startswith("depends:"): - block_type = self._get_depends_block(block_type.split(":")[1]) - template = self._spec.get_block_template(block_type) - if not template: - raise BOFProgrammingError("Unknown block type ({0})".format(block_type)) - self.name = self.name if len(self.name) else kwargs["type"] - self.append(self.factory(template=template)) - # TODO - elif "cemi" in kwargs: - cemi = self._spec.get_cemi_name(kwargs["cemi"]) - if not cemi: - raise BOFProgrammingError("cEMI is unknown ({0})".format(kwargs["cemi"])) - self.name = self.name if len(self.name) else "cemi" - self.append(self.factory(template=self._spec.blocks["DP_cEMI"])) # TODO - self.message_code.value = self._spec.get_cemi_id(cemi) - - # TMP location - + super().__init__(**kwargs) + #-------------------------------------------------------------------------# # Public # #-------------------------------------------------------------------------# # TODO - @classmethod - def factory(cls, **kwargs) -> object: - """Factory method to create a list of ``KnxBlock`` according to kwargs. - Available keywords arguments: + # @classmethod + # def factory(cls, **kwargs) -> object: + # """Factory method to create a list of ``KnxBlock`` according to kwargs. + # Available keywords arguments: - :param template: Cannot be used with ``type``. - :param type: Type of block. Cannot be used with ``cemi``. - :param cemi: Type of block if this is a cemi structure. Cannot be used - with ``type``. - :returns: A list of ``KnxBlock`` objects. + # :param template: Cannot be used with ``type``. + # :param type: Type of block. Cannot be used with ``cemi``. + # :param cemi: Type of block if this is a cemi structure. Cannot be used + # with ``type``. + # :returns: A list of ``KnxBlock`` objects. - """ + # """ - if "template" in kwargs: - cemi = kwargs["cemi"] if "cemi" in kwargs else None - optional = kwargs["optional"] if "optional" in kwargs else False - return cls.create_from_template(kwargs["template"], cemi, optional) - if "type" in kwargs: - return cls(type=kwargs["type"], name=name) - if "cemi" in kwargs: - optional = kwargs["optional"] if "optional" in kwargs else False - return cls(cemi=kwargs["cemi"], name="cEMI") - return None + # if "template" in kwargs: + # cemi = kwargs["cemi"] if "cemi" in kwargs else None + # optional = kwargs["optional"] if "optional" in kwargs else False + # return cls.create_from_template(kwargs["template"], cemi, optional) + # if "type" in kwargs: + # return cls(type=kwargs["type"], name=name) + # if "cemi" in kwargs: + # optional = kwargs["optional"] if "optional" in kwargs else False + # return cls(cemi=kwargs["cemi"], name="cEMI") + # return None # TODO @classmethod @@ -371,20 +374,33 @@ def __init__(self, **kwargs): optional fields (from spec). :param bytes: Raw bytearray used to build a KnxFrame object. """ - super().__init__() spec = KnxSpec() + super().__init__() + # We store some values before starting building the frame + additional_args = {} sid = spec.get_service_id(kwargs["type"]) if "type" in kwargs else None + if "cemi" in kwargs: + additional_args["message code"] = spec.get_cemi_id(kwargs["cemi"]) + if "connection" in kwargs: + additional_args["connection type code"] = spec.get_connection_id(kwargs["connection"]) + # Now we can start for block in spec.frame: - self._blocks[block["name"]] = KnxBlock(**block, parent=self) + # Create block + self._blocks[block["name"]] = KnxBlock( + defaults=additional_args, **block, parent=self) + # Add fields as attributes to current frame block + for field in self._blocks[block["name"]].fields: + self._blocks[block["name"]]._add_property(field.name, field) + # KNX-header specific attribute if block["name"] == "header" and sid: self._blocks[block["name"]].service_identifier.value = sid #TODO - if "type" in kwargs: - cemi = kwargs["cemi"] if "cemi" in kwargs else None - optional = kwargs["optional"] if "optional" in kwargs else False - self.format(kwargs["type"], cemi=cemi, optional=optional) - log("Created new frame from service identifier {0}".format(kwargs["type"])) - elif "bytes" in kwargs: + # if "type" in kwargs: + # cemi = kwargs["cemi"] if "cemi" in kwargs else None + # optional = kwargs["optional"] if "optional" in kwargs else False + # self.format(kwargs["type"], cemi=cemi, optional=optional) + # log("Created new frame from service identifier {0}".format(kwargs["type"])) + if "bytes" in kwargs: self.fill(kwargs["bytes"]) log("Created new frame from byte array {0}.".format(kwargs["bytes"])) # Update total frame length in header @@ -394,7 +410,7 @@ def __init__(self, **kwargs): # Public # #-------------------------------------------------------------------------# - # TEST REQUIRED + # to remove def format(self, service, **kwargs) -> None: """Fill in the KnxFrame object according to a predefined frame format corresponding to a service identifier. The frame format (blocks @@ -418,24 +434,24 @@ def format(self, service, **kwargs) -> None: frame = KnxFrame() frame.build_from_sid("DESCRIPTION REQUEST") """ - if not isinstance(service, bytes) and not isinstance(service, str): - raise BOFProgrammingError("Service id should be a string or a bytearray.") - spec = KnxSpec() + # if not isinstance(service, bytes) and not isinstance(service, str): + # raise BOFProgrammingError("Service id should be a string or a bytearray.") + # spec = KnxSpec() # Get data associated service identifier - service_name = spec.get_service_name(service) - if not service_name or service_name not in spec.blocks: - raise BOFProgrammingError("Service {0} does not exist.".format(service_name)) - template = spec.get_block_template(service_name) + # service_name = spec.get_service_name(service) + # if not service_name or service_name not in spec.blocks: + # raise BOFProgrammingError("Service {0} does not exist.".format(service_name)) + # template = spec.get_block_template(service_name) # Create KnxBlock according to template - self._blocks["body"].append(KnxBlock.factory( - template=template, **kwargs)) + # self._blocks["body"].append(KnxBlock.factory( + # template=template, **kwargs)) # Add fields names as properties to body :) - for field in self._blocks["body"].fields: - self._blocks["body"]._add_property(field.name, field) + # for field in self._blocks["body"].fields: + # self._blocks["body"]._add_property(field.name, field) # Update header - self._blocks["header"].service_identifier._update_value( - spec.get_service_id(service_name)) - self.update() + # self._blocks["header"].service_identifier._update_value( + # spec.get_service_id(service_name)) + # self.update() # TEST REQUIRED def fill(self, frame:bytes) -> None: diff --git a/bof/knx/knxnet.json b/bof/knx/knxnet.json index e63bcfa..b264d6c 100644 --- a/bof/knx/knxnet.json +++ b/bof/knx/knxnet.json @@ -100,11 +100,11 @@ {"name": "connection type code", "type": "field", "size": 1}, {"name": "connection data", "type": "depends:connection type code"} ], - "DEVICE MANAGEMENT CONNECTION": [ + "DEVICE_MANAGEMENT_CONNECTION": [ {"name": "ip address", "type": "field", "size": 4}, {"name": "port", "type": "field", "size": 2} ], - "TUNNELING CONNECTION": [ + "TUNNELING_CONNECTION": [ {"name": "knx address", "type": "field", "size": 2} ], "CEMI": [ diff --git a/tests/test_knx_connect.py b/tests/test_knx_connect.py index 3155a6f..d821e4b 100644 --- a/tests/test_knx_connect.py +++ b/tests/test_knx_connect.py @@ -7,7 +7,7 @@ import unittest -BOIBOITE = "192.168.1.10" +BOIBOITE = "192.168.1.242" class Test01Import(unittest.TestCase): """Test class for KNX submodule import.""" diff --git a/tests/test_knx_device.py b/tests/test_knx_device.py index de000b4..f52b2d6 100644 --- a/tests/test_knx_device.py +++ b/tests/test_knx_device.py @@ -7,7 +7,7 @@ import unittest from bof import byte, knx, BOFProgrammingError -BOIBOITE = "192.168.1.10" +BOIBOITE = "192.168.1.242" class Test01DeviceDiscovery(unittest.TestCase): """Test class for basic KNX frame creation and usage.""" @@ -15,7 +15,7 @@ def test_01_knxdiscover(self): device = knx.discover(BOIBOITE) self.assertTrue(isinstance(device, knx.KnxDevice)) def test_02_knxmultidiscover(self): - devices = knx.discover("192.168.1.1,192.168.1.10") + devices = knx.discover("192.168.1.232,192.168.1.242") self.assertTrue(isinstance(devices, list)) self.assertTrue(isinstance(devices[0], knx.KnxDevice)) @unittest.skip("slow") @@ -23,7 +23,7 @@ def test_03_knxrangediscover(self): devices = knx.discover("192.168.1.0/24") self.assertTrue(isinstance(devices, list)) self.assertTrue(isinstance(devices[0], knx.KnxDevice)) - self.assertEqual(devices[0].address, "192.168.1.10") + self.assertEqual(devices[0].address, BOIBOITE) self.assertEqual(devices[0].port, 3671) def test_04_knxwrongdiscover(self): device = knx.discover("192.168.1.1") diff --git a/tests/test_knx_frame.py b/tests/test_knx_frame.py index e46e8c3..6ff5e19 100644 --- a/tests/test_knx_frame.py +++ b/tests/test_knx_frame.py @@ -9,7 +9,7 @@ import unittest from bof import knx, byte, BOFProgrammingError -BOIBOITE = "192.168.1.10" +BOIBOITE = "192.168.1.242" class Test01BasicKnxFrame(unittest.TestCase): """Test class for basic KNX frame creation and usage.""" @@ -286,7 +286,7 @@ def test_03_knx_cemi_bitfields(self): def test_04_knx_cemi_bitfields_parsing(self): """Test that a received cEMI frame with bit fields is parsed.""" knxnet = knx.KnxNet() - knxnet.connect("192.168.1.10", 3671) + knxnet.connect(BOIBOITE, 3671) # ConnectReq connectreq = knx.KnxFrame(type="CONNECT REQUEST") connectreq.body.connection_request_information.connection_type_code.value = \ From 0fa4a6c1ab2d87ea963c1347a3f7e2ddd60e7052 Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 27 Jul 2020 15:31:00 +0200 Subject: [PATCH 16/23] Depends seems OK, now let's rewrite the parsing --- bof/frame.py | 24 +++++--- bof/knx/knxframe.py | 142 +++++++++----------------------------------- 2 files changed, 45 insertions(+), 121 deletions(-) diff --git a/bof/frame.py b/bof/frame.py index 2cfc6ba..9040e5d 100644 --- a/bof/frame.py +++ b/bof/frame.py @@ -133,7 +133,7 @@ def __init__(self, **kwargs): self._fixed_value = kwargs["fixed_value"] if "fixed_value" in kwargs else False self._set_bitfields(**kwargs) # From now on, _update_value must be used to modify values within the code - if "value" in kwargs: + if "value" in kwargs and len(kwargs["value"]): self._update_value(kwargs["value"]) elif "default" in kwargs: self._update_value(kwargs["default"]) @@ -278,9 +278,24 @@ class BOFBlock(object): @classmethod def factory(cls, template) -> object: + """Class method to use when the object to create is not + necessarily a BOFBlock class. It should be instantiated + in protocol implementation classes. + """ raise NotImplementedError("Factory should be instantiated in subclasses.") def __init__(self, defaults:dict=None, **kwargs): + """Initialize a block according to a set or arguments (template). + + A template usually contains the following information and has the + following format in a protocol's specification file: + + {"name": "control endpoint", "type": "HPAI"}, + + :param defaults: Dictionary for optional keyword arguments to force + values of fields to depend on to create a field (ex: message code). + Defaults values are transmitted to children. + """ self.name = kwargs["name"] if "name" in kwargs else "" self._parent = kwargs["parent"] if "parent" in kwargs else None self._content = [] @@ -419,13 +434,6 @@ def _get_depends_block(self, field:str, defaults:dict=None): :raises BOFProgrammingError: If specified field was not found. """ field = to_property(field) - # First search in default values list - if defaults: - for key in defaults: - if field == to_property(key): - block = self._spec.get_code_name(key, defaults[key]) - return block - # Then we look for it in parent values field_list = list(self._parent) field_list.reverse() for frame_field in field_list: diff --git a/bof/knx/knxframe.py b/bof/knx/knxframe.py index 2f28dc8..51d126e 100644 --- a/bof/knx/knxframe.py +++ b/bof/knx/knxframe.py @@ -58,12 +58,6 @@ def __init__(self, filepath:str=None): # Public # #-------------------------------------------------------------------------# - def get_service_id(self, name:str) -> bytes: - """Returns the content of parameter ``id`` for a given service - identifier name in KNX spec JSON file. - """ - return self.__get_code_id(self.codes["service identifier"], name) - def get_service_name(self, sid:bytes) -> str: """Returns the name of the service identifier with id ``sid``.""" if isinstance(sid, bytes): @@ -79,12 +73,6 @@ def get_block_template(self, name:str) -> list: """Returns a template associated to a body, as a list, or None.""" return self.__get_dict_value(self.blocks, name) - def get_cemi_id(self, name:str) -> bytes: - """Returns the content of parameter ``id`` for a given service - identifier name in KNX spec JSON file. - """ - return self.__get_code_id(self.codes["message code"], name) - def get_cemi_name(self, cid:bytes) -> str: """Returns the name of the cemi with id ``cid``.""" if isinstance(cid, bytes): @@ -96,12 +84,6 @@ def get_cemi_name(self, cid:bytes) -> str: return cemi return None - def get_connection_id(self, name:str) -> bytes: - """Returns the content of parameter ``id`` for a given service - identifier name in KNX spec JSON file. - """ - return self.__get_code_id(self.codes["connection type code"], name) - def get_connection_name(self, cid:bytes) -> str: """Returns the name of the cemi with id ``cid``.""" if isinstance(cid, bytes): @@ -120,16 +102,15 @@ def get_code_name(self, dict_key:str, identifier:bytes) -> str: raise BOFProgrammingError("Association not found for {0} ({1})".format( dict_key, identifier)) - #-------------------------------------------------------------------------# - # Internals # - #-------------------------------------------------------------------------# - - def __get_code_id(self, dictionary:dict, name:str) -> bytes: + def get_code_id(self, dict_key:dict, name:str) -> bytes: name = to_property(name) - for key, value in dictionary.items(): + for key, value in self.codes[dict_key].items(): if name == to_property(value): return bytes.fromhex(key) return None + #-------------------------------------------------------------------------# + # Internals # + #-------------------------------------------------------------------------# def __get_dict_value(self, dictionary:dict, key:str) -> object: """Return the value associated to a key from a given dictionary. Key @@ -212,98 +193,31 @@ class KnxBlock(BOFBlock): @classmethod def factory(cls, template, **kwargs) -> object: + """Returns either a KnxBlock or a KnxField, that's why it's a + factory as a class method. + + :param template: Template of a block or field as a dictionary. + ;returns: A new instance of a KnxBlock or a KnxField. + """ if "type" in template and template["type"] == "field": - return KnxField(**template) + value = b'' + if "defaults" in kwargs and template["name"] in kwargs["defaults"]: + value = kwargs["defaults"][template["name"]] + return KnxField(**template, value=value) return cls(**template, **kwargs) - def __init__(self, **kwargs): + def __init__(self, defaults:dict=None, **kwargs): """Initialize the ``KnxBlock`` with a mandatory name and optional arguments to fill in the block content list (with fields or nested blocks). - - From the specification file, the KnxBlock takes as argument a "block" - line, such as:: - - {"name": "control endpoint", "type": "HPAI"}, - - Optional keyword arguments can be given to force values of fields - to depend on to create a field (ex: message code) """ self._spec = KnxSpec() - super().__init__(**kwargs) + super().__init__(defaults, **kwargs) #-------------------------------------------------------------------------# # Public # #-------------------------------------------------------------------------# - # TODO - # @classmethod - # def factory(cls, **kwargs) -> object: - # """Factory method to create a list of ``KnxBlock`` according to kwargs. - # Available keywords arguments: - - # :param template: Cannot be used with ``type``. - # :param type: Type of block. Cannot be used with ``cemi``. - # :param cemi: Type of block if this is a cemi structure. Cannot be used - # with ``type``. - # :returns: A list of ``KnxBlock`` objects. - - # """ - - # if "template" in kwargs: - # cemi = kwargs["cemi"] if "cemi" in kwargs else None - # optional = kwargs["optional"] if "optional" in kwargs else False - # return cls.create_from_template(kwargs["template"], cemi, optional) - # if "type" in kwargs: - # return cls(type=kwargs["type"], name=name) - # if "cemi" in kwargs: - # optional = kwargs["optional"] if "optional" in kwargs else False - # return cls(cemi=kwargs["cemi"], name="cEMI") - # return None - - # TODO - @classmethod - def create_from_template(cls, template, cemi:str=None, optional:bool=False) -> list: - """Creates a list of ``KnxBlock``-inherited object according to the - list of templates specified in parameter ``template``. - - :param template: template dictionary or list of template dictionaries - for ``KnxBlock`` object instantiation. - :param cemi: when a block is a cEMI, we need to know what type of - cEMI it is to build it accordingly. - :param optional: build optional templates (default: no/False) - :returns: A list of ``KnxBlock`` objects (one by item in ``template``). - :raises BOFProgrammingError: If the value of argument "type" in a - template dictionary is unknown. - - Example:: - - block = KnxBlock(name="new block") - block.append(KnxBlock.factory(template=KnxSpec().blocks["HPAI"])) - """ - blocklist = [] - specs = KnxSpec() - if isinstance(template, list): - for item in template: - blocklist += cls.create_from_template(item, cemi, optional) - elif isinstance(template, dict): - if "optional" in template.keys() and template["optional"] == True and not optional: - return blocklist - if not "type" in template or template["type"] == "block": - blocklist.append(cls(**template)) - elif template["type"] == "field": - blocklist.append(KnxField(**template)) - elif template["type"] == "cemi": - blocklist.append(cls(cemi=cemi)) - elif template["type"] in specs.blocks.keys(): - nestedblock = cls(name=template["name"]) - content = specs.blocks[template["type"]] - nestedblock.append(cls.create_from_template(content, cemi, optional)) - blocklist.append(nestedblock) - else: - raise BOFProgrammingError("Unknown block type ({0})".format(template)) - return blocklist - # TODO def fill(self, frame:bytes) -> bytes: """Fills in the fields in object with the content of the frame. @@ -349,6 +263,13 @@ class KnxFrame(BOFFrame): **KNX Standard v2.1 03_08_02** """ + __defaults = { + # {Argument name: field name} + "type": "service identifier", + "cemi": "message code", + "connection": "connection type code" + } + # TODO def __init__(self, **kwargs): """Initialize a KnxFrame object from various origins using values from @@ -377,23 +298,18 @@ def __init__(self, **kwargs): spec = KnxSpec() super().__init__() # We store some values before starting building the frame - additional_args = {} - sid = spec.get_service_id(kwargs["type"]) if "type" in kwargs else None - if "cemi" in kwargs: - additional_args["message code"] = spec.get_cemi_id(kwargs["cemi"]) - if "connection" in kwargs: - additional_args["connection type code"] = spec.get_connection_id(kwargs["connection"]) + defaults = {} + for arg, code in self.__defaults.items(): + if arg in kwargs: + defaults[code] = spec.get_code_id(code, kwargs[arg]) # Now we can start for block in spec.frame: # Create block self._blocks[block["name"]] = KnxBlock( - defaults=additional_args, **block, parent=self) + defaults=defaults, **block, parent=self) # Add fields as attributes to current frame block for field in self._blocks[block["name"]].fields: self._blocks[block["name"]]._add_property(field.name, field) - # KNX-header specific attribute - if block["name"] == "header" and sid: - self._blocks[block["name"]].service_identifier.value = sid #TODO # if "type" in kwargs: # cemi = kwargs["cemi"] if "cemi" in kwargs else None From c5cb7d257d8277f4f4f2235747049daa50b953b3 Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 27 Jul 2020 16:55:36 +0200 Subject: [PATCH 17/23] Parsing almost ok, tests still wont pass, req refactoring and bugfix --- bof/frame.py | 12 +++++- bof/knx/knxframe.py | 100 +++++--------------------------------------- 2 files changed, 21 insertions(+), 91 deletions(-) diff --git a/bof/frame.py b/bof/frame.py index 9040e5d..9bfd260 100644 --- a/bof/frame.py +++ b/bof/frame.py @@ -284,7 +284,7 @@ def factory(cls, template) -> object: """ raise NotImplementedError("Factory should be instantiated in subclasses.") - def __init__(self, defaults:dict=None, **kwargs): + def __init__(self, **kwargs): """Initialize a block according to a set or arguments (template). A template usually contains the following information and has the @@ -303,6 +303,8 @@ def __init__(self, defaults:dict=None, **kwargs): if not "type" in kwargs or kwargs["type"] == "block": return # Now we extract the final type of block from the arguments + value = kwargs["value"] if "value" in kwargs else None + defaults = kwargs["defaults"] if "defaults" in kwargs else {} block_type = kwargs["type"] if block_type.startswith("depends:"): field_name = to_property(block_type.split(":")[1]) @@ -314,7 +316,13 @@ def __init__(self, defaults:dict=None, **kwargs): # And we fill the block according to its content template = [template] if not isinstance(template, list) else template for item in template: - self.append(self.factory(item, defaults=defaults, parent=self)) + new_item = self.factory(item, value=value, defaults=defaults, parent=self) + self.append(new_item) + # Update value + if value: + if len(new_item) >= len(value): + break + value = value[len(new_item):] def __bytes__(self): return b''.join(bytes(item) for item in self._content) diff --git a/bof/knx/knxframe.py b/bof/knx/knxframe.py index 51d126e..9936474 100644 --- a/bof/knx/knxframe.py +++ b/bof/knx/knxframe.py @@ -203,16 +203,18 @@ def factory(cls, template, **kwargs) -> object: value = b'' if "defaults" in kwargs and template["name"] in kwargs["defaults"]: value = kwargs["defaults"][template["name"]] + elif "value" in kwargs and kwargs["value"]: + value = kwargs["value"][:template["size"]] return KnxField(**template, value=value) return cls(**template, **kwargs) - def __init__(self, defaults:dict=None, **kwargs): + def __init__(self, **kwargs): """Initialize the ``KnxBlock`` with a mandatory name and optional arguments to fill in the block content list (with fields or nested blocks). """ self._spec = KnxSpec() - super().__init__(defaults, **kwargs) + super().__init__(**kwargs) #-------------------------------------------------------------------------# # Public # @@ -298,6 +300,7 @@ def __init__(self, **kwargs): spec = KnxSpec() super().__init__() # We store some values before starting building the frame + value = kwargs["bytes"] if "bytes" in kwargs else None defaults = {} for arg, code in self.__defaults.items(): if arg in kwargs: @@ -306,19 +309,15 @@ def __init__(self, **kwargs): for block in spec.frame: # Create block self._blocks[block["name"]] = KnxBlock( - defaults=defaults, **block, parent=self) + value=value, defaults=defaults, parent=self, **block) # Add fields as attributes to current frame block for field in self._blocks[block["name"]].fields: self._blocks[block["name"]]._add_property(field.name, field) - #TODO - # if "type" in kwargs: - # cemi = kwargs["cemi"] if "cemi" in kwargs else None - # optional = kwargs["optional"] if "optional" in kwargs else False - # self.format(kwargs["type"], cemi=cemi, optional=optional) - # log("Created new frame from service identifier {0}".format(kwargs["type"])) - if "bytes" in kwargs: - self.fill(kwargs["bytes"]) - log("Created new frame from byte array {0}.".format(kwargs["bytes"])) + # If a value is used to fill the blocks, update it: + if value: + if len(self._blocks[block["name"]]) >= len(value): + break + value = value[len(self._blocks[block["name"]]):] # Update total frame length in header self.update() @@ -326,83 +325,6 @@ def __init__(self, **kwargs): # Public # #-------------------------------------------------------------------------# - # to remove - def format(self, service, **kwargs) -> None: - """Fill in the KnxFrame object according to a predefined frame format - corresponding to a service identifier. The frame format (blocks - and field) can be found or added in the KNX specification JSON file. - - :param service_id: Service identifier as a string (service name) or as a - byte array (normally on 2 bytes but, whatever). - - Keyword arguments: - - :param *: Unrecognized params may be used later when creating blocks; - if the param name matches with a field required to define - how to build the rest of the block, its value will be used - to shape the block accordingly. - - :raises BOFProgrammingError: If the service identifier cannot be found - in given JSON file. - - Example:: - - frame = KnxFrame() - frame.build_from_sid("DESCRIPTION REQUEST") - """ - # if not isinstance(service, bytes) and not isinstance(service, str): - # raise BOFProgrammingError("Service id should be a string or a bytearray.") - # spec = KnxSpec() - # Get data associated service identifier - # service_name = spec.get_service_name(service) - # if not service_name or service_name not in spec.blocks: - # raise BOFProgrammingError("Service {0} does not exist.".format(service_name)) - # template = spec.get_block_template(service_name) - # Create KnxBlock according to template - # self._blocks["body"].append(KnxBlock.factory( - # template=template, **kwargs)) - # Add fields names as properties to body :) - # for field in self._blocks["body"].fields: - # self._blocks["body"]._add_property(field.name, field) - # Update header - # self._blocks["header"].service_identifier._update_value( - # spec.get_service_id(service_name)) - # self.update() - - # TEST REQUIRED - def fill(self, frame:bytes) -> None: - """Fill in the KnxFrame object using a frame as a raw byte array. This - method is used when receiving and parsing a file from a KNX object. - - :param frame: KNX frame as a byte array - - Example:: - - data, address = knx_connection.receive() - frame = KnxFrame(bytes=data) - """ - spec = KnxSpec() - header = frame[:frame[0]] - body = frame[frame[0]:] - # Fill in the header and retrieve information about the frame. - self._blocks["header"].fill(header) # TODO - sid = spec.get_service_name(self._blocks["header"].service_identifier.value) - template = spec.get_block_template(sid) - if not template: - raise BOFProgrammingError("Unknown service identifier ({0})".format(sid)) - # BODY - cursor = 0 # We start at index len(header) (== 6) - for block in template: - if block["type"] == "field": - entry = KnxField(**block) - entry.value = body[cursor:cursor+len(entry)] - else: - entry = KnxBlock(bytes=body[cursor:], **block) - self._blocks["body"].append(entry) - cursor += len(entry) - if cursor >= len(body): - break - def update(self): """Update all fields corresponding to block lengths. From e1d66f2895de2501628126f4c1742e2f05444e78 Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 27 Jul 2020 17:29:10 +0200 Subject: [PATCH 18/23] Cleaned the code a little, now lets debug --- bof/frame.py | 26 +--------------- bof/knx/knxframe.py | 75 +++++++++++++++++++-------------------------- 2 files changed, 32 insertions(+), 69 deletions(-) diff --git a/bof/frame.py b/bof/frame.py index 9bfd260..9ee0391 100644 --- a/bof/frame.py +++ b/bof/frame.py @@ -133,7 +133,7 @@ def __init__(self, **kwargs): self._fixed_value = kwargs["fixed_value"] if "fixed_value" in kwargs else False self._set_bitfields(**kwargs) # From now on, _update_value must be used to modify values within the code - if "value" in kwargs and len(kwargs["value"]): + if "value" in kwargs and kwargs["value"] != b'': self._update_value(kwargs["value"]) elif "default" in kwargs: self._update_value(kwargs["default"]) @@ -299,30 +299,6 @@ def __init__(self, **kwargs): self.name = kwargs["name"] if "name" in kwargs else "" self._parent = kwargs["parent"] if "parent" in kwargs else None self._content = [] - # Without a type, the block remains empty - if not "type" in kwargs or kwargs["type"] == "block": - return - # Now we extract the final type of block from the arguments - value = kwargs["value"] if "value" in kwargs else None - defaults = kwargs["defaults"] if "defaults" in kwargs else {} - block_type = kwargs["type"] - if block_type.startswith("depends:"): - field_name = to_property(block_type.split(":")[1]) - block_type = self._get_depends_block(field_name, defaults) - # We extract the block's content according to its type - template = self._spec.get_block_template(block_type) - if not template: - raise BOFProgrammingError("Unknown block type ({0})".format(block_type)) - # And we fill the block according to its content - template = [template] if not isinstance(template, list) else template - for item in template: - new_item = self.factory(item, value=value, defaults=defaults, parent=self) - self.append(new_item) - # Update value - if value: - if len(new_item) >= len(value): - break - value = value[len(new_item):] def __bytes__(self): return b''.join(bytes(item) for item in self._content) diff --git a/bof/knx/knxframe.py b/bof/knx/knxframe.py index 9936474..bc304ab 100644 --- a/bof/knx/knxframe.py +++ b/bof/knx/knxframe.py @@ -73,27 +73,6 @@ def get_block_template(self, name:str) -> list: """Returns a template associated to a body, as a list, or None.""" return self.__get_dict_value(self.blocks, name) - def get_cemi_name(self, cid:bytes) -> str: - """Returns the name of the cemi with id ``cid``.""" - if isinstance(cid, bytes): - return self.get_code_name("message code", cid) - if isinstance(cid, str): - cid = to_property(cid) - for cemi in self.codes["message code"].values(): - if cid == to_property(cemi): - return cemi - return None - - def get_connection_name(self, cid:bytes) -> str: - """Returns the name of the cemi with id ``cid``.""" - if isinstance(cid, bytes): - return self.get_code_name("connection type code", cid) - if isinstance(cid, str): - cid = to_property(cid) - for connect in self.codes["connection type code"].values(): - if cid == to_property(connect): - return connect - return None def get_code_name(self, dict_key:str, identifier:bytes) -> str: for key in self.codes[dict_key]: @@ -198,6 +177,12 @@ def factory(cls, template, **kwargs) -> object: :param template: Template of a block or field as a dictionary. ;returns: A new instance of a KnxBlock or a KnxField. + + Keyword arguments: + + :param defaults: Default values to assign a field as a dictionary + with format {"field name": b"value"} + :param value: Content of block or field to set. """ if "type" in template and template["type"] == "field": value = b'' @@ -215,29 +200,31 @@ def __init__(self, **kwargs): """ self._spec = KnxSpec() super().__init__(**kwargs) - - #-------------------------------------------------------------------------# - # Public # - #-------------------------------------------------------------------------# - - # TODO - def fill(self, frame:bytes) -> bytes: - """Fills in the fields in object with the content of the frame. - - The frame is read byte by byte and used to fill the field in ``fields()`` - order according to each field's size. Hopefully, the frame is the same - size as what is expected for the format of this block. - - :param frame: A raw byte array corresponding to part of a KNX frame. - :returns: The remainder of the frame (if any) or 0 - """ - cursor = 0 - for field in self.fields: - field.value = frame[cursor:cursor+field.size] - cursor += field.size - if frame[cursor:len(frame)] and self.fields[-1].size == 0: # Varying size - self.fields[-1].size = len(frame) - cursor - self.fields[-1].value = frame[cursor:cursor+field.size] + # Without a type, the block remains empty + if not "type" in kwargs or kwargs["type"] == "block": + return + # Now we extract the final type of block from the arguments + value = kwargs["value"] if "value" in kwargs else None + defaults = kwargs["defaults"] if "defaults" in kwargs else {} + block_type = kwargs["type"] + if block_type.startswith("depends:"): + field_name = to_property(block_type.split(":")[1]) + block_type = self._get_depends_block(field_name, defaults) + # We extract the block's content according to its type + template = self._spec.get_block_template(block_type) + if not template: + raise BOFProgrammingError("Unknown block type ({0})".format(block_type)) + # And we fill the block according to its content + template = [template] if not isinstance(template, list) else template + for item in template: + new_item = self.factory(item, value=value, + defaults=defaults, parent=self) + self.append(new_item) + # Update value + if value: + if len(new_item) >= len(value): + break + value = value[len(new_item):] #-----------------------------------------------------------------------------# # KNX frames / datagram representation # From f7441940004a3243e19774afa4a8a657cb4852ef Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 28 Jul 2020 11:55:11 +0200 Subject: [PATCH 19/23] Beginning of bug fix for example, next is connect request bug fix --- bof/knx/knxnet.json | 4 ++-- examples/all_frames.py | 15 ++++++++------- examples/cemi.py | 13 +++++++------ examples/dumb_fuzzer.py | 12 ++++++------ 4 files changed, 23 insertions(+), 21 deletions(-) diff --git a/bof/knx/knxnet.json b/bof/knx/knxnet.json index b264d6c..db1e2ca 100644 --- a/bof/knx/knxnet.json +++ b/bof/knx/knxnet.json @@ -97,7 +97,7 @@ ], "CRI_CRD": [ {"name": "structure length", "type": "field", "size": 1, "is_length": true}, - {"name": "connection type code", "type": "field", "size": 1}, + {"name": "connection type code", "type": "field", "size": 1, "default": "03"}, {"name": "connection data", "type": "depends:connection type code"} ], "DEVICE_MANAGEMENT_CONNECTION": [ @@ -144,7 +144,7 @@ "0209": "DISCONNECT REQUEST", "020A": "DISCONNECT RESPONSE", "0310": "CONFIGURATION REQUEST", - "0311": "CONFIGURATION RESPONSE" + "0311": "CONFIGURATION ACK" }, "message code": { "FC": "PropRead.req", diff --git a/examples/all_frames.py b/examples/all_frames.py index 13d74ed..cfa56b5 100644 --- a/examples/all_frames.py +++ b/examples/all_frames.py @@ -4,14 +4,15 @@ from bof import BOFNetworkError, knx def all_frames() -> knx.KnxFrame: - specs = knx.KnxSpec() - for sid in specs.service_identifiers: + spec = knx.KnxSpec() + for sid, block in spec.codes["service identifier"].items(): # If the frame has a cEMI block, we try all cEMI possibilities - if "cemi" in [template["type"] for template in specs.bodies[sid]]: - for cemi_type in specs.cemis: - yield knx.KnxFrame(type=sid, cemi=cemi_type) + if "CEMI" in [template["type"] for template in spec.blocks[block]]: + for cid, cemi in spec.codes["message code"].items(): + print(block, cemi) + yield knx.KnxFrame(type=block, cemi=cemi) else: - yield knx.KnxFrame(type=sid) + yield knx.KnxFrame(type=block) # RUN if len(argv) < 2: @@ -20,11 +21,11 @@ def all_frames() -> knx.KnxFrame: knxnet = knx.KnxNet() knxnet.connect(argv[1], 3671) for frame in all_frames(): + print(frame.sid) try: print("[SEND] {0}".format(frame)) response = knxnet.send_receive(frame) print("[RECV] {0}".format(response)) except BOFNetworkError: print("[NO RESPONSE]") - finally knxnet.disconnect() diff --git a/examples/cemi.py b/examples/cemi.py index 4a5e02e..1b74f2b 100644 --- a/examples/cemi.py +++ b/examples/cemi.py @@ -6,7 +6,7 @@ def connect_request(knxnet, connection_type): ip, port = knxnet.source connectreq = knx.KnxFrame(type="CONNECT REQUEST") - connectreq.body.connection_request_information.connection_type_code.value = knxspecs.connection_types[connection_type] + connectreq.body.connection_request_information.connection_type_code.value = knx.KnxSpec().get_code_id("connection type code", connection_type) connectreq.body.control_endpoint.ip_address.value = ip connectreq.body.control_endpoint.port.value = port connectreq.body.data_endpoint.ip_address.value = ip @@ -14,7 +14,7 @@ def connect_request(knxnet, connection_type): if connection_type == "Tunneling Connection": connectreq.body.connection_request_information.append(knx.KnxField(name="link layer", size=1, value=b"\x02")) connectreq.body.connection_request_information.append(knx.KnxField(name="reserved", size=1, value=b"\x00")) - # print(connectreq) + print(connectreq) connectresp = knxnet.send_receive(connectreq) knxnet.channel = connectresp.body.communication_channel_id.value return connectresp @@ -30,10 +30,11 @@ def read_property(knxnet, sequence_counter, object_type, property_id): request = knx.KnxFrame(type="CONFIGURATION REQUEST", cemi="PropRead.req") request.body.communication_channel_id.value = knxnet.channel request.body.sequence_counter.value = sequence_counter - request.body.cemi.number_of_elements.value = 1 - request.body.cemi.object_type.value = knxspecs.object_types[object_type] - request.body.cemi.object_instance.value = 1 - request.body.cemi.property_id.value = knxspecs.properties[object_type][property_id] + propread = request.body.cemi.cemi_data.propread_req + propread.number_of_elements.value = 1 + propread.object_type.value = knxspecs.object_types[object_type] + propread.object_instance.value = 1 + propread.property_id.value = knxspecs.properties[object_type][property_id] try: response = knxnet.send_receive(request) # ACK while (1): diff --git a/examples/dumb_fuzzer.py b/examples/dumb_fuzzer.py index 95f3ae7..9ca27d6 100644 --- a/examples/dumb_fuzzer.py +++ b/examples/dumb_fuzzer.py @@ -5,14 +5,14 @@ from bof import BOFNetworkError, knx, byte def all_frames() -> knx.KnxFrame: - specs = knx.KnxSpec() - for sid in specs.service_identifiers: + spec = knx.KnxSpec() + for sid, block in spec.codes["service identifier"].items(): # If the frame has a cEMI block, we try all cEMI possibilities - if "cemi" in [template["type"] for template in specs.bodies[sid]]: - for cemi_type in specs.cemis: - yield knx.KnxFrame(type=sid, cemi=cemi_type) + if "CEMI" in [template["type"] for template in spec.blocks[block]]: + for cemi in spec.codes["message code"]: + yield knx.KnxFrame(type=block, cemi=cemi) else: - yield knx.KnxFrame(type=sid) + yield knx.KnxFrame(type=block) def mutate(frame:bytes) -> (bytes, list): changelog = [] From c3d4d6884c124bd48143253e5d72fb7824969c5e Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 28 Jul 2020 13:19:45 +0200 Subject: [PATCH 20/23] Made unittest pass, except 2 of them, obvjously not the easiest ones --- bof/frame.py | 5 +++- bof/knx/knxframe.py | 57 ++++++++++++++++++++--------------------- bof/knx/knxnet.json | 10 ++++++-- examples/cemi.py | 8 ++---- tests/test_knx_frame.py | 50 +++++++++++++++++------------------- 5 files changed, 65 insertions(+), 65 deletions(-) diff --git a/bof/frame.py b/bof/frame.py index 9ee0391..4ccd8b5 100644 --- a/bof/frame.py +++ b/bof/frame.py @@ -133,6 +133,9 @@ def __init__(self, **kwargs): self._fixed_value = kwargs["fixed_value"] if "fixed_value" in kwargs else False self._set_bitfields(**kwargs) # From now on, _update_value must be used to modify values within the code + if "optional" in kwargs and kwargs["optional"] and self._value == b'': + self._size = 0 # We create the field byt don't use it. + return if "value" in kwargs and kwargs["value"] != b'': self._update_value(kwargs["value"]) elif "default" in kwargs: @@ -424,7 +427,7 @@ def _get_depends_block(self, field:str, defaults:dict=None): if field == to_property(frame_field.name): block = self._spec.get_code_name(frame_field.name, frame_field.value) return block - raise BOFProgrammingError("Field nout found ({0}).".format(field)) + raise BOFProgrammingError("Field not found ({0}).".format(field)) #-------------------------------------------------------------------------# # Properties # diff --git a/bof/knx/knxframe.py b/bof/knx/knxframe.py index bc304ab..04dbc0d 100644 --- a/bof/knx/knxframe.py +++ b/bof/knx/knxframe.py @@ -58,39 +58,44 @@ def __init__(self, filepath:str=None): # Public # #-------------------------------------------------------------------------# - def get_service_name(self, sid:bytes) -> str: - """Returns the name of the service identifier with id ``sid``.""" - if isinstance(sid, bytes): - return self.get_code_name("service identifier", sid) - if isinstance(sid, str): - sid = to_property(sid) + def get_block_template(self, name:str) -> list: + """Returns a template associated to a body, as a list, or None.""" + return self.__get_dict_value(self.blocks, name) if name else None + + def get_code_name(self, dict_key:str, identifier) -> str: + dict_key = self.__get_dict_key(self.codes, dict_key) + if isinstance(identifier, bytes): + for key in self.codes[dict_key]: + if identifier == bytes.fromhex(key): + return self.codes[dict_key][key] + if isinstance(identifier, str): + identifier = to_property(identifier) for service in self.codes["service identifier"].values(): - if sid == to_property(service): + if identifier == to_property(service): return service return None - def get_block_template(self, name:str) -> list: - """Returns a template associated to a body, as a list, or None.""" - return self.__get_dict_value(self.blocks, name) - - - def get_code_name(self, dict_key:str, identifier:bytes) -> str: - for key in self.codes[dict_key]: - if identifier == bytes.fromhex(key): - return self.codes[dict_key][key] - raise BOFProgrammingError("Association not found for {0} ({1})".format( - dict_key, identifier)) - def get_code_id(self, dict_key:dict, name:str) -> bytes: name = to_property(name) for key, value in self.codes[dict_key].items(): if name == to_property(value): return bytes.fromhex(key) return None + #-------------------------------------------------------------------------# # Internals # #-------------------------------------------------------------------------# + def __get_dict_key(self, dictionary:dict, dict_key:str) -> str: + """As a key can be given with wrong formatting (underscores, + capital, lower, upper cases, we match the value given with + the actual key in the dictionary. + """ + dict_key = to_property(dict_key) + for key in dictionary: + if to_property(key) == dict_key: + return key + def __get_dict_value(self, dictionary:dict, key:str) -> object: """Return the value associated to a key from a given dictionary. Key is insensitive, the value can have different types. Must be called @@ -102,15 +107,6 @@ def __get_dict_value(self, dictionary:dict, key:str) -> object: return dictionary[entry] return None - def __get_dict_key(self, dictionary:dict, inner_key:str, value:object) -> str: - """Return the key associated to a value from a given dictionary inside a - dictionary. Must be called inside class only. - """ - for entry in dictionary: - if bytes.fromhex(dictionary[entry][inner_key]) == value: - return entry - return None - ############################################################################### # KNX FRAME CONTENT # ############################################################################### @@ -210,6 +206,8 @@ def __init__(self, **kwargs): if block_type.startswith("depends:"): field_name = to_property(block_type.split(":")[1]) block_type = self._get_depends_block(field_name, defaults) + if not block_type: + raise BOFProgrammingError("Association not found for field {0}".format(field_name)) # We extract the block's content according to its type template = self._spec.get_block_template(block_type) if not template: @@ -341,7 +339,8 @@ def sid(self) -> str: """Return the name associated to the frame's service identifier, or empty string if it is not set. """ - sid = KnxSpec().get_service_name(self._blocks["header"].service_identifier.value) + sid = KnxSpec().get_code_name("service identifier", + self._blocks["header"].service_identifier.value) return sid if sid else str(self._blocks["header"].service_identifier.value) @property diff --git a/bof/knx/knxnet.json b/bof/knx/knxnet.json index db1e2ca..cb29c65 100644 --- a/bof/knx/knxnet.json +++ b/bof/knx/knxnet.json @@ -10,6 +10,9 @@ {"name": "service identifier", "type": "field", "size": 2}, {"name": "total length", "type": "field", "size": 2} ], + "EMPTY": [ + {} + ], "SEARCH REQUEST": [ {"name": "discovery endpoint", "type": "HPAI"} ], @@ -101,8 +104,10 @@ {"name": "connection data", "type": "depends:connection type code"} ], "DEVICE_MANAGEMENT_CONNECTION": [ - {"name": "ip address", "type": "field", "size": 4}, - {"name": "port", "type": "field", "size": 2} + {"name": "ip address", "type": "field", "size": 4, "optional": true}, + {"name": "port", "type": "field", "size": 2, "optional": true}, + {"name": "ip address", "type": "field", "size": 4, "optional": true}, + {"name": "port", "type": "field", "size": 2, "optional": true} ], "TUNNELING_CONNECTION": [ {"name": "knx address", "type": "field", "size": 2} @@ -133,6 +138,7 @@ }, "codes" : { "service identifier": { + "0000": "EMPTY", "0201": "SEARCH REQUEST", "0202": "SEARCH RESPONSE", "0203": "DESCRIPTION REQUEST", diff --git a/examples/cemi.py b/examples/cemi.py index 1b74f2b..afc3d7b 100644 --- a/examples/cemi.py +++ b/examples/cemi.py @@ -14,7 +14,6 @@ def connect_request(knxnet, connection_type): if connection_type == "Tunneling Connection": connectreq.body.connection_request_information.append(knx.KnxField(name="link layer", size=1, value=b"\x02")) connectreq.body.connection_request_information.append(knx.KnxField(name="reserved", size=1, value=b"\x00")) - print(connectreq) connectresp = knxnet.send_receive(connectreq) knxnet.channel = connectresp.body.communication_channel_id.value return connectresp @@ -59,14 +58,11 @@ def read_property(knxnet, sequence_counter, object_type, property_id): # Gather device information connectresp = connect_request(knxnet, "Device Management Connection") -print(connectresp) -knx_addr = read_property(knxnet, 0, "IP PARAMETER OBJECTS", "PID_ADDITIONAL_INDIVIDUAL_ADDRESSES") -print("Device individual address: {0}".format(knx_addr.body.cemi.data)) -# read_property(knxnet, 1, "DEVICE", "PID_MANUFACTURER_ID") +read_property(knxnet, 0, "IP PARAMETER OBJECTS", "PID_ADDITIONAL_INDIVIDUAL_ADDRESSES") disconnect_request(knxnet) # Establish tunneling connection to read and write objects connectresp = connect_request(knxnet, "Tunneling Connection") -print("Device individual address: {0}".format(connectresp.body.connection_response_data_block.knx_address.value)) +print("Device individual address: {0}".format(connectresp.body.connection_response_data_block.connection_data.knx_address.value)) # TODO disconnect_request(knxnet) diff --git a/tests/test_knx_frame.py b/tests/test_knx_frame.py index 6ff5e19..3b78791 100644 --- a/tests/test_knx_frame.py +++ b/tests/test_knx_frame.py @@ -65,13 +65,13 @@ class Test01KnxSpecTesting(unittest.TestCase): """Test class for KnxSpec public methods.""" def test_01_get_service_id(self): """Test that we can get a service identifier from its name""" - sid = knx.KnxSpec().get_service_id("description request") + sid = knx.KnxSpec().get_code_id("service identifier", "description request") self.assertEqual(sid, b"\x02\x03") def test_02_get_service_name(self): """Test that we can get the name of a service identifier from its id.""" - name = knx.KnxSpec().get_service_name(b"\x02\x03") + name = knx.KnxSpec().get_code_name("service identifier", b"\x02\x03") self.assertEqual(name, "DESCRIPTION REQUEST") - name = knx.KnxSpec().get_service_name("DESCRIPTION_REQUEST") + name = knx.KnxSpec().get_code_name("service identifier", "DESCRIPTION_REQUEST") self.assertEqual(name, "DESCRIPTION REQUEST") def test_03_get_template_from_body(self): """Test that we can retrieve the frame template associated to a body name.""" @@ -79,7 +79,7 @@ def test_03_get_template_from_body(self): self.assertEqual(isinstance(template, list), True) def test_04_get_cemi_name(self): """Test that we can retrieve the name of a cEMI from its message code.""" - cemi = knx.KnxSpec().get_cemi_name(b"\xfc") + cemi = knx.KnxSpec().get_code_name("message_code", b"\xfc") self.assertEqual(cemi, "PropRead.req") class Test02AdvancedKnxHeaderCrafting(unittest.TestCase): @@ -196,7 +196,7 @@ class Test04DIBSpecificationClass(unittest.TestCase): """Test class for specification class building from JSON file.""" def test_01_knx_spec_instantiate(self): spec = knx.KnxSpec() - self.assertEqual(list(spec.codes["service identifier"].values())[0], "SEARCH REQUEST") + self.assertEqual(list(spec.codes["service identifier"].values())[0], "EMPTY") def test_01_knx_spec_clear(self): spec = knx.KnxSpec() spec.clear() @@ -232,10 +232,6 @@ def test_04_knx_body_description_response(self): frame.body.device_hardware.friendly_name.value = "sushi" frame.body.friendly_name.value = "pizza" self.assertEqual(bytes(frame.body.device_hardware.friendly_name).decode('utf-8'), "pizza") - def test_05_knx_body_with_optional(self): - """Test that we can build a frame with the optional keyword.""" - frame = knx.KnxFrame(type="CONNECT REQUEST", optional=True) - self.assertEqual(bytes(frame.body.port_2), b'\x00\x00') class Test05ReceivedFrameParsing(unittest.TestCase): """Test class for received frame parsing.""" @@ -252,7 +248,7 @@ def test_01_knx_parse_descrresp(self): def test_02_knx_parse_connectresp(self): connectreq = knx.KnxFrame(type="CONNECT_REQUEST") connectreq.body.connection_request_information.connection_type_code.value = \ - knx.KnxSpec().connection_types["Device Management Connection"] + knx.KnxSpec().get_code_id("connection type code", "Device Management Connection") self.connection.send(connectreq) connectresp = self.connection.receive() channel = connectresp.body.communication_channel_id.value @@ -270,19 +266,19 @@ def test_01_knx_config_req(self): """Test that cEMI frames definition in JSON is handled.""" frame = knx.KnxFrame(type="CONFIGURATION REQUEST", cemi="PropRead.req") self.assertEqual(bytes(frame.body.cemi.message_code), b"\xfc") - def test_02_knx_single_cemi(self): + def test_02_knx_single_cemi(self): # FIX: no type == empty block """Test that we can build a singleblock from cEMI.""" propwrite = knx.KnxBlock(cemi="PropWrite.con") - self.assertEqual(bytes(propwrite.message_code), b"\xf5") + self.assertEqual(bytes(propwrite.cemi.cemi_data.propwrite_con.message_code), b"\xf5") def test_03_knx_cemi_bitfields(self): """Test that cemi blocks with bit fields (subfields) work.""" frame = knx.KnxFrame(type="CONFIGURATION REQUEST", cemi="PropRead.req") - self.assertEqual(frame.body.cemi.number_of_elements.value, [0,0,0,0]) - frame.body.cemi.number_of_elements.value = 15 - frame.body.cemi.start_index.value = 1 - self.assertEqual(frame.body.cemi.number_of_elements.value, [1,1,1,1]) - self.assertEqual(frame.body.cemi.start_index.value, [0,0,0,0,0,0,0,0,0,0,0,1]) - self.assertEqual(frame.body.cemi.number_of_elements_start_index.value, b'\xF0\x01') + self.assertEqual(frame.body.cemi.cemi_data.propread_req.number_of_elements.value, [0,0,0,0]) + frame.body.cemi.cemi_data.propread_req.number_of_elements.value = 15 + frame.body.cemi.cemi_data.propread_req.start_index.value = 1 + self.assertEqual(frame.body.cemi.cemi_data.propread_req.number_of_elements.value, [1,1,1,1]) + self.assertEqual(frame.body.cemi.cemi_data.propread_req.start_index.value, [0,0,0,0,0,0,0,0,0,0,0,1]) + self.assertEqual(frame.body.cemi.cemi_data.propread_req.number_of_elements_start_index.value, b'\xF0\x01') def test_04_knx_cemi_bitfields_parsing(self): """Test that a received cEMI frame with bit fields is parsed.""" knxnet = knx.KnxNet() @@ -290,7 +286,7 @@ def test_04_knx_cemi_bitfields_parsing(self): # ConnectReq connectreq = knx.KnxFrame(type="CONNECT REQUEST") connectreq.body.connection_request_information.connection_type_code.value = \ - knx.KnxSpec().connection_types["Device Management Connection"] + knx.KnxSpec().get_code_id("connection type code", "Device Management Connection") connectreq.body.control_endpoint.ip_address.value = byte.from_ipv4(knxnet.source[0]) connectreq.body.control_endpoint.port.value = byte.from_int(knxnet.source[1]) connectreq.body.data_endpoint.ip_address.value = byte.from_ipv4(knxnet.source[0]) @@ -301,20 +297,20 @@ def test_04_knx_cemi_bitfields_parsing(self): #ConfigReq request = knx.KnxFrame(type="CONFIGURATION REQUEST", cemi="PropRead.req") request.body.communication_channel_id.value = channel - request.body.cemi.number_of_elements.value = 1 - request.body.cemi.object_type.value = 11 - request.body.cemi.property_id.value = 53 + request.body.cemi.cemi_data.propread_req.number_of_elements.value = 1 + request.body.cemi.cemi_data.propread_req.object_type.value = 11 + request.body.cemi.cemi_data.propread_req.property_id.value = 53 # Ack + ConfigReq response response = knxnet.send_receive(request) # ACK while (1): response = knxnet.receive() # PropRead.con if response.sid == "CONFIGURATION REQUEST": # TEST SUBFIELDS - self.assertEqual(byte.bit_list_to_int(response.body.cemi.number_of_elements.value), 0) - self.assertEqual(byte.bit_list_to_int(response.body.cemi.start_index.value), 0) - response.body.cemi.number_of_elements_start_index.value = b'\x10\x01' - self.assertEqual(byte.bit_list_to_int(response.body.cemi.number_of_elements.value), 1) - self.assertEqual(byte.bit_list_to_int(response.body.cemi.start_index.value), 1) + self.assertEqual(byte.bit_list_to_int(response.body.cemi.cemi_data.propread_con.number_of_elements.value), 0) + self.assertEqual(byte.bit_list_to_int(response.body.cemi.cemi_data.propread_con.start_index.value), 0) + response.body.cemi.cemi_data.propread_con.number_of_elements_start_index.value = b'\x10\x01' + self.assertEqual(byte.bit_list_to_int(response.body.cemi.cemi_data.propread_con.number_of_elements.value), 1) + self.assertEqual(byte.bit_list_to_int(response.body.cemi.cemi_data.propread_con.start_index.value), 1) # We tell the boiboite we received it ack = knx.KnxFrame(type="CONFIGURATION ACK") ack.body.communication_channel_id.value = channel From 17a2ddf2d71156ff2e2c104e9bdecbb50c3a6ef5 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 28 Jul 2020 16:24:14 +0200 Subject: [PATCH 21/23] New version works, needs some cleaning, tests and doc --- bof/frame.py | 4 +++- bof/knx/knxframe.py | 37 +++++-------------------------------- bof/spec.py | 29 +++++++++++++++++++++++++++++ examples/all_frames.py | 3 ++- examples/cemi_fuzzer.py | 4 ++-- examples/dumb_fuzzer.py | 3 ++- tests/test_knx_frame.py | 11 +++-------- 7 files changed, 46 insertions(+), 45 deletions(-) diff --git a/bof/frame.py b/bof/frame.py index 4ccd8b5..b9ea88d 100644 --- a/bof/frame.py +++ b/bof/frame.py @@ -522,7 +522,9 @@ def append(self, name, block) -> None: if not isinstance(block, BOFBlock): raise BOFProgrammingError("Frame can only contain BOF blocks.") self._blocks[name] = block - setattr(self, to_property(name), self._blocks[name]) + # Add fields as attributes to current frame block + for field in self._blocks[name].fields: + self._blocks[name]._add_property(field.name, field) def remove(self, name:str) -> None: """Remove a block or feld according to its name from the frame. diff --git a/bof/knx/knxframe.py b/bof/knx/knxframe.py index 04dbc0d..1a3650f 100644 --- a/bof/knx/knxframe.py +++ b/bof/knx/knxframe.py @@ -60,10 +60,10 @@ def __init__(self, filepath:str=None): def get_block_template(self, name:str) -> list: """Returns a template associated to a body, as a list, or None.""" - return self.__get_dict_value(self.blocks, name) if name else None + return self._get_dict_value(self.blocks, name) if name else None def get_code_name(self, dict_key:str, identifier) -> str: - dict_key = self.__get_dict_key(self.codes, dict_key) + dict_key = self._get_dict_key(self.codes, dict_key) if isinstance(identifier, bytes): for key in self.codes[dict_key]: if identifier == bytes.fromhex(key): @@ -77,36 +77,12 @@ def get_code_name(self, dict_key:str, identifier) -> str: def get_code_id(self, dict_key:dict, name:str) -> bytes: name = to_property(name) + dict_key = self._get_dict_key(self.codes, dict_key) for key, value in self.codes[dict_key].items(): if name == to_property(value): return bytes.fromhex(key) return None - #-------------------------------------------------------------------------# - # Internals # - #-------------------------------------------------------------------------# - - def __get_dict_key(self, dictionary:dict, dict_key:str) -> str: - """As a key can be given with wrong formatting (underscores, - capital, lower, upper cases, we match the value given with - the actual key in the dictionary. - """ - dict_key = to_property(dict_key) - for key in dictionary: - if to_property(key) == dict_key: - return key - - def __get_dict_value(self, dictionary:dict, key:str) -> object: - """Return the value associated to a key from a given dictionary. Key - is insensitive, the value can have different types. Must be called - inside class only. - """ - key = to_property(key) - for entry in dictionary: - if to_property(entry) == key: - return dictionary[entry] - return None - ############################################################################### # KNX FRAME CONTENT # ############################################################################### @@ -293,11 +269,8 @@ def __init__(self, **kwargs): # Now we can start for block in spec.frame: # Create block - self._blocks[block["name"]] = KnxBlock( - value=value, defaults=defaults, parent=self, **block) - # Add fields as attributes to current frame block - for field in self._blocks[block["name"]].fields: - self._blocks[block["name"]]._add_property(field.name, field) + knxblock = KnxBlock(value=value, defaults=defaults, parent=self, **block) + self.append(block["name"], knxblock) # If a value is used to fill the blocks, update it: if value: if len(self._blocks[block["name"]]) >= len(value): diff --git a/bof/spec.py b/bof/spec.py index afdd2a6..95eea82 100644 --- a/bof/spec.py +++ b/bof/spec.py @@ -83,6 +83,10 @@ def __init__(self, filepath:str=None): self.load(filepath) self.__is_init = True + #-------------------------------------------------------------------------# + # Public # + #-------------------------------------------------------------------------# + def load(self, filepath): """Loads the content of a JSON file and adds its categories as attributes to this class. @@ -114,3 +118,28 @@ def clear(self): attributes = list(self.__dict__.keys()).copy() for key in attributes: delattr(self, key) + + #-------------------------------------------------------------------------# + # Internals # + #-------------------------------------------------------------------------# + + def _get_dict_key(self, dictionary:dict, dict_key:str) -> str: + """As a key can be given with wrong formatting (underscores, + capital, lower, upper cases, we match the value given with + the actual key in the dictionary. + """ + dict_key = to_property(dict_key) + for key in dictionary: + if to_property(key) == dict_key: + return key + + def _get_dict_value(self, dictionary:dict, key:str) -> object: + """Return the value associated to a key from a given dictionary. Key + is insensitive, the value can have different types. Must be called + inside class only. + """ + key = to_property(key) + for entry in dictionary: + if to_property(entry) == key: + return dictionary[entry] + return None diff --git a/examples/all_frames.py b/examples/all_frames.py index cfa56b5..6b8b3c5 100644 --- a/examples/all_frames.py +++ b/examples/all_frames.py @@ -7,7 +7,8 @@ def all_frames() -> knx.KnxFrame: spec = knx.KnxSpec() for sid, block in spec.codes["service identifier"].items(): # If the frame has a cEMI block, we try all cEMI possibilities - if "CEMI" in [template["type"] for template in spec.blocks[block]]: + if "CEMI" in [template["type"] for template in spec.blocks[block] \ + if "type" in template]: for cid, cemi in spec.codes["message code"].items(): print(block, cemi) yield knx.KnxFrame(type=block, cemi=cemi) diff --git a/examples/cemi_fuzzer.py b/examples/cemi_fuzzer.py index ae54936..4e657f3 100644 --- a/examples/cemi_fuzzer.py +++ b/examples/cemi_fuzzer.py @@ -19,7 +19,7 @@ def connect(ip:str, port:int) -> (knx.KnxNet, int): knxnet.connect(ip, port) connectreq = knx.KnxFrame(type="CONNECT REQUEST") connectreq.body.connection_request_information.connection_type_code.value = \ - knx.KnxSpec().connection_types["Device Management Connection"] + knx.KnxSpec().get_code_id("connection_type_code", "Device Management Connection") connectreq.body.control_endpoint.ip_address.value = byte.from_ipv4(knxnet.source[0]) connectreq.body.control_endpoint.port.value = byte.from_int(knxnet.source[1]) connectreq.body.data_endpoint.ip_address.value = byte.from_ipv4(knxnet.source[0]) @@ -130,6 +130,6 @@ def fuzz(ip, generator, initial_frame): quit() propread = knx.KnxFrame(type="CONFIGURATION REQUEST", cemi="PropRead.req") -propread.body.cemi.number_of_elements.value = 1 +propread.body.cemi.cemi_data.propread_req.number_of_elements.value = 1 # fuzz(all_properties, propread) fuzz(argv[1], random_properties, propread) diff --git a/examples/dumb_fuzzer.py b/examples/dumb_fuzzer.py index 9ca27d6..2a05f73 100644 --- a/examples/dumb_fuzzer.py +++ b/examples/dumb_fuzzer.py @@ -8,7 +8,8 @@ def all_frames() -> knx.KnxFrame: spec = knx.KnxSpec() for sid, block in spec.codes["service identifier"].items(): # If the frame has a cEMI block, we try all cEMI possibilities - if "CEMI" in [template["type"] for template in spec.blocks[block]]: + if "CEMI" in [template["type"] for template in spec.blocks[block] \ + if "type" in template]: for cemi in spec.codes["message code"]: yield knx.KnxFrame(type=block, cemi=cemi) else: diff --git a/tests/test_knx_frame.py b/tests/test_knx_frame.py index 3b78791..84f2c3c 100644 --- a/tests/test_knx_frame.py +++ b/tests/test_knx_frame.py @@ -229,8 +229,7 @@ def test_04_knx_body_description_response(self): frame = knx.KnxFrame(type="DESCRIPTION_RESPONSE") self.assertEqual(bytes(frame.header.service_identifier), b'\x02\x04') self.assertEqual(frame.sid, "DESCRIPTION RESPONSE") - frame.body.device_hardware.friendly_name.value = "sushi" - frame.body.friendly_name.value = "pizza" + frame.body.device_hardware.friendly_name.value = "pizza" self.assertEqual(bytes(frame.body.device_hardware.friendly_name).decode('utf-8'), "pizza") class Test05ReceivedFrameParsing(unittest.TestCase): @@ -266,11 +265,7 @@ def test_01_knx_config_req(self): """Test that cEMI frames definition in JSON is handled.""" frame = knx.KnxFrame(type="CONFIGURATION REQUEST", cemi="PropRead.req") self.assertEqual(bytes(frame.body.cemi.message_code), b"\xfc") - def test_02_knx_single_cemi(self): # FIX: no type == empty block - """Test that we can build a singleblock from cEMI.""" - propwrite = knx.KnxBlock(cemi="PropWrite.con") - self.assertEqual(bytes(propwrite.cemi.cemi_data.propwrite_con.message_code), b"\xf5") - def test_03_knx_cemi_bitfields(self): + def test_02_knx_cemi_bitfields(self): """Test that cemi blocks with bit fields (subfields) work.""" frame = knx.KnxFrame(type="CONFIGURATION REQUEST", cemi="PropRead.req") self.assertEqual(frame.body.cemi.cemi_data.propread_req.number_of_elements.value, [0,0,0,0]) @@ -279,7 +274,7 @@ def test_03_knx_cemi_bitfields(self): self.assertEqual(frame.body.cemi.cemi_data.propread_req.number_of_elements.value, [1,1,1,1]) self.assertEqual(frame.body.cemi.cemi_data.propread_req.start_index.value, [0,0,0,0,0,0,0,0,0,0,0,1]) self.assertEqual(frame.body.cemi.cemi_data.propread_req.number_of_elements_start_index.value, b'\xF0\x01') - def test_04_knx_cemi_bitfields_parsing(self): + def test_03_knx_cemi_bitfields_parsing(self): """Test that a received cEMI frame with bit fields is parsed.""" knxnet = knx.KnxNet() knxnet.connect(BOIBOITE, 3671) From dc9f96f783432e439509551e3e00c9e3ad1af29c Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 28 Jul 2020 16:48:51 +0200 Subject: [PATCH 22/23] Doc fixes --- bof/knx/knxframe.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bof/knx/knxframe.py b/bof/knx/knxframe.py index 1a3650f..66e625a 100644 --- a/bof/knx/knxframe.py +++ b/bof/knx/knxframe.py @@ -148,7 +148,7 @@ def factory(cls, template, **kwargs) -> object: factory as a class method. :param template: Template of a block or field as a dictionary. - ;returns: A new instance of a KnxBlock or a KnxField. + :returns: A new instance of a KnxBlock or a KnxField. Keyword arguments: @@ -257,6 +257,8 @@ def __init__(self, **kwargs): :param optional: Boolean, set to True if we want to create a frame with optional fields (from spec). :param bytes: Raw bytearray used to build a KnxFrame object. + :param *: Other params corresponding to default values can be given. + The param name must be the name of the field to fill. """ spec = KnxSpec() super().__init__() @@ -271,7 +273,7 @@ def __init__(self, **kwargs): # Create block knxblock = KnxBlock(value=value, defaults=defaults, parent=self, **block) self.append(block["name"], knxblock) - # If a value is used to fill the blocks, update it: + # If a value is used to fill the blocks, update it if value: if len(self._blocks[block["name"]]) >= len(value): break From 549656b9ec8ff302560986080123b55902c1fabc Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 28 Jul 2020 16:54:36 +0200 Subject: [PATCH 23/23] Version 0.2.0 ready --- docs/conf.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 9549f62..b6886da 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -22,7 +22,7 @@ author = 'Lex' # The full version, including alpha/beta/rc tags -release = '1.0.0' +release = '0.2.0' # -- General configuration --------------------------------------------------- diff --git a/setup.py b/setup.py index 9ac0c24..c677519 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name="boiboite-opener-framework", - version="0.1.0", + version="0.2.0", author="Claire Vacherot", author_email="claire.vacherot@orange.com", description="Industrial network protocols communication and packet crafting framework",