Repository: XKNX/xknx
Pull request #1469 (2024-06-01 16:16:18)

Parse DPT transcoders by dictionaries with "main" and "sub" keys
<!--
  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.
-->
Dict data in `DPTBase.parse_transcoder()`s `value_type` parameter is supported in the following format:
```json
{"main": 9, "sub": 1}
```
xknxproject data can be used like this
```py
data: KNXProject
ga_types: dict[str, type[DPTBase] | None]
ga_types = {
    ga["address"]: DPTBase.parse_transcoder(ga["dpt"])
    for ga in data["group_addresses"].values()
}
```

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/1469?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 [(`7762954`)](https://app.codecov.io/gh/XKNX/xknx/commit/7762954be0bc343d65e290b3920e6cff3cc33571?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) to head [(`c9c056d`)](https://app.codecov.io/gh/XKNX/xknx/commit/c9c056d1aa7d6c9ad086ad667ddc668bbb71d219?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/1469/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/1469?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX)

```diff
@@           Coverage Diff           @@
##             main    #1469   +/-   ##
=======================================
  Coverage   96.65%   96.65%           
=======================================
  Files         152      152           
  Lines        9953     9965   +12     
=======================================
+ Hits         9620     9632   +12     
  Misses        333      333           
```


| [Files](https://app.codecov.io/gh/XKNX/xknx/pull/1469?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/1469?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%> (ø)` | |

</details>

Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index fa6aaaec..790f34cc 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -16,6 +16,7 @@ nav_order: 2
   - 235.001 - DPTTariffActiveEnergy - TariffActiveEnergy
   - 242.600 - DPTColorXYY - XYYColor
   - 251.600 - DPTColorRGBW - RGBWColor
+- Support dict values with "main" and "sub" keys for `DPTBase.parse_transcoder()`
 
 # 2.12.2 Fix thread leak 2024-03-05
 
diff --git a/xknx/dpt/dpt.py b/xknx/dpt/dpt.py
index 70821ce8..789364f8 100644
--- a/xknx/dpt/dpt.py
+++ b/xknx/dpt/dpt.py
@@ -6,7 +6,7 @@ from abc import ABC, abstractmethod
 from collections.abc import Iterator, Mapping
 from dataclasses import dataclass
 from inspect import isabstract
-from typing import Any, Generic, TypeVar, cast
+from typing import Any, Generic, TypedDict, TypeVar, cast
 
 from xknx.exceptions import ConversionError, CouldNotParseTelegram
 
@@ -16,6 +16,13 @@ T = TypeVar("T", bound=type["DPTBase"])  # pylint: disable=invalid-name
 TComplexData = TypeVar("TComplexData", bound="DPTComplexData")  # pylint: disable=invalid-name
 
 
+class _DPTMainSubDict(TypedDict):
+    """DPT type dictionary in accordance to xknxproject DPTType data."""
+
+    main: int
+    sub: int | None
+
+
 class DPTBase(ABC):
     """
     Base class for KNX data point type transcoder.
@@ -155,8 +162,15 @@ class DPTBase(ABC):
         return None
 
     @classmethod
-    def parse_transcoder(cls: T, value_type: int | str) -> T | None:
-        """Return Class reference of DPTBase subclass from value_type or DPT number."""
+    def parse_transcoder(cls: T, value_type: int | str | _DPTMainSubDict) -> T | None:
+        """
+        Return Class reference of DPTBase subclass from value_type or DPT number.
+
+        `value_type` accepts
+        - Integer: DPT main number
+        - String: value_type or "." separated dpt main and sub numbers (eg. "9.001")
+        - Mapping: "main" and "sub" keys with DPT main and sub numbers (in accordance to xknxproject data)
+        """
         if isinstance(value_type, int):
             return cls.transcoder_by_dpt(value_type)
         if isinstance(value_type, str):
@@ -175,6 +189,16 @@ class DPTBase(ABC):
                     except (ValueError, IndexError):
                         pass
             return transcoder
+        if isinstance(value_type, Mapping):
+            try:
+                main = int(value_type["main"])
+                if (_sub := value_type.get("sub")) is not None:
+                    _sub = int(_sub)
+                else:
+                    _sub = None
+            except (KeyError, TypeError, ValueError):
+                return None
+            return cls.transcoder_by_dpt(dpt_main=main, dpt_sub=_sub)
 
 
 class DPTNumeric(DPTBase):


Test patch:
diff --git a/test/dpt_tests/dpt_test.py b/test/dpt_tests/dpt_test.py
index 268ccdc3..9a199fc6 100644
--- a/test/dpt_tests/dpt_test.py
+++ b/test/dpt_tests/dpt_test.py
@@ -1,5 +1,7 @@
 """Unit test for KNX binary/integer objects."""
 
+from typing import Any
+
 import pytest
 
 from xknx.dpt import (
@@ -62,19 +64,20 @@ class TestDPTBase:
         ]
         assert len(dpt_tuples) == len(set(dpt_tuples))
 
-    def test_dpt_alternative_notations(self):
-        """Test the parser for accepting alternateive notations for the same DPT class."""
-        dpt1 = DPTBase.parse_transcoder("2byte_unsigned")
-        dpt2 = DPTBase.parse_transcoder(7)
-        dpt3 = DPTBase.parse_transcoder("DPT-7")
-        assert dpt1 == dpt2
-        assert dpt2 == dpt3
-        dpt4 = DPTBase.parse_transcoder("temperature")
-        dpt5 = DPTBase.parse_transcoder("9.001")
-        assert dpt4 == dpt5
-        dpt7 = DPTBase.parse_transcoder("active_energy")
-        dpt8 = DPTBase.parse_transcoder("13.010")
-        assert dpt7 == dpt8
+    @pytest.mark.parametrize(
+        "equal_dpts",
+        [
+            # strings in dictionaries would fail type checking, but should work nevertheless
+            ["2byte_unsigned", 7, "DPT-7", {"main": 7}, {"main": "7", "sub": None}],
+            ["temperature", "9.001", {"main": 9, "sub": 1}, {"main": "9", "sub": "1"}],
+            ["active_energy", "13.010", {"main": 13, "sub": 10}],
+        ],
+    )
+    def test_dpt_alternative_notations(self, equal_dpts: list[Any]):
+        """Test the parser for accepting alternative notations for the same DPT class."""
+        parsed = [DPTBase.parse_transcoder(dpt) for dpt in equal_dpts]
+        assert issubclass(parsed[0], DPTBase)
+        assert all(parsed[0] == dpt for dpt in parsed)
 
     def test_parse_transcoder_from_subclass(self):
         """Test parsing only subclasses of a DPT class."""
@@ -90,6 +93,26 @@ class TestDPTBase:
         assert DPTNumeric.parse_transcoder("temperature") == DPTTemperature
         assert DPT2ByteFloat.parse_transcoder("temperature") == DPTTemperature
 
+    @pytest.mark.parametrize(
+        "value",
+        [
+            None,
+            0,
+            999999999,
+            9.001,  # float is not valid
+            "invalid_string",
+            {"sub": 1},
+            {"main": None, "sub": None},
+            {"main": "invalid"},
+            {"main": 9, "sub": "invalid"},
+            [9, 1],
+            (9,),
+        ],
+    )
+    def test_parse_transcoder_invalid_data(self, value: Any):
+        """Test parsing invalid data."""
+        assert DPTBase.parse_transcoder(value) is None
+
 
 class TestDPTBaseSubclass:
     """Test subclass of transcoder base object."""