Repository: XKNX/xknx
Pull request #1094 (2022-12-03 22:57:06)

Add ExposeSensor cooldown option
<!--
  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.
-->
- ExposeSensor: Add `cooldown` option to allow rate-limiting of sent telegrams.
- Add `task.done()` to TaskRegistry tasks.

See #765

## 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)
- [ ] This change requires a documentation update

## 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/1094?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) Report
> Merging [#1094](https://codecov.io/gh/XKNX/xknx/pull/1094?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) (18c25f4) into [main](https://codecov.io/gh/XKNX/xknx/commit/1474ac6f66be56d8e799077f35dd13b02bf44295?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) (1474ac6) will **increase** coverage by `0.01%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/XKNX/xknx/pull/1094/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/1094?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX)

```diff
@@            Coverage Diff             @@
##             main    #1094      +/-   ##
==========================================
+ Coverage   96.29%   96.30%   +0.01%     
==========================================
  Files         138      138              
  Lines        8641     8669      +28     
==========================================
+ Hits         8321     8349      +28     
  Misses        320      320              
```


| [Impacted Files](https://codecov.io/gh/XKNX/xknx/pull/1094?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) | Coverage Δ | |
|---|---|---|
| [xknx/core/task\_registry.py](https://codecov.io/gh/XKNX/xknx/pull/1094/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9jb3JlL3Rhc2tfcmVnaXN0cnkucHk=) | `100.00% <100.00%> (ø)` | |
| [xknx/devices/expose\_sensor.py](https://codecov.io/gh/XKNX/xknx/pull/1094/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9kZXZpY2VzL2V4cG9zZV9zZW5zb3IucHk=) | `100.00% <100.00%> (ø)` | |

farmio: Current implementation cancels the sending of a new value with GVWrite if it was sent via GVResponse while waiting for cooldown. 

Not 100% sure if this can cause troubles - probably depends on the receivers flags Settings. 

Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 5d6d6875..9f127541 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -8,9 +8,15 @@ nav_order: 2
 
 # Unreleased changes
 
-### Internal
+### Devices
+
+- ExposeSensor: Add `cooldown` option to allow rate-limiting of sent telegrams.
+
+### Internals
 
+- Add `task.done()` to TaskRegistry tasks.
 - Decouple KNXIPFrame parsing from CEMIFrame parsing. TunnellingRequest and RoutingIndication now carry the raw cemi frame payload as bytes. This allows decoupled CEMIFrame parsing at a later time (in Interface class rather than in KNXIPTransport class) for better error handling and upcoming features.
+- Make KNXIPFrame body non-optional. Return KNXIPFrame object and remaining bytes from `KNXIPFrame.from_knx()` staticmethod.
 
 # 2.1.0 Enhance notification device
 
diff --git a/xknx/core/task_registry.py b/xknx/core/task_registry.py
index ce88b8f7..7a753f33 100644
--- a/xknx/core/task_registry.py
+++ b/xknx/core/task_registry.py
@@ -47,6 +47,10 @@ class Task:
             self._task.cancel()
             self._task = None
 
+    def done(self) -> bool:
+        """Return if task is finished."""
+        return self._task is None or self._task.done()
+
     def connection_lost(self) -> None:
         """Cancel a task if connection was lost and the task should be cancelled if no connection is established."""
         if self.restart_after_reconnect and self._task:
diff --git a/xknx/devices/expose_sensor.py b/xknx/devices/expose_sensor.py
index af7a90a5..31cd4229 100644
--- a/xknx/devices/expose_sensor.py
+++ b/xknx/devices/expose_sensor.py
@@ -12,9 +12,11 @@ KNX devices may show this value within their display.)
 """
 from __future__ import annotations
 
+import asyncio
 from collections.abc import Iterator
 from typing import TYPE_CHECKING, Any
 
+from xknx.core import Task
 from xknx.remote_value import (
     GroupAddressesType,
     RemoteValue,
@@ -39,10 +41,12 @@ class ExposeSensor(Device):
         group_address: GroupAddressesType | None = None,
         respond_to_read: bool = True,
         value_type: int | str | None = None,
+        cooldown: float = 0,
         device_updated_cb: DeviceCallbackType[ExposeSensor] | None = None,
     ):
         """Initialize Sensor class."""
         super().__init__(xknx, name, device_updated_cb)
+        self.cooldown = cooldown
         self.respond_to_read = respond_to_read
         self.sensor_value: RemoteValueSensor | RemoteValueSwitch
         if value_type == "binary":
@@ -62,24 +66,56 @@ class ExposeSensor(Device):
                 after_update_cb=self.after_update,
                 value_type=value_type,
             )
+        self._cooldown_latest_value: Any | None = None
+        self._cooldown_task: Task | None = None
+        self._cooldown_task_name = f"expose_sensor.cooldown_{id(self)}"
+
+    async def after_update(self) -> None:
+        """Call after state was updated."""
+        self._cooldown_latest_value = self.sensor_value.value
+        await super().after_update()
 
     def _iter_remote_values(self) -> Iterator[RemoteValue[Any, Any]]:
         """Iterate the devices RemoteValue classes."""
         yield self.sensor_value
 
+    def _iter_tasks(self) -> Iterator[Task | None]:
+        """Iterate the device tasks."""
+        yield self._cooldown_task
+
     async def process_group_write(self, telegram: Telegram) -> None:
         """Process incoming and outgoing GROUP WRITE telegram."""
         await self.sensor_value.process(telegram)
 
     async def process_group_read(self, telegram: Telegram) -> None:
         """Process incoming GROUP READ telegram."""
-        if self.respond_to_read:
-            await self.sensor_value.respond()
+        if not self.respond_to_read:
+            return
+        if self._cooldown_latest_value is not None:
+            await self.sensor_value.set(self._cooldown_latest_value, response=True)
+            return
+        await self.sensor_value.respond()
 
     async def set(self, value: Any) -> None:
         """Set new value."""
+        if self.cooldown:
+            self._cooldown_latest_value = value
+            if self._cooldown_task is not None and not self._cooldown_task.done():
+                return
+            self._cooldown_task = self.xknx.task_registry.register(
+                name=self._cooldown_task_name,
+                async_func=self._cooldown_wait,
+            ).start()
         await self.sensor_value.set(value)
 
+    async def _cooldown_wait(self) -> None:
+        """Send value after cooldown if it differs from last processed value."""
+        while True:
+            await asyncio.sleep(self.cooldown)
+            if self.sensor_value.value == self._cooldown_latest_value:
+                break
+            await self.sensor_value.set(self._cooldown_latest_value)  # type: ignore[arg-type]
+
     def unit_of_measurement(self) -> str | None:
         """Return the unit of measurement."""
         return self.sensor_value.unit_of_measurement
diff --git a/xknx/io/ip_secure.py b/xknx/io/ip_secure.py
index d7781892..52ee86fa 100644
--- a/xknx/io/ip_secure.py
+++ b/xknx/io/ip_secure.py
@@ -104,8 +104,7 @@ class _IPSecureTransportLayer(ABC):
                 "Verification of message authentication code failed"
             )
 
-        knxipframe = KNXIPFrame()
-        knxipframe.from_knx(dec_frame)
+        knxipframe, _ = KNXIPFrame.from_knx(dec_frame)
         return knxipframe
 
     def encrypt_frame(self, plain_frame: KNXIPFrame) -> KNXIPFrame:
diff --git a/xknx/io/self_description.py b/xknx/io/self_description.py
index fe112d74..7b04b0ae 100644
--- a/xknx/io/self_description.py
+++ b/xknx/io/self_description.py
@@ -154,7 +154,7 @@ class _SelfDescriptionQuery(ABC):
             port=self.transport.remote_addr[1],
             local_ip=self.transport.getsockname()[0],
         )
-        gateway.parse_dibs(knxipframe.body.dibs)  # type: ignore[union-attr]
+        gateway.parse_dibs(knxipframe.body.dibs)  # type: ignore[attr-defined]
         self.gateway_descriptor = gateway
 
 
diff --git a/xknx/io/transport/tcp_transport.py b/xknx/io/transport/tcp_transport.py
index a0e114b9..80eb04d4 100644
--- a/xknx/io/transport/tcp_transport.py
+++ b/xknx/io/transport/tcp_transport.py
@@ -72,8 +72,7 @@ class TCPTransport(KNXIPTransport):
         if not raw:
             return
         try:
-            knxipframe = KNXIPFrame()
-            frame_length = knxipframe.from_knx(raw)
+            knxipframe, next_frame_part = KNXIPFrame.from_knx(raw)
         except IncompleteKNXIPFrame:
             self._buffer = raw
             raw_socket_logger.debug(
@@ -88,8 +87,6 @@ class TCPTransport(KNXIPTransport):
                 couldnotparseknxip.description,
                 raw.hex(),
             )
-            if not (frame_length := knxipframe.header.total_length):
-                return
         else:
             knx_logger.debug(
                 "Received from %s at %s:\n%s",
@@ -99,8 +96,8 @@ class TCPTransport(KNXIPTransport):
             )
             self.handle_knxipframe(knxipframe, self.remote_hpai)
         # parse data after current KNX/IP frame
-        if len(raw) > frame_length:
-            self.data_received_callback(raw[frame_length:])
+        if next_frame_part:
+            self.data_received_callback(next_frame_part)
 
     async def connect(self) -> None:
         """Connect TCP socket."""
diff --git a/xknx/io/transport/udp_transport.py b/xknx/io/transport/udp_transport.py
index ee51091a..1c601d3d 100644
--- a/xknx/io/transport/udp_transport.py
+++ b/xknx/io/transport/udp_transport.py
@@ -78,8 +78,7 @@ class UDPTransport(KNXIPTransport):
         """Parse and process KNXIP frame. Callback for having received an UDP packet."""
         if raw:
             try:
-                knxipframe = KNXIPFrame()
-                knxipframe.from_knx(raw)
+                knxipframe, _ = KNXIPFrame.from_knx(raw)
             except CouldNotParseKNXIP as couldnotparseknxip:
                 knx_logger.debug(
                     "Unsupported KNXIPFrame from %s:%s at %s: %s in %s",
diff --git a/xknx/knxip/body.py b/xknx/knxip/body.py
index 4f056992..2d86b8cd 100644
--- a/xknx/knxip/body.py
+++ b/xknx/knxip/body.py
@@ -2,14 +2,11 @@
 from __future__ import annotations
 
 from abc import ABC, abstractmethod
-import logging
 from typing import ClassVar, cast
 
 from .error_code import ErrorCode
 from .knxip_enum import KNXIPServiceType
 
-logger = logging.getLogger("xknx.log")
-
 
 class KNXIPBody(ABC):
     """Base class for all KNX/IP bodies."""
diff --git a/xknx/knxip/knxip.py b/xknx/knxip/knxip.py
index ada3e41d..4155d54f 100644
--- a/xknx/knxip/knxip.py
+++ b/xknx/knxip/knxip.py
@@ -39,97 +39,96 @@ from .tunnelling_request import TunnellingRequest
 class KNXIPFrame:
     """Class for KNX/IP Frames."""
 
-    def __init__(self) -> None:
+    def __init__(self, header: KNXIPHeader, body: KNXIPBody) -> None:
         """Initialize object."""
-        self.header = KNXIPHeader()
-        self.body: KNXIPBody | None = None
+        self.header = header
+        self.body = body
+
+    @staticmethod
+    def init_from_body(knxip_body: KNXIPBody) -> KNXIPFrame:
+        """Return KNXIPFrame from KNXIPBody."""
+        header = KNXIPHeader()
+        header.service_type_ident = knxip_body.__class__.SERVICE_TYPE
+        header.set_length(knxip_body)
+        return KNXIPFrame(header=header, body=knxip_body)
 
-    def init(self, service_type_ident: KNXIPServiceType) -> KNXIPBody:
-        """Init object by service_type_ident. Will instanciate a body object depending on service_type_ident."""
-        self.header.service_type_ident = service_type_ident
+    @staticmethod
+    def from_knx(data: bytes) -> tuple[KNXIPFrame, bytes]:
+        """
+        Parse/deserialize from KNX/IP raw data.
+
+        Returns a tuple of the KNXIPFrame and the rest of the data.
+
+        Raises IncompleteKNXIPFrame if the data is not enough to parse the KNXIPFrame
+        or CouldNotParseKNXIP on parsing error.
+        """
+        header = KNXIPHeader()
+        pos_body = header.from_knx(data)
+        if len(data) < header.total_length:
+            raise IncompleteKNXIPFrame("Incomplete data for KNXIPFrame")
+        # limit data to self.header.total_length for streaming socket data
+        raw_body = data[pos_body : header.total_length]
 
         body: KNXIPBody
         # Core
-        if service_type_ident == KNXIPServiceType.SEARCH_REQUEST:
+        if header.service_type_ident == KNXIPServiceType.SEARCH_REQUEST:
             body = SearchRequest()
-        elif service_type_ident == KNXIPServiceType.SEARCH_REQUEST_EXTENDED:
+        elif header.service_type_ident == KNXIPServiceType.SEARCH_REQUEST_EXTENDED:
             body = SearchRequestExtended()
-        elif service_type_ident == KNXIPServiceType.SEARCH_RESPONSE:
+        elif header.service_type_ident == KNXIPServiceType.SEARCH_RESPONSE:
             body = SearchResponse()
-        elif service_type_ident == KNXIPServiceType.SEARCH_RESPONSE_EXTENDED:
+        elif header.service_type_ident == KNXIPServiceType.SEARCH_RESPONSE_EXTENDED:
             body = SearchResponseExtended()
-        elif service_type_ident == KNXIPServiceType.DESCRIPTION_REQUEST:
+        elif header.service_type_ident == KNXIPServiceType.DESCRIPTION_REQUEST:
             body = DescriptionRequest()
-        elif service_type_ident == KNXIPServiceType.DESCRIPTION_RESPONSE:
+        elif header.service_type_ident == KNXIPServiceType.DESCRIPTION_RESPONSE:
             body = DescriptionResponse()
-        elif service_type_ident == KNXIPServiceType.CONNECT_REQUEST:
+        elif header.service_type_ident == KNXIPServiceType.CONNECT_REQUEST:
             body = ConnectRequest()
-        elif service_type_ident == KNXIPServiceType.CONNECT_RESPONSE:
+        elif header.service_type_ident == KNXIPServiceType.CONNECT_RESPONSE:
             body = ConnectResponse()
-        elif service_type_ident == KNXIPServiceType.CONNECTIONSTATE_REQUEST:
+        elif header.service_type_ident == KNXIPServiceType.CONNECTIONSTATE_REQUEST:
             body = ConnectionStateRequest()
-        elif service_type_ident == KNXIPServiceType.CONNECTIONSTATE_RESPONSE:
+        elif header.service_type_ident == KNXIPServiceType.CONNECTIONSTATE_RESPONSE:
             body = ConnectionStateResponse()
-        elif service_type_ident == KNXIPServiceType.DISCONNECT_REQUEST:
+        elif header.service_type_ident == KNXIPServiceType.DISCONNECT_REQUEST:
             body = DisconnectRequest()
-        elif service_type_ident == KNXIPServiceType.DISCONNECT_RESPONSE:
+        elif header.service_type_ident == KNXIPServiceType.DISCONNECT_RESPONSE:
             body = DisconnectResponse()
         # Tunneling
-        elif service_type_ident == KNXIPServiceType.TUNNELLING_REQUEST:
+        elif header.service_type_ident == KNXIPServiceType.TUNNELLING_REQUEST:
             body = TunnellingRequest()
-        elif service_type_ident == KNXIPServiceType.TUNNELLING_ACK:
+        elif header.service_type_ident == KNXIPServiceType.TUNNELLING_ACK:
             body = TunnellingAck()
         # Routing
-        elif service_type_ident == KNXIPServiceType.ROUTING_INDICATION:
+        elif header.service_type_ident == KNXIPServiceType.ROUTING_INDICATION:
             body = RoutingIndication()
-        elif service_type_ident == KNXIPServiceType.ROUTING_BUSY:
+        elif header.service_type_ident == KNXIPServiceType.ROUTING_BUSY:
             body = RoutingBusy()
-        elif service_type_ident == KNXIPServiceType.ROUTING_LOST_MESSAGE:
+        elif header.service_type_ident == KNXIPServiceType.ROUTING_LOST_MESSAGE:
             body = RoutingLostMessage()
         # Secure
-        elif service_type_ident == KNXIPServiceType.SECURE_WRAPPER:
+        elif header.service_type_ident == KNXIPServiceType.SECURE_WRAPPER:
             body = SecureWrapper()
-        elif service_type_ident == KNXIPServiceType.SESSION_AUTHENTICATE:
+        elif header.service_type_ident == KNXIPServiceType.SESSION_AUTHENTICATE:
             body = SessionAuthenticate()
-        elif service_type_ident == KNXIPServiceType.SESSION_REQUEST:
+        elif header.service_type_ident == KNXIPServiceType.SESSION_REQUEST:
             body = SessionRequest()
-        elif service_type_ident == KNXIPServiceType.SESSION_RESPONSE:
+        elif header.service_type_ident == KNXIPServiceType.SESSION_RESPONSE:
             body = SessionResponse()
-        elif service_type_ident == KNXIPServiceType.SESSION_STATUS:
+        elif header.service_type_ident == KNXIPServiceType.SESSION_STATUS:
             body = SessionStatus()
-        elif service_type_ident == KNXIPServiceType.TIMER_NOTIFY:
+        elif header.service_type_ident == KNXIPServiceType.TIMER_NOTIFY:
             body = TimerNotify()
         else:
             raise CouldNotParseKNXIP(
-                f"KNXIPServiceType not implemented: {service_type_ident.name}"
+                f"KNXIPServiceType not implemented: {header.service_type_ident.name}"
             )
-        self.body = body
-        return body
-
-    @staticmethod
-    def init_from_body(knxip_body: KNXIPBody) -> KNXIPFrame:
-        """Return KNXIPFrame from KNXIPBody."""
-        knxipframe = KNXIPFrame()
-        knxipframe.header.service_type_ident = knxip_body.__class__.SERVICE_TYPE
-        knxipframe.body = knxip_body
-        knxipframe.header.set_length(knxip_body)
-        return knxipframe
-
-    def from_knx(self, data: bytes) -> int:
-        """Parse/deserialize from KNX/IP raw data."""
-        pos = self.header.from_knx(data)
-        if len(data) < self.header.total_length:
-            raise IncompleteKNXIPFrame("Incomplete data for KNXIPFrame")
-        # limit data to self.header.total_length for streaming socket data
-        self.init(self.header.service_type_ident).from_knx(
-            data[pos : self.header.total_length]
-        )
-        return self.header.total_length
+        body.from_knx(raw_body)
+        return KNXIPFrame(header=header, body=body), data[header.total_length :]
 
     def to_knx(self) -> bytes:
         """Serialize to KNX/IP raw data."""
-        if self.body is None:
-            raise CouldNotParseKNXIP("No body defined in KNXIPFrame.")
         return self.header.to_knx() + self.body.to_knx()
 
     def __repr__(self) -> str:
diff --git a/xknx/knxip/tunnelling_ack.py b/xknx/knxip/tunnelling_ack.py
index 1f403922..69fd56a1 100644
--- a/xknx/knxip/tunnelling_ack.py
+++ b/xknx/knxip/tunnelling_ack.py
@@ -19,11 +19,16 @@ class TunnellingAck(KNXIPBodyResponse):
     SERVICE_TYPE = KNXIPServiceType.TUNNELLING_ACK
     BODY_LENGTH = 4
 
-    def __init__(self, communication_channel_id: int = 1, sequence_counter: int = 0):
+    def __init__(
+        self,
+        communication_channel_id: int = 1,
+        sequence_counter: int = 0,
+        status_code: ErrorCode = ErrorCode.E_NO_ERROR,
+    ):
         """Initialize TunnellingAck object."""
         self.communication_channel_id = communication_channel_id
         self.sequence_counter = sequence_counter
-        self.status_code = ErrorCode.E_NO_ERROR
+        self.status_code = status_code
 
     def calculated_length(self) -> int:
         """Get length of KNX/IP body."""


Test patch:
diff --git a/requirements/testing.txt b/requirements/testing.txt
index 3aa96588..f829302a 100644
--- a/requirements/testing.txt
+++ b/requirements/testing.txt
@@ -9,5 +9,5 @@ pytest==7.2.0
 pytest-asyncio==0.20.3
 pytest-cov==4.0.0
 setuptools==65.6.3
-tox==4.0.9
+tox==4.0.11
 mypy==0.991
diff --git a/test/core_tests/task_registry_test.py b/test/core_tests/task_registry_test.py
index 66a0cc48..f10e3874 100644
--- a/test/core_tests/task_registry_test.py
+++ b/test/core_tests/task_registry_test.py
@@ -25,8 +25,12 @@ class TestTaskRegistry:
             async_func=callback,
         )
         assert len(xknx.task_registry.tasks) == 1
+
         task.start()
+        assert not task.done()
+
         await xknx.task_registry.block_till_done()
+        assert task.done()
         assert len(xknx.task_registry.tasks) == 0
 
     async def test_unregister(self):
@@ -45,6 +49,7 @@ class TestTaskRegistry:
         task.start()
         xknx.task_registry.unregister(task.name)
         assert len(xknx.task_registry.tasks) == 0
+        assert task.done()
 
     #
     # TEST START/STOP
diff --git a/test/devices_tests/expose_sensor_test.py b/test/devices_tests/expose_sensor_test.py
index 49323bcf..724e03bf 100644
--- a/test/devices_tests/expose_sensor_test.py
+++ b/test/devices_tests/expose_sensor_test.py
@@ -1,5 +1,5 @@
 """Unit test for Sensor objects."""
-from unittest.mock import AsyncMock
+from unittest.mock import AsyncMock, call
 
 from xknx import XKNX
 from xknx.devices import ExposeSensor
@@ -214,3 +214,159 @@ class TestExposeSensor:
         await expose_sensor.set(21.0)
         await xknx.devices.process(xknx.telegrams.get_nowait())
         after_update_callback.assert_called_with(expose_sensor)
+
+    #
+    # TEST COOLDOWN
+    #
+    async def test_cooldown(self, time_travel):
+        """Test cooldown."""
+        xknx = XKNX()
+        xknx.knxip_interface = AsyncMock()
+        await xknx.telegram_queue.start()
+
+        expose_sensor_cd = ExposeSensor(
+            xknx,
+            "TestSensor",
+            group_address="1/2/3",
+            value_type="temperature",
+            cooldown=10,
+        )
+        expose_sensor_no_cd = ExposeSensor(
+            xknx,
+            "TestSensor",
+            group_address="1/2/4",
+            value_type="temperature",
+        )
+
+        await expose_sensor_cd.set(21.0)
+        await expose_sensor_no_cd.set(21.0)
+        await time_travel(0)
+        xknx.knxip_interface.send_telegram.assert_has_calls(
+            [
+                call(
+                    Telegram(
+                        destination_address=GroupAddress("1/2/3"),
+                        payload=GroupValueWrite(DPTArray((0x0C, 0x1A))),
+                    )
+                ),
+                call(
+                    Telegram(
+                        destination_address=GroupAddress("1/2/4"),
+                        payload=GroupValueWrite(DPTArray((0x0C, 0x1A))),
+                    )
+                ),
+            ]
+        )
+        xknx.knxip_interface.send_telegram.reset_mock()
+
+        # don't send telegram with same payload twice if cooldown is active
+        await expose_sensor_cd.set(21.0)
+        await expose_sensor_no_cd.set(21.0)
+        await time_travel(0)
+        xknx.knxip_interface.send_telegram.assert_has_calls(
+            [
+                call(
+                    Telegram(
+                        destination_address=GroupAddress("1/2/4"),
+                        payload=GroupValueWrite(DPTArray((0x0C, 0x1A))),
+                    )
+                ),
+            ]
+        )
+        xknx.knxip_interface.send_telegram.reset_mock()
+
+        await time_travel(10)
+        assert xknx.telegrams.qsize() == 0
+        xknx.knxip_interface.send_telegram.assert_not_called()
+
+        # different payload after cooldown
+        await expose_sensor_cd.set(10.0)
+        await expose_sensor_no_cd.set(10.0)
+        await time_travel(0)
+        xknx.knxip_interface.send_telegram.assert_has_calls(
+            [
+                call(
+                    Telegram(
+                        destination_address=GroupAddress("1/2/3"),
+                        payload=GroupValueWrite(DPTArray((0x03, 0xE8))),
+                    )
+                ),
+                call(
+                    Telegram(
+                        destination_address=GroupAddress("1/2/4"),
+                        payload=GroupValueWrite(DPTArray((0x03, 0xE8))),
+                    )
+                ),
+            ]
+        )
+        xknx.knxip_interface.send_telegram.reset_mock()
+        # different payload immediately (payload of 3.111 equals 3.11)
+        await expose_sensor_cd.set(3.111)
+        await expose_sensor_no_cd.set(3.111)
+        await time_travel(0)
+        xknx.knxip_interface.send_telegram.assert_has_calls(
+            [
+                call(
+                    Telegram(
+                        destination_address=GroupAddress("1/2/4"),
+                        payload=GroupValueWrite(DPTArray((0x01, 0x37))),
+                    )
+                ),
+            ]
+        )
+        xknx.knxip_interface.send_telegram.reset_mock()
+        assert expose_sensor_cd._cooldown_latest_value == 3.111
+        assert expose_sensor_cd.sensor_value.value == 10
+        await time_travel(10)
+        xknx.knxip_interface.send_telegram.assert_has_calls(
+            [
+                call(
+                    Telegram(
+                        destination_address=GroupAddress("1/2/3"),
+                        payload=GroupValueWrite(DPTArray((0x01, 0x37))),
+                    )
+                ),
+            ]
+        )
+        xknx.knxip_interface.send_telegram.reset_mock()
+        assert expose_sensor_cd._cooldown_latest_value == 3.11
+        assert expose_sensor_cd.sensor_value.value == 3.11
+        await time_travel(10)
+        xknx.knxip_interface.send_telegram.assert_not_called()
+
+        # reading unsent value
+        await expose_sensor_cd.set(10)
+        #   first send new value
+        await time_travel(0)
+        xknx.knxip_interface.send_telegram.assert_has_calls(
+            [
+                call(
+                    Telegram(
+                        destination_address=GroupAddress("1/2/3"),
+                        payload=GroupValueWrite(DPTArray((0x03, 0xE8))),
+                    )
+                ),
+            ]
+        )
+        xknx.knxip_interface.send_telegram.reset_mock()
+        #   in cooldown with a new value - receiving a read request
+        await expose_sensor_cd.set(21)
+        await expose_sensor_cd.process(
+            Telegram(GroupAddress("1/2/3"), payload=GroupValueRead())
+        )
+        await time_travel(0)
+        xknx.knxip_interface.send_telegram.assert_has_calls(
+            [
+                call(
+                    Telegram(
+                        destination_address=GroupAddress("1/2/3"),
+                        payload=GroupValueResponse(DPTArray((0x0C, 0x1A))),
+                    )
+                ),
+            ]
+        )
+        xknx.knxip_interface.send_telegram.reset_mock()
+        #   after cooldown - new value not sent again (already in GroupValueResponse)
+        await time_travel(10)
+        xknx.knxip_interface.send_telegram.assert_not_called()
+        await xknx.stop()
diff --git a/test/io_tests/gateway_scanner_test.py b/test/io_tests/gateway_scanner_test.py
index 27cb150d..fff0685e 100644
--- a/test/io_tests/gateway_scanner_test.py
+++ b/test/io_tests/gateway_scanner_test.py
@@ -137,8 +137,7 @@ class TestGatewayDescriptor:
     )
     def test_parser(self, raw, expected):
         """Test parsing GatewayDescriptor objects from real-world responses."""
-        response = KNXIPFrame()
-        response.from_knx(raw)
+        response, _ = KNXIPFrame.from_knx(raw)
         assert isinstance(response.body, (SearchResponse, SearchResponseExtended))
 
         descriptor = GatewayDescriptor(
diff --git a/test/io_tests/request_response_tests/authenticate_test.py b/test/io_tests/request_response_tests/authenticate_test.py
index ea0d2a48..2b423a47 100644
--- a/test/io_tests/request_response_tests/authenticate_test.py
+++ b/test/io_tests/request_response_tests/authenticate_test.py
@@ -4,6 +4,7 @@ from unittest.mock import Mock, patch
 from xknx.io.request_response import Authenticate
 from xknx.knxip import (
     HPAI,
+    ConnectionStateRequest,
     KNXIPFrame,
     KNXIPServiceType,
     SessionAuthenticate,
@@ -41,16 +42,14 @@ class TestAuthenticate:
         transport_mock.send.assert_called_with(exp_knxipframe)
 
         # Response KNX/IP-Frame with wrong type
-        wrong_knxipframe = KNXIPFrame()
-        wrong_knxipframe.init(KNXIPServiceType.CONNECTIONSTATE_REQUEST)
+        wrong_knxipframe = KNXIPFrame.init_from_body(ConnectionStateRequest())
         with patch("logging.Logger.warning") as mock_warning:
             authenticate.response_rec_callback(wrong_knxipframe, HPAI(), None)
             mock_warning.assert_called_with("Could not understand knxipframe")
             assert authenticate.success is False
 
         # Correct Response KNX/IP-Frame:
-        res_knxipframe = KNXIPFrame()
-        res_knxipframe.init(KNXIPServiceType.SESSION_STATUS)
+        res_knxipframe = KNXIPFrame.init_from_body(SessionStatus())
         res_knxipframe.body.status = (
             SecureSessionStatusCode.STATUS_AUTHENTICATION_SUCCESS
         )
diff --git a/test/io_tests/request_response_tests/connect_test.py b/test/io_tests/request_response_tests/connect_test.py
index 49997e22..f76cf6f5 100644
--- a/test/io_tests/request_response_tests/connect_test.py
+++ b/test/io_tests/request_response_tests/connect_test.py
@@ -5,12 +5,12 @@ from xknx.io.request_response import Connect
 from xknx.io.transport import UDPTransport
 from xknx.knxip import (
     HPAI,
+    ConnectionStateRequest,
     ConnectRequest,
     ConnectRequestType,
     ConnectResponse,
     ErrorCode,
     KNXIPFrame,
-    KNXIPServiceType,
 )
 
 
@@ -43,16 +43,15 @@ class TestConnect:
             mock_udp_send.assert_called_with(exp_knxipframe)
 
         # Response KNX/IP-Frame with wrong type
-        wrong_knxipframe = KNXIPFrame()
-        wrong_knxipframe.init(KNXIPServiceType.CONNECTIONSTATE_REQUEST)
+        wrong_knxipframe = KNXIPFrame.init_from_body(ConnectionStateRequest())
         with patch("logging.Logger.warning") as mock_warning:
             connect.response_rec_callback(wrong_knxipframe, HPAI(), None)
             mock_warning.assert_called_with("Could not understand knxipframe")
 
         # Response KNX/IP-Frame with error:
-        err_knxipframe = KNXIPFrame()
-        err_knxipframe.init(KNXIPServiceType.CONNECT_RESPONSE)
-        err_knxipframe.body.status_code = ErrorCode.E_CONNECTION_ID
+        err_knxipframe = KNXIPFrame.init_from_body(
+            ConnectResponse(status_code=ErrorCode.E_CONNECTION_ID)
+        )
         with patch("logging.Logger.debug") as mock_warning:
             connect.response_rec_callback(err_knxipframe, HPAI(), None)
             mock_warning.assert_called_with(
@@ -63,10 +62,12 @@ class TestConnect:
             )
 
         # Correct Response KNX/IP-Frame:
-        res_knxipframe = KNXIPFrame()
-        res_knxipframe.init(KNXIPServiceType.CONNECT_RESPONSE)
-        res_knxipframe.body.communication_channel = 23
-        res_knxipframe.body.identifier = 7
+        res_knxipframe = KNXIPFrame.init_from_body(
+            ConnectResponse(
+                communication_channel=23,
+                identifier=7,
+            )
+        )
         connect.response_rec_callback(res_knxipframe, HPAI(), None)
         assert connect.success
         assert connect.communication_channel == 23
@@ -93,16 +94,15 @@ class TestConnect:
             mock_udp_send.assert_called_with(exp_knxipframe)
 
         # Response KNX/IP-Frame with wrong type
-        wrong_knxipframe = KNXIPFrame()
-        wrong_knxipframe.init(KNXIPServiceType.CONNECTIONSTATE_REQUEST)
+        wrong_knxipframe = KNXIPFrame.init_from_body(ConnectionStateRequest())
         with patch("logging.Logger.warning") as mock_warning:
             connect.response_rec_callback(wrong_knxipframe, HPAI(), None)
             mock_warning.assert_called_with("Could not understand knxipframe")
 
         # Response KNX/IP-Frame with error:
-        err_knxipframe = KNXIPFrame()
-        err_knxipframe.init(KNXIPServiceType.CONNECT_RESPONSE)
-        err_knxipframe.body.status_code = ErrorCode.E_CONNECTION_ID
+        err_knxipframe = KNXIPFrame.init_from_body(
+            ConnectResponse(status_code=ErrorCode.E_CONNECTION_ID)
+        )
         with patch("logging.Logger.debug") as mock_warning:
             connect.response_rec_callback(err_knxipframe, HPAI(), None)
             mock_warning.assert_called_with(
@@ -113,10 +113,12 @@ class TestConnect:
             )
 
         # Correct Response KNX/IP-Frame:
-        res_knxipframe = KNXIPFrame()
-        res_knxipframe.init(KNXIPServiceType.CONNECT_RESPONSE)
-        res_knxipframe.body.communication_channel = 23
-        res_knxipframe.body.identifier = 7
+        res_knxipframe = KNXIPFrame.init_from_body(
+            ConnectResponse(
+                communication_channel=23,
+                identifier=7,
+            )
+        )
         connect.response_rec_callback(res_knxipframe, HPAI(), None)
         assert connect.success
         assert connect.communication_channel == 23
diff --git a/test/io_tests/request_response_tests/connectionstate_test.py b/test/io_tests/request_response_tests/connectionstate_test.py
index 49d8d462..9938f1f2 100644
--- a/test/io_tests/request_response_tests/connectionstate_test.py
+++ b/test/io_tests/request_response_tests/connectionstate_test.py
@@ -9,7 +9,6 @@ from xknx.knxip import (
     ConnectionStateResponse,
     ErrorCode,
     KNXIPFrame,
-    KNXIPServiceType,
 )
 
 
@@ -44,16 +43,15 @@ class TestConnectionState:
             mock_udp_send.assert_called_with(exp_knxipframe)
 
         # Response KNX/IP-Frame with wrong type
-        wrong_knxipframe = KNXIPFrame()
-        wrong_knxipframe.init(KNXIPServiceType.CONNECTIONSTATE_REQUEST)
+        wrong_knxipframe = KNXIPFrame.init_from_body(ConnectionStateRequest())
         with patch("logging.Logger.warning") as mock_warning:
             connectionstate.response_rec_callback(wrong_knxipframe, HPAI(), None)
             mock_warning.assert_called_with("Could not understand knxipframe")
 
         # Response KNX/IP-Frame with error:
-        err_knxipframe = KNXIPFrame()
-        err_knxipframe.init(KNXIPServiceType.CONNECTIONSTATE_RESPONSE)
-        err_knxipframe.body.status_code = ErrorCode.E_CONNECTION_ID
+        err_knxipframe = KNXIPFrame.init_from_body(
+            ConnectionStateResponse(status_code=ErrorCode.E_CONNECTION_ID)
+        )
         with patch("logging.Logger.debug") as mock_warning:
             connectionstate.response_rec_callback(err_knxipframe, HPAI(), None)
             mock_warning.assert_called_with(
@@ -64,8 +62,7 @@ class TestConnectionState:
             )
 
         # Correct Response KNX/IP-Frame:
-        res_knxipframe = KNXIPFrame()
-        res_knxipframe.init(KNXIPServiceType.CONNECTIONSTATE_RESPONSE)
+        res_knxipframe = KNXIPFrame.init_from_body(ConnectionStateResponse())
         connectionstate.response_rec_callback(res_knxipframe, HPAI(), None)
         assert connectionstate.success
 
@@ -96,16 +93,15 @@ class TestConnectionState:
             mock_udp_send.assert_called_with(exp_knxipframe)
 
         # Response KNX/IP-Frame with wrong type
-        wrong_knxipframe = KNXIPFrame()
-        wrong_knxipframe.init(KNXIPServiceType.CONNECTIONSTATE_REQUEST)
+        wrong_knxipframe = KNXIPFrame.init_from_body(ConnectionStateRequest())
         with patch("logging.Logger.warning") as mock_warning:
             connectionstate.response_rec_callback(wrong_knxipframe, HPAI(), None)
             mock_warning.assert_called_with("Could not understand knxipframe")
 
         # Response KNX/IP-Frame with error:
-        err_knxipframe = KNXIPFrame()
-        err_knxipframe.init(KNXIPServiceType.CONNECTIONSTATE_RESPONSE)
-        err_knxipframe.body.status_code = ErrorCode.E_CONNECTION_ID
+        err_knxipframe = KNXIPFrame.init_from_body(
+            ConnectionStateResponse(status_code=ErrorCode.E_CONNECTION_ID)
+        )
         with patch("logging.Logger.debug") as mock_warning:
             connectionstate.response_rec_callback(err_knxipframe, HPAI(), None)
             mock_warning.assert_called_with(
@@ -116,7 +112,6 @@ class TestConnectionState:
             )
 
         # Correct Response KNX/IP-Frame:
-        res_knxipframe = KNXIPFrame()
-        res_knxipframe.init(KNXIPServiceType.CONNECTIONSTATE_RESPONSE)
+        res_knxipframe = KNXIPFrame.init_from_body(ConnectionStateResponse())
         connectionstate.response_rec_callback(res_knxipframe, HPAI(), None)
         assert connectionstate.success
diff --git a/test/io_tests/request_response_tests/disconnect_test.py b/test/io_tests/request_response_tests/disconnect_test.py
index e693fc0c..da05922b 100644
--- a/test/io_tests/request_response_tests/disconnect_test.py
+++ b/test/io_tests/request_response_tests/disconnect_test.py
@@ -9,7 +9,6 @@ from xknx.knxip import (
     DisconnectResponse,
     ErrorCode,
     KNXIPFrame,
-    KNXIPServiceType,
 )
 
 
@@ -44,16 +43,15 @@ class TestDisconnect:
             mock_udp_send.assert_called_with(exp_knxipframe)
 
         # Response KNX/IP-Frame with wrong type
-        wrong_knxipframe = KNXIPFrame()
-        wrong_knxipframe.init(KNXIPServiceType.DISCONNECT_REQUEST)
+        wrong_knxipframe = KNXIPFrame.init_from_body(DisconnectRequest())
         with patch("logging.Logger.warning") as mock_warning:
             disconnect.response_rec_callback(wrong_knxipframe, HPAI(), None)
             mock_warning.assert_called_with("Could not understand knxipframe")
 
         # Response KNX/IP-Frame with error:
-        err_knxipframe = KNXIPFrame()
-        err_knxipframe.init(KNXIPServiceType.DISCONNECT_RESPONSE)
-        err_knxipframe.body.status_code = ErrorCode.E_CONNECTION_ID
+        err_knxipframe = KNXIPFrame.init_from_body(
+            DisconnectResponse(status_code=ErrorCode.E_CONNECTION_ID)
+        )
         with patch("logging.Logger.debug") as mock_warning:
             disconnect.response_rec_callback(err_knxipframe, HPAI(), None)
             mock_warning.assert_called_with(
@@ -64,8 +62,7 @@ class TestDisconnect:
             )
 
         # Correct Response KNX/IP-Frame:
-        res_knxipframe = KNXIPFrame()
-        res_knxipframe.init(KNXIPServiceType.DISCONNECT_RESPONSE)
+        res_knxipframe = KNXIPFrame.init_from_body(DisconnectResponse())
         disconnect.response_rec_callback(res_knxipframe, HPAI(), None)
         assert disconnect.success
 
@@ -96,16 +93,15 @@ class TestDisconnect:
             mock_udp_send.assert_called_with(exp_knxipframe)
 
         # Response KNX/IP-Frame with wrong type
-        wrong_knxipframe = KNXIPFrame()
-        wrong_knxipframe.init(KNXIPServiceType.DISCONNECT_REQUEST)
+        wrong_knxipframe = KNXIPFrame.init_from_body(DisconnectRequest())
         with patch("logging.Logger.warning") as mock_warning:
             disconnect.response_rec_callback(wrong_knxipframe, HPAI(), None)
             mock_warning.assert_called_with("Could not understand knxipframe")
 
         # Response KNX/IP-Frame with error:
-        err_knxipframe = KNXIPFrame()
-        err_knxipframe.init(KNXIPServiceType.DISCONNECT_RESPONSE)
-        err_knxipframe.body.status_code = ErrorCode.E_CONNECTION_ID
+        err_knxipframe = KNXIPFrame.init_from_body(
+            DisconnectResponse(status_code=ErrorCode.E_CONNECTION_ID)
+        )
         with patch("logging.Logger.debug") as mock_warning:
             disconnect.response_rec_callback(err_knxipframe, HPAI(), None)
             mock_warning.assert_called_with(
@@ -116,7 +112,6 @@ class TestDisconnect:
             )
 
         # Correct Response KNX/IP-Frame:
-        res_knxipframe = KNXIPFrame()
-        res_knxipframe.init(KNXIPServiceType.DISCONNECT_RESPONSE)
+        res_knxipframe = KNXIPFrame.init_from_body(DisconnectResponse())
         disconnect.response_rec_callback(res_knxipframe, HPAI(), None)
         assert disconnect.success
diff --git a/test/io_tests/request_response_tests/session_test.py b/test/io_tests/request_response_tests/session_test.py
index c00f2e83..ce823ffe 100644
--- a/test/io_tests/request_response_tests/session_test.py
+++ b/test/io_tests/request_response_tests/session_test.py
@@ -2,13 +2,7 @@
 from unittest.mock import Mock, patch
 
 from xknx.io.request_response import Session
-from xknx.knxip import (
-    HPAI,
-    KNXIPFrame,
-    KNXIPServiceType,
-    SessionRequest,
-    SessionResponse,
-)
+from xknx.knxip import HPAI, KNXIPFrame, SessionRequest, SessionResponse, SessionStatus
 
 
 class TestSession:
@@ -35,17 +29,14 @@ class TestSession:
         transport_mock.send.assert_called_with(exp_knxipframe)
 
         # Response KNX/IP-Frame with wrong type
-        wrong_knxipframe = KNXIPFrame()
-        wrong_knxipframe.init(KNXIPServiceType.SESSION_STATUS)
+        wrong_knxipframe = KNXIPFrame.init_from_body(SessionStatus())
         with patch("logging.Logger.warning") as mock_warning:
             session.response_rec_callback(wrong_knxipframe, HPAI(), None)
             mock_warning.assert_called_with("Could not understand knxipframe")
             assert session.success is False
 
         # Correct Response KNX/IP-Frame:
-        res_knxipframe = KNXIPFrame()
-        res_knxipframe.init(KNXIPServiceType.SESSION_RESPONSE)
-        res_knxipframe.body.secure_session_id = 5
+        res_knxipframe = KNXIPFrame.init_from_body(SessionResponse(secure_session_id=5))
         session.response_rec_callback(res_knxipframe, HPAI(), None)
         assert session.success is True
         assert session.response.secure_session_id == 5
diff --git a/test/io_tests/request_response_tests/tunnelling_test.py b/test/io_tests/request_response_tests/tunnelling_test.py
index ea45d048..ea52e1dd 100644
--- a/test/io_tests/request_response_tests/tunnelling_test.py
+++ b/test/io_tests/request_response_tests/tunnelling_test.py
@@ -7,9 +7,9 @@ from xknx.io.transport import UDPTransport
 from xknx.knxip import (
     HPAI,
     CEMIFrame,
+    ConnectionStateRequest,
     ErrorCode,
     KNXIPFrame,
-    KNXIPServiceType,
     TunnellingAck,
     TunnellingRequest,
 )
@@ -49,16 +49,15 @@ class TestTunnelling:
             mock_udp_send.assert_called_with(exp_knxipframe, addr=data_endpoint)
 
         # Response KNX/IP-Frame with wrong type
-        wrong_knxipframe = KNXIPFrame()
-        wrong_knxipframe.init(KNXIPServiceType.CONNECTIONSTATE_REQUEST)
+        wrong_knxipframe = KNXIPFrame.init_from_body(ConnectionStateRequest())
         with patch("logging.Logger.warning") as mock_warning:
             tunnelling.response_rec_callback(wrong_knxipframe, HPAI(), None)
             mock_warning.assert_called_with("Could not understand knxipframe")
 
         # Response KNX/IP-Frame with error:
-        err_knxipframe = KNXIPFrame()
-        err_knxipframe.init(KNXIPServiceType.TUNNELLING_ACK)
-        err_knxipframe.body.status_code = ErrorCode.E_CONNECTION_ID
+        err_knxipframe = KNXIPFrame.init_from_body(
+            TunnellingAck(status_code=ErrorCode.E_CONNECTION_ID)
+        )
         with patch("logging.Logger.debug") as mock_warning:
             tunnelling.response_rec_callback(err_knxipframe, HPAI(), None)
             mock_warning.assert_called_with(
@@ -69,7 +68,6 @@ class TestTunnelling:
             )
 
         # Correct Response KNX/IP-Frame:
-        res_knxipframe = KNXIPFrame()
-        res_knxipframe.init(KNXIPServiceType.TUNNELLING_ACK)
+        res_knxipframe = KNXIPFrame.init_from_body(TunnellingAck())
         tunnelling.response_rec_callback(res_knxipframe, HPAI(), None)
         assert tunnelling.success
diff --git a/test/io_tests/transport_tests/ip_transport_test.py b/test/io_tests/transport_tests/ip_transport_test.py
index e382f790..d844789c 100644
--- a/test/io_tests/transport_tests/ip_transport_test.py
+++ b/test/io_tests/transport_tests/ip_transport_test.py
@@ -2,7 +2,13 @@
 from unittest.mock import Mock, patch
 
 from xknx.io.transport import KNXIPTransport
-from xknx.knxip import HPAI, KNXIPFrame, KNXIPServiceType
+from xknx.knxip import (
+    HPAI,
+    ConnectionStateRequest,
+    ConnectionStateResponse,
+    KNXIPFrame,
+    KNXIPServiceType,
+)
 
 
 class TestKNXIPTransport:
@@ -20,16 +26,14 @@ class TestKNXIPTransport:
         )
         assert len(transport.callbacks) == 1
         # Handle KNX/IP frame with different service type
-        wrong_service_type_frame = KNXIPFrame()
-        wrong_service_type_frame.header.service_type_ident = (
-            KNXIPServiceType.CONNECTIONSTATE_REQUEST
+        wrong_service_type_frame = KNXIPFrame.init_from_body(
+            ConnectionStateRequest(),
         )
         transport.handle_knxipframe(wrong_service_type_frame, HPAI())
         callback_mock.assert_not_called()
         # Handle KNX/IP frame with correct service type
-        correct_service_type_frame = KNXIPFrame()
-        correct_service_type_frame.header.service_type_ident = (
-            KNXIPServiceType.CONNECTIONSTATE_RESPONSE
+        correct_service_type_frame = KNXIPFrame.init_from_body(
+            ConnectionStateResponse(),
         )
         transport.handle_knxipframe(correct_service_type_frame, HPAI())
         callback_mock.assert_called_once_with(
diff --git a/test/knxip_tests/connect_request_test.py b/test/knxip_tests/connect_request_test.py
index 7505ccf9..12ffca75 100644
--- a/test/knxip_tests/connect_request_test.py
+++ b/test/knxip_tests/connect_request_test.py
@@ -14,8 +14,7 @@ class TestKNXIPConnectRequest:
             "06 10 02 05 00 1A 08 01 C0 A8 2A 01 84 95 08 01"
             "C0 A8 2A 01 CC A9 04 04 02 00"
         )
-        knxipframe = KNXIPFrame()
-        knxipframe.from_knx(raw)
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
 
         assert isinstance(knxipframe.body, ConnectRequest)
         assert knxipframe.body.request_type == ConnectRequestType.TUNNEL_CONNECTION
@@ -39,9 +38,8 @@ class TestKNXIPConnectRequest:
             "06 10 02 05 00 1A 08 01 C0 A8 2A 01 84 95 08 01"
             "C0 A8 2A 01 CC A9 02 04 02 00"
         )
-        knxipframe = KNXIPFrame()
         with pytest.raises(CouldNotParseKNXIP):
-            knxipframe.from_knx(raw)
+            KNXIPFrame.from_knx(raw)
 
     def test_from_knx_wrong_cri(self):
         """Test parsing and streaming wrong ConnectRequest."""
@@ -49,6 +47,5 @@ class TestKNXIPConnectRequest:
             "06 10 02 05 00 1A 08 01 C0 A8 2A 01 84 95 08 01"
             "C0 A8 2A 01 CC A9 04 04 02"
         )
-        knxipframe = KNXIPFrame()
         with pytest.raises(CouldNotParseKNXIP):
-            knxipframe.from_knx(raw)
+            KNXIPFrame.from_knx(raw)
diff --git a/test/knxip_tests/connect_response_test.py b/test/knxip_tests/connect_response_test.py
index 7c2323eb..be19bab8 100644
--- a/test/knxip_tests/connect_response_test.py
+++ b/test/knxip_tests/connect_response_test.py
@@ -13,8 +13,7 @@ class TestKNXIPConnectResponse:
         raw = bytes.fromhex(
             "06 10 02 06 00 14 01 00 08 01 C0 A8 2A 0A 0E 57 04 04 11 FF"
         )
-        knxipframe = KNXIPFrame()
-        knxipframe.from_knx(raw)
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
         assert isinstance(knxipframe.body, ConnectResponse)
         assert knxipframe.body.communication_channel == 1
         assert knxipframe.body.status_code == ErrorCode.E_NO_ERROR
@@ -38,16 +37,14 @@ class TestKNXIPConnectResponse:
         raw = bytes.fromhex(
             "06 10 02 06 00 14 01 00 08 01 C0 A8 2A 0A 0E 57 03 04 11 FF"
         )
-        knxipframe = KNXIPFrame()
         with pytest.raises(CouldNotParseKNXIP):
-            knxipframe.from_knx(raw)
+            KNXIPFrame.from_knx(raw)
 
     def test_from_knx_wrong_crd2(self):
         """Test parsing and streaming wrong ConnectRequest (wrong CRD length)."""
         raw = bytes.fromhex("06 10 02 06 00 14 01 00 08 01 C0 A8 2A 0A 0E 57 04 04")
-        knxipframe = KNXIPFrame()
         with pytest.raises(CouldNotParseKNXIP):
-            knxipframe.from_knx(raw)
+            KNXIPFrame.from_knx(raw)
 
     def test_connect_response_connection_error_gira(self):
         """
@@ -58,8 +55,7 @@ class TestKNXIPConnectResponse:
         raw = bytes.fromhex(
             "06 10 02 06 00 14 C0 24 08 01 0A 01 00 29 0E 57 04 04 00 00"
         )
-        knxipframe = KNXIPFrame()
-        knxipframe.from_knx(raw)
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
         assert isinstance(knxipframe.body, ConnectResponse)
         assert knxipframe.body.status_code == ErrorCode.E_NO_MORE_CONNECTIONS
         assert knxipframe.body.communication_channel == 192
@@ -84,8 +80,7 @@ class TestKNXIPConnectResponse:
         raw = bytes.fromhex(
             "06 10 02 06 00 14 00 24 08 01 C0 A8 01 01 0E 57 00 00 00 00"
         )
-        knxipframe = KNXIPFrame()
-        knxipframe.from_knx(raw)
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
         assert isinstance(knxipframe.body, ConnectResponse)
         assert knxipframe.body.status_code == ErrorCode.E_NO_MORE_CONNECTIONS
         assert knxipframe.body.communication_channel == 0
@@ -99,8 +94,7 @@ class TestKNXIPConnectResponse:
         HPAI and CRD all zero. This was received from MDT device (2020).
         """
         raw = bytes.fromhex("06 10 02 06 00 08 00 24 00 00 00 00 00 00 00 00 00 00")
-        knxipframe = KNXIPFrame()
-        knxipframe.from_knx(raw)
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
         assert isinstance(knxipframe.body, ConnectResponse)
         assert knxipframe.body.status_code == ErrorCode.E_NO_MORE_CONNECTIONS
         assert knxipframe.body.communication_channel == 0
diff --git a/test/knxip_tests/connectionstate_request_test.py b/test/knxip_tests/connectionstate_request_test.py
index 886861ac..a750eb4b 100644
--- a/test/knxip_tests/connectionstate_request_test.py
+++ b/test/knxip_tests/connectionstate_request_test.py
@@ -11,8 +11,7 @@ class TestKNXIPConnectionStateRequest:
     def test_connection_state_request(self):
         """Test parsing and streaming connection state request KNX/IP packet."""
         raw = bytes.fromhex("06 10 02 07 00 10 15 00 08 01 C0 A8 C8 0C C3 B4")
-        knxipframe = KNXIPFrame()
-        knxipframe.from_knx(raw)
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
 
         assert isinstance(knxipframe.body, ConnectionStateRequest)
 
@@ -32,6 +31,5 @@ class TestKNXIPConnectionStateRequest:
     def test_from_knx_wrong_info(self):
         """Test parsing and streaming wrong ConnectionStateRequest."""
         raw = bytes((0x06, 0x10, 0x02, 0x07, 0x00, 0x010))
-        knxipframe = KNXIPFrame()
         with pytest.raises(CouldNotParseKNXIP):
-            knxipframe.from_knx(raw)
+            KNXIPFrame.from_knx(raw)
diff --git a/test/knxip_tests/connectionstate_response_test.py b/test/knxip_tests/connectionstate_response_test.py
index b1dc1fc0..3741ce2e 100644
--- a/test/knxip_tests/connectionstate_response_test.py
+++ b/test/knxip_tests/connectionstate_response_test.py
@@ -11,8 +11,7 @@ class TestKNXIPConnectionStateResponse:
     def test_disconnect_response(self):
         """Test parsing and streaming connection state response KNX/IP packet."""
         raw = bytes((0x06, 0x10, 0x02, 0x08, 0x00, 0x08, 0x15, 0x21))
-        knxipframe = KNXIPFrame()
-        knxipframe.from_knx(raw)
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
 
         assert isinstance(knxipframe.body, ConnectionStateResponse)
 
@@ -30,6 +29,5 @@ class TestKNXIPConnectionStateResponse:
     def test_from_knx_wrong_header(self):
         """Test parsing and streaming wrong ConnectionStateResponse (wrong header length)."""
         raw = bytes((0x06, 0x10, 0x02, 0x08, 0x00, 0x08, 0x15))
-        knxipframe = KNXIPFrame()
         with pytest.raises(CouldNotParseKNXIP):
-            knxipframe.from_knx(raw)
+            KNXIPFrame.from_knx(raw)
diff --git a/test/knxip_tests/description_request_test.py b/test/knxip_tests/description_request_test.py
index 798171a2..a43298a2 100644
--- a/test/knxip_tests/description_request_test.py
+++ b/test/knxip_tests/description_request_test.py
@@ -8,8 +8,7 @@ class TestKNXIPDescriptionRequest:
     def test_description_request(self):
         """Test parsing and streaming DescriptionRequest KNX/IP packet."""
         raw = bytes.fromhex("06 10 02 03 00 0E 08 01 7F 00 00 02 0E 57")
-        knxipframe = KNXIPFrame()
-        knxipframe.from_knx(raw)
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
 
         assert isinstance(knxipframe.body, DescriptionRequest)
         assert knxipframe.body.control_endpoint == HPAI(ip_addr="127.0.0.2", port=3671)
diff --git a/test/knxip_tests/description_response_test.py b/test/knxip_tests/description_response_test.py
index 8e00bf8e..2aa013e0 100644
--- a/test/knxip_tests/description_response_test.py
+++ b/test/knxip_tests/description_response_test.py
@@ -20,8 +20,9 @@ class TestKNXIPDescriptionResponse:
             "00 00 00 00 00 00 00 00 00 00 00 00 0c 02 02 02"
             "03 02 04 02 05 02 07 01",
         )
-        knxipframe = KNXIPFrame()
-        assert knxipframe.from_knx(raw) == 72
+        knxipframe, rest = KNXIPFrame.from_knx(raw)
+        assert not rest
+        assert knxipframe.header.total_length == 72
         assert knxipframe.to_knx() == raw
 
         assert isinstance(knxipframe.body, DescriptionResponse)
diff --git a/test/knxip_tests/disconnect_request_test.py b/test/knxip_tests/disconnect_request_test.py
index a5d51c12..2fbcfa4e 100644
--- a/test/knxip_tests/disconnect_request_test.py
+++ b/test/knxip_tests/disconnect_request_test.py
@@ -11,8 +11,7 @@ class TestKNXIPDisconnectRequest:
     def test_disconnect_request(self):
         """Test parsing and streaming DisconnectRequest KNX/IP packet."""
         raw = bytes.fromhex("06 10 02 09 00 10 15 00 08 01 C0 A8 C8 0C C3 B4")
-        knxipframe = KNXIPFrame()
-        knxipframe.from_knx(raw)
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
 
         assert isinstance(knxipframe.body, DisconnectRequest)
 
@@ -32,6 +31,5 @@ class TestKNXIPDisconnectRequest:
     def test_from_knx_wrong_length(self):
         """Test parsing and streaming wrong DisconnectRequest."""
         raw = bytes((0x06, 0x10, 0x02, 0x09, 0x00, 0x10))
-        knxipframe = KNXIPFrame()
         with pytest.raises(CouldNotParseKNXIP):
-            knxipframe.from_knx(raw)
+            KNXIPFrame.from_knx(raw)
diff --git a/test/knxip_tests/disconnect_response_test.py b/test/knxip_tests/disconnect_response_test.py
index fb47c4aa..483a9650 100644
--- a/test/knxip_tests/disconnect_response_test.py
+++ b/test/knxip_tests/disconnect_response_test.py
@@ -11,8 +11,7 @@ class TestKNXIPDisconnectResponse:
     def test_disconnect_response(self):
         """Test parsing and streaming DisconnectResponse KNX/IP packet."""
         raw = bytes((0x06, 0x10, 0x02, 0x0A, 0x00, 0x08, 0x15, 0x25))
-        knxipframe = KNXIPFrame()
-        knxipframe.from_knx(raw)
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
 
         assert isinstance(knxipframe.body, DisconnectResponse)
 
@@ -30,6 +29,5 @@ class TestKNXIPDisconnectResponse:
     def test_from_knx_wrong_length(self):
         """Test parsing and streaming wrong DisconnectResponse."""
         raw = bytes((0x06, 0x10, 0x02, 0x0A, 0x00, 0x08, 0x15))
-        knxipframe = KNXIPFrame()
         with pytest.raises(CouldNotParseKNXIP):
-            knxipframe.from_knx(raw)
+            KNXIPFrame.from_knx(raw)
diff --git a/test/knxip_tests/knxip_test.py b/test/knxip_tests/knxip_test.py
index c20ce49c..5d404e62 100644
--- a/test/knxip_tests/knxip_test.py
+++ b/test/knxip_tests/knxip_test.py
@@ -2,7 +2,7 @@
 import pytest
 
 from xknx.exceptions import CouldNotParseKNXIP, IncompleteKNXIPFrame
-from xknx.knxip import KNXIPFrame
+from xknx.knxip import KNXIPFrame, KNXIPHeader
 from xknx.knxip.knxip_enum import KNXIPServiceType
 
 
@@ -11,35 +11,27 @@ class TestKNXIPFrame:
 
     def test_wrong_init(self):
         """Testing init method with wrong service_type_ident."""
-        knxipframe = KNXIPFrame()
-        with pytest.raises(AttributeError):
-            knxipframe.init(23)
-
+        header = KNXIPHeader()
+        header.service_type_ident = KNXIPServiceType.REMOTE_DIAG_RESPONSE
         with pytest.raises(CouldNotParseKNXIP):
             # this is not yet implemented in xknx
-            knxipframe.init(KNXIPServiceType.REMOTE_DIAG_RESPONSE)
+            KNXIPFrame.from_knx(header.to_knx())
 
     def test_double_frame(self):
         """Test parsing KNX/IP frame from streaming data containing two frames."""
-        knxipframe = KNXIPFrame()
         raw = bytes.fromhex(
             "06 10 04 20 00 15 04 02 51 00 29 00 bc e0 10 fa"
             "09 2d 01 00 80"
             "06 10 04 20 00 15 04 02 52 00 29 00 bc e0 10 1f"
             "08 2d 01 00 80"
         )  # both frames have lenght 21
-        assert knxipframe.from_knx(raw) == 21
-        assert knxipframe.from_knx(raw[21:]) == 21
+        frame_1, rest_1 = KNXIPFrame.from_knx(raw)
+        frame_2, rest_2 = KNXIPFrame.from_knx(rest_1)
+        assert frame_1.header.total_length == 21
+        assert frame_2.header.total_length == 21
 
     def test_parsing_too_short_knxip(self):
         """Test parsing and streaming connection state request KNX/IP packet."""
         raw = bytes.fromhex("06 10 02 07 00 10 15 00 08 01 C0 A8 C8 0C C3")
-        knxipframe = KNXIPFrame()
         with pytest.raises(IncompleteKNXIPFrame):
-            knxipframe.from_knx(raw)
-
-    def test_to_knx_no_body(self):
-        """Test to_knx method without body raises exception."""
-        knxipframe = KNXIPFrame()
-        with pytest.raises(CouldNotParseKNXIP):
-            knxipframe.to_knx()
+            KNXIPFrame.from_knx(raw)
diff --git a/test/knxip_tests/routing_busy_test.py b/test/knxip_tests/routing_busy_test.py
index 40ab6e57..b63f8bee 100644
--- a/test/knxip_tests/routing_busy_test.py
+++ b/test/knxip_tests/routing_busy_test.py
@@ -13,8 +13,7 @@ class TestKNXIPRoutingBusy:
         raw = bytes(
             (0x06, 0x10, 0x05, 0x32, 0x00, 0x0C, 0x06, 0x00, 0x00, 0x64, 0x00, 0x00)
         )
-        knxipframe = KNXIPFrame()
-        knxipframe.from_knx(raw)
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
 
         assert isinstance(knxipframe.body, RoutingBusy)
         assert knxipframe.body.device_state == 0
@@ -31,13 +30,11 @@ class TestKNXIPRoutingBusy:
         raw = bytes(
             (0x06, 0x10, 0x05, 0x32, 0x00, 0x0C, 0x08, 0x00, 0x00, 0x64, 0x00, 0x00)
         )
-        knxipframe = KNXIPFrame()
         with pytest.raises(CouldNotParseKNXIP):
-            knxipframe.from_knx(raw)
+            KNXIPFrame.from_knx(raw)
 
     def test_from_knx_wrong_busy_information2(self):
         """Test parsing and streaming wrong RoutingBusy (wrong length)."""
         raw = bytes((0x06, 0x10, 0x05, 0x32, 0x00, 0x0C, 0x06, 0x00, 0x00, 0x64, 0x00))
-        knxipframe = KNXIPFrame()
         with pytest.raises(CouldNotParseKNXIP):
-            knxipframe.from_knx(raw)
+            KNXIPFrame.from_knx(raw)
diff --git a/test/knxip_tests/routing_indication_test.py b/test/knxip_tests/routing_indication_test.py
index 7b09d46e..156e4742 100644
--- a/test/knxip_tests/routing_indication_test.py
+++ b/test/knxip_tests/routing_indication_test.py
@@ -13,8 +13,7 @@ class TestKNXIPRountingIndication:
     def test_from_knx(self):
         """Test parsing and streaming CEMIFrame KNX/IP packet (payload=0xf0)."""
         raw = bytes.fromhex("06 10 05 30 00 12 29 00 bc d0 12 02 01 51 02 00 40 f0")
-        knxipframe = KNXIPFrame()
-        knxipframe.from_knx(raw)
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
 
         assert isinstance(knxipframe.body, RoutingIndication)
         assert isinstance(knxipframe.body.raw_cemi, bytes)
@@ -23,8 +22,7 @@ class TestKNXIPRountingIndication:
     def test_from_knx_to_knx(self):
         """Test parsing and streaming CEMIFrame KNX/IP."""
         raw = bytes.fromhex("06 10 05 30 00 12 29 00 bc d0 12 02 01 51 02 00 40 f0")
-        knxipframe = KNXIPFrame()
-        knxipframe.from_knx(raw)
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
 
         assert knxipframe.header.to_knx() == raw[0:6]
         assert knxipframe.body.to_knx() == raw[6:]
@@ -56,8 +54,7 @@ class TestKNXIPRountingIndication:
         """Test parsing and streaming RoutingIndication KNX/IP packet."""
         # Switch off Kitchen-L1
         raw = bytes.fromhex("06 10 05 30 00 11 29 00 bc d0 ff f9 01 49 01 00 80")
-        knxipframe = KNXIPFrame()
-        knxipframe.from_knx(raw)
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
         raw_cemi = knxipframe.body.raw_cemi
 
         routing_indication = RoutingIndication(raw_cemi=raw_cemi)
diff --git a/test/knxip_tests/routing_lost_message_test.py b/test/knxip_tests/routing_lost_message_test.py
index 3c7a4051..4de57bc6 100644
--- a/test/knxip_tests/routing_lost_message_test.py
+++ b/test/knxip_tests/routing_lost_message_test.py
@@ -11,8 +11,7 @@ class TestKNXIPRoutingLostMessage:
     def test_routing_lost_message(self):
         """Test parsing and streaming RoutingLostMessage KNX/IP packet."""
         raw = bytes((0x06, 0x10, 0x05, 0x31, 0x00, 0x0A, 0x04, 0x00, 0x00, 0x05))
-        knxipframe = KNXIPFrame()
-        knxipframe.from_knx(raw)
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
 
         assert isinstance(knxipframe.body, RoutingLostMessage)
         assert knxipframe.body.device_state == 0
@@ -26,13 +25,11 @@ class TestKNXIPRoutingLostMessage:
     def test_from_knx_wrong_lost_message_information(self):
         """Test parsing and streaming wrong RoutingLostMessage (wrong length byte)."""
         raw = bytes((0x06, 0x10, 0x05, 0x31, 0x00, 0x0A, 0x06, 0x00, 0x00, 0x05))
-        knxipframe = KNXIPFrame()
         with pytest.raises(CouldNotParseKNXIP):
-            knxipframe.from_knx(raw)
+            KNXIPFrame.from_knx(raw)
 
     def test_from_knx_wrong_lost_message_information2(self):
         """Test parsing and streaming wrong RoutingLostMessage (wrong length)."""
         raw = bytes((0x06, 0x10, 0x05, 0x31, 0x00, 0x0A, 0x04, 0x00, 0x00))
-        knxipframe = KNXIPFrame()
         with pytest.raises(CouldNotParseKNXIP):
-            knxipframe.from_knx(raw)
+            KNXIPFrame.from_knx(raw)
diff --git a/test/knxip_tests/search_request_extended_test.py b/test/knxip_tests/search_request_extended_test.py
index e76e0161..adf1a753 100644
--- a/test/knxip_tests/search_request_extended_test.py
+++ b/test/knxip_tests/search_request_extended_test.py
@@ -9,8 +9,7 @@ class TestKNXIPSearchRequestExtended:
     def test_search_request_extended(self):
         """Test parsing and streaming SearchRequest Extended KNX/IP packet."""
         raw = bytes.fromhex("06 10 02 0b 00 0e 08 01 e0 00 17 0C 0E 57")
-        knxipframe = KNXIPFrame()
-        knxipframe.from_knx(raw)
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
 
         assert isinstance(knxipframe.body, SearchRequestExtended)
         assert knxipframe.body.discovery_endpoint == HPAI(
@@ -28,8 +27,7 @@ class TestKNXIPSearchRequestExtended:
     def test_search_request_extended_srp(self):
         """Test parsing and streaming SearchRequest Extended with SRPs KNX/IP packet."""
         raw = bytes.fromhex("06 10 02 0b 00 10 08 01 e0 00 17 0c 0e 57 02 81")
-        knxipframe = KNXIPFrame()
-        knxipframe.from_knx(raw)
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
 
         assert isinstance(knxipframe.body, SearchRequestExtended)
         assert knxipframe.body.discovery_endpoint == HPAI(
@@ -52,8 +50,7 @@ class TestKNXIPSearchRequestExtended:
         raw = bytes.fromhex(
             "06 10 02 0b 00 18 08 01 e0 00 17 0c 0e 57 02 81 08 82 01 02 03 04 05 06"
         )
-        knxipframe = KNXIPFrame()
-        knxipframe.from_knx(raw)
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
 
         assert isinstance(knxipframe.body, SearchRequestExtended)
         assert knxipframe.body.discovery_endpoint == HPAI(
diff --git a/test/knxip_tests/search_request_test.py b/test/knxip_tests/search_request_test.py
index 74da66bc..1b9f0168 100644
--- a/test/knxip_tests/search_request_test.py
+++ b/test/knxip_tests/search_request_test.py
@@ -8,8 +8,7 @@ class TestKNXIPSearchRequest:
     def test_search_request(self):
         """Test parsing and streaming SearchRequest KNX/IP packet."""
         raw = bytes.fromhex("06 10 02 01 00 0E 08 01 E0 00 17 0C 0E 57")
-        knxipframe = KNXIPFrame()
-        knxipframe.from_knx(raw)
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
 
         assert isinstance(knxipframe.body, SearchRequest)
         assert knxipframe.body.discovery_endpoint == HPAI(
diff --git a/test/knxip_tests/search_response_extended_test.py b/test/knxip_tests/search_response_extended_test.py
index ed45111a..172f16e8 100644
--- a/test/knxip_tests/search_response_extended_test.py
+++ b/test/knxip_tests/search_response_extended_test.py
@@ -21,8 +21,9 @@ class TestKNXIPSearchResponseExtended:
             "50 2D 52 6F 75 74 65 72 00 00 00 00 00 00 00 00"
             "00 00 00 00 0C 02 02 01 03 02 04 01 05 01 07 01"
         )
-        knxipframe = KNXIPFrame()
-        assert knxipframe.from_knx(raw) == 80
+        knxipframe, rest = KNXIPFrame.from_knx(raw)
+        assert knxipframe.header.total_length == 80
+        assert not rest
         assert knxipframe.to_knx() == raw
 
         assert isinstance(knxipframe.body, SearchResponseExtended)
diff --git a/test/knxip_tests/search_response_test.py b/test/knxip_tests/search_response_test.py
index c3c766ab..60eaedec 100644
--- a/test/knxip_tests/search_response_test.py
+++ b/test/knxip_tests/search_response_test.py
@@ -21,8 +21,9 @@ class TestKNXIPSearchResponse:
             "50 2D 52 6F 75 74 65 72 00 00 00 00 00 00 00 00"
             "00 00 00 00 0C 02 02 01 03 02 04 01 05 01 07 01"
         )
-        knxipframe = KNXIPFrame()
-        assert knxipframe.from_knx(raw) == 80
+        knxipframe, rest = KNXIPFrame.from_knx(raw)
+        assert knxipframe.header.total_length == 80
+        assert not rest
         assert knxipframe.to_knx() == raw
 
         assert isinstance(knxipframe.body, SearchResponse)
diff --git a/test/knxip_tests/secure_wrapper_test.py b/test/knxip_tests/secure_wrapper_test.py
index 73cca920..43d73161 100644
--- a/test/knxip_tests/secure_wrapper_test.py
+++ b/test/knxip_tests/secure_wrapper_test.py
@@ -29,8 +29,7 @@ class TestKNXIPSecureWrapper:
             + encrypted_data
             + message_authentication_code
         )
-        knxipframe = KNXIPFrame()
-        knxipframe.from_knx(raw)
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
 
         assert isinstance(knxipframe.body, SecureWrapper)
         assert knxipframe.body.secure_session_id == 1
diff --git a/test/knxip_tests/session_authenticate_test.py b/test/knxip_tests/session_authenticate_test.py
index 183631ad..9a90f1b8 100644
--- a/test/knxip_tests/session_authenticate_test.py
+++ b/test/knxip_tests/session_authenticate_test.py
@@ -15,8 +15,7 @@ class TestKNXIPSessionAuthenticate:
             bytes.fromhex("06 10 09 53 00 18" "00 01")  # KNXnet/IP header  # User ID
             + message_authentication_code
         )
-        knxipframe = KNXIPFrame()
-        knxipframe.from_knx(raw)
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
 
         assert isinstance(knxipframe.body, SessionAuthenticate)
         assert knxipframe.body.user_id == 1
diff --git a/test/knxip_tests/session_request_test.py b/test/knxip_tests/session_request_test.py
index 2241ea3a..407d2773 100644
--- a/test/knxip_tests/session_request_test.py
+++ b/test/knxip_tests/session_request_test.py
@@ -21,8 +21,7 @@ class TestKNXIPSessionRequest:
             )
             + public_key
         )
-        knxipframe = KNXIPFrame()
-        knxipframe.from_knx(raw)
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
 
         assert isinstance(knxipframe.body, SessionRequest)
         assert knxipframe.body.control_endpoint == HPAI(protocol=HostProtocol.IPV4_TCP)
diff --git a/test/knxip_tests/session_response_test.py b/test/knxip_tests/session_response_test.py
index a0571da6..8946a884 100644
--- a/test/knxip_tests/session_response_test.py
+++ b/test/knxip_tests/session_response_test.py
@@ -25,8 +25,7 @@ class TestKNXIPSessionResponse:
             + public_key
             + message_authentication_code
         )
-        knxipframe = KNXIPFrame()
-        knxipframe.from_knx(raw)
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
 
         assert isinstance(knxipframe.body, SessionResponse)
         assert knxipframe.body.secure_session_id == 1
diff --git a/test/knxip_tests/session_status_test.py b/test/knxip_tests/session_status_test.py
index e423530d..65f3592c 100644
--- a/test/knxip_tests/session_status_test.py
+++ b/test/knxip_tests/session_status_test.py
@@ -13,8 +13,7 @@ class TestKNXIPSessionStatus:
             "00"  # status code 00h STATUS_AUTHENTICATION_SUCCESS
             "00"  # reserved
         )
-        knxipframe = KNXIPFrame()
-        knxipframe.from_knx(raw)
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
 
         assert isinstance(knxipframe.body, SessionStatus)
         assert (
diff --git a/test/knxip_tests/timer_notify_test.py b/test/knxip_tests/timer_notify_test.py
index 51c3c6be..ad8e900a 100644
--- a/test/knxip_tests/timer_notify_test.py
+++ b/test/knxip_tests/timer_notify_test.py
@@ -19,8 +19,7 @@ class TestKNXIPTimerNotify:
             )
             + message_authentication_code
         )
-        knxipframe = KNXIPFrame()
-        knxipframe.from_knx(raw)
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
 
         assert isinstance(knxipframe.body, TimerNotify)
         assert knxipframe.body.timer_value == 211938428830917
diff --git a/test/knxip_tests/tunnelling_ack_test.py b/test/knxip_tests/tunnelling_ack_test.py
index be0ce273..86f2caf4 100644
--- a/test/knxip_tests/tunnelling_ack_test.py
+++ b/test/knxip_tests/tunnelling_ack_test.py
@@ -11,8 +11,7 @@ class TestKNXIPTunnellingAck:
     def test_tunnelling_ack(self):
         """Test parsing and streaming tunneling ACK KNX/IP packet."""
         raw = bytes((0x06, 0x10, 0x04, 0x21, 0x00, 0x0A, 0x04, 0x2A, 0x17, 0x00))
-        knxipframe = KNXIPFrame()
-        knxipframe.from_knx(raw)
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
 
         assert isinstance(knxipframe.body, TunnellingAck)
         assert knxipframe.body.communication_channel_id == 42
@@ -30,13 +29,11 @@ class TestKNXIPTunnellingAck:
     def test_from_knx_wrong_ack_information(self):
         """Test parsing and streaming wrong TunnellingAck (wrong length byte)."""
         raw = bytes((0x06, 0x10, 0x04, 0x21, 0x00, 0x0A, 0x03, 0x2A, 0x17, 0x00))
-        knxipframe = KNXIPFrame()
         with pytest.raises(CouldNotParseKNXIP):
-            knxipframe.from_knx(raw)
+            KNXIPFrame.from_knx(raw)
 
     def test_from_knx_wrong_ack_information2(self):
         """Test parsing and streaming wrong TunnellingAck (wrong length)."""
         raw = bytes((0x06, 0x10, 0x04, 0x21, 0x00, 0x0A, 0x04, 0x2A, 0x17))
-        knxipframe = KNXIPFrame()
         with pytest.raises(CouldNotParseKNXIP):
-            knxipframe.from_knx(raw)
+            KNXIPFrame.from_knx(raw)
diff --git a/test/knxip_tests/tunnelling_request_test.py b/test/knxip_tests/tunnelling_request_test.py
index 5c359630..434b8ab9 100644
--- a/test/knxip_tests/tunnelling_request_test.py
+++ b/test/knxip_tests/tunnelling_request_test.py
@@ -16,8 +16,7 @@ class TestKNXIPTunnellingRequest:
         raw = bytes.fromhex(
             "06 10 04 20 00 15 04 01 17 00 11 00 BC E0 00 00 48 08 01 00 81"
         )
-        knxipframe = KNXIPFrame()
-        knxipframe.from_knx(raw)
+        knxipframe, _ = KNXIPFrame.from_knx(raw)
 
         assert isinstance(knxipframe.body, TunnellingRequest)
         assert knxipframe.body.communication_channel_id == 1
@@ -48,13 +47,11 @@ class TestKNXIPTunnellingRequest:
     def test_from_knx_wrong_header(self):
         """Test parsing and streaming wrong TunnellingRequest (wrong header length byte)."""
         raw = bytes((0x06, 0x10, 0x04, 0x20, 0x00, 0x15, 0x03))
-        knxipframe = KNXIPFrame()
         with pytest.raises(CouldNotParseKNXIP):
-            knxipframe.from_knx(raw)
+            KNXIPFrame.from_knx(raw)
 
     def test_from_knx_wrong_header2(self):
         """Test parsing and streaming wrong TunnellingRequest (wrong header length)."""
         raw = bytes((0x06, 0x10, 0x04, 0x20, 0x00, 0x15, 0x04))
-        knxipframe = KNXIPFrame()
         with pytest.raises(CouldNotParseKNXIP):
-            knxipframe.from_knx(raw)
+            KNXIPFrame.from_knx(raw)
diff --git a/test/str_test.py b/test/str_test.py
index 14d3a342..0cfd21c5 100644
--- a/test/str_test.py
+++ b/test/str_test.py
@@ -642,25 +642,25 @@ class TestStringRepresentations:
     def test_cemi_frame(self):
         """Test string representation of KNX/IP CEMI Frame."""
         cemi_frame = CEMIFrame()
-        cemi_frame.src_addr = GroupAddress("1/2/3")
+        cemi_frame.src_addr = IndividualAddress("1.2.3")
         cemi_frame.telegram = Telegram(
             destination_address=GroupAddress("1/2/5"),
             payload=GroupValueWrite(DPTBinary(7)),
         )
         assert (
             str(cemi_frame)
-            == '<CEMIFrame code="L_DATA_IND" src_addr="GroupAddress("1/2/3")" dst_addr="GroupAddress("1/2/5")" '
+            == '<CEMIFrame code="L_DATA_IND" src_addr="IndividualAddress("1.2.3")" dst_addr="GroupAddress("1/2/5")" '
             'flags="1011110011100000" tpci="TDataGroup()" payload="<GroupValueWrite value="<DPTBinary value="7" />" />" />'
         )
 
     def test_knxip_frame(self):
         """Test string representation of KNX/IP Frame."""
-        knxipframe = KNXIPFrame()
-        knxipframe.init(KNXIPServiceType.SEARCH_REQUEST)
+        search_request = SearchRequest()
+        knxipframe = KNXIPFrame.init_from_body(search_request)
         assert (
             str(knxipframe)
             == '<KNXIPFrame <KNXIPHeader HeaderLength="6" ProtocolVersion="16" KNXIPServiceType="SEARCH_REQUEST" '
-            'Reserve="0" TotalLength="0" />\n'
+            'Reserve="0" TotalLength="14" />\n'
             ' body="<SearchRequest discovery_endpoint="0.0.0.0:0/udp" />" />'
         )