class DIBDeviceInformation(DIB): """Class for serialization and deserialization of KNX DIB Device Information Block.""" # pylint: disable=too-many-instance-attributes LENGTH = 54 def __init__(self) -> None: """Initialize DIBDeviceInformation class.""" super().__init__() self.knx_medium: KNXMedium = KNXMedium.TP1 self.programming_mode: bool = False self.individual_address: IndividualAddress = IndividualAddress(None) self.installation_number: int = 0 self.project_number: int = 0 self.serial_number: str = "" self.multicast_address: str = "224.0.23.12" self.mac_address: str = "" self.name: str = "" def calculated_length(self) -> int: """Get length of KNX/IP object.""" return DIBDeviceInformation.LENGTH def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" if len(raw) < DIBDeviceInformation.LENGTH: raise CouldNotParseKNXIP("wrong connection header length") if raw[0] != DIBDeviceInformation.LENGTH: raise CouldNotParseKNXIP("wrong connection header length") if DIBTypeCode(raw[1]) != DIBTypeCode.DEVICE_INFO: raise CouldNotParseKNXIP("DIB is no device info") self.knx_medium = KNXMedium(raw[2]) # last bit of device_status. All other bits are unused self.programming_mode = bool(raw[3]) self.individual_address = IndividualAddress((raw[4], raw[5])) installation_project_identifier = raw[6] * 256 + raw[7] self.project_number = installation_project_identifier >> 4 self.installation_number = installation_project_identifier & 15 self.serial_number = ":".join("%02x" % i for i in raw[8:14]) self.multicast_address = ".".join("%i" % i for i in raw[14:18]) self.mac_address = ":".join("%02x" % i for i in raw[18:24]) self.name = "".join(map(chr, raw[24:54])).rstrip("\0") return DIBDeviceInformation.LENGTH def to_knx(self) -> List[int]: """Serialize to KNX/IP raw data.""" def hex_notation_to_knx(serial_number: str) -> Iterator[int]: """Serialize hex notation.""" for part in serial_number.split(":"): yield int(part, 16) def ip_to_knx(ip_addr: str) -> Iterator[int]: """Serialize ip.""" for part in ip_addr.split("."): yield int(part) def str_to_knx(string: str, length: int) -> Iterator[int]: """Serialize string.""" if len(string) > length - 1: string = string[: length - 1] for char in string: yield ord(char) for _ in range(0, 30 - len(string)): yield 0x00 installation_project_identifier = ( self.project_number * 16 ) + self.installation_number data = [] data.append(DIBDeviceInformation.LENGTH) data.append(DIBTypeCode.DEVICE_INFO.value) data.append(self.knx_medium.value) data.append(int(self.programming_mode)) data.extend(self.individual_address.to_knx()) data.append((installation_project_identifier >> 8) & 255) data.append(installation_project_identifier & 255) data.extend(hex_notation_to_knx(self.serial_number)) data.extend(ip_to_knx(self.multicast_address)) data.extend(hex_notation_to_knx(self.mac_address)) data.extend(str_to_knx(self.name, 30)) return data def __str__(self) -> str: """Return object as readable string.""" return ( "<DIBDeviceInformation " '\n\tknx_medium="{}" ' '\n\tprogramming_mode="{}" ' '\n\tindividual_address="{}" ' '\n\tinstallation_number="{}" ' '\n\tproject_number="{}" ' '\n\tserial_number="{}" ' '\n\tmulticast_address="{}" ' '\n\tmac_address="{}" ' '\n\tname="{}" />'.format( self.knx_medium, self.programming_mode, self.individual_address, self.installation_number, self.project_number, self.serial_number, self.multicast_address, self.mac_address, self.name, ) )
class DIBDeviceInformation(DIB): """Class for serialization and deserialization of KNX DIB Device Information Block.""" LENGTH = 54 def __init__(self) -> None: """Initialize DIBDeviceInformation class.""" self.knx_medium: KNXMedium = KNXMedium.TP1 self.programming_mode: bool = False self.individual_address: IndividualAddress = IndividualAddress(None) self.installation_number: int = 0 self.project_number: int = 0 self.serial_number: str = "" self.multicast_address: str = "224.0.23.12" self.mac_address: str = "" self.name: str = "" def calculated_length(self) -> int: """Get length of KNX/IP object.""" return DIBDeviceInformation.LENGTH def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" if len(raw) < DIBDeviceInformation.LENGTH: raise CouldNotParseKNXIP("wrong connection header length") if raw[0] != DIBDeviceInformation.LENGTH: raise CouldNotParseKNXIP("wrong connection header length") if DIBTypeCode(raw[1]) != DIBTypeCode.DEVICE_INFO: raise CouldNotParseKNXIP("DIB is no device info") self.knx_medium = KNXMedium(raw[2]) # last bit of device_status. All other bits are unused self.programming_mode = bool(raw[3]) self.individual_address = IndividualAddress((raw[4], raw[5])) installation_project_identifier = raw[6] * 256 + raw[7] self.project_number = installation_project_identifier >> 4 self.installation_number = installation_project_identifier & 15 self.serial_number = raw[8:14].hex(":") self.multicast_address = socket.inet_ntoa(raw[14:18]) self.mac_address = raw[18:24].hex(":") self.name = raw[24:54].decode(encoding="latin_1", errors="replace").rstrip("\0") return DIBDeviceInformation.LENGTH def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" def hex_notation_to_knx(colon_hex: str) -> bytes: """Serialize hex notation.""" return bytes.fromhex(colon_hex.replace(":", "")) def ip_to_knx(ip_addr: str) -> bytes: """Serialize ip.""" return socket.inet_aton(ip_addr) def name_str_to_knx(string: str) -> bytes: """Serialize name string.""" # pad with null bytes to length 30; ISO 8859-1 (latin_1) according to KNX specification return bytes(string[:30], "latin_1").ljust(30, b"\0") installation_project_identifier = ((self.project_number * 16) + self.installation_number).to_bytes( 2, "big") return (bytes(( DIBDeviceInformation.LENGTH, DIBTypeCode.DEVICE_INFO.value, self.knx_medium.value, self.programming_mode, )) + bytes(self.individual_address.to_knx()) + installation_project_identifier + hex_notation_to_knx(self.serial_number) + ip_to_knx(self.multicast_address) + hex_notation_to_knx(self.mac_address) + name_str_to_knx(self.name)) def __repr__(self) -> str: """Return object as readable string.""" return ("<DIBDeviceInformation " f'\n\tknx_medium="{self.knx_medium}" ' f'\n\tprogramming_mode="{self.programming_mode}" ' f'\n\tindividual_address="{self.individual_address}" ' f'\n\tinstallation_number="{self.installation_number}" ' f'\n\tproject_number="{self.project_number}" ' f'\n\tserial_number="{self.serial_number}" ' f'\n\tmulticast_address="{self.multicast_address}" ' f'\n\tmac_address="{self.mac_address}" ' f'\n\tname="{self.name}" />')
class CEMIFrame: """Representation of a CEMI Frame.""" def __init__( self, xknx: XKNX, code: CEMIMessageCode = CEMIMessageCode.L_DATA_IND, flags: int = 0, src_addr: IndividualAddress = IndividualAddress(None), dst_addr: GroupAddress | IndividualAddress = GroupAddress(None), mpdu_len: int = 0, payload: APCI | None = None, ): """Initialize CEMIFrame object.""" self.xknx = xknx self.code = code self.flags = flags self.src_addr = src_addr self.dst_addr = dst_addr self.mpdu_len = mpdu_len self.payload = payload @staticmethod def init_from_telegram( xknx: XKNX, telegram: Telegram, code: CEMIMessageCode = CEMIMessageCode.L_DATA_IND, src_addr: IndividualAddress = IndividualAddress(None), ) -> CEMIFrame: """Return CEMIFrame from a Telegram.""" cemi = CEMIFrame(xknx, code=code, src_addr=src_addr) # dst_addr, payload and cmd are set by telegram.setter - mpdu_len not needed for outgoing telegram cemi.telegram = telegram return cemi @property def telegram(self) -> Telegram: """Return telegram.""" return Telegram( destination_address=self.dst_addr, payload=self.payload, source_address=self.src_addr, ) @telegram.setter def telegram(self, telegram: Telegram) -> None: """Set telegram.""" # TODO: Move to separate function, together with setting of # CEMIMessageCode self.flags = ( CEMIFlags.FRAME_TYPE_STANDARD | CEMIFlags.DO_NOT_REPEAT | CEMIFlags.BROADCAST | CEMIFlags.PRIORITY_LOW | CEMIFlags.NO_ACK_REQUESTED | CEMIFlags.CONFIRM_NO_ERROR | CEMIFlags.HOP_COUNT_1ST ) if isinstance(telegram.destination_address, GroupAddress): self.flags |= CEMIFlags.DESTINATION_GROUP_ADDRESS elif isinstance(telegram.destination_address, IndividualAddress): self.flags |= CEMIFlags.DESTINATION_INDIVIDUAL_ADDRESS else: raise TypeError() self.dst_addr = telegram.destination_address self.payload = telegram.payload def set_hops(self, hops: int) -> None: """Set hops.""" # Resetting hops self.flags &= 0xFFFF ^ 0x0070 # Setting new hops self.flags |= hops << 4 def calculated_length(self) -> int: """Get length of KNX/IP body.""" if not isinstance(self.payload, APCI): raise TypeError() return 10 + self.payload.calculated_length() def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" try: self.code = CEMIMessageCode(raw[0]) except ValueError: raise UnsupportedCEMIMessage( "CEMIMessageCode not implemented: {} in CEMI: {}".format( raw[0], raw.hex() ) ) if self.code not in ( CEMIMessageCode.L_DATA_IND, CEMIMessageCode.L_DATA_REQ, CEMIMessageCode.L_DATA_CON, ): raise UnsupportedCEMIMessage( "Could not handle CEMIMessageCode: {} / {} in CEMI: {}".format( self.code, raw[0], raw.hex() ) ) return self.from_knx_data_link_layer(raw) def from_knx_data_link_layer(self, cemi: bytes) -> int: """Parse L_DATA_IND, CEMIMessageCode.L_DATA_REQ, CEMIMessageCode.L_DATA_CON.""" if len(cemi) < 11: # eg. ETS Line-Scan issues L_DATA_IND with length 10 raise UnsupportedCEMIMessage( "CEMI too small. Length: {}; CEMI: {}".format(len(cemi), cemi.hex()) ) # AddIL (Additional Info Length), as specified within # KNX Chapter 3.6.3/4.1.4.3 "Additional information." # Additional information is not yet parsed. addil = cemi[1] # Control field 1 and Control field 2 - first 2 octets after Additional information self.flags = cemi[2 + addil] * 256 + cemi[3 + addil] self.src_addr = IndividualAddress((cemi[4 + addil], cemi[5 + addil])) if self.flags & CEMIFlags.DESTINATION_GROUP_ADDRESS: self.dst_addr = GroupAddress( (cemi[6 + addil], cemi[7 + addil]), levels=self.xknx.address_format ) else: self.dst_addr = IndividualAddress((cemi[6 + addil], cemi[7 + addil])) self.mpdu_len = cemi[8 + addil] # TPCI (transport layer control information) -> First 14 bit # APCI (application layer control information) -> Last 10 bit apdu = cemi[9 + addil :] if len(apdu) != (self.mpdu_len + 1): raise CouldNotParseKNXIP( "APDU LEN should be {} but is {} in CEMI: {}".format( self.mpdu_len, len(apdu), cemi.hex() ) ) tpci_apci = (apdu[0] << 8) + apdu[1] try: self.payload = APCI.resolve_apci(tpci_apci & 0x03FF) except ConversionError: raise UnsupportedCEMIMessage( "APCI not supported: {:#012b}".format(tpci_apci & 0x03FF) ) self.payload.from_knx(apdu) return 10 + addil + self.mpdu_len def to_knx(self) -> list[int]: """Serialize to KNX/IP raw data.""" if not isinstance(self.payload, APCI): raise TypeError() if not isinstance(self.src_addr, (GroupAddress, IndividualAddress)): raise ConversionError("src_addr not set") if not isinstance(self.dst_addr, (GroupAddress, IndividualAddress)): raise ConversionError("dst_addr not set") data = [] data.append(self.code.value) data.append(0x00) data.append((self.flags >> 8) & 255) data.append(self.flags & 255) data.extend(self.src_addr.to_knx()) data.extend(self.dst_addr.to_knx()) data.append(self.payload.calculated_length()) data.extend(self.payload.to_knx()) return data def __str__(self) -> str: """Return object as readable string.""" return ( '<CEMIFrame SourceAddress="{}" DestinationAddress="{}" ' 'Flags="{:16b}" payload="{}" />'.format( self.src_addr.__repr__(), self.dst_addr.__repr__(), self.flags, self.payload, ) ) def __eq__(self, other: object) -> bool: """Equal operator.""" return self.__dict__ == other.__dict__
class CEMIFrame: """Representation of a CEMI Frame.""" def __init__( self, code: CEMIMessageCode = CEMIMessageCode.L_DATA_IND, flags: int = 0, src_addr: IndividualAddress = IndividualAddress(None), dst_addr: GroupAddress | IndividualAddress = GroupAddress(None), tpci: TPCI = TDataGroup(), payload: APCI | None = None, ): """Initialize CEMIFrame object.""" self.code = code self.flags = flags self.src_addr = src_addr self.dst_addr = dst_addr self.tpci = tpci self.payload = payload @staticmethod def init_from_telegram( telegram: Telegram, code: CEMIMessageCode = CEMIMessageCode.L_DATA_IND, src_addr: IndividualAddress = IndividualAddress(None), ) -> CEMIFrame: """Return CEMIFrame from a Telegram.""" cemi = CEMIFrame(code=code, src_addr=src_addr) # dst_addr, payload and cmd are set by telegram.setter cemi.telegram = telegram return cemi @property def telegram(self) -> Telegram: """Return telegram.""" return Telegram( destination_address=self.dst_addr, payload=self.payload, source_address=self.src_addr, tpci=self.tpci, ) @telegram.setter def telegram(self, telegram: Telegram) -> None: """Set telegram.""" # TODO: Move to separate function, together with setting of # CEMIMessageCode self.flags = (CEMIFlags.FRAME_TYPE_STANDARD | CEMIFlags.DO_NOT_REPEAT | CEMIFlags.BROADCAST | CEMIFlags.NO_ACK_REQUESTED | CEMIFlags.CONFIRM_NO_ERROR | CEMIFlags.HOP_COUNT_1ST) if isinstance(telegram.destination_address, GroupAddress): self.flags |= CEMIFlags.DESTINATION_GROUP_ADDRESS | CEMIFlags.PRIORITY_LOW elif isinstance(telegram.destination_address, IndividualAddress): self.flags |= (CEMIFlags.DESTINATION_INDIVIDUAL_ADDRESS | CEMIFlags.PRIORITY_SYSTEM) else: raise TypeError() self.dst_addr = telegram.destination_address self.tpci = telegram.tpci self.payload = telegram.payload def set_hops(self, hops: int) -> None: """Set hops.""" # Resetting hops self.flags &= 0xFFFF ^ 0x0070 # Setting new hops self.flags |= hops << 4 def calculated_length(self) -> int: """Get length of KNX/IP body.""" if not self.tpci.control and self.payload is not None: return 10 + self.payload.calculated_length() if self.tpci.control and self.payload is None: return 10 raise TypeError( "Data TPDU must have a payload; control TPDU must not.") def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" try: self.code = CEMIMessageCode(raw[0]) except ValueError: raise UnsupportedCEMIMessage( f"CEMIMessageCode not implemented: {raw[0]} in CEMI: {raw.hex()}" ) if self.code not in ( CEMIMessageCode.L_DATA_IND, CEMIMessageCode.L_DATA_REQ, CEMIMessageCode.L_DATA_CON, ): raise UnsupportedCEMIMessage( f"Could not handle CEMIMessageCode: {self.code} / {raw[0]} in CEMI: {raw.hex()}" ) return self.from_knx_data_link_layer(raw) def from_knx_data_link_layer(self, cemi: bytes) -> int: """Parse L_DATA_IND, CEMIMessageCode.L_DATA_REQ, CEMIMessageCode.L_DATA_CON.""" if len(cemi) < 10: raise UnsupportedCEMIMessage( f"CEMI too small. Length: {len(cemi)}; CEMI: {cemi.hex()}") # AddIL (Additional Info Length), as specified within # KNX Chapter 3.6.3/4.1.4.3 "Additional information." # Additional information is not yet parsed. addil = cemi[1] # Control field 1 and Control field 2 - first 2 octets after Additional information self.flags = cemi[2 + addil] * 256 + cemi[3 + addil] self.src_addr = IndividualAddress((cemi[4 + addil], cemi[5 + addil])) dst_is_group_address = bool(self.flags & CEMIFlags.DESTINATION_GROUP_ADDRESS) dst_raw_address = (cemi[6 + addil], cemi[7 + addil]) self.dst_addr = (GroupAddress(dst_raw_address) if dst_is_group_address else IndividualAddress(dst_raw_address)) npdu_len = cemi[8 + addil] apdu = cemi[9 + addil:] if len(apdu) != (npdu_len + 1): # TCPI octet not included in NPDU length raise CouldNotParseKNXIP( f"APDU LEN should be {npdu_len} but is {len(apdu) - 1} in CEMI: {cemi.hex()}" ) # TPCI (transport layer control information) # - with control bit set -> 8 bit; no APDU # - no control bit set (data) -> First 6 bit # APCI (application layer control information) -> Last 10 bit of TPCI/APCI try: self.tpci = TPCI.resolve(raw_tpci=cemi[9 + addil], dst_is_group_address=dst_is_group_address) except ConversionError as err: raise UnsupportedCEMIMessage( f"TPCI not supported: {cemi[9 + addil]:#10b}") from err if self.tpci.control: if npdu_len: raise UnsupportedCEMIMessage( f"Invalid length for control TPDU {self.tpci}: {npdu_len}") return 10 + addil _apci = apdu[0] * 256 + apdu[1] try: self.payload = APCI.resolve_apci(_apci) except ConversionError as err: raise UnsupportedCEMIMessage( f"APCI not supported: {_apci:#012b}") from err self.payload.from_knx(apdu) return 10 + addil + npdu_len def to_knx(self) -> bytes: """Serialize to KNX/IP raw data.""" if self.tpci.control: tpdu = bytes([self.tpci.to_knx()]) npdu_len = 0 else: if not isinstance(self.payload, APCI): raise ConversionError( f"Invalid payload set for data TPDU: {self.payload.__class__}" ) tpdu = self.payload.to_knx() tpdu[0] |= self.tpci.to_knx() npdu_len = self.payload.calculated_length() if not isinstance(self.src_addr, IndividualAddress): raise ConversionError("src_addr invalid") if not isinstance(self.dst_addr, (GroupAddress, IndividualAddress)): raise ConversionError("dst_addr invalid") return (bytes(( self.code.value, 0x00, # Additional information length )) + self.flags.to_bytes(2, "big") + bytes(( *self.src_addr.to_knx(), *self.dst_addr.to_knx(), npdu_len, )) + tpdu) def __repr__(self) -> str: """Return object as readable string.""" return ("<CEMIFrame " f'code="{self.code.name}" ' f'src_addr="{self.src_addr.__repr__()}" ' f'dst_addr="{self.dst_addr.__repr__()}" ' f'flags="{self.flags:16b}" ' f'tpci="{self.tpci}" ' f'payload="{self.payload}" />') def __eq__(self, other: object) -> bool: """Equal operator.""" return self.__dict__ == other.__dict__