Repository: XKNX/xknx (python)

Issue:
Feature Request : Integration of DPT 235.001 / DPT_Tariff_ActiveEnergy
Hello, do you think it's possible to integrate the DPT 235.001. Is a 6bytes DPT. This contains DPT 13.010 and 5.006+1 data validity byte. 

![image](https://user-images.githubusercontent.com/10090794/208304366-843c6c51-d2e9-4af5-a133-ad0a8e516d23.png)

Thank you



Pull request:
Add DPTTariffActiveEnergy transcoder
<!--
  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.
-->


Closes #1108

## 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)

Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index f017803e..fa6aaaec 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -13,6 +13,7 @@ nav_order: 2
 - 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:
   - 232.600 - DPTColorRGB - RGBColor
+  - 235.001 - DPTTariffActiveEnergy - TariffActiveEnergy
   - 242.600 - DPTColorXYY - XYYColor
   - 251.600 - DPTColorRGBW - RGBWColor
 
diff --git a/xknx/dpt/__init__.py b/xknx/dpt/__init__.py
index 8ef40f0b..0543456a 100644
--- a/xknx/dpt/__init__.py
+++ b/xknx/dpt/__init__.py
@@ -16,6 +16,7 @@ from .dpt_1byte_uint import (
     DPTValue1ByteUnsigned,
     DPTValue1Ucount,
 )
+from .dpt_235 import DPTTariffActiveEnergy, TariffActiveEnergy
 from .dpt_2byte_float import (
     DPT2ByteFloat,
     DPTAbsoluteHumidity,
diff --git a/xknx/dpt/dpt_1byte_uint.py b/xknx/dpt/dpt_1byte_uint.py
index dd890c76..1263e7de 100644
--- a/xknx/dpt/dpt_1byte_uint.py
+++ b/xknx/dpt/dpt_1byte_uint.py
@@ -42,7 +42,7 @@ class DPTValue1ByteUnsigned(DPTNumeric):
         try:
             knx_value = int(value)
             if not cls._test_boundaries(knx_value):
-                raise ValueError("Value out of range")
+                raise ValueError(f"Value out of range {cls.value_min}..{cls.value_max}")
             return DPTArray(knx_value)
         except ValueError as err:
             raise ConversionError(
diff --git a/xknx/dpt/dpt_235.py b/xknx/dpt/dpt_235.py
new file mode 100644
index 00000000..bf9d9817
--- /dev/null
+++ b/xknx/dpt/dpt_235.py
@@ -0,0 +1,94 @@
+"""Implementation of the KNX 235 data point type."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass
+
+from .dpt import DPTComplex, DPTComplexData
+from .dpt_1byte_uint import DPTTariff
+from .dpt_4byte_int import DPTActiveEnergy
+from .payload import DPTArray, DPTBinary
+
+
+@dataclass
+class TariffActiveEnergy(DPTComplexData):
+    """
+    Representation of Tariff and ActiveEnergy.
+
+    `energy`: int 4byte signed; None if invalid
+    `tariff`: int 0..254; None if invalid
+    """
+
+    energy: int | None = None
+    tariff: int | None = None
+
+    @classmethod
+    def from_dict(cls, data: Mapping[str, int]) -> TariffActiveEnergy:
+        """Init from a dictionary."""
+        try:
+            energy = data.get("energy")
+            tariff = data.get("tariff")
+        except AttributeError as err:
+            raise ValueError(f"Invalid value for TariffActiveEnergy: {err}") from err
+        if energy is not None:
+            try:
+                energy = int(energy)
+            except ValueError as err:
+                raise ValueError(f"Invalid value for energy: {err}") from err
+        if tariff is not None:
+            try:
+                tariff = int(tariff)
+            except ValueError as err:
+                raise ValueError(f"Invalid value for tariff: {err}") from err
+        return cls(energy=energy, tariff=tariff)
+
+    def as_dict(self) -> dict[str, int | None]:
+        """Create a JSON serializable dictionary."""
+        return {
+            "energy": self.energy,
+            "tariff": self.tariff,
+        }
+
+
+class DPTTariffActiveEnergy(DPTComplex[TariffActiveEnergy]):
+    """Abstraction for KNX 6 octet Tariff and ActiveEnergy (DPT 235.001)."""
+
+    data_type = TariffActiveEnergy
+    payload_type = DPTArray
+    payload_length = 6
+    dpt_main_number = 235
+    dpt_sub_number = 1
+    value_type = "tariff_active_energy"
+
+    @classmethod
+    def from_knx(cls, payload: DPTArray | DPTBinary) -> TariffActiveEnergy:
+        """Parse/deserialize from KNX/IP raw data."""
+        raw = cls.validate_payload(payload)
+        energy_valid = raw[5] >> 1 & 0b1
+        tariff_valid = raw[5] & 0b1
+        return TariffActiveEnergy(
+            energy=(
+                DPTActiveEnergy.from_knx(DPTArray(raw[:4])) if energy_valid else None
+            ),
+            tariff=DPTTariff.from_knx(DPTArray([raw[4]])) if tariff_valid else None,
+        )
+
+    @classmethod
+    def _to_knx(cls, value: TariffActiveEnergy) -> DPTArray:
+        """Serialize to KNX/IP raw data."""
+        energy = (
+            DPTActiveEnergy.to_knx(value.energy).value
+            if value.energy is not None
+            else [0, 0, 0, 0]
+        )
+        tariff = (
+            DPTTariff.to_knx(value.tariff).value if value.tariff is not None else [0]
+        )
+        return DPTArray(
+            (
+                *energy,
+                *tariff,
+                (value.energy is not None) << 1 | (value.tariff is not None),
+            )
+        )
diff --git a/xknx/dpt/dpt_color.py b/xknx/dpt/dpt_color.py
index 51da3800..4e28e4d7 100644
--- a/xknx/dpt/dpt_color.py
+++ b/xknx/dpt/dpt_color.py
@@ -1,4 +1,4 @@
-"""Implementation of the KNX date data point."""
+"""Implementation of KNX color data point types."""
 
 from __future__ import annotations
 


Test patch:
diff --git a/test/dpt_tests/dpt_235_test.py b/test/dpt_tests/dpt_235_test.py
new file mode 100644
index 00000000..55d8b894
--- /dev/null
+++ b/test/dpt_tests/dpt_235_test.py
@@ -0,0 +1,147 @@
+"""Unit test for KNX DPT 235 objects."""
+
+import pytest
+
+from xknx.dpt import DPTArray, DPTTariffActiveEnergy, TariffActiveEnergy
+from xknx.exceptions import ConversionError, CouldNotParseTelegram
+
+
+class TestTariffActiveEnergy:
+    """Test TariffActiveEnergy class."""
+
+    @pytest.mark.parametrize(
+        ("data", "value"),
+        [
+            (
+                {"energy": -2_147_483_648, "tariff": 0},
+                TariffActiveEnergy(-2_147_483_648, 0),
+            ),
+            (
+                {"energy": 2_147_483_647, "tariff": 255},
+                TariffActiveEnergy(2_147_483_647, 255),
+            ),
+            ({"tariff": 128}, TariffActiveEnergy(None, 128)),
+            ({"energy": 50}, TariffActiveEnergy(50, None)),
+            ({}, TariffActiveEnergy()),
+            (
+                {"energy": 255, "tariff": None},
+                TariffActiveEnergy(255, None),
+            ),
+            (
+                {"energy": None, "tariff": 128},
+                TariffActiveEnergy(None, 128),
+            ),
+            ({"energy": 255, "tariff": None}, TariffActiveEnergy(255, None)),
+            ({"tariff": 128}, TariffActiveEnergy(None, 128)),
+            ({"energy": 128}, TariffActiveEnergy(128, None)),
+            ({"energy": 128, "tariff": 128}, TariffActiveEnergy(128, 128)),
+        ],
+    )
+    def test_dict(self, data, value):
+        """Test from_dict and as_dict methods."""
+        test_value = TariffActiveEnergy.from_dict(data)
+        assert test_value == value
+        # fields default to `None`
+        default_dict = {"energy": None, "tariff": None}
+        assert value.as_dict() == default_dict | data
+
+    @pytest.mark.parametrize(
+        "data",
+        [
+            # invalid data
+            {"energy": 128, "tariff": "a"},
+            {"energy": "a", "tariff": 128},
+        ],
+    )
+    def test_dict_invalid(self, data):
+        """Test from_dict and as_dict methods."""
+        with pytest.raises(ValueError):
+            TariffActiveEnergy.from_dict(data)
+
+
+class TestDPTTariffActiveEnergy:
+    """Test class for KNX DPTTariffActiveEnergy objects."""
+
+    @pytest.mark.parametrize(
+        ("value", "raw"),
+        [
+            (
+                TariffActiveEnergy(0, 0),
+                (0x00, 0x00, 0x00, 0x00, 0x00, 0x03),
+            ),  # zero values
+            (
+                TariffActiveEnergy(-2_147_483_648, 0),
+                (0x80, 0x00, 0x00, 0x00, 0x00, 0x03),
+            ),  # min values
+            (
+                TariffActiveEnergy(2_147_483_647, 254),
+                (0x7F, 0xFF, 0xFF, 0xFF, 0xFE, 0x03),
+            ),  # max values
+            (
+                TariffActiveEnergy(None, 0),
+                (0x00, 0x00, 0x00, 0x00, 0x00, 0x01),
+            ),  # energy None
+            (
+                TariffActiveEnergy(0, None),
+                (0x00, 0x00, 0x00, 0x00, 0x00, 0x02),
+            ),  # tariff None
+            (TariffActiveEnergy(None, None), (0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
+            (TariffActiveEnergy(128, 128), (0x00, 0x00, 0x00, 0x80, 0x80, 0x03)),
+            (TariffActiveEnergy(204, 204), (0x00, 0x00, 0x00, 0xCC, 0xCC, 0x03)),
+        ],
+    )
+    def test_dpt_tariff_active_energy_value(self, value, raw):
+        """Test DPTTariffActiveEnergy parsing and streaming."""
+        knx_value = DPTTariffActiveEnergy.to_knx(value)
+        assert knx_value == DPTArray(raw)
+        assert DPTTariffActiveEnergy.from_knx(knx_value) == value
+
+    @pytest.mark.parametrize(
+        ("value", "raw"),
+        [
+            ({"tariff": 128, "energy": 128}, (0x00, 0x00, 0x00, 0x80, 0x80, 0x03)),
+            ({"tariff": 204, "energy": 204}, (0x00, 0x00, 0x00, 0xCC, 0xCC, 0x03)),
+        ],
+    )
+    def test_dpt_tariff_active_energy_to_knx_from_dict(self, value, raw):
+        """Test DPTTariffActiveEnergy parsing from a dict."""
+        knx_value = DPTTariffActiveEnergy.to_knx(value)
+        assert knx_value == DPTArray(raw)
+
+    @pytest.mark.parametrize(
+        ("value"),
+        [
+            TariffActiveEnergy(-2_147_483_649, 0),
+            TariffActiveEnergy(2_147_483_648, 0),
+            TariffActiveEnergy(0, -1),
+            TariffActiveEnergy(0, 255),
+        ],
+    )
+    def test_dpt_tariff_active_energy_to_knx_limits(self, value):
+        """Test initialization of DPTTariffActiveEnergy with wrong value."""
+        with pytest.raises(ConversionError):
+            DPTTariffActiveEnergy.to_knx(value)
+
+    @pytest.mark.parametrize(
+        "value",
+        [
+            None,
+            (0xFF, 0x4E),
+            1,
+            ((0x00, 0xFF), 0x4E),
+            ((0xFF, 0x4E), (0x12, 0x00)),
+            ((0, 0), "a"),
+            ((0, 0), 0.4),
+            TariffActiveEnergy(tariff=0, energy="a"),
+            TariffActiveEnergy(tariff="a", energy=0),
+        ],
+    )
+    def test_dpt_tariff_active_energy_wrong_value_to_knx(self, value):
+        """Test DPTTariffActiveEnergy parsing with wrong value."""
+        with pytest.raises(ConversionError):
+            DPTTariffActiveEnergy.to_knx(value)
+
+    def test_dpt_tariff_active_energy_wrong_value_from_knx(self):
+        """Test DPTTariffActiveEnergy parsing with wrong value."""
+        with pytest.raises(CouldNotParseTelegram):
+            DPTTariffActiveEnergy.from_knx(DPTArray((0xFF, 0x4E)))