Repository: XKNX/xknx
Pull request #1038 (2022-09-26 18:47:41)

Add RoutingLostMessage
<!--
  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.
-->
Add RoutingLostMessage frame parser, log a warning when receiving one.

Fixes # (issue)

## 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/1038?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) Report
> Merging [#1038](https://codecov.io/gh/XKNX/xknx/pull/1038?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) (047bea4) into [main](https://codecov.io/gh/XKNX/xknx/commit/c88296603b4715ec6a589688d1b3ae95224bd375?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) (c882966) will **increase** coverage by `0.01%`.
> The diff coverage is `96.29%`.

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

```diff
@@            Coverage Diff             @@
##             main    #1038      +/-   ##
==========================================
+ Coverage   96.41%   96.42%   +0.01%     
==========================================
  Files         136      137       +1     
  Lines        8307     8334      +27     
==========================================
+ Hits         8009     8036      +27     
  Misses        298      298              
```


| [Impacted Files](https://codecov.io/gh/XKNX/xknx/pull/1038?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) | Coverage Δ | |
|---|---|---|
| [xknx/knxip/routing\_lost\_message.py](https://codecov.io/gh/XKNX/xknx/pull/1038/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9rbnhpcC9yb3V0aW5nX2xvc3RfbWVzc2FnZS5weQ==) | `95.45% <95.45%> (ø)` | |
| [xknx/io/routing.py](https://codecov.io/gh/XKNX/xknx/pull/1038/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9pby9yb3V0aW5nLnB5) | `89.47% <100.00%> (+0.97%)` | :arrow_up: |
| [xknx/knxip/\_\_init\_\_.py](https://codecov.io/gh/XKNX/xknx/pull/1038/diff?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.py](https://codecov.io/gh/XKNX/xknx/pull/1038/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9rbnhpcC9rbnhpcC5weQ==) | `100.00% <100.00%> (ø)` | |


Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 02f03e95..9312243e 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -19,6 +19,7 @@ nav_order: 2
 ### Protocol
 
 - Add RoutingBusy frame parser
+- Add RoutingLostMessage frame parser
 
 ## 1.0.2 Route-back reconnect 2022-08-31
 
diff --git a/xknx/io/routing.py b/xknx/io/routing.py
index 12fb9c84..1e5699f5 100644
--- a/xknx/io/routing.py
+++ b/xknx/io/routing.py
@@ -22,6 +22,7 @@ from xknx.knxip import (
     KNXIPServiceType,
     RoutingBusy,
     RoutingIndication,
+    RoutingLostMessage,
 )
 from xknx.telegram import Telegram, TelegramDirection
 
@@ -84,7 +85,7 @@ class _RoutingFlowControl:
         previous_busy_frame_time = self._last_busy_frame_time
         self._last_busy_frame_time = now
         if self._wait_start_time is None:
-            logger.warning(
+            logger.info(
                 "RoutingBusy received: %s",
                 routing_busy,
             )
@@ -149,6 +150,7 @@ class Routing(Interface):
             [
                 KNXIPServiceType.ROUTING_INDICATION,
                 KNXIPServiceType.ROUTING_BUSY,
+                KNXIPServiceType.ROUTING_LOST_MESSAGE,
             ],
         )
         self._flow_control = _RoutingFlowControl()
@@ -227,6 +229,12 @@ class Routing(Interface):
             self._handle_routing_indication(knxipframe.body)
         elif isinstance(knxipframe.body, RoutingBusy):
             self._flow_control.handle_routing_busy(knxipframe.body)
+        elif isinstance(knxipframe.body, RoutingLostMessage):
+            logger.warning(
+                "RoutingLostMessage received from %s - %s lost messages.",
+                source.ip_addr,
+                knxipframe.body.lost_messages,
+            )
         else:
             logger.warning("Service not implemented: %s", knxipframe)
 
diff --git a/xknx/knxip/__init__.py b/xknx/knxip/__init__.py
index 99431368..6e787ffd 100644
--- a/xknx/knxip/__init__.py
+++ b/xknx/knxip/__init__.py
@@ -35,6 +35,7 @@ from .knxip_enum import (
 )
 from .routing_busy import RoutingBusy
 from .routing_indication import RoutingIndication
+from .routing_lost_message import RoutingLostMessage
 from .search_request import SearchRequest
 from .search_request_extended import SearchRequestExtended
 from .search_response import SearchResponse
@@ -80,6 +81,7 @@ __all__ = [
     "KNXMedium",
     "RoutingBusy",
     "RoutingIndication",
+    "RoutingLostMessage",
     "SearchRequest",
     "SearchRequestExtended",
     "SearchRequestParameterType",
diff --git a/xknx/knxip/knxip.py b/xknx/knxip/knxip.py
index e4ebf0c7..5d024059 100644
--- a/xknx/knxip/knxip.py
+++ b/xknx/knxip/knxip.py
@@ -21,6 +21,7 @@ from .header import KNXIPHeader
 from .knxip_enum import KNXIPServiceType
 from .routing_busy import RoutingBusy
 from .routing_indication import RoutingIndication
+from .routing_lost_message import RoutingLostMessage
 from .search_request import SearchRequest
 from .search_request_extended import SearchRequestExtended
 from .search_response import SearchResponse
@@ -82,6 +83,8 @@ class KNXIPFrame:
             body = RoutingIndication()
         elif service_type_ident == KNXIPServiceType.ROUTING_BUSY:
             body = RoutingBusy()
+        elif service_type_ident == KNXIPServiceType.ROUTING_LOST_MESSAGE:
+            body = RoutingLostMessage()
         # Secure
         elif service_type_ident == KNXIPServiceType.SECURE_WRAPPER:
             body = SecureWrapper()
diff --git a/xknx/knxip/routing_lost_message.py b/xknx/knxip/routing_lost_message.py
new file mode 100644
index 00000000..364e453a
--- /dev/null
+++ b/xknx/knxip/routing_lost_message.py
@@ -0,0 +1,55 @@
+"""
+Module for Serialization and Deserialization of KNX RoutingLostMessage frames.
+
+RoutingLostMessage frames are used for system supervision.
+"""
+from __future__ import annotations
+
+from xknx.exceptions import CouldNotParseKNXIP
+
+from .body import KNXIPBody
+from .knxip_enum import KNXIPServiceType
+
+
+class RoutingLostMessage(KNXIPBody):
+    """Representation of a KNX RoutingLostMessage frame."""
+
+    SERVICE_TYPE = KNXIPServiceType.ROUTING_LOST_MESSAGE
+    BODY_LENGTH = 4
+
+    def __init__(
+        self,
+        device_state: int = 0,
+        lost_messages: int = 1,
+    ):
+        """Initialize RoutingLostMessage object."""
+        self.device_state = device_state
+        self.lost_messages = lost_messages
+
+    def calculated_length(self) -> int:
+        """Get length of KNX/IP body."""
+        return RoutingLostMessage.BODY_LENGTH
+
+    def from_knx(self, raw: bytes) -> int:
+        """Parse/deserialize from KNX/IP raw data."""
+        if raw[0] != RoutingLostMessage.BODY_LENGTH:  # structure_length field
+            raise CouldNotParseKNXIP("RoutingLostMessage body has invalid length")
+        if len(raw) != RoutingLostMessage.BODY_LENGTH:
+            raise CouldNotParseKNXIP("RoutingLostMessage has wrong length")
+        self.device_state = raw[1]
+        self.lost_messages = raw[2] * 256 + raw[3]
+        return RoutingLostMessage.BODY_LENGTH
+
+    def to_knx(self) -> bytes:
+        """Serialize to KNX/IP raw data."""
+        return bytes(
+            (RoutingLostMessage.BODY_LENGTH, self.device_state)
+        ) + self.lost_messages.to_bytes(2, "big")
+
+    def __repr__(self) -> str:
+        """Return object as readable string."""
+        return (
+            "<RoutingLostMessage "
+            f"device_state={self.device_state} "
+            f"lost_messages={self.lost_messages} />"
+        )


Test patch:
diff --git a/test/io_tests/routing_test.py b/test/io_tests/routing_test.py
index e9157655..f80fd56c 100644
--- a/test/io_tests/routing_test.py
+++ b/test/io_tests/routing_test.py
@@ -62,6 +62,22 @@ class TestRouting:
             call(response_frame),
         ]
 
+    @patch("logging.Logger.warning")
+    async def test_routing_lost_message(self, logging_mock):
+        """Test class for received RoutingLostMessage frames."""
+        routing = Routing(
+            self.xknx,
+            AsyncMock(),
+            local_ip="192.168.1.1",
+        )
+        raw = bytes((0x06, 0x10, 0x05, 0x31, 0x00, 0x0A, 0x04, 0x00, 0x00, 0x05))
+        routing.udp_transport.data_received_callback(raw, ("192.168.1.2", 3671))
+        assert logging_mock.called_once_with(
+            "RoutingLostMessage received from %s - %s lost messages.",
+            "192.168.1.2",
+            5,
+        )
+
 
 class TestFlowControl:
     """Test class for KNXnet/IP routing flow control."""
diff --git a/test/knxip_tests/routing_busy_test.py b/test/knxip_tests/routing_busy_test.py
index 62f204b4..40ab6e57 100644
--- a/test/knxip_tests/routing_busy_test.py
+++ b/test/knxip_tests/routing_busy_test.py
@@ -1,4 +1,4 @@
-"""Unit test for KNX/IP TunnellingAck objects."""
+"""Unit test for KNX/IP RoutingBusy objects."""
 import pytest
 
 from xknx.exceptions import CouldNotParseKNXIP
diff --git a/test/knxip_tests/routing_lost_message_test.py b/test/knxip_tests/routing_lost_message_test.py
new file mode 100644
index 00000000..3c7a4051
--- /dev/null
+++ b/test/knxip_tests/routing_lost_message_test.py
@@ -0,0 +1,38 @@
+"""Unit test for KNX/IP RoutingLostMessage objects."""
+import pytest
+
+from xknx.exceptions import CouldNotParseKNXIP
+from xknx.knxip import KNXIPFrame, RoutingLostMessage
+
+
+class TestKNXIPRoutingLostMessage:
+    """Test class for KNX/IP RoutingLostMessage objects."""
+
+    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)
+
+        assert isinstance(knxipframe.body, RoutingLostMessage)
+        assert knxipframe.body.device_state == 0
+        assert knxipframe.body.lost_messages == 5
+
+        routing_lost_message = RoutingLostMessage(lost_messages=5)
+        knxipframe2 = KNXIPFrame.init_from_body(routing_lost_message)
+
+        assert knxipframe2.to_knx() == raw
+
+    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)
+
+    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)