Repository: XKNX/xknx
Pull request #701 (2021-05-26 21:03:40)

Light: support xyy-colors
<!--
  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.
-->
Support xyY-color DPT 242.600

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)
- [x] This change requires a documentation update

## Checklist:

- [ ] I have performed a self-review of my own code
- [x] The documentation has been adjusted accordingly
- [x] The changes generate no new warnings
- [x] Tests have been added that prove the fix is effective or that the feature works
- [x] The changes are documented in the changelog
- [ ] The Homeassistant plugin has been adjusted in case of new config options


codecov[bot]: # [Codecov](https://codecov.io/gh/XKNX/xknx/pull/701?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) Report
> Merging [#701](https://codecov.io/gh/XKNX/xknx/pull/701?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) (63facde) into [main](https://codecov.io/gh/XKNX/xknx/commit/bc6b84ff47cb821455191f80f8c370cb28e075f6?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) (bc6b84f) will **decrease** coverage by `0.23%`.
> The diff coverage is `75.60%`.

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

```diff
@@            Coverage Diff             @@
##             main     #701      +/-   ##
==========================================
- Coverage   94.46%   94.22%   -0.24%     
==========================================
  Files         101      103       +2     
  Lines        6465     6547      +82     
==========================================
+ Hits         6107     6169      +62     
- Misses        358      378      +20     
```


| [Impacted Files](https://codecov.io/gh/XKNX/xknx/pull/701?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) | Coverage Δ | |
|---|---|---|
| [xknx/devices/light.py](https://codecov.io/gh/XKNX/xknx/pull/701/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9kZXZpY2VzL2xpZ2h0LnB5) | `91.66% <38.46%> (-7.78%)` | :arrow_down: |
| [xknx/remote\_value/remote\_value\_color\_xyy.py](https://codecov.io/gh/XKNX/xknx/pull/701/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9yZW1vdGVfdmFsdWUvcmVtb3RlX3ZhbHVlX2NvbG9yX3h5eS5weQ==) | `80.00% <80.00%> (ø)` | |
| [xknx/dpt/dpt\_color.py](https://codecov.io/gh/XKNX/xknx/pull/701/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9kcHQvZHB0X2NvbG9yLnB5) | `97.43% <97.43%> (ø)` | |
| [xknx/dpt/\_\_init\_\_.py](https://codecov.io/gh/XKNX/xknx/pull/701/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9kcHQvX19pbml0X18ucHk=) | `100.00% <100.00%> (ø)` | |
| [xknx/remote\_value/\_\_init\_\_.py](https://codecov.io/gh/XKNX/xknx/pull/701/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9yZW1vdGVfdmFsdWUvX19pbml0X18ucHk=) | `100.00% <100.00%> (ø)` | |


Patch:
diff --git a/changelog.md b/changelog.md
index 2d2f3f66..b867462a 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,5 +1,11 @@
 # Changelog
 
+## Unreleased changes
+
+### Devices
+
+- Light: support for xyY-color (DPT 242.600)
+
 ## 0.18.2 Climate and Light improvements 2021-05-11
 
 ### Devices
diff --git a/docs/light.md b/docs/light.md
index 53b00892..7c5a2c4e 100644
--- a/docs/light.md
+++ b/docs/light.md
@@ -23,6 +23,8 @@ The Light object is either a representation of a binary or dimm actor, LED-contr
 - `group_address_color_state` KNX group address for the current RGB color. *DPT 232.600*
 - `group_address_rgbw` KNX group address to set the RGBW color. *DPT 251.600*
 - `group_address_rgbw_state` KNX group address for the current RGBW color. *DPT 251.600*
+- `group_address_xyy_color`: KNX group address to set the xyY color. *DPT 242.600*
+- `group_address_xyy_color_state`: KNX group address for the current xyY color. *DPT 242.600*
 - `group_address_tunable_white` KNX group address to set relative color temperature. *DPT 5.001*
 - `group_address_tunable_white_state` KNX group address for the current relative color temperature. *DPT 5.001*
 - `group_address_color_temperature` KNX group address to set absolute color temperature. *DPT 7.600*
diff --git a/xknx/devices/light.py b/xknx/devices/light.py
index 86287ed1..91adb5b4 100644
--- a/xknx/devices/light.py
+++ b/xknx/devices/light.py
@@ -16,11 +16,13 @@ from enum import Enum
 import logging
 from typing import TYPE_CHECKING, Any, Awaitable, Callable, Iterator, Tuple, cast
 
+from xknx.dpt.dpt_color import XYYColor
 from xknx.remote_value import (
     GroupAddressesType,
     RemoteValue,
     RemoteValueColorRGB,
     RemoteValueColorRGBW,
+    RemoteValueColorXYY,
     RemoteValueDpt2ByteUnsigned,
     RemoteValueScaling,
     RemoteValueSwitch,
@@ -121,6 +123,8 @@ class Light(Device):
         group_address_color_state: GroupAddressesType | None = None,
         group_address_rgbw: GroupAddressesType | None = None,
         group_address_rgbw_state: GroupAddressesType | None = None,
+        group_address_xyy_color: GroupAddressesType | None = None,
+        group_address_xyy_color_state: GroupAddressesType | None = None,
         group_address_tunable_white: GroupAddressesType | None = None,
         group_address_tunable_white_state: GroupAddressesType | None = None,
         group_address_color_temperature: GroupAddressesType | None = None,
@@ -184,6 +188,15 @@ class Light(Device):
             after_update_cb=self.after_update,
         )
 
+        self._xyy_color_valid: XYYColor | None = None
+        self.xyy_color = RemoteValueColorXYY(
+            xknx,
+            group_address_xyy_color,
+            group_address_xyy_color_state,
+            device_name=self.name,
+            after_update_cb=self._xyy_color_from_rv,
+        )
+
         self.tunable_white = RemoteValueScaling(
             xknx,
             group_address_tunable_white,
@@ -257,6 +270,7 @@ class Light(Device):
         yield self.brightness
         yield self.color
         yield self.rgbw
+        yield self.xyy_color
         yield self.tunable_white
         yield self.color_temperature
         for color in self._iter_individual_colors():
@@ -297,6 +311,11 @@ class Light(Device):
             c.brightness.initialized for c in self._iter_individual_colors()
         )
 
+    @property
+    def supports_xyy_color(self) -> bool:
+        """Return if light supports xyY-color."""
+        return self.xyy_color.initialized
+
     @property
     def supports_tunable_white(self) -> bool:
         """Return if light supports tunable white / relative color temperature."""
@@ -493,6 +512,32 @@ class Light(Device):
                     return
             logger.warning("Colors not supported for device %s", self.get_name())
 
+    async def _xyy_color_from_rv(self) -> None:
+        """Update the current xyY-color from RemoteValue (Callback)."""
+        new_xyy = self.xyy_color.value
+        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
+            if new_color is None:
+                new_color = self._xyy_color_valid.color
+            if new_brightness is None:
+                new_brightness = self._xyy_color_valid.brightness
+            self._xyy_color_valid = XYYColor(color=new_color, brightness=new_brightness)
+        await self.after_update()
+
+    @property
+    def current_xyy_color(self) -> XYYColor | None:
+        """Return current xyY-color of the light."""
+        return self._xyy_color_valid
+
+    async def set_xyy_color(self, xyy: XYYColor) -> None:
+        """Set xyY-color of the light."""
+        if not self.supports_xyy_color:
+            logger.warning("XYY-color not supported for device %s", self.get_name())
+            return
+        await self.xyy_color.set(xyy)
+
     @property
     def current_tunable_white(self) -> int | None:
         """Return current relative color temperature of light."""
diff --git a/xknx/dpt/__init__.py b/xknx/dpt/__init__.py
index aa0c3182..92d882f8 100644
--- a/xknx/dpt/__init__.py
+++ b/xknx/dpt/__init__.py
@@ -167,6 +167,7 @@ from .dpt_4byte_int import (
     DPTReactiveEnergykVARh,
     DPTValue4Count,
 )
+from .dpt_color import DPTColorXYY
 from .dpt_date import DPTDate
 from .dpt_datetime import DPTDateTime
 from .dpt_hvac_mode import DPTControllerStatus, DPTHVACContrMode, DPTHVACMode
@@ -207,6 +208,7 @@ __all__ = [
     "DPTChargeDensitySurface",
     "DPTChargeDensityVolume",
     "DPTColorTemperature",
+    "DPTColorXYY",
     "DPTCommonTemperature",
     "DPTCompressibility",
     "DPTConductance",
diff --git a/xknx/dpt/dpt.py b/xknx/dpt/dpt.py
index 8eb88a5c..ba163d69 100644
--- a/xknx/dpt/dpt.py
+++ b/xknx/dpt/dpt.py
@@ -164,6 +164,10 @@ class DPTBinary:
             return self.value == other.value
         return False
 
+    def __repr__(self) -> str:
+        """Return object representation."""
+        return f"DPTBinary({hex(self.value)})"
+
     def __str__(self) -> str:
         """Return object as readable string."""
         return f'<DPTBinary value="{self.value}" />'
@@ -192,6 +196,10 @@ class DPTArray:
             return self.value == other.value
         return False
 
+    def __repr__(self) -> str:
+        """Return object representation."""
+        return "DPTArray(({}))".format(", ".join(hex(b) for b in self.value))
+
     def __str__(self) -> str:
         """Return object as readable string."""
         return '<DPTArray value="[{}]" />'.format(",".join(hex(b) for b in self.value))
diff --git a/xknx/dpt/dpt_color.py b/xknx/dpt/dpt_color.py
new file mode 100644
index 00000000..cd203967
--- /dev/null
+++ b/xknx/dpt/dpt_color.py
@@ -0,0 +1,85 @@
+"""Implementation of the KNX date data point."""
+from __future__ import annotations
+
+from typing import NamedTuple
+
+from xknx.exceptions import ConversionError
+
+from .dpt import DPTBase
+
+
+class XYYColor(NamedTuple):
+    """
+    Representation of XY color with brightness.
+
+    `color`: tuple(x-axis, y-axis) each 0..1; None if invalid.
+    `brigtness`: 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):
+    """Abstraction for KNX 6 octet color xyY (DPT 242.600)."""
+
+    payload_length = 6
+
+    @classmethod
+    def from_knx(cls, raw: tuple[int, ...]) -> XYYColor:
+        """Parse/deserialize from KNX/IP raw data."""
+        cls.test_bytesarray(raw)
+
+        x_axis_int = raw[0] << 8 | raw[1]
+        y_axis_int = raw[2] << 8 | raw[3]
+        brightness = raw[4]
+
+        color_valid = raw[5] >> 1 & 0b1
+        brightness_valid = raw[5] & 0b1
+
+        return XYYColor(
+            color=(
+                # round to 5 digits for better readability but still preserving precicion
+                round(x_axis_int / 0xFFFF, 5),
+                round(y_axis_int / 0xFFFF, 5),
+            )
+            if color_valid
+            else None,
+            brightness=brightness if brightness_valid else None,
+        )
+
+    @classmethod
+    def to_knx(
+        cls, value: XYYColor | tuple[tuple[float, float] | None, int | None]
+    ) -> tuple[int, int, int, int, int, int]:
+        """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_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_valid = True
+                brightness = int(value.brightness)
+
+            return (
+                x_axis >> 8,
+                x_axis & 0xFF,
+                y_axis >> 8,
+                y_axis & 0xFF,
+                brightness,
+                color_valid << 1 | brightness_valid,
+            )
+        except (ValueError, TypeError):
+            raise ConversionError("Could not serialize %s" % cls.__name__, value=value)
diff --git a/xknx/remote_value/__init__.py b/xknx/remote_value/__init__.py
index 5c2df5cd..059427cc 100644
--- a/xknx/remote_value/__init__.py
+++ b/xknx/remote_value/__init__.py
@@ -10,6 +10,7 @@ from .remote_value_climate_mode import (
 )
 from .remote_value_color_rgb import RemoteValueColorRGB
 from .remote_value_color_rgbw import RemoteValueColorRGBW
+from .remote_value_color_xyy import RemoteValueColorXYY
 from .remote_value_control import RemoteValueControl
 from .remote_value_datetime import RemoteValueDateTime
 from .remote_value_dpt_2_byte_unsigned import RemoteValueDpt2ByteUnsigned
@@ -32,6 +33,7 @@ __all__ = [
     "RemoteValueBinaryOperationMode",
     "RemoteValueColorRGB",
     "RemoteValueColorRGBW",
+    "RemoteValueColorXYY",
     "RemoteValueControl",
     "RemoteValueControllerMode",
     "RemoteValueDateTime",
diff --git a/xknx/remote_value/remote_value_color_xyy.py b/xknx/remote_value/remote_value_color_xyy.py
new file mode 100644
index 00000000..746f9666
--- /dev/null
+++ b/xknx/remote_value/remote_value_color_xyy.py
@@ -0,0 +1,59 @@
+"""
+Module for managing an xyY-color remote value.
+
+DPT 242.600.
+"""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from xknx.dpt import DPTArray, DPTBinary
+from xknx.dpt.dpt_color import DPTColorXYY, XYYColor
+
+from .remote_value import AsyncCallbackType, GroupAddressesType, RemoteValue
+
+if TYPE_CHECKING:
+    from xknx.xknx import XKNX
+
+
+class RemoteValueColorXYY(RemoteValue[DPTArray, XYYColor]):
+    """Abstraction for remote value of KNX DPT 242.600 (DPT_Colour_xyY)."""
+
+    # pylint: disable=no-self-use
+    PAYLOAD_LENGTH = 6
+
+    def __init__(
+        self,
+        xknx: XKNX,
+        group_address: GroupAddressesType | None = None,
+        group_address_state: GroupAddressesType | None = None,
+        device_name: str | None = None,
+        feature_name: str = "Color xyY",
+        after_update_cb: AsyncCallbackType | None = None,
+    ):
+        """Initialize remote value of KNX DPT 242.600 (DPT_Colour_xyY)."""
+        super().__init__(
+            xknx,
+            group_address,
+            group_address_state,
+            device_name=device_name,
+            feature_name=feature_name,
+            after_update_cb=after_update_cb,
+        )
+
+    def payload_valid(self, payload: DPTArray | DPTBinary | None) -> DPTArray | None:
+        """Test if telegram payload may be parsed."""
+        return (
+            payload
+            if isinstance(payload, DPTArray)
+            and len(payload.value) == self.PAYLOAD_LENGTH
+            else None
+        )
+
+    def to_knx(self, value: XYYColor) -> DPTArray:
+        """Convert value to payload."""
+        return DPTArray(DPTColorXYY.to_knx(value))
+
+    def from_knx(self, payload: DPTArray) -> XYYColor:
+        """Convert current payload to value."""
+        return DPTColorXYY.from_knx(payload.value)


Test patch:
diff --git a/test/devices_tests/light_test.py b/test/devices_tests/light_test.py
index 4d04b15e..766a8849 100644
--- a/test/devices_tests/light_test.py
+++ b/test/devices_tests/light_test.py
@@ -17,7 +17,7 @@ class TestLight:
     #
     # TEST SUPPORT DIMMING
     #
-    def test_supports_dimm_yes(self):
+    def test_supports_dimm_true(self):
         """Test supports_dimm attribute with a light with dimmer."""
         xknx = XKNX()
         light = Light(
@@ -28,7 +28,7 @@ class TestLight:
         )
         assert light.supports_brightness
 
-    def test_supports_dimm_no(self):
+    def test_supports_dimm_false(self):
         """Test supports_dimm attribute with a Light without dimmer."""
         xknx = XKNX()
         light = Light(xknx, "Diningroom.Light_1", group_address_switch="1/6/4")
@@ -189,6 +189,23 @@ class TestLight:
         )
         assert not light.supports_rgbw
 
+    def test_supports_xyy_color_true(self):
+        """Test supports_xyy_color true."""
+        xknx = XKNX()
+        light = Light(
+            xknx,
+            "Diningroom.Light_1",
+            group_address_switch="1/6/4",
+            group_address_xyy_color="1/6/5",
+        )
+        assert light.supports_xyy_color
+
+    def test_supports_xyy_color_false(self):
+        """Test supports_xyy_color false."""
+        xknx = XKNX()
+        light = Light(xknx, "Diningroom.Light_1", group_address_switch="1/6/4")
+        assert not light.supports_xyy_color
+
     #
     # TEST SUPPORT TUNABLE WHITE
     #
@@ -242,16 +259,18 @@ class TestLight:
             group_address_switch_state="1/2/3",
             group_address_brightness_state="1/2/5",
             group_address_color_state="1/2/6",
+            group_address_xyy_color_state="1/2/4",
             group_address_tunable_white_state="1/2/7",
             group_address_color_temperature_state="1/2/8",
             group_address_rgbw_state="1/2/9",
         )
-        await light.sync()
+        expected_telegrams = 7
 
-        assert xknx.telegrams.qsize() == 6
+        await light.sync()
+        assert xknx.telegrams.qsize() == expected_telegrams
 
         telegrams = []
-        for _ in range(6):
+        for _ in range(expected_telegrams):
             telegrams.append(xknx.telegrams.get_nowait())
 
         test_telegrams = [
@@ -264,6 +283,9 @@ class TestLight:
             Telegram(
                 destination_address=GroupAddress("1/2/6"), payload=GroupValueRead()
             ),
+            Telegram(
+                destination_address=GroupAddress("1/2/4"), payload=GroupValueRead()
+            ),
             Telegram(
                 destination_address=GroupAddress("1/2/9"), payload=GroupValueRead()
             ),
@@ -274,9 +296,8 @@ class TestLight:
                 destination_address=GroupAddress("1/2/8"), payload=GroupValueRead()
             ),
         ]
-
-        assert len(telegrams) == 6
-        assert telegrams == test_telegrams
+        assert len(set(telegrams)) == expected_telegrams
+        assert set(telegrams) == set(test_telegrams)
 
     async def test_sync_individual_color(self):
         """Test sync function / sending group reads to KNX bus. Testing with a Light without dimm functionality."""
@@ -341,64 +362,9 @@ class TestLight:
                 payload=GroupValueRead(),
             ),
         ]
-
         assert len(set(telegrams)) == 8
         assert set(telegrams) == set(test_telegrams)
 
-    #
-    # SYNC WITH STATE ADDRESS
-    #
-    async def test_sync_state_address(self):
-        """Test sync function / sending group reads to KNX bus. Testing with a Light with dimm functionality."""
-        xknx = XKNX()
-        light = Light(
-            xknx,
-            name="TestLight",
-            group_address_switch="1/2/3",
-            group_address_switch_state="1/2/4",
-            group_address_brightness="1/2/5",
-            group_address_brightness_state="1/2/6",
-            group_address_color="1/2/7",
-            group_address_color_state="1/2/8",
-            group_address_tunable_white="1/2/9",
-            group_address_tunable_white_state="1/2/10",
-            group_address_color_temperature="1/2/11",
-            group_address_color_temperature_state="1/2/12",
-            group_address_rgbw="1/2/13",
-            group_address_rgbw_state="1/2/14",
-        )
-        await light.sync()
-
-        assert xknx.telegrams.qsize() == 6
-
-        telegrams = []
-        for _ in range(6):
-            telegrams.append(xknx.telegrams.get_nowait())
-
-        test_telegrams = [
-            Telegram(
-                destination_address=GroupAddress("1/2/4"), payload=GroupValueRead()
-            ),
-            Telegram(
-                destination_address=GroupAddress("1/2/6"), payload=GroupValueRead()
-            ),
-            Telegram(
-                destination_address=GroupAddress("1/2/8"), payload=GroupValueRead()
-            ),
-            Telegram(
-                destination_address=GroupAddress("1/2/14"), payload=GroupValueRead()
-            ),
-            Telegram(
-                destination_address=GroupAddress("1/2/10"), payload=GroupValueRead()
-            ),
-            Telegram(
-                destination_address=GroupAddress("1/2/12"), payload=GroupValueRead()
-            ),
-        ]
-
-        assert len(telegrams) == 6
-        assert telegrams == test_telegrams
-
     #
     # TEST SET ON
     #
@@ -938,6 +904,45 @@ class TestLight:
                 "RGBW not supported for device %s", "TestLight"
             )
 
+    #
+    # TEST SET COLOR AS RGBW
+    #
+    async def test_set_xyy_color(self):
+        """Test setting XYY value of a Light."""
+        xknx = XKNX()
+        light = Light(
+            xknx,
+            name="TestLight",
+            group_address_switch="1/2/3",
+            group_address_xyy_color="1/2/4",
+        )
+        await light.set_xyy_color(((0.52, 0.31), 25))
+        assert xknx.telegrams.qsize() == 1
+        telegram = xknx.telegrams.get_nowait()
+        assert telegram == Telegram(
+            destination_address=GroupAddress("1/2/4"),
+            payload=GroupValueWrite(DPTArray((0x85, 0x1E, 0x4F, 0x5C, 0x19, 0x03))),
+        )
+        await xknx.devices.process(telegram)
+        assert light.current_xyy_color == ((0.52, 0.31), 25)
+
+    async def test_set_xyy_color_not_possible(self):
+        """Test setting XYY value of a non light without color."""
+        xknx = XKNX()
+        light = Light(
+            xknx,
+            name="TestLight",
+            group_address_switch="1/2/3",
+            group_address_color="1/2/4",
+        )
+        with patch("logging.Logger.warning") as mock_warn:
+            await light.set_xyy_color(((0.5, 0.3), 25))
+
+            assert xknx.telegrams.qsize() == 0
+            mock_warn.assert_called_with(
+                "XYY-color not supported for device %s", "TestLight"
+            )
+
     #
     # TEST SET TUNABLE WHITE
     #
@@ -1246,6 +1251,57 @@ class TestLight:
         await light.process(telegram)
         assert light.current_color == (None, 42)
 
+    async def test_process_xyy_color(self):
+        """Test process / reading telegrams from telegram queue. Test if color is processed."""
+        xknx = XKNX()
+        light = Light(
+            xknx,
+            name="TestLight",
+            group_address_switch="1/2/3",
+            group_address_xyy_color="1/2/5",
+        )
+        assert light.current_xyy_color is None
+        # initial with invalid brightness
+        await light.process(
+            Telegram(
+                destination_address=GroupAddress("1/2/5"),
+                payload=GroupValueWrite(DPTArray((0x2E, 0x14, 0x40, 0x00, 0x55, 0x02))),
+            )
+        )
+        assert light.current_xyy_color == ((0.18, 0.25), None)
+        # add valid brightness
+        await light.process(
+            Telegram(
+                destination_address=GroupAddress("1/2/5"),
+                payload=GroupValueWrite(DPTArray((0x2E, 0x14, 0x40, 0x00, 0x55, 0x03))),
+            )
+        )
+        assert light.current_xyy_color == ((0.18, 0.25), 85)
+        # invalid color
+        await light.process(
+            Telegram(
+                destination_address=GroupAddress("1/2/5"),
+                payload=GroupValueWrite(DPTArray((0xD1, 0xEB, 0xB0, 0xA3, 0xA5, 0x01))),
+            )
+        )
+        assert light.current_xyy_color == ((0.18, 0.25), 165)
+        # both valid
+        await light.process(
+            Telegram(
+                destination_address=GroupAddress("1/2/5"),
+                payload=GroupValueWrite(DPTArray((0xD1, 0xEB, 0xB0, 0xA3, 0xA5, 0x03))),
+            )
+        )
+        assert light.current_xyy_color == ((0.82, 0.69), 165)
+        # invalid brightness
+        await light.process(
+            Telegram(
+                destination_address=GroupAddress("1/2/5"),
+                payload=GroupValueWrite(DPTArray((0x2E, 0x14, 0x40, 0x00, 0x00, 0x02))),
+            )
+        )
+        assert light.current_xyy_color == ((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."""
         xknx = XKNX()
@@ -1371,6 +1427,8 @@ class TestLight:
             group_address_color_temperature_state="1/7/10",
             group_address_rgbw="1/7/11",
             group_address_rgbw_state="1/7/12",
+            group_address_xyy_color="1/7/13",
+            group_address_xyy_color_state="1/7/14",
             group_address_switch_red="1/1/1",
             group_address_switch_red_state="1/1/2",
             group_address_brightness_red="1/1/3",
@@ -1400,6 +1458,8 @@ class TestLight:
         assert light.has_group_address(GroupAddress("1/7/10"))
         assert light.has_group_address(GroupAddress("1/7/11"))
         assert light.has_group_address(GroupAddress("1/7/12"))
+        assert light.has_group_address(GroupAddress("1/7/13"))
+        assert light.has_group_address(GroupAddress("1/7/14"))
 
         assert light.has_group_address(GroupAddress("1/1/1"))
         assert light.has_group_address(GroupAddress("1/1/2"))
@@ -1418,7 +1478,7 @@ class TestLight:
         assert light.has_group_address(GroupAddress("1/1/15"))
         assert light.has_group_address(GroupAddress("1/1/16"))
 
-        assert not light.has_group_address(GroupAddress("1/7/13"))
+        assert not light.has_group_address(GroupAddress("1/7/15"))
 
     def test_unique_id(self):
         """Test unique id functionality."""
diff --git a/test/dpt_tests/dpt_color_test.py b/test/dpt_tests/dpt_color_test.py
new file mode 100644
index 00000000..76811616
--- /dev/null
+++ b/test/dpt_tests/dpt_color_test.py
@@ -0,0 +1,101 @@
+"""Unit test for KNX color objects."""
+import pytest
+from xknx.dpt.dpt_color import DPTColorXYY, XYYColor
+from xknx.exceptions import ConversionError
+
+
+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))
+
+    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))
+
+    def test_xyycolor_value_max_value(self):
+        """Test DPTColorXYY parsing and streaming."""
+        assert DPTColorXYY.to_knx(((1, 1), 255)) == (0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x03)
+        assert DPTColorXYY.from_knx((0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x03)) == (
+            (1, 1),
+            255,
+        )
+
+    def test_xyycolor_value_min_value(self):
+        """Test DPTColorXYY parsing and streaming with null values."""
+        assert DPTColorXYY.to_knx(((0, 0), 0)) == (0x00, 0x00, 0x00, 0x00, 0x00, 0x03)
+        assert DPTColorXYY.from_knx((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)) == (0x00, 0x00, 0x00, 0x00, 0x00, 0x01)
+        assert DPTColorXYY.from_knx((0x00, 0x00, 0x00, 0x00, 0x00, 0x01)) == (None, 0)
+
+        assert DPTColorXYY.to_knx(((0, 0), None)) == (
+            0x00,
+            0x00,
+            0x00,
+            0x00,
+            0x00,
+            0x02,
+        )
+        assert DPTColorXYY.from_knx((0x00, 0x00, 0x00, 0x00, 0x00, 0x02)) == (
+            (0, 0),
+            None,
+        )
+
+        assert DPTColorXYY.to_knx((None, None)) == (0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
+        assert DPTColorXYY.from_knx((0x00, 0x00, 0x00, 0x00, 0x00, 0x00)) == (
+            None,
+            None,
+        )
+
+    def test_xyycolor_value(self):
+        """Test DPTColorXYY parsing and streaming with valid value."""
+        assert DPTColorXYY.to_knx(((0.2, 0.2), 128)) == (
+            0x33,
+            0x33,
+            0x33,
+            0x33,
+            0x80,
+            0x03,
+        )
+        assert DPTColorXYY.from_knx((0x33, 0x33, 0x33, 0x33, 0x80, 0x03)) == (
+            (0.2, 0.2),
+            128,
+        )
+        assert DPTColorXYY.to_knx(XYYColor(color=(0.8, 0.8), brightness=204)) == (
+            0xCC,
+            0xCC,
+            0xCC,
+            0xCC,
+            0xCC,
+            0x03,
+        )
+        assert DPTColorXYY.from_knx((0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0x03)) == XYYColor(
+            color=(0.8, 0.8), brightness=204
+        )
+
+    def test_xyycolor_wrong_value_to_knx(self):
+        """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))
+
+    def test_xyycolor_wrong_value_from_knx(self):
+        """Test DPTColorXYY parsing with wrong value."""
+        with pytest.raises(ConversionError):
+            DPTColorXYY.from_knx((0xFF, 0x4E, 0x12))
diff --git a/test/dpt_tests/dpt_test.py b/test/dpt_tests/dpt_test.py
index 1591a5c7..cbe8997f 100644
--- a/test/dpt_tests/dpt_test.py
+++ b/test/dpt_tests/dpt_test.py
@@ -69,6 +69,11 @@ class TestDPT:
         with pytest.raises(TypeError):
             DPTArray("bla")
 
+    def test_dpt_representation(self):
+        """Test representation of DPTBinary and DPTArray."""
+        assert DPTBinary(True).__repr__() == "DPTBinary(0x1)"
+        assert DPTArray((5, 15)).__repr__() == "DPTArray((0x5, 0xf))"
+
 
 class TestDPTBase:
     """Test class for transcoder base object."""
diff --git a/test/remote_value_tests/remote_value_color_xyy_test.py b/test/remote_value_tests/remote_value_color_xyy_test.py
new file mode 100644
index 00000000..8cd5184f
--- /dev/null
+++ b/test/remote_value_tests/remote_value_color_xyy_test.py
@@ -0,0 +1,96 @@
+"""Unit test for RemoteValueColorXYY objects."""
+import pytest
+from xknx import XKNX
+from xknx.dpt import DPTArray, DPTBinary
+from xknx.exceptions import ConversionError, CouldNotParseTelegram
+from xknx.remote_value import RemoteValueColorXYY
+from xknx.telegram import GroupAddress, Telegram
+from xknx.telegram.apci import GroupValueWrite
+
+
+@pytest.mark.asyncio
+class TestRemoteValueColorXYY:
+    """Test class for RemoteValueColorXYY objects."""
+
+    def test_to_knx(self):
+        """Test to_knx function with normal operation."""
+        xknx = XKNX()
+        remote_value = RemoteValueColorXYY(xknx)
+        assert remote_value.to_knx(((1, 0.9), 102)) == DPTArray(
+            (0xFF, 0xFF, 0xE6, 0x66, 0x66, 0x03)
+        )
+        assert remote_value.to_knx(((1, 0), 102)) == DPTArray(
+            (0xFF, 0xFF, 0x00, 0x00, 0x66, 0x03)
+        )
+
+    def test_from_knx(self):
+        """Test from_knx function with normal operation."""
+        xknx = XKNX()
+        remote_value = RemoteValueColorXYY(xknx)
+        assert remote_value.from_knx(
+            DPTArray((0x99, 0x99, 0x99, 0x99, 0x66, 0x03))
+        ) == ((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))
+        with pytest.raises(ConversionError):
+            remote_value.to_knx(((-1, 1), 2))
+        with pytest.raises(ConversionError):
+            remote_value.to_knx(((0.3, 0.5), 256))
+        with pytest.raises(ConversionError):
+            remote_value.to_knx((("0.4", 0), 102))
+        with pytest.raises(ConversionError):
+            remote_value.to_knx(((1, 1), "102"))
+        with pytest.raises(ConversionError):
+            remote_value.to_knx(((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))
+        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))
+        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, 0xFF, 0x03))),
+        )
+
+    async def test_process(self):
+        """Test process telegram."""
+        xknx = XKNX()
+        remote_value = RemoteValueColorXYY(xknx, group_address=GroupAddress("1/2/3"))
+        telegram = Telegram(
+            destination_address=GroupAddress("1/2/3"),
+            payload=GroupValueWrite(DPTArray((0xFF, 0xFF, 0x66, 0x66, 0xFA, 0x03))),
+        )
+        await remote_value.process(telegram)
+        assert remote_value.value == ((1, 0.4), 250)
+
+    async def test_to_process_error(self):
+        """Test process errornous telegram."""
+        xknx = XKNX()
+        remote_value = RemoteValueColorXYY(xknx, group_address=GroupAddress("1/2/3"))
+        with pytest.raises(CouldNotParseTelegram):
+            telegram = Telegram(
+                destination_address=GroupAddress("1/2/3"),
+                payload=GroupValueWrite(DPTBinary(1)),
+            )
+            await remote_value.process(telegram)
+        with pytest.raises(CouldNotParseTelegram):
+            telegram = Telegram(
+                destination_address=GroupAddress("1/2/3"),
+                payload=GroupValueWrite(DPTArray((0x64, 0x65, 0x66, 0x67))),
+            )
+            await remote_value.process(telegram)