Repository: XKNX/xknx
Pull request #1530 (2024-07-22 20:49:40)

Add DPT 18.001 DPTSceneControl
<!--
  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.
-->


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)
- [ ] Code quality improvements to existing code or addition of tests

## Checklist

- [ ] The documentation has been adjusted accordingly
- [x] Tests have been added that prove the fix is effective or that the feature works
- [x] The changes are documented in the changelog (docs/changelog.md)

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/XKNX/xknx/pull/1530?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 96.71%. Comparing base [(`aa5e22c`)](https://app.codecov.io/gh/XKNX/xknx/commit/aa5e22c4700f19e3ee1f3f16e8a9afd573bda2d1?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) to head [(`c215ed6`)](https://app.codecov.io/gh/XKNX/xknx/commit/c215ed6afe1a53f66137df66f77f1c37661370d4?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX).

<details><summary>Additional details and impacted files</summary>


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

```diff
@@            Coverage Diff             @@
##             main    #1530      +/-   ##
==========================================
+ Coverage   96.70%   96.71%   +0.01%     
==========================================
  Files         156      157       +1     
  Lines       10095    10136      +41     
==========================================
+ Hits         9762     9803      +41     
  Misses        333      333              
```

| [Files](https://app.codecov.io/gh/XKNX/xknx/pull/1530?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) | Coverage Δ | |
|---|---|---|
| [xknx/dpt/\_\_init\_\_.py](https://app.codecov.io/gh/XKNX/xknx/pull/1530?src=pr&el=tree&filepath=xknx%2Fdpt%2F__init__.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9kcHQvX19pbml0X18ucHk=) | `100.00% <100.00%> (ø)` | |
| [xknx/dpt/dpt\_18.py](https://app.codecov.io/gh/XKNX/xknx/pull/1530?src=pr&el=tree&filepath=xknx%2Fdpt%2Fdpt_18.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9kcHQvZHB0XzE4LnB5) | `100.00% <100.00%> (ø)` | |

</details>

Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 76692d8a..6f729964 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -43,6 +43,7 @@ nav_order: 2
 - Added or refactored complex DPTs and dataclasses:
   - 3.007 - DPTControlDimming
   - 3.008 - DPTControlBlinds
+  - 18.001 - DPTSceneControl
   - 232.600 - DPTColorRGB - RGBColor
   - 235.001 - DPTTariffActiveEnergy - TariffActiveEnergy
   - 242.600 - DPTColorXYY - XYYColor
diff --git a/xknx/dpt/__init__.py b/xknx/dpt/__init__.py
index a31ab837..d88eee84 100644
--- a/xknx/dpt/__init__.py
+++ b/xknx/dpt/__init__.py
@@ -183,6 +183,7 @@ from .dpt_14 import (
 )
 from .dpt_16 import DPTLatin1, DPTString
 from .dpt_17 import DPTSceneNumber
+from .dpt_18 import DPTSceneControl, SceneControl
 from .dpt_19 import DPTDateTime
 from .dpt_20 import DPTHVACContrMode, DPTHVACMode, DPTHVACStatus
 from .dpt_232 import DPTColorRGB, RGBColor
diff --git a/xknx/dpt/dpt_18.py b/xknx/dpt/dpt_18.py
new file mode 100644
index 00000000..55df7fa8
--- /dev/null
+++ b/xknx/dpt/dpt_18.py
@@ -0,0 +1,70 @@
+"""Implementation of KNX DPT 18 Scene control."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass
+from typing import Any
+
+from .dpt import DPTComplex, DPTComplexData
+from .dpt_17 import DPTSceneNumber
+from .payload import DPTArray, DPTBinary
+
+
+@dataclass(slots=True)
+class SceneControl(DPTComplexData):
+    """Class for scene control."""
+
+    scene_number: int
+    learn: bool = False
+
+    @classmethod
+    def from_dict(cls, data: Mapping[str, Any]) -> SceneControl:
+        """Init from a dictionary."""
+        try:
+            scene_number = int(data["scene_number"])
+            learn = data.get("learn", False)
+        except (KeyError, TypeError, ValueError) as err:
+            raise ValueError(f"Invalid value for SceneControl: {err}") from err
+        if learn not in (True, False):
+            raise ValueError(f"Invalid value for SceneControl value `learn`: {learn}")
+        return cls(scene_number=scene_number, learn=bool(learn))
+
+    def as_dict(self) -> dict[str, int | str]:
+        """Create a JSON serializable dictionary."""
+        return {
+            "scene_number": self.scene_number,
+            "learn": self.learn,
+        }
+
+
+class DPTSceneControl(DPTComplex[SceneControl]):
+    """
+    Abstraction for KNX 1 Octet Scene Control.
+
+    DPT 18.001
+    """
+
+    data_type = SceneControl
+    payload_type = DPTArray
+    payload_length = 1
+    dpt_main_number = 18
+    dpt_sub_number = 1
+    value_type = "scene_control"
+
+    @classmethod
+    def from_knx(cls, payload: DPTArray | DPTBinary) -> SceneControl:
+        """Parse/deserialize from KNX/IP raw data."""
+        raw = cls.validate_payload(payload)[0]
+        return SceneControl(
+            learn=bool(raw & 0x80),
+            scene_number=(raw & 0x3F) + 1,
+        )
+
+    @classmethod
+    def _to_knx(cls, value: SceneControl) -> DPTArray:
+        """Serialize to KNX/IP raw data."""
+        scene_payload = DPTSceneNumber.to_knx(value.scene_number)
+        if value.learn:
+            return DPTArray(scene_payload.value[0] | 0x80)
+        return scene_payload


Test patch:
diff --git a/test/dpt_tests/dpt_18_scene_control_test.py b/test/dpt_tests/dpt_18_scene_control_test.py
new file mode 100644
index 00000000..7c887790
--- /dev/null
+++ b/test/dpt_tests/dpt_18_scene_control_test.py
@@ -0,0 +1,100 @@
+"""Unit test for KNX DPT 18 objects."""
+
+import pytest
+
+from xknx.dpt import DPTArray, DPTBinary, DPTSceneControl, SceneControl
+from xknx.exceptions import ConversionError, CouldNotParseTelegram
+
+
+class TestSceneControl:
+    """Test SceneControl class."""
+
+    @pytest.mark.parametrize(
+        ("data", "value"),
+        [
+            (
+                {"scene_number": 5, "learn": False},
+                SceneControl(5, False),
+            ),
+            (
+                {"scene_number": 2},
+                SceneControl(scene_number=2),
+            ),
+            (
+                {"scene_number": 17, "learn": True},
+                SceneControl(scene_number=17, learn=True),
+            ),
+        ],
+    )
+    def test_dict(self, data, value):
+        """Test from_dict and as_dict methods."""
+        test_value = SceneControl.from_dict(data)
+        assert test_value == value
+        # learn defaults to `False`
+        default_dict = {"learn": False}
+        assert value.as_dict() == default_dict | data
+
+    @pytest.mark.parametrize(
+        "data",
+        [
+            # invalid data
+            {"learn": False},
+            {"scene_number": "a"},
+            {"scene_number": None, "learn": True},
+            {"scene_number": 1, "learn": "a"},
+            {"scene_number": 1, "learn": None},
+        ],
+    )
+    def test_dict_invalid(self, data):
+        """Test from_dict and as_dict methods."""
+        with pytest.raises(ValueError):
+            SceneControl.from_dict(data)
+
+
+class TestDPTSceneControl:
+    """Test class for KNX DPTSceneControl objects."""
+
+    @pytest.mark.parametrize(
+        ("value", "raw"),
+        [
+            (SceneControl(1, False), (0b00000000,)),
+            (SceneControl(1, True), (0b10000000,)),
+            (SceneControl(64, False), (0b00111111,)),
+            (SceneControl(64, True), (0b10111111,)),
+        ],
+    )
+    def test_parse(self, value, raw):
+        """Test DPTTariffActiveEnergy parsing and streaming."""
+        knx_value = DPTSceneControl.to_knx(value)
+        assert knx_value == DPTArray(raw)
+        assert DPTSceneControl.from_knx(knx_value) == value
+
+    @pytest.mark.parametrize(
+        ("value", "raw"),
+        [
+            ({"scene_number": 17, "learn": False}, (0x10,)),
+            ({"scene_number": 21, "learn": True}, (0x94,)),
+        ],
+    )
+    def test_to_knx_from_dict(self, value, raw):
+        """Test DPTTariffActiveEnergy parsing from a dict."""
+        assert DPTSceneControl.to_knx(value) == DPTArray(raw)
+
+    @pytest.mark.parametrize(
+        ("value"),
+        [
+            SceneControl(scene_number=0, learn=False),
+            SceneControl(scene_number=65, learn=True),
+        ],
+    )
+    def test_to_knx_limits(self, value):
+        """Test initialization of DPTTariffActiveEnergy with wrong value."""
+        with pytest.raises(ConversionError):
+            DPTSceneControl.to_knx(value)
+
+    def test_from_knx_wrong_value(self):
+        """Test DPTTariffActiveEnergy parsing with wrong value."""
+        with pytest.raises(CouldNotParseTelegram):
+            DPTSceneControl.from_knx(DPTArray((0xFF, 0x4E)))
+        with pytest.raises(CouldNotParseTelegram):
+            DPTSceneControl.from_knx(DPTBinary(True))
diff --git a/test/dpt_tests/dpt_lookup_test.py b/test/dpt_tests/dpt_lookup_test.py
index 85124b68..dec8066c 100644
--- a/test/dpt_tests/dpt_lookup_test.py
+++ b/test/dpt_tests/dpt_lookup_test.py
@@ -160,6 +160,7 @@ from xknx.dpt import *
         ("resistance", DPTResistance, 14, 60, "Ω"),
         ("resistivity", DPTResistivity, 14, 61, "Ωm"),
         ("rotation_angle", DPTRotationAngle, 8, 11, "°"),
+        ("scene_control", DPTSceneControl, 18, 1, None),
         ("scene_number", DPTSceneNumber, 17, 1, None),
         ("self_inductance", DPTSelfInductance, 14, 62, "H"),
         ("solid_angle", DPTSolidAngle, 14, 63, "sr"),