Repository: XKNX/xknx
Pull request #1042 (2022-09-30 06:13:43)

Add TimerNotify frame parser
<!--
  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 TimerNotify frame parser

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/1042?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) Report
> Merging [#1042](https://codecov.io/gh/XKNX/xknx/pull/1042?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) (365bb42) into [main](https://codecov.io/gh/XKNX/xknx/commit/951e85b856289d1d2cc1a75c2bde7e87ee531971?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) (951e85b) will **increase** coverage by `0.00%`.
> The diff coverage is `96.55%`.

> :exclamation: Current head 365bb42 differs from pull request most recent head c170f08. Consider uploading reports for the commit c170f08 to get more accurate results

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

```diff
@@           Coverage Diff           @@
##             main    #1042   +/-   ##
=======================================
  Coverage   96.42%   96.42%           
=======================================
  Files         137      138    +1     
  Lines        8334     8363   +29     
=======================================
+ Hits         8036     8064   +28     
- Misses        298      299    +1     
```


| [Impacted Files](https://codecov.io/gh/XKNX/xknx/pull/1042?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) | Coverage Δ | |
|---|---|---|
| [xknx/knxip/timer\_notify.py](https://codecov.io/gh/XKNX/xknx/pull/1042/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9rbnhpcC90aW1lcl9ub3RpZnkucHk=) | `96.00% <96.00%> (ø)` | |
| [xknx/knxip/\_\_init\_\_.py](https://codecov.io/gh/XKNX/xknx/pull/1042/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/1042/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 ff3d1514..0bc6d12d 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -6,6 +6,12 @@ nav_order: 2
 
 # Changelog
 
+# Unreleased changes
+
+### Protocol
+
+- Add TimerNotify frame parser
+
 ## 1.1.0 Routing flow control 2022-09-26
 
 ### Added
diff --git a/xknx/knxip/__init__.py b/xknx/knxip/__init__.py
index 6e787ffd..aeeeb50f 100644
--- a/xknx/knxip/__init__.py
+++ b/xknx/knxip/__init__.py
@@ -46,6 +46,7 @@ from .session_request import SessionRequest
 from .session_response import SessionResponse
 from .session_status import SessionStatus
 from .srp import SRP
+from .timer_notify import TimerNotify
 from .tunnelling_ack import TunnellingAck
 from .tunnelling_request import TunnellingRequest
 
@@ -93,6 +94,7 @@ __all__ = [
     "SessionResponse",
     "SessionStatus",
     "SRP",
+    "TimerNotify",
     "TunnellingAck",
     "TunnellingRequest",
 ]
diff --git a/xknx/knxip/knxip.py b/xknx/knxip/knxip.py
index 5d024059..ada3e41d 100644
--- a/xknx/knxip/knxip.py
+++ b/xknx/knxip/knxip.py
@@ -31,6 +31,7 @@ from .session_authenticate import SessionAuthenticate
 from .session_request import SessionRequest
 from .session_response import SessionResponse
 from .session_status import SessionStatus
+from .timer_notify import TimerNotify
 from .tunnelling_ack import TunnellingAck
 from .tunnelling_request import TunnellingRequest
 
@@ -96,6 +97,8 @@ class KNXIPFrame:
             body = SessionResponse()
         elif service_type_ident == KNXIPServiceType.SESSION_STATUS:
             body = SessionStatus()
+        elif service_type_ident == KNXIPServiceType.TIMER_NOTIFY:
+            body = TimerNotify()
         else:
             raise CouldNotParseKNXIP(
                 f"KNXIPServiceType not implemented: {service_type_ident.name}"
diff --git a/xknx/knxip/timer_notify.py b/xknx/knxip/timer_notify.py
new file mode 100644
index 00000000..448d6125
--- /dev/null
+++ b/xknx/knxip/timer_notify.py
@@ -0,0 +1,67 @@
+"""
+Module for Serialization and Deserialization of KNX Timer Notify.
+
+This frame shall be sent during secure KNXnet/IP multicast group communication
+to keep the multicast group member's timer values synchronized.
+"""
+from __future__ import annotations
+
+from typing import Final
+
+from xknx.exceptions import CouldNotParseKNXIP
+
+from .body import KNXIPBody
+from .knxip_enum import KNXIPServiceType
+
+
+class TimerNotify(KNXIPBody):
+    """Representation of a KNX Timer Notify."""
+
+    SERVICE_TYPE = KNXIPServiceType.TIMER_NOTIFY
+    LENGTH: Final = 30
+
+    def __init__(
+        self,
+        timer_value: int = 0,
+        serial_number: bytes = bytes(6),
+        message_tag: bytes = bytes(2),
+        message_authentication_code: bytes = bytes(16),
+    ):
+        """Initialize TimerNotify object."""
+        self.timer_value = timer_value
+        self.serial_number = serial_number
+        self.message_tag = message_tag
+        self.message_authentication_code = message_authentication_code
+
+    def calculated_length(self) -> int:
+        """Get length of KNX/IP body."""
+        return TimerNotify.LENGTH
+
+    def from_knx(self, raw: bytes) -> int:
+        """Parse/deserialize from KNX/IP raw data."""
+        if len(raw) != TimerNotify.LENGTH:
+            raise CouldNotParseKNXIP("TimerNotify has wrong length")
+        self.timer_value = int.from_bytes(raw[:6], "big")
+        self.serial_number = raw[6:12]
+        self.message_tag = raw[12:14]
+        self.message_authentication_code = raw[14:]
+        return TimerNotify.LENGTH
+
+    def to_knx(self) -> bytes:
+        """Serialize to KNX/IP raw data."""
+        return (
+            self.timer_value.to_bytes(6, "big")
+            + self.serial_number
+            + self.message_tag
+            + self.message_authentication_code
+        )
+
+    def __repr__(self) -> str:
+        """Return object as readable string."""
+        return (
+            "<TimerNotify "
+            f'timer_value="{self.timer_value}" '
+            f'serial_number="{self.serial_number.hex()}" '
+            f'message_tag="{self.message_tag.hex()}" '
+            f'message_authentication_code="{self.message_authentication_code.hex()}" />'
+        )


Test patch:
diff --git a/requirements/testing.txt b/requirements/testing.txt
index 0b6750be..6c1e47ae 100644
--- a/requirements/testing.txt
+++ b/requirements/testing.txt
@@ -8,6 +8,6 @@ pylint==2.15.3
 pytest==7.1.3
 pytest-asyncio==0.19.0
 pytest-cov==4.0.0
-setuptools==65.4.0
+setuptools==65.4.1
 tox==3.26.0
 mypy==0.981
diff --git a/test/knxip_tests/secure_wrapper_test.py b/test/knxip_tests/secure_wrapper_test.py
index 98569a44..73cca920 100644
--- a/test/knxip_tests/secure_wrapper_test.py
+++ b/test/knxip_tests/secure_wrapper_test.py
@@ -53,7 +53,4 @@ class TestKNXIPSecureWrapper:
             message_authentication_code=message_authentication_code,
         )
         knxipframe2 = KNXIPFrame.init_from_body(secure_wrapper)
-
-        print(knxipframe2.to_knx().hex())
-        print(raw.hex())
         assert knxipframe2.to_knx() == raw
diff --git a/test/knxip_tests/timer_notify_test.py b/test/knxip_tests/timer_notify_test.py
new file mode 100644
index 00000000..51c3c6be
--- /dev/null
+++ b/test/knxip_tests/timer_notify_test.py
@@ -0,0 +1,43 @@
+"""Unit test for KNX/IP TimerNotify objects."""
+from xknx.knxip import KNXIPFrame, TimerNotify
+
+
+class TestKNXIPTimerNotify:
+    """Test class for KNX/IP TimerNotify objects."""
+
+    def test_timer_notify(self):
+        """Test parsing and streaming TimerNotify KNX/IP packet."""
+        message_authentication_code = bytes.fromhex(
+            "72 12 a0 3a aa e4 9d a8" "56 89 77 4c 1d 2b 4d a4"
+        )
+        raw = (
+            bytes.fromhex(
+                "06 10 09 55 00 24"  # KNXnet/IP header - length 36 octets
+                "c0 c1 c2 c3 c4 c5"  # timer value
+                "00 fa 12 34 56 78"  # KNX serial number
+                "af fe"  # message tag
+            )
+            + message_authentication_code
+        )
+        knxipframe = KNXIPFrame()
+        knxipframe.from_knx(raw)
+
+        assert isinstance(knxipframe.body, TimerNotify)
+        assert knxipframe.body.timer_value == 211938428830917
+        assert knxipframe.body.serial_number == bytes.fromhex("00 fa 12 34 56 78")
+        assert knxipframe.body.message_tag == bytes.fromhex("af fe")
+        assert (
+            knxipframe.body.message_authentication_code == message_authentication_code
+        )
+
+        assert knxipframe.to_knx() == raw
+
+        timer_notify = TimerNotify(
+            timer_value=211938428830917,
+            serial_number=bytes.fromhex("00 fa 12 34 56 78"),
+            message_tag=bytes.fromhex("af fe"),
+            message_authentication_code=message_authentication_code,
+        )
+        knxipframe2 = KNXIPFrame.init_from_body(timer_notify)
+
+        assert knxipframe2.to_knx() == raw