Repository: XKNX/xknx
Pull request #1496 (2024-06-23 20:48:34)

Common interface for DPT transcoders with enumeration data
<!--
  You are awesome! Thanks for contributing to our project!
  Please, DO NOT DELETE ANY TEXT from this template!.
-->
## Description
<!--
Please include a summary of the change and which issue is fixed.
Please also include relevant motivation and context.
List any dependencies that are required for this change.
-->
This adds `DPTEnum` DPT-base-class, a common interface for DPT transcoders with enumueration values. Transcoders accept Enum or string values for encoding.

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
- [ ] 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/1496?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.65%. Comparing base [(`6912960`)](https://app.codecov.io/gh/XKNX/xknx/commit/69129602b7504b21c62fa085761d8b2e6697e4ae?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) to head [(`5693d52`)](https://app.codecov.io/gh/XKNX/xknx/commit/5693d5223336702b39697751d204c73f51e0106a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX).

> :exclamation: **Current head 5693d52 differs from pull request most recent head ec6a1e4**
> 
> Please [upload](https://docs.codecov.com/docs/codecov-uploader) reports for the commit ec6a1e4 to get more accurate results.

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


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

```diff
@@           Coverage Diff           @@
##             main    #1496   +/-   ##
=======================================
  Coverage   96.64%   96.65%           
=======================================
  Files         156      156           
  Lines       10038    10060   +22     
=======================================
+ Hits         9701     9723   +22     
  Misses        337      337           
```

| [Files](https://app.codecov.io/gh/XKNX/xknx/pull/1496?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/dpt.py](https://app.codecov.io/gh/XKNX/xknx/pull/1496?src=pr&el=tree&filepath=xknx%2Fdpt%2Fdpt.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9kcHQvZHB0LnB5) | `100.00% <100.00%> (ø)` | |
| [xknx/dpt/dpt\_20.py](https://app.codecov.io/gh/XKNX/xknx/pull/1496?src=pr&el=tree&filepath=xknx%2Fdpt%2Fdpt_20.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9kcHQvZHB0XzIwLnB5) | `100.00% <100.00%> (ø)` | |
| [xknx/remote\_value/remote\_value\_climate\_mode.py](https://app.codecov.io/gh/XKNX/xknx/pull/1496?src=pr&el=tree&filepath=xknx%2Fremote_value%2Fremote_value_climate_mode.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9yZW1vdGVfdmFsdWUvcmVtb3RlX3ZhbHVlX2NsaW1hdGVfbW9kZS5weQ==) | `97.40% <100.00%> (+0.03%)` | :arrow_up: |

</details>

Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 768ecaf8..a1e7c9d7 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -33,11 +33,12 @@ nav_order: 2
 ### DPT
 
 - DPTComplex: Common interface for DPT transcoders with multi-value data. Resulting dataclasses can be converted to and from a dict with DPT specific properties to be JSON compatible.
-- Added or refactored DPTs and dataclasses:
+- Added or refactored complex DPTs and dataclasses:
   - 232.600 - DPTColorRGB - RGBColor
   - 235.001 - DPTTariffActiveEnergy - TariffActiveEnergy
   - 242.600 - DPTColorXYY - XYYColor
   - 251.600 - DPTColorRGBW - RGBWColor
+- DPTEnum: Common interface for DPT transcoders with enumueration values. Transcoders accept Enum or string values for encoding.
 - Support dict values with "main" and "sub" keys for `DPTBase.parse_transcoder()`
 
 ### Address
diff --git a/xknx/dpt/dpt.py b/xknx/dpt/dpt.py
index 925b0d12..4057217f 100644
--- a/xknx/dpt/dpt.py
+++ b/xknx/dpt/dpt.py
@@ -5,6 +5,7 @@ from __future__ import annotations
 from abc import ABC, abstractmethod
 from collections.abc import Iterator, Mapping
 from dataclasses import dataclass
+from enum import Enum
 from inspect import isabstract
 from typing import Any, Generic, TypedDict, TypeVar, cast
 
@@ -220,6 +221,60 @@ class DPTNumeric(DPTBase):
         """Serialize to KNX/IP raw data."""
 
 
+EnumT = TypeVar("EnumT", bound=Enum)
+
+
+class DPTEnum(DPTBase, Generic[EnumT]):
+    """Abstraction for KNX DPT Enum."""
+
+    payload_type = DPTArray
+    payload_length = 1
+
+    data_type: type[EnumT]
+    VALUE_MAP: Mapping[int, EnumT]
+
+    @classmethod
+    def from_knx(cls, payload: DPTArray | DPTBinary) -> EnumT:
+        """Parse/deserialize from KNX/IP raw data."""
+        raw = cls.validate_payload(payload)
+        try:
+            return cls.VALUE_MAP[raw[0]]  # type: ignore[no-any-return]  # without ClassVar this is Any, ClassVar doesn't work with bound TypeVar though
+        except KeyError:
+            raise ConversionError(
+                f"Payload not supported for {cls.__name__}", raw=raw
+            ) from None
+
+    @classmethod
+    def to_knx(cls, value: EnumT | str) -> DPTArray:
+        """Serialize to KNX/IP raw data."""
+        if isinstance(value, str):
+            try:
+                # snake_cased name may be used as translation key or serializable value
+                value = cls.data_type[value.upper()]
+            except KeyError:
+                try:
+                    value = cls.data_type(value)
+                except ValueError:
+                    raise ConversionError(
+                        f"Invalid value for {cls.data_type.__name__} in {cls.__name__}",
+                        value=value,
+                        valid_values=cls.get_valid_values(),
+                    ) from None
+        for knx_raw_value, enum_member in cls.VALUE_MAP.items():
+            if enum_member is value:
+                return DPTArray(knx_raw_value)
+        raise ConversionError(
+            f"Value not supported for {cls.data_type.__name__} in {cls.__name__}",
+            value=value,
+            valid_values=cls.get_valid_values(),
+        )
+
+    @classmethod
+    def get_valid_values(cls) -> list[EnumT]:
+        """Return list of valid values."""
+        return list(cls.VALUE_MAP.values())
+
+
 @dataclass(slots=True)
 class DPTComplexData(ABC):
     """Base class for KNX data point types decoding complex values."""
diff --git a/xknx/dpt/dpt_20.py b/xknx/dpt/dpt_20.py
index 69154176..c5e82d5c 100644
--- a/xknx/dpt/dpt_20.py
+++ b/xknx/dpt/dpt_20.py
@@ -3,15 +3,12 @@
 from __future__ import annotations
 
 from enum import Enum
-from typing import Generic, TypeVar
 
 from xknx.exceptions import ConversionError
 
-from .dpt import DPTBase
+from .dpt import DPTEnum
 from .payload import DPTArray, DPTBinary
 
-HVACModeT = TypeVar("HVACModeT", "HVACControllerMode", "HVACOperationMode")
-
 
 # ruff: noqa: RUF012  # Mutable class attributes should be annotated with `typing.ClassVar`
 class HVACOperationMode(Enum):
@@ -43,42 +40,18 @@ class HVACControllerMode(Enum):
     NODEM = "NoDem"
 
 
-class _DPTClimateMode(DPTBase, Generic[HVACModeT]):
-    """Base class for KNX Climate modes."""
-
-    SUPPORTED_MODES: dict[int, HVACModeT] = {}
-
-    payload_type = DPTArray
-    payload_length = 1
-
-    @classmethod
-    def from_knx(cls, payload: DPTArray | DPTBinary) -> HVACModeT:
-        """Parse/deserialize from KNX/IP raw data."""
-        raw = cls.validate_payload(payload)
-        try:
-            return cls.SUPPORTED_MODES[raw[0]]
-        except KeyError:
-            raise ConversionError(
-                f"Payload not supported for {cls.__name__}", raw=raw
-            ) from None
-
-    @classmethod
-    def to_knx(cls, value: HVACModeT) -> DPTArray:
-        """Serialize to KNX/IP raw data."""
-        for knx_value, mode in cls.SUPPORTED_MODES.items():
-            if mode == value:
-                return DPTArray(knx_value)
-        raise ConversionError(f"Value not supported for {cls.__name__}", value=value)
-
-
-class DPTHVACContrMode(_DPTClimateMode[HVACControllerMode]):
+class DPTHVACContrMode(DPTEnum[HVACControllerMode]):
     """
     Abstraction for KNX HVAC controller mode.
 
     DPT 20.105
     """
 
-    SUPPORTED_MODES: dict[int, HVACControllerMode] = {
+    dpt_main_number = 20
+    dpt_sub_number = 105
+    value_type = "hvac_controller_mode"
+    data_type = HVACControllerMode
+    VALUE_MAP = {
         0: HVACControllerMode.AUTO,
         1: HVACControllerMode.HEAT,
         2: HVACControllerMode.MORNING_WARMUP,
@@ -96,14 +69,18 @@ class DPTHVACContrMode(_DPTClimateMode[HVACControllerMode]):
     }
 
 
-class DPTHVACMode(_DPTClimateMode[HVACOperationMode]):
+class DPTHVACMode(DPTEnum[HVACOperationMode]):
     """
     Abstraction for KNX HVAC mode.
 
     DPT 20.102
     """
 
-    SUPPORTED_MODES: dict[int, HVACOperationMode] = {
+    dpt_main_number = 20
+    dpt_sub_number = 102
+    value_type = "hvac_mode"
+    data_type = HVACOperationMode
+    VALUE_MAP = {
         0: HVACOperationMode.AUTO,
         1: HVACOperationMode.COMFORT,
         2: HVACOperationMode.STANDBY,
@@ -112,7 +89,7 @@ class DPTHVACMode(_DPTClimateMode[HVACOperationMode]):
     }
 
 
-class DPTControllerStatus(_DPTClimateMode[HVACOperationMode]):
+class DPTControllerStatus(DPTEnum[HVACOperationMode]):
     """
     Abstraction for KNX HVAC Controller status.
 
@@ -123,7 +100,8 @@ class DPTControllerStatus(_DPTClimateMode[HVACOperationMode]):
     notes on the correct implementation of this type are highly appreciated.
     """
 
-    SUPPORTED_MODES: dict[int, HVACOperationMode] = {
+    data_type = HVACOperationMode
+    VALUE_MAP = {
         0x21: HVACOperationMode.COMFORT,
         0x22: HVACOperationMode.STANDBY,
         0x24: HVACOperationMode.NIGHT,
diff --git a/xknx/remote_value/remote_value_climate_mode.py b/xknx/remote_value/remote_value_climate_mode.py
index 6f92162f..18f2a36e 100644
--- a/xknx/remote_value/remote_value_climate_mode.py
+++ b/xknx/remote_value/remote_value_climate_mode.py
@@ -8,7 +8,7 @@ from __future__ import annotations
 
 from abc import abstractmethod
 from enum import Enum
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, TypeVar
 
 from xknx.dpt import (
     DPTArray,
@@ -17,7 +17,7 @@ from xknx.dpt import (
     DPTHVACContrMode,
     DPTHVACMode,
 )
-from xknx.dpt.dpt_20 import HVACControllerMode, HVACModeT, HVACOperationMode
+from xknx.dpt.dpt_20 import HVACControllerMode, HVACOperationMode
 from xknx.exceptions import ConversionError, CouldNotParseTelegram
 from xknx.typing import CallbackType
 
@@ -27,6 +27,9 @@ if TYPE_CHECKING:
     from xknx.xknx import XKNX
 
 
+HVACModeT = TypeVar("HVACModeT", "HVACControllerMode", "HVACOperationMode")
+
+
 class RemoteValueClimateModeBase(RemoteValue[HVACModeT | None]):
     """Base class for binary climate mode remote values."""
 
@@ -80,7 +83,7 @@ class RemoteValueOperationMode(RemoteValueClimateModeBase[HVACOperationMode]):
 
     def supported_operation_modes(self) -> list[HVACOperationMode]:
         """Return a list of all supported operation modes."""
-        return list(self._climate_mode_transcoder.SUPPORTED_MODES.values())
+        return self._climate_mode_transcoder.get_valid_values()
 
     def to_knx(self, value: Any) -> DPTArray:
         """Convert value to payload."""
@@ -117,7 +120,7 @@ class RemoteValueControllerMode(RemoteValueClimateModeBase[HVACControllerMode]):
 
     def supported_operation_modes(self) -> list[HVACControllerMode]:
         """Return a list of all supported operation modes."""
-        return list(DPTHVACContrMode.SUPPORTED_MODES.values())
+        return DPTHVACContrMode.get_valid_values()
 
     def to_knx(self, value: Any) -> DPTArray:
         """Convert value to payload."""


Test patch:
diff --git a/requirements/testing.txt b/requirements/testing.txt
index 31600bc3..b93f8cce 100644
--- a/requirements/testing.txt
+++ b/requirements/testing.txt
@@ -5,8 +5,8 @@ pytest==8.2.2
 pytest-asyncio==0.23.7
 pytest-cov==5.0.0
 pytest-icdiff==0.9
-ruff==0.4.9
-setuptools==70.0.0
+ruff==0.4.10
+setuptools==70.1.0
 tox==4.15.1
 tox-gh-actions==3.2.0
 mypy==1.10.0
diff --git a/test/devices_tests/climate_test.py b/test/devices_tests/climate_test.py
index bd54c131..222dbd3d 100644
--- a/test/devices_tests/climate_test.py
+++ b/test/devices_tests/climate_test.py
@@ -271,7 +271,7 @@ class TestClimate:
             xknx, "TestClimate", group_address_controller_mode="1/2/4"
         )
 
-        for _, controller_mode in DPTHVACContrMode.SUPPORTED_MODES.items():
+        for controller_mode in DPTHVACContrMode.get_valid_values():
             await climate_mode.set_controller_mode(controller_mode)
             assert xknx.telegrams.qsize() == 1
             telegram = xknx.telegrams.get_nowait()
@@ -1042,7 +1042,7 @@ class TestClimate:
         climate_mode = ClimateMode(
             xknx, "TestClimate", group_address_controller_mode="1/2/5"
         )
-        for _, controller_mode in DPTHVACContrMode.SUPPORTED_MODES.items():
+        for controller_mode in DPTHVACContrMode.get_valid_values():
             telegram = Telegram(
                 destination_address=GroupAddress("1/2/5"),
                 payload=GroupValueWrite(DPTHVACContrMode.to_knx(controller_mode)),
diff --git a/test/dpt_tests/dpt_hvac_mode_test.py b/test/dpt_tests/dpt_hvac_mode_test.py
index 9087f837..fe073733 100644
--- a/test/dpt_tests/dpt_hvac_mode_test.py
+++ b/test/dpt_tests/dpt_hvac_mode_test.py
@@ -20,6 +20,16 @@ class TestDPTControllerStatus:
             (0x04,)
         )
 
+    def test_mode_to_knx_by_string(self):
+        """Test parsing DPTHVACMode string values to KNX."""
+        assert DPTHVACMode.to_knx("auto") == DPTArray((0x00,))
+        assert DPTHVACMode.to_knx("Comfort") == DPTArray((0x01,))
+        assert DPTHVACMode.to_knx("standby") == DPTArray((0x02,))
+        assert DPTHVACMode.to_knx("NIGHT") == DPTArray((0x03,))
+        assert DPTHVACMode.to_knx(
+            "Frost Protection"  # Enum value, not name
+        ) == DPTArray((0x04,))
+
     def test_mode_to_knx_wrong_value(self):
         """Test serializing DPTHVACMode to KNX with wrong value."""
         with pytest.raises(ConversionError):