Repository: XKNX/xknx (python)

Issue:
Feature Request: Improve generic DPT handling
Part 1:

It seems that some DPT parsing is implemented in remote values (e.g. [DPT 251.600](https://github.com/XKNX/xknx/blob/92b80c22827b5d5725d2f00118a40727b06dd187/xknx/remote_value/remote_value_color_rgbw.py#L49)). This makes these DPTs unavailable for generic transcoding via [transcoder_by_dpt](https://github.com/XKNX/xknx/blob/92b80c22827b5d5725d2f00118a40727b06dd187/xknx/dpt/dpt.py#L135) or [transcode_by_value_type](https://github.com/XKNX/xknx/blob/92b80c22827b5d5725d2f00118a40727b06dd187/xknx/dpt/dpt.py#L146).

Is there any deeper reason, that those DPTs are not part of the standard DPT handling? Is it, because they consist out of multiple values (in the RGBW case)?

If yes, followup question: is there a possibility to restructure the code to generically (via DPT main and sub number) get transcoders for those DPTs as well?

Part 2:

Some DPT implementations (like [DPTDate](https://github.com/XKNX/xknx/blob/92b80c22827b5d5725d2f00118a40727b06dd187/xknx/dpt/dpt_date.py#L12) seem to lack dpt_main_number and dpt_sub_number definitions from what I see.

Is that on purpose, am I missing something or is it just not implemented yet?

I'm happy to provide PRs to improve the handling of this, once it's clear, how it should be done.

Pull request description:
Common interface for DPT transcoders with multi-value 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.
-->
Use Dataclasses as container for complex DPT values.
Resulting dataclasses can be converted to and from a dict with DPT specific properties to be JSON compatible.

As an example `XYYColor` is migrated in this PR.

Pros (compared to TypedDict):

- properly typed, any type supported
- default values supported
- lightweight (in py3.10 we can even set it `slots=True`)
- useable with data validation libraries - maybe outsource dict / json serialisation to external library or just use `__post_init__` to move validation to the dataclass if needed
- decoupled internal data schema (for use in xknx Devices) from user-visible (HA events etc.)

Cons:

- extra conversion step needed to get from user input (dict / json) to data
- extra step needed to get displayable data (eg. for group monitor) - `.as_dict()` is implemented for this. This is directly compatible with current HA json encoder https://github.com/home-assistant/core/blob/6a10e89f6de73623711d8add796864e29fa34c6d/homeassistant/helpers/json.py#L38 
- extra documentation effort required

Closes #1453

## 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
- [ ] The changes are documented in the changelog (docs/changelog.md)

Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index c79c94f3..6623333e 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -6,6 +6,12 @@ nav_order: 2
 
 # Changelog
 
+# Unreleased changes
+
+### 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.
+
 # 2.12.2 Fix thread leak 2024-03-05
 
 ### Bugfixes
diff --git a/xknx/devices/light.py b/xknx/devices/light.py
index 76cd0e3c..c5bd35db 100644
--- a/xknx/devices/light.py
+++ b/xknx/devices/light.py
@@ -552,7 +552,8 @@ class Light(Device):
         if new_xyy is None or self._xyy_color_valid is None:
             self._xyy_color_valid = new_xyy
         else:
-            new_color, new_brightness = new_xyy
+            new_color = new_xyy.color
+            new_brightness = new_xyy.brightness
             if new_color is None:
                 new_color = self._xyy_color_valid.color
             if new_brightness is None:
diff --git a/xknx/dpt/dpt.py b/xknx/dpt/dpt.py
index 0db01250..9cf5a59a 100644
--- a/xknx/dpt/dpt.py
+++ b/xknx/dpt/dpt.py
@@ -3,15 +3,17 @@
 from __future__ import annotations
 
 from abc import ABC, abstractmethod
-from collections.abc import Iterator
+from collections.abc import Iterator, Mapping
+from dataclasses import dataclass
 from inspect import isabstract
-from typing import Any, TypeVar, cast
+from typing import Any, Generic, TypeVar, cast
 
-from xknx.exceptions import CouldNotParseTelegram
+from xknx.exceptions import ConversionError, CouldNotParseTelegram
 
 from .payload import DPTArray, DPTBinary
 
 T = TypeVar("T", bound=type["DPTBase"])  # pylint: disable=invalid-name
+TComplexData = TypeVar("TComplexData", bound="DPTComplexData")  # pylint: disable=invalid-name
 
 
 class DPTBase(ABC):
@@ -192,3 +194,48 @@ class DPTNumeric(DPTBase):
     @abstractmethod
     def to_knx(cls, value: int | float) -> DPTArray:
         """Serialize to KNX/IP raw data."""
+
+
+# py3.10 use @dataclass(slots=True) here and for the subclasses
+@dataclass
+class DPTComplexData(ABC):
+    """Base class for KNX data point types decoding complex values."""
+
+    @classmethod
+    @abstractmethod
+    def from_dict(
+        cls, data: Mapping[str, Any]
+    ) -> DPTComplexData:  # py3.10: use typing.Self
+        """Init from a dictionary."""
+
+    @abstractmethod
+    def as_dict(self) -> dict[str, str | int | float | bool | None]:
+        """Create a JSON serializable dictionary."""
+
+
+class DPTComplex(DPTBase, Generic[TComplexData]):
+    """Base class for KNX data point types decoding complex values."""
+
+    data_type: type[TComplexData]
+
+    @classmethod
+    @abstractmethod
+    def from_knx(cls, payload: DPTArray | DPTBinary) -> TComplexData:
+        """Parse/deserialize from KNX/IP payload data."""
+
+    @classmethod
+    def to_knx(cls, value: TComplexData | Mapping[str, Any]) -> DPTArray:
+        """Serialize to KNX/IP raw data."""
+        try:
+            if isinstance(value, cls.data_type):
+                return cls._to_knx(value)
+            return cls._to_knx(cls.data_type.from_dict(value))
+        except (ValueError, TypeError, AttributeError, ConversionError) as err:
+            raise ConversionError(
+                f"Could not serialize {cls.__name__}: {err}", value=value
+            ) from err
+
+    @classmethod
+    @abstractmethod
+    def _to_knx(cls, value: TComplexData) -> DPTArray:
+        """Serialize to KNX/IP raw data."""
diff --git a/xknx/dpt/dpt_color.py b/xknx/dpt/dpt_color.py
index 8b0909d6..83583ddc 100644
--- a/xknx/dpt/dpt_color.py
+++ b/xknx/dpt/dpt_color.py
@@ -2,32 +2,68 @@
 
 from __future__ import annotations
 
-from typing import NamedTuple
+from collections.abc import Mapping
+from dataclasses import dataclass
+from typing import Any
 
-from xknx.exceptions import ConversionError
-
-from .dpt import DPTBase
+from .dpt import DPTComplex, DPTComplexData
 from .payload import DPTArray, DPTBinary
 
 
-class XYYColor(NamedTuple):
+@dataclass
+class XYYColor(DPTComplexData):
     """
     Representation of XY color with brightness.
 
     `color`: tuple(x-axis, y-axis) each 0..1; None if invalid.
     `brightness`: int 0..255; None if invalid.
-    tuple(tuple(float, float) | None, int | None)
     """
 
     color: tuple[float, float] | None = None
     brightness: int | None = None
 
-
-class DPTColorXYY(DPTBase):
+    @classmethod
+    def from_dict(cls, data: Mapping[str, Any]) -> XYYColor:
+        """Init from a dictionary."""
+        color = None
+        brightness = data.get("brightness")
+        x_axis = data.get("x_axis")
+        y_axis = data.get("y_axis")
+
+        if x_axis is not None and y_axis is not None:
+            try:
+                color = (float(x_axis), float(y_axis))
+            except ValueError as err:
+                raise ValueError(f"Invalid value for color axis: {err}") from err
+        elif x_axis is not None or y_axis is not None:
+            raise ValueError("Both x_axis and y_axis must be provided")
+
+        if brightness is not None:
+            try:
+                brightness = int(brightness)
+            except ValueError as err:
+                raise ValueError(f"Invalid value for brightness: {err}") from err
+
+        return cls(color=color, brightness=brightness)
+
+    def as_dict(self) -> dict[str, str | int | float | bool | None]:
+        """Create a JSON serializable dictionary."""
+        return {
+            "x_axis": self.color[0] if self.color is not None else None,
+            "y_axis": self.color[1] if self.color is not None else None,
+            "brightness": self.brightness,
+        }
+
+
+class DPTColorXYY(DPTComplex[XYYColor]):
     """Abstraction for KNX 6 octet color xyY (DPT 242.600)."""
 
+    data_type = XYYColor
     payload_type = DPTArray
     payload_length = 6
+    dpt_main_number = 242
+    dpt_sub_number = 600
+    value_type = "color_xyy"
 
     @classmethod
     def from_knx(cls, payload: DPTArray | DPTBinary) -> XYYColor:
@@ -53,40 +89,34 @@ class DPTColorXYY(DPTBase):
         )
 
     @classmethod
-    def to_knx(
-        cls, value: XYYColor | tuple[tuple[float, float] | None, int | None]
-    ) -> DPTArray:
+    def _to_knx(cls, value: XYYColor) -> DPTArray:
         """Serialize to KNX/IP raw data."""
-        try:
-            if not isinstance(value, XYYColor):
-                value = XYYColor(*value)
-            color_valid = False
-            brightness_valid = False
-            x_axis, y_axis, brightness = 0, 0, 0
-
-            if value.color is not None:
-                for _ in (axis for axis in value.color if not 0 <= axis <= 1):
-                    raise ValueError("Color out of range")
-                color_valid = True
-                x_axis, y_axis = (round(axis * 0xFFFF) for axis in value.color)
-
-            if value.brightness is not None:
-                if not 0 <= value.brightness <= 255:
-                    raise ValueError("Brightness out of range")
-                brightness_valid = True
-                brightness = int(value.brightness)
-
-            return DPTArray(
-                (
-                    x_axis >> 8,
-                    x_axis & 0xFF,
-                    y_axis >> 8,
-                    y_axis & 0xFF,
-                    brightness,
-                    color_valid << 1 | brightness_valid,
-                )
+        x_axis, y_axis, brightness = 0, 0, 0
+        color_valid = False
+        brightness_valid = False
+
+        if value.color is not None:
+            for axis in value.color:
+                if not 0 <= axis <= 1:
+                    raise ValueError(
+                        f"Color axis value out of range 0..1: {value.color}"
+                    )
+            x_axis, y_axis = (round(axis * 0xFFFF) for axis in value.color)
+            color_valid = True
+
+        if value.brightness is not None:
+            brightness = value.brightness
+            if not 0 <= brightness <= 255:
+                raise ValueError(f"Brightness out of range 0..255: {brightness}")
+            brightness_valid = True
+
+        return DPTArray(
+            (
+                x_axis >> 8,
+                x_axis & 0xFF,
+                y_axis >> 8,
+                y_axis & 0xFF,
+                brightness,
+                color_valid << 1 | brightness_valid,
             )
-        except (ValueError, TypeError) as err:
-            raise ConversionError(
-                f"Could not serialize {cls.__name__}", value=value
-            ) from err
+        )


Test patch:
diff --git a/test/devices_tests/light_test.py b/test/devices_tests/light_test.py
index 06ea5b82..6a0973d3 100644
--- a/test/devices_tests/light_test.py
+++ b/test/devices_tests/light_test.py
@@ -6,6 +6,7 @@ from xknx import XKNX
 from xknx.devices import Light
 from xknx.devices.light import ColorTemperatureType
 from xknx.dpt import DPTArray, DPTBinary
+from xknx.dpt.dpt_color import XYYColor
 from xknx.telegram import GroupAddress, Telegram
 from xknx.telegram.apci import GroupValueRead, GroupValueWrite
 
@@ -1075,7 +1076,7 @@ class TestLight:
             group_address_switch="1/2/3",
             group_address_xyy_color="1/2/4",
         )
-        await light.set_xyy_color(((0.52, 0.31), 25))
+        await light.set_xyy_color(XYYColor((0.52, 0.31), 25))
         assert xknx.telegrams.qsize() == 1
         telegram = xknx.telegrams.get_nowait()
         assert telegram == Telegram(
@@ -1083,7 +1084,7 @@ class TestLight:
             payload=GroupValueWrite(DPTArray((0x85, 0x1E, 0x4F, 0x5C, 0x19, 0x03))),
         )
         await xknx.devices.process(telegram)
-        assert light.current_xyy_color == ((0.52, 0.31), 25)
+        assert light.current_xyy_color == XYYColor((0.52, 0.31), 25)
 
     async def test_set_xyy_color_not_possible(self):
         """Test setting XYY value of a light not supporting it."""
@@ -1095,7 +1096,7 @@ class TestLight:
             group_address_color="1/2/4",
         )
         with patch("logging.Logger.warning") as mock_warn:
-            await light.set_xyy_color(((0.5, 0.3), 25))
+            await light.set_xyy_color(XYYColor((0.5, 0.3), 25))
 
             assert xknx.telegrams.qsize() == 0
             mock_warn.assert_called_with(
@@ -1582,7 +1583,7 @@ class TestLight:
                 payload=GroupValueWrite(DPTArray((0x2E, 0x14, 0x40, 0x00, 0x55, 0x02))),
             )
         )
-        assert light.current_xyy_color == ((0.18, 0.25), None)
+        assert light.current_xyy_color == XYYColor((0.18, 0.25), None)
         # add valid brightness
         await light.process(
             Telegram(
@@ -1590,7 +1591,7 @@ class TestLight:
                 payload=GroupValueWrite(DPTArray((0x2E, 0x14, 0x40, 0x00, 0x55, 0x03))),
             )
         )
-        assert light.current_xyy_color == ((0.18, 0.25), 85)
+        assert light.current_xyy_color == XYYColor((0.18, 0.25), 85)
         # invalid color
         await light.process(
             Telegram(
@@ -1598,7 +1599,7 @@ class TestLight:
                 payload=GroupValueWrite(DPTArray((0xD1, 0xEB, 0xB0, 0xA3, 0xA5, 0x01))),
             )
         )
-        assert light.current_xyy_color == ((0.18, 0.25), 165)
+        assert light.current_xyy_color == XYYColor((0.18, 0.25), 165)
         # both valid
         await light.process(
             Telegram(
@@ -1606,7 +1607,7 @@ class TestLight:
                 payload=GroupValueWrite(DPTArray((0xD1, 0xEB, 0xB0, 0xA3, 0xA5, 0x03))),
             )
         )
-        assert light.current_xyy_color == ((0.82, 0.69), 165)
+        assert light.current_xyy_color == XYYColor((0.82, 0.69), 165)
         # invalid brightness
         await light.process(
             Telegram(
@@ -1614,7 +1615,7 @@ class TestLight:
                 payload=GroupValueWrite(DPTArray((0x2E, 0x14, 0x40, 0x00, 0x00, 0x02))),
             )
         )
-        assert light.current_xyy_color == ((0.18, 0.25), 165)
+        assert light.current_xyy_color == XYYColor((0.18, 0.25), 165)
 
     async def test_process_tunable_white(self):
         """Test process / reading telegrams from telegram queue. Test if tunable white is processed."""
diff --git a/test/dpt_tests/dpt_color_test.py b/test/dpt_tests/dpt_color_test.py
index 6bd21e69..9c8ef8e7 100644
--- a/test/dpt_tests/dpt_color_test.py
+++ b/test/dpt_tests/dpt_color_test.py
@@ -6,95 +6,126 @@ from xknx.dpt.dpt_color import DPTArray, DPTColorXYY, XYYColor
 from xknx.exceptions import ConversionError, CouldNotParseTelegram
 
 
-class TestDPTColorXYY:
-    """Test class for KNX xyY-color objects."""
-
-    def test_xyycolor_assert_min_exceeded(self):
-        """Test initialization of DPTColorXYY with wrong value (Underflow)."""
-        with pytest.raises(ConversionError):
-            DPTColorXYY.to_knx(((-0.1, 0), 0))
-            DPTColorXYY.to_knx(((0, -0.1), 0))
-            DPTColorXYY.to_knx(((0, 0), -1))
+class TestXYYColor:
+    """Test XYYColor class."""
 
-    def test_xyycolor_to_knx_exceed_limits(self):
-        """Test initialization of DPTColorXYY with wrong value (Overflow)."""
-        with pytest.raises(ConversionError):
-            DPTColorXYY.to_knx(((1.1, 0), 0))
-            DPTColorXYY.to_knx(((0, 1.1), 0))
-            DPTColorXYY.to_knx(((0, 0), 256))
+    @pytest.mark.parametrize(
+        "data",
+        [
+            {"x_axis": 0.1, "y_axis": 0.2, "brightness": 128},
+            {"x_axis": 0.5, "y_axis": 0.5, "brightness": 255},
+            {"x_axis": 0.9, "y_axis": 0.8},
+            {"brightness": 50},
+            {},
+            {"x_axis": None, "y_axis": None, "brightness": 255},
+            {"x_axis": 0.5, "y_axis": 0.5, "brightness": None},
+            {"x_axis": None, "brightness": 255},
+            {"y_axis": None, "brightness": 255},
+        ],
+    )
+    def test_dict(self, data):
+        """Test from_dict and as_dict methods."""
+        value = XYYColor.from_dict(data)
+        assert value
+        # fields default to `None`
+        default_dict = {"x_axis": None, "y_axis": None, "brightness": None}
+        assert value.as_dict() == default_dict | data
 
-    def test_xyycolor_value_max_value(self):
-        """Test DPTColorXYY parsing and streaming."""
-        assert DPTColorXYY.to_knx(((1, 1), 255)) == DPTArray(
-            (0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x03)
-        )
-        assert DPTColorXYY.from_knx(DPTArray((0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x03))) == (
-            (1, 1),
-            255,
-        )
+    @pytest.mark.parametrize(
+        "data",
+        [
+            # incomplete data
+            {"x_axis": 0.1},
+            {"y_axis": 0.1},
+            {"x_axis": 0.1, "brightness": 128},
+            {"y_axis": 0.1, "brightness": 128},
+            # invalid data
+            {"x_axis": "a", "y_axis": 0.1, "brightness": 128},
+            {"x_axis": 0.1, "y_axis": "a", "brightness": 128},
+            {"x_axis": 0.1, "y_axis": 0.1, "brightness": "a"},
+        ],
+    )
+    def test_dict_invalid(self, data):
+        """Test from_dict and as_dict methods."""
+        with pytest.raises(ValueError):
+            XYYColor.from_dict(data)
 
-    def test_xyycolor_value_min_value(self):
-        """Test DPTColorXYY parsing and streaming with null values."""
-        assert DPTColorXYY.to_knx(((0, 0), 0)) == DPTArray(
-            (0x00, 0x00, 0x00, 0x00, 0x00, 0x03)
-        )
-        assert DPTColorXYY.from_knx(DPTArray((0x00, 0x00, 0x00, 0x00, 0x00, 0x03))) == (
-            (0, 0),
-            0,
-        )
 
-    def test_xyycolor_value_none_value(self):
-        """Test DPTColorXYY parsing and streaming with null values."""
-        assert DPTColorXYY.to_knx((None, 0)) == DPTArray(
-            (0x00, 0x00, 0x00, 0x00, 0x00, 0x01)
-        )
-        assert DPTColorXYY.from_knx(DPTArray((0x00, 0x00, 0x00, 0x00, 0x00, 0x01))) == (
-            None,
-            0,
-        )
+class TestDPTColorXYY:
+    """Test class for KNX xyY-color objects."""
 
-        assert DPTColorXYY.to_knx(((0, 0), None)) == DPTArray(
-            (0x00, 0x00, 0x00, 0x00, 0x00, 0x02)
-        )
-        assert DPTColorXYY.from_knx(DPTArray((0x00, 0x00, 0x00, 0x00, 0x00, 0x02))) == (
-            (0, 0),
-            None,
-        )
+    @pytest.mark.parametrize(
+        ("value", "raw"),
+        [
+            (XYYColor((0, 0), 0), (0x00, 0x00, 0x00, 0x00, 0x00, 0x03)),  # min values
+            (XYYColor((1, 1), 255), (0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x03)),  # max values
+            (XYYColor(None, 0), (0x00, 0x00, 0x00, 0x00, 0x00, 0x01)),  # color None
+            (XYYColor((0, 0), None), (0x00, 0x00, 0x00, 0x00, 0x00, 0x02)),
+            (XYYColor(None, None), (0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
+            (XYYColor((0.2, 0.2), 128), (0x33, 0x33, 0x33, 0x33, 0x80, 0x03)),
+            (XYYColor((0.8, 0.8), 204), (0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0x03)),
+        ],
+    )
+    def test_xyycolor_value(self, value, raw):
+        """Test DPTColorXYY parsing and streaming."""
+        knx_value = DPTColorXYY.to_knx(value)
+        assert knx_value == DPTArray(raw)
+        assert DPTColorXYY.from_knx(knx_value) == value
 
-        assert DPTColorXYY.to_knx((None, None)) == DPTArray(
-            (0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
-        )
-        assert DPTColorXYY.from_knx(DPTArray((0x00, 0x00, 0x00, 0x00, 0x00, 0x00))) == (
-            None,
-            None,
-        )
+    @pytest.mark.parametrize(
+        ("value", "raw"),
+        [
+            (
+                {"x_axis": 0.2, "y_axis": 0.2, "brightness": 128},
+                (0x33, 0x33, 0x33, 0x33, 0x80, 0x03),
+            ),
+            (
+                {"x_axis": 0.8, "y_axis": 0.8},
+                (0xCC, 0xCC, 0xCC, 0xCC, 0x00, 0x02),
+            ),
+        ],
+    )
+    def test_xyycolor_to_knx_from_dict(self, value, raw):
+        """Test DPTColorXYY parsing from a dict."""
+        knx_value = DPTColorXYY.to_knx(value)
+        assert knx_value == DPTArray(raw)
 
-    def test_xyycolor_value(self):
-        """Test DPTColorXYY parsing and streaming with valid value."""
-        assert DPTColorXYY.to_knx(((0.2, 0.2), 128)) == DPTArray(
-            (0x33, 0x33, 0x33, 0x33, 0x80, 0x03)
-        )
-        assert DPTColorXYY.from_knx(DPTArray((0x33, 0x33, 0x33, 0x33, 0x80, 0x03))) == (
-            (0.2, 0.2),
-            128,
-        )
-        assert DPTColorXYY.to_knx(
-            XYYColor(color=(0.8, 0.8), brightness=204)
-        ) == DPTArray((0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0x03))
-        assert DPTColorXYY.from_knx(
-            DPTArray((0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0x03))
-        ) == XYYColor(color=(0.8, 0.8), brightness=204)
+    @pytest.mark.parametrize(
+        ("color", "brightness"),
+        [
+            ((-0.1, 0), 0),
+            ((0, -0.1), 0),
+            ((0, 0), -1),
+            ((1.1, 0), 0),
+            ((0, 1.1), 0),
+            ((0, 0), 256),
+        ],
+    )
+    def test_xyycolor_to_knx_limits(self, color, brightness):
+        """Test initialization of DPTColorXYY with wrong value."""
+        value = XYYColor(color=color, brightness=brightness)
+        with pytest.raises(ConversionError):
+            DPTColorXYY.to_knx(value)
 
-    def test_xyycolor_wrong_value_to_knx(self):
+    @pytest.mark.parametrize(
+        "value",
+        [
+            None,
+            (0xFF, 0x4E, 0x12),
+            1,
+            ((0x00, 0xFF, 0x4E), 0x12),
+            ((0xFF, 0x4E), (0x12, 0x00)),
+            ((0, 0), "a"),
+            ((0, 0), 0.4),
+            XYYColor(color=(0, 0), brightness="a"),
+            XYYColor(color=4, brightness=4),
+            XYYColor(color=("a", 0), brightness=1),
+        ],
+    )
+    def test_xyycolor_wrong_value_to_knx(self, value):
         """Test DPTColorXYY parsing with wrong value."""
         with pytest.raises(ConversionError):
-            DPTColorXYY.to_knx(None)
-            DPTColorXYY.to_knx((0xFF, 0x4E, 0x12))
-            DPTColorXYY.to_knx(1)
-            DPTColorXYY.to_knx(((0x00, 0xFF, 0x4E), 0x12))
-            DPTColorXYY.to_knx(((0xFF, 0x4E), (0x12, 0x00)))
-            DPTColorXYY.to_knx(((0, 0), "a"))
-            DPTColorXYY.to_knx(((0, 0), 0.4))
+            DPTColorXYY.to_knx(value)
 
     def test_xyycolor_wrong_value_from_knx(self):
         """Test DPTColorXYY parsing with wrong value."""
diff --git a/test/remote_value_tests/remote_value_color_xyy_test.py b/test/remote_value_tests/remote_value_color_xyy_test.py
index e2987c7d..cbd09ba7 100644
--- a/test/remote_value_tests/remote_value_color_xyy_test.py
+++ b/test/remote_value_tests/remote_value_color_xyy_test.py
@@ -4,6 +4,7 @@ import pytest
 
 from xknx import XKNX
 from xknx.dpt import DPTArray, DPTBinary
+from xknx.dpt.dpt_color import XYYColor
 from xknx.exceptions import ConversionError
 from xknx.remote_value import RemoteValueColorXYY
 from xknx.telegram import GroupAddress, Telegram
@@ -17,10 +18,10 @@ class TestRemoteValueColorXYY:
         """Test to_knx function with normal operation."""
         xknx = XKNX()
         remote_value = RemoteValueColorXYY(xknx)
-        assert remote_value.to_knx(((1, 0.9), 102)) == DPTArray(
+        assert remote_value.to_knx(XYYColor((1, 0.9), 102)) == DPTArray(
             (0xFF, 0xFF, 0xE6, 0x66, 0x66, 0x03)
         )
-        assert remote_value.to_knx(((1, 0), 102)) == DPTArray(
+        assert remote_value.to_knx(XYYColor((1, 0), 102)) == DPTArray(
             (0xFF, 0xFF, 0x00, 0x00, 0x66, 0x03)
         )
 
@@ -30,37 +31,37 @@ class TestRemoteValueColorXYY:
         remote_value = RemoteValueColorXYY(xknx)
         assert remote_value.from_knx(
             DPTArray((0x99, 0x99, 0x99, 0x99, 0x66, 0x03))
-        ) == ((0.6, 0.6), 102)
+        ) == XYYColor((0.6, 0.6), 102)
 
     def test_to_knx_error(self):
         """Test to_knx function with wrong parametern."""
         xknx = XKNX()
         remote_value = RemoteValueColorXYY(xknx)
         with pytest.raises(ConversionError):
-            remote_value.to_knx(((2, 1), 1))
+            remote_value.to_knx(XYYColor((2, 1), 1))
         with pytest.raises(ConversionError):
-            remote_value.to_knx(((-1, 1), 2))
+            remote_value.to_knx(XYYColor((-1, 1), 2))
         with pytest.raises(ConversionError):
-            remote_value.to_knx(((0.3, 0.5), 256))
+            remote_value.to_knx(XYYColor((0.3, 0.5), 256))
         with pytest.raises(ConversionError):
-            remote_value.to_knx((("0.4", 0), 102))
+            remote_value.to_knx(XYYColor(("0.4", 0), 102))
         with pytest.raises(ConversionError):
-            remote_value.to_knx(((1, 1), "102"))
+            remote_value.to_knx(XYYColor((1, 1), "102"))
         with pytest.raises(ConversionError):
-            remote_value.to_knx(((1,), 1))
+            remote_value.to_knx(XYYColor((1,), 1))
 
     async def test_set(self):
         """Test setting value."""
         xknx = XKNX()
         remote_value = RemoteValueColorXYY(xknx, group_address=GroupAddress("1/2/3"))
-        await remote_value.set(((1, 0.9), 102))
+        await remote_value.set(XYYColor((1, 0.9), 102))
         assert xknx.telegrams.qsize() == 1
         telegram = xknx.telegrams.get_nowait()
         assert telegram == Telegram(
             destination_address=GroupAddress("1/2/3"),
             payload=GroupValueWrite(DPTArray((0xFF, 0xFF, 0xE6, 0x66, 0x66, 0x03))),
         )
-        await remote_value.set(((1, 0.9), 255))
+        await remote_value.set(XYYColor((1, 0.9), 255))
         assert xknx.telegrams.qsize() == 1
         telegram = xknx.telegrams.get_nowait()
         assert telegram == Telegram(
@@ -77,7 +78,7 @@ class TestRemoteValueColorXYY:
             payload=GroupValueWrite(DPTArray((0xFF, 0xFF, 0x66, 0x66, 0xFA, 0x03))),
         )
         await remote_value.process(telegram)
-        assert remote_value.value == ((1, 0.4), 250)
+        assert remote_value.value == XYYColor((1, 0.4), 250)
 
     async def test_to_process_error(self):
         """Test process erroneous telegram."""