Repository: XKNX/xknx
Pull request #1260 (2023-05-02 09:47:45)

Add support for Tunnelling Feature service messages
<!--
  You are awesome! Thanks for contributing to our project!
  Please, DO NOT DELETE ANY TEXT from this template!.
-->
## Description
<!--
Please include a summary of the change and which issue is fixed.
Please also include relevant motivation and context.
List any dependencies that are required for this change.
-->
This PR adds support for (de)serialization of Tunnelling Feature requests and responses. 

## Type of change
<!--
Please tick the applicable options.
NOTE: Ticking multiple options most likely indicates
that your change is to big and it is suggested to split it into several smaller PRs.
-->

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Code quality improvements to existing code or addition of tests

## Checklist

- [ ] The documentation has been adjusted accordingly
- [x] Tests have been added that prove the fix is effective or that the feature works
- [x] The changes are documented in the changelog (docs/changelog.md)

codecov[bot]: ## [Codecov](https://codecov.io/gh/XKNX/xknx/pull/1260?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) Report
> Merging [#1260](https://codecov.io/gh/XKNX/xknx/pull/1260?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) (8e22895) into [main](https://codecov.io/gh/XKNX/xknx/commit/c4b4d53d1b1e5ab462192aab592030ce738409c7?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) (c4b4d53) will **decrease** coverage by `0.23%`.
> The diff coverage is `62.50%`.

[![Impacted file tree graph](https://codecov.io/gh/XKNX/xknx/pull/1260/graphs/tree.svg?width=650&height=150&src=pr&token=irWbIygS84&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX)](https://codecov.io/gh/XKNX/xknx/pull/1260?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX)

```diff
@@            Coverage Diff             @@
##             main    #1260      +/-   ##
==========================================
- Coverage   96.50%   96.28%   -0.23%     
==========================================
  Files         150      151       +1     
  Lines        9512     9576      +64     
==========================================
+ Hits         9180     9220      +40     
- Misses        332      356      +24     
```


| [Impacted Files](https://codecov.io/gh/XKNX/xknx/pull/1260?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) | Coverage Δ | |
|---|---|---|
| [xknx/knxip/tunnelling\_feature.py](https://codecov.io/gh/XKNX/xknx/pull/1260?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9rbnhpcC90dW5uZWxsaW5nX2ZlYXR1cmUucHk=) | `54.54% <54.54%> (ø)` | |
| [xknx/knxip/knxip.py](https://codecov.io/gh/XKNX/xknx/pull/1260?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9rbnhpcC9rbnhpcC5weQ==) | `96.46% <55.55%> (-3.54%)` | :arrow_down: |
| [xknx/knxip/\_\_init\_\_.py](https://codecov.io/gh/XKNX/xknx/pull/1260?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9rbnhpcC9fX2luaXRfXy5weQ==) | `100.00% <100.00%> (ø)` | |
| [xknx/knxip/knxip\_enum.py](https://codecov.io/gh/XKNX/xknx/pull/1260?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9rbnhpcC9rbnhpcF9lbnVtLnB5) | `100.00% <100.00%> (ø)` | |


Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 9a5e80fa..2bf9655a 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -6,6 +6,12 @@ nav_order: 2
 
 # Changelog
 
+# Unreleased changes
+
+### Protocol
+
+- Support Tunnelling Feature service messages.
+
 # 2.9.0 Spring cleanup 2023-04-22
 
 ### Dependencies
diff --git a/xknx/knxip/__init__.py b/xknx/knxip/__init__.py
index 55b82319..d4687bf1 100644
--- a/xknx/knxip/__init__.py
+++ b/xknx/knxip/__init__.py
@@ -30,6 +30,7 @@ from .knxip_enum import (
     KNXIPServiceType,
     KNXMedium,
     SearchRequestParameterType,
+    TunnellingFeatureType,
     TunnellingLayer,
 )
 from .routing_busy import RoutingBusy
@@ -47,6 +48,12 @@ from .session_status import SessionStatus
 from .srp import SRP
 from .timer_notify import TimerNotify
 from .tunnelling_ack import TunnellingAck
+from .tunnelling_feature import (
+    TunnellingFeatureGet,
+    TunnellingFeatureInfo,
+    TunnellingFeatureResponse,
+    TunnellingFeatureSet,
+)
 from .tunnelling_request import TunnellingRequest
 
 __all__ = [
@@ -96,6 +103,11 @@ __all__ = [
     "SRP",
     "TimerNotify",
     "TunnellingAck",
+    "TunnellingFeatureGet",
+    "TunnellingFeatureInfo",
+    "TunnellingFeatureResponse",
+    "TunnellingFeatureSet",
+    "TunnellingFeatureType",
     "TunnellingLayer",
     "TunnellingRequest",
 ]
diff --git a/xknx/knxip/knxip.py b/xknx/knxip/knxip.py
index 79cd00b3..2c1c48b6 100644
--- a/xknx/knxip/knxip.py
+++ b/xknx/knxip/knxip.py
@@ -35,6 +35,12 @@ from .session_response import SessionResponse
 from .session_status import SessionStatus
 from .timer_notify import TimerNotify
 from .tunnelling_ack import TunnellingAck
+from .tunnelling_feature import (
+    TunnellingFeatureGet,
+    TunnellingFeatureInfo,
+    TunnellingFeatureResponse,
+    TunnellingFeatureSet,
+)
 from .tunnelling_request import TunnellingRequest
 
 
@@ -102,11 +108,19 @@ class KNXIPFrame:
             body = DeviceConfigurationRequest()
         elif header.service_type_ident == KNXIPServiceType.DEVICE_CONFIGURATION_ACK:
             body = DeviceConfigurationAck()
-        # Tunneling
+        # Tunnelling
         elif header.service_type_ident == KNXIPServiceType.TUNNELLING_REQUEST:
             body = TunnellingRequest()
         elif header.service_type_ident == KNXIPServiceType.TUNNELLING_ACK:
             body = TunnellingAck()
+        elif header.service_type_ident == KNXIPServiceType.TUNNELLING_FEATURE_GET:
+            body = TunnellingFeatureGet()
+        elif header.service_type_ident == KNXIPServiceType.TUNNELLING_FEATURE_INFO:
+            body = TunnellingFeatureInfo()
+        elif header.service_type_ident == KNXIPServiceType.TUNNELLING_FEATURE_RESPONSE:
+            body = TunnellingFeatureResponse()
+        elif header.service_type_ident == KNXIPServiceType.TUNNELLING_FEATURE_SET:
+            body = TunnellingFeatureSet()
         # Routing
         elif header.service_type_ident == KNXIPServiceType.ROUTING_INDICATION:
             body = RoutingIndication()
diff --git a/xknx/knxip/knxip_enum.py b/xknx/knxip/knxip_enum.py
index 0ab5e94d..f27bde6f 100644
--- a/xknx/knxip/knxip_enum.py
+++ b/xknx/knxip/knxip_enum.py
@@ -167,3 +167,24 @@ class SearchRequestParameterType(Enum):
     SELECT_BY_SERVICE = 0x03
     # The Client shall include this SRP to indicate that it is interested in the listed DIBs
     REQUEST_DIBS = 0x04
+
+
+class TunnellingFeatureType(Enum):
+    """Enum class for KNX/IP Tunnel Features services."""
+
+    # Getting the supported EMI type(s) (only cEMI allowed)
+    SUPPORTED_EMI_TYPE = 0x01
+    # Getting the local Device Descriptor Type 0 for possible local device management
+    DEVICE_DESCRIPTOR_TYPE_0 = 0x02
+    # Getting and informing on the bus connection status
+    BUS_CONNECTION_STATUS = 0x03
+    # Getting the manufacturer code of the Bus Access Server
+    MANUFACTURER_CODE = 0x04
+    # Getting and Setting the EMI type to use
+    ACTIVE_EMI_TYPE = 0x05
+    # Getting, Setting and informing on the Interface Individual Address
+    INTERFACE_INDIVIDUAL_ADDRESS = 0x06
+    # Getting and informing on the maximum APDU length
+    MAX_APDU_LENGTH = 0x07
+    # Getting and Setting the Interface Feature Info service Enable
+    INTERFACE_FEATURE_INFO_ENABLE = 0x08
diff --git a/xknx/knxip/tunnelling_feature.py b/xknx/knxip/tunnelling_feature.py
new file mode 100644
index 00000000..6525fbe0
--- /dev/null
+++ b/xknx/knxip/tunnelling_feature.py
@@ -0,0 +1,173 @@
+"""
+Module for Serialization and Deserialization of a KNX Tunnelling Feature service.
+
+The Tunnelling Client shall use the Tunnelling Feature services TUNNELLING_FEATURE_GET and
+TUNNELLING_FEATURE_SET to read and respectively write features related to the Tunnelling
+interface and the Tunnelling host device.
+The Tunnelling Server shall use the service TUNNELLING_FEATURE_INFO to spontaneously report
+Tunnelling Clients about relevant changes in the state of itself or the Tunnelling connection.
+"""
+from __future__ import annotations
+
+import struct
+
+from xknx.exceptions import CouldNotParseKNXIP
+
+from .body import KNXIPBody, KNXIPBodyResponse
+from .error_code import ErrorCode
+from .knxip_enum import KNXIPServiceType, TunnellingFeatureType
+
+
+class _TunnellingFeature:
+    """Base representation of a KNX Tunnelling Feature interface request."""
+
+    HEADER_LENGTH = 4
+    FEATURE_ID_LENGTH = 2
+
+    def __init__(
+        self,
+        communication_channel_id: int = 1,
+        sequence_counter: int = 0,
+        status_code: ErrorCode = ErrorCode.E_NO_ERROR,
+        feature_type: TunnellingFeatureType = TunnellingFeatureType.SUPPORTED_EMI_TYPE,
+        data: bytes = b"",
+    ):
+        """Initialize _TunnellingFeature object."""
+        self.communication_channel_id = communication_channel_id
+        self.sequence_counter = sequence_counter
+        self.status_code = status_code
+        self.feature_type = feature_type
+        self.data = data
+
+    def _has_data(self) -> bool:
+        return True
+
+    def calculated_length(self) -> int:
+        """Get length of KNX/IP body."""
+        data_size = len(self.data) + (len(self.data) % 2) if self._has_data() else 0
+        return (
+            _TunnellingFeature.HEADER_LENGTH
+            + _TunnellingFeature.FEATURE_ID_LENGTH
+            + data_size
+        )
+
+    def from_knx(self, raw: bytes) -> int:
+        """Parse/deserialize from KNX/IP raw data."""
+        if raw[0] != _TunnellingFeature.HEADER_LENGTH:  # structure_length field
+            raise CouldNotParseKNXIP("TunnellingFeature header has invalid length")
+        self.communication_channel_id = raw[1]
+        self.sequence_counter = raw[2]
+        self.status_code = ErrorCode(raw[3])
+        self.feature_type = TunnellingFeatureType(raw[4])
+        self.data = raw[6:]
+        if self._has_data() and len(self.data) == 0:
+            raise CouldNotParseKNXIP("TunnellingFeature missing data")
+        if not self._has_data() and len(self.data) > 0:
+            raise CouldNotParseKNXIP("TunnellingFeature unexpected data")
+        return len(raw)
+
+    def to_knx(self) -> bytes:
+        """Serialize to KNX/IP raw data."""
+        data_size = len(self.data) + (len(self.data) % 2) if self._has_data() else 0
+        return struct.pack(
+            # Append a NUL byte if data size is uneven
+            f"!BBBBBx{data_size}s",
+            _TunnellingFeature.HEADER_LENGTH,
+            self.communication_channel_id,
+            self.sequence_counter,
+            self.status_code.value,
+            self.feature_type.value,
+            self.data,
+        )
+
+    def __repr__(self) -> str:
+        """Return object as readable string."""
+        _data = f'data="{self.data.hex()}" ' if self._has_data() else ""
+        return (
+            f"<{self.__class__.__name__} "
+            f'communication_channel_id="{self.communication_channel_id}" '
+            f'sequence_counter="{self.sequence_counter}" '
+            f'feature_type="{self.feature_type}" '
+            f"{_data}/>"
+        )
+
+
+class TunnellingFeatureGet(_TunnellingFeature, KNXIPBody):
+    """Representation of a KNX Tunnelling Feature Get request."""
+
+    SERVICE_TYPE = KNXIPServiceType.TUNNELLING_FEATURE_GET
+
+    def __init__(
+        self,
+        communication_channel_id: int = 1,
+        sequence_counter: int = 0,
+        feature_type: TunnellingFeatureType = TunnellingFeatureType.SUPPORTED_EMI_TYPE,
+    ):
+        """Initialize TunnellingFeatureGet object."""
+        super().__init__(
+            communication_channel_id=communication_channel_id,
+            sequence_counter=sequence_counter,
+            feature_type=feature_type,
+        )
+
+    def _has_data(self) -> bool:
+        return False
+
+
+class TunnellingFeatureSet(_TunnellingFeature, KNXIPBody):
+    """Representation of a KNX Tunnelling Feature Set request."""
+
+    SERVICE_TYPE = KNXIPServiceType.TUNNELLING_FEATURE_SET
+
+    def __init__(
+        self,
+        communication_channel_id: int = 1,
+        sequence_counter: int = 0,
+        feature_type: TunnellingFeatureType = TunnellingFeatureType.SUPPORTED_EMI_TYPE,
+        data: bytes = b"",
+    ):
+        """Initialize TunnellingFeatureSet object."""
+        super().__init__(
+            communication_channel_id=communication_channel_id,
+            sequence_counter=sequence_counter,
+            feature_type=feature_type,
+            data=data,
+        )
+
+
+class TunnellingFeatureInfo(_TunnellingFeature, KNXIPBody):
+    """Representation of a KNX Tunnelling Feature Info indication."""
+
+    SERVICE_TYPE = KNXIPServiceType.TUNNELLING_FEATURE_INFO
+
+    def __init__(
+        self,
+        communication_channel_id: int = 1,
+        sequence_counter: int = 0,
+        feature_type: TunnellingFeatureType = TunnellingFeatureType.SUPPORTED_EMI_TYPE,
+        data: bytes = b"",
+    ):
+        """Initialize TunnellingFeatureInfo object."""
+        super().__init__(
+            communication_channel_id=communication_channel_id,
+            sequence_counter=sequence_counter,
+            feature_type=feature_type,
+            data=data,
+        )
+
+
+class TunnellingFeatureResponse(_TunnellingFeature, KNXIPBodyResponse):
+    """Representation of a KNX Tunnelling Feature response."""
+
+    SERVICE_TYPE = KNXIPServiceType.TUNNELLING_FEATURE_RESPONSE
+
+    def __repr__(self) -> str:
+        """Return object as readable string."""
+        return (
+            "<TunnellingFeatureResponse "
+            f'communication_channel_id="{self.communication_channel_id}" '
+            f'sequence_counter="{self.sequence_counter}" '
+            f'status_code="{self.status_code}" '
+            f'feature_type="{self.feature_type}" '
+            f'data="{self.data.hex()}" />'
+        )


Test patch:
diff --git a/test/knxip_tests/tunnelling_feature_test.py b/test/knxip_tests/tunnelling_feature_test.py
new file mode 100644
index 00000000..dc3bc1e2
--- /dev/null
+++ b/test/knxip_tests/tunnelling_feature_test.py
@@ -0,0 +1,129 @@
+"""Unit test for KNX/IP TunnellingRequest objects."""
+import pytest
+
+from xknx.exceptions import CouldNotParseKNXIP
+from xknx.knxip import (
+    ErrorCode,
+    KNXIPFrame,
+    TunnellingFeatureGet,
+    TunnellingFeatureInfo,
+    TunnellingFeatureResponse,
+    TunnellingFeatureSet,
+    TunnellingFeatureType,
+)
+
+
+class TestKNXIPTunnellingFeature:
+    """Test class for KNX/IP TunnelingFeature objects."""
+
+    def test_get(self):
+        """Test parsing and streaming connection tunneling feature get KNX/IP packet."""
+        raw = bytes.fromhex("06 10 04 22 00 0c 04 01 17 00 03 00")
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
+
+        assert isinstance(knxipframe.body, TunnellingFeatureGet)
+        assert knxipframe.body.communication_channel_id == 1
+        assert knxipframe.body.sequence_counter == 23
+        assert (
+            knxipframe.body.feature_type == TunnellingFeatureType.BUS_CONNECTION_STATUS
+        )
+        assert len(knxipframe.body.data) == 0
+
+        tunnelling_request = TunnellingFeatureGet(
+            communication_channel_id=1,
+            sequence_counter=23,
+            feature_type=TunnellingFeatureType.BUS_CONNECTION_STATUS,
+        )
+        knxipframe2 = KNXIPFrame.init_from_body(tunnelling_request)
+
+        assert knxipframe2.to_knx() == raw
+
+    def test_info(self):
+        """Test parsing and streaming connection tunneling feature info KNX/IP packet."""
+        raw = bytes.fromhex("06 10 04 25 00 0e 04 01 17 00 03 00 01 00")
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
+
+        assert isinstance(knxipframe.body, TunnellingFeatureInfo)
+        assert knxipframe.body.communication_channel_id == 1
+        assert knxipframe.body.sequence_counter == 23
+        assert (
+            knxipframe.body.feature_type == TunnellingFeatureType.BUS_CONNECTION_STATUS
+        )
+        assert knxipframe.body.data == b"\x01\x00"
+
+        tunnelling_request = TunnellingFeatureInfo(
+            communication_channel_id=1,
+            sequence_counter=23,
+            feature_type=TunnellingFeatureType.BUS_CONNECTION_STATUS,
+            data=b"\x01\x00",
+        )
+        knxipframe2 = KNXIPFrame.init_from_body(tunnelling_request)
+
+        assert knxipframe2.to_knx() == raw
+
+    def test_response(self):
+        """Test parsing and streaming connection tunneling feature response KNX/IP packet."""
+        raw = bytes.fromhex("06 10 04 23 00 0e 04 01 17 00 03 00 01 00")
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
+
+        assert isinstance(knxipframe.body, TunnellingFeatureResponse)
+        assert knxipframe.body.communication_channel_id == 1
+        assert knxipframe.body.sequence_counter == 23
+        assert knxipframe.body.status_code == ErrorCode.E_NO_ERROR
+        assert (
+            knxipframe.body.feature_type == TunnellingFeatureType.BUS_CONNECTION_STATUS
+        )
+        assert knxipframe.body.data == b"\x01\x00"
+
+        tunnelling_request = TunnellingFeatureResponse(
+            communication_channel_id=1,
+            sequence_counter=23,
+            status_code=ErrorCode.E_NO_ERROR,
+            feature_type=TunnellingFeatureType.BUS_CONNECTION_STATUS,
+            data=b"\x01\x00",
+        )
+        knxipframe2 = KNXIPFrame.init_from_body(tunnelling_request)
+
+        assert knxipframe2.to_knx() == raw
+
+    def test_set(self):
+        """Test parsing and streaming connection tunneling feature set KNX/IP packet."""
+        raw = bytes.fromhex("06 10 04 24 00 0e 04 01 17 00 08 00 01 00")
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
+
+        assert isinstance(knxipframe.body, TunnellingFeatureSet)
+        assert knxipframe.body.communication_channel_id == 1
+        assert knxipframe.body.sequence_counter == 23
+        assert (
+            knxipframe.body.feature_type
+            == TunnellingFeatureType.INTERFACE_FEATURE_INFO_ENABLE
+        )
+        assert knxipframe.body.data == b"\x01\x00"
+
+        tunnelling_request = TunnellingFeatureSet(
+            communication_channel_id=1,
+            sequence_counter=23,
+            feature_type=TunnellingFeatureType.INTERFACE_FEATURE_INFO_ENABLE,
+            data=b"\x01\x00",
+        )
+        knxipframe2 = KNXIPFrame.init_from_body(tunnelling_request)
+
+        assert knxipframe2.to_knx() == raw
+
+    def test_from_knx_wrong_header(self):
+        """Test parsing and streaming wrong Tunnelling Feature (wrong header length byte)."""
+        raw = bytes.fromhex("06 10 04 22 00 0c 06 01 17 00 03 00")
+        with pytest.raises(CouldNotParseKNXIP):
+            KNXIPFrame.from_knx(raw)
+
+    def test_get_with_data(self):
+        """Test parsing and streaming wrong Get (unexpected data)."""
+        raw = bytes.fromhex("06 10 04 22 00 0e 04 01 17 00 03 00 01 00")
+        with pytest.raises(CouldNotParseKNXIP):
+            KNXIPFrame.from_knx(raw)
+
+    def test_set_without_data(self):
+        """Test parsing and streaming wrong Get (missing data)."""
+        raw = bytes.fromhex("06 10 04 24 00 0c 04 01 17 00 03 00")
+        with pytest.raises(CouldNotParseKNXIP):
+            KNXIPFrame.from_knx(raw)
diff --git a/test/str_test.py b/test/str_test.py
index 91759527..6b26172c 100644
--- a/test/str_test.py
+++ b/test/str_test.py
@@ -57,6 +57,11 @@ from xknx.knxip import (
     SearchResponse,
     SearchResponseExtended,
     TunnellingAck,
+    TunnellingFeatureGet,
+    TunnellingFeatureInfo,
+    TunnellingFeatureResponse,
+    TunnellingFeatureSet,
+    TunnellingFeatureType,
     TunnellingRequest,
 )
 from xknx.profile.const import ResourceKNXNETIPPropertyId, ResourceObjectType
@@ -648,6 +653,59 @@ class TestStringRepresentations:
             == '<TunnellingAck communication_channel_id="23" sequence_counter="42" status_code="ErrorCode.E_NO_ERROR" />'
         )
 
+    def test_tunnelling_feature_get(self):
+        """Test string representation of KNX/IP TunnellingFeatureGet."""
+        tunnelling_feature = TunnellingFeatureGet()
+        tunnelling_feature.communication_channel_id = 23
+        tunnelling_feature.sequence_counter = 42
+        tunnelling_feature.feature_type = TunnellingFeatureType.BUS_CONNECTION_STATUS
+        assert (
+            str(tunnelling_feature)
+            == '<TunnellingFeatureGet communication_channel_id="23" sequence_counter="42" '
+            'feature_type="TunnellingFeatureType.BUS_CONNECTION_STATUS" />'
+        )
+
+    def test_tunnelling_feature_info(self):
+        """Test string representation of KNX/IP TunnellingFeatureInfo."""
+        tunnelling_feature = TunnellingFeatureInfo()
+        tunnelling_feature.communication_channel_id = 23
+        tunnelling_feature.sequence_counter = 42
+        tunnelling_feature.feature_type = TunnellingFeatureType.BUS_CONNECTION_STATUS
+        tunnelling_feature.data = b"\x01\x00"
+        assert (
+            str(tunnelling_feature)
+            == '<TunnellingFeatureInfo communication_channel_id="23" sequence_counter="42" '
+            'feature_type="TunnellingFeatureType.BUS_CONNECTION_STATUS" data="0100" />'
+        )
+
+    def test_tunnelling_feature_response(self):
+        """Test string representation of KNX/IP TunnellingFeatureResponse."""
+        tunnelling_feature = TunnellingFeatureResponse()
+        tunnelling_feature.communication_channel_id = 23
+        tunnelling_feature.sequence_counter = 42
+        tunnelling_feature.feature_type = TunnellingFeatureType.BUS_CONNECTION_STATUS
+        tunnelling_feature.data = b"\x01\x00"
+        assert (
+            str(tunnelling_feature)
+            == '<TunnellingFeatureResponse communication_channel_id="23" sequence_counter="42" status_code="ErrorCode.E_NO_ERROR" '
+            'feature_type="TunnellingFeatureType.BUS_CONNECTION_STATUS" data="0100" />'
+        )
+
+    def test_tunnelling_feature_set(self):
+        """Test string representation of KNX/IP TunnellingFeatureSet."""
+        tunnelling_feature = TunnellingFeatureSet()
+        tunnelling_feature.communication_channel_id = 23
+        tunnelling_feature.sequence_counter = 42
+        tunnelling_feature.feature_type = (
+            TunnellingFeatureType.INTERFACE_FEATURE_INFO_ENABLE
+        )
+        tunnelling_feature.data = b"\x01\x00"
+        assert (
+            str(tunnelling_feature)
+            == '<TunnellingFeatureSet communication_channel_id="23" sequence_counter="42" '
+            'feature_type="TunnellingFeatureType.INTERFACE_FEATURE_INFO_ENABLE" data="0100" />'
+        )
+
     def test_cemi_ldata_frame(self):
         """Test string representation of KNX/IP CEMI Frame."""
         cemi_frame = CEMIFrame(