Repository: XKNX/xknx
Pull request #1465 (2024-05-26 07:43:04)

Add DPTColorRGBW 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.
-->


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/1465?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) Report
Attention: Patch coverage is `98.46154%` with `1 lines` in your changes are missing coverage. Please review.
> Project coverage is 96.63%. Comparing base [(`88a5912`)](https://app.codecov.io/gh/XKNX/xknx/commit/88a5912d09fde5fb3914402ee2480b4435e58cd9?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) to head [(`5aa5c61`)](https://app.codecov.io/gh/XKNX/xknx/commit/5aa5c61c08b7935d2e1840c6360edd8151f610d3?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/1465/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/1465?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX)

```diff
@@            Coverage Diff             @@
##             main    #1465      +/-   ##
==========================================
- Coverage   96.63%   96.63%   -0.01%     
==========================================
  Files         151      151              
  Lines        9879     9904      +25     
==========================================
+ Hits         9547     9571      +24     
- Misses        332      333       +1     
```


| [Files](https://app.codecov.io/gh/XKNX/xknx/pull/1465?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/\_\_init\_\_.py](https://app.codecov.io/gh/XKNX/xknx/pull/1465?src=pr&el=tree&filepath=xknx%2Fdpt%2F__init__.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9kcHQvX19pbml0X18ucHk=) | `100.00% <100.00%> (ø)` | |
| [xknx/dpt/dpt\_color.py](https://app.codecov.io/gh/XKNX/xknx/pull/1465?src=pr&el=tree&filepath=xknx%2Fdpt%2Fdpt_color.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9kcHQvZHB0X2NvbG9yLnB5) | `100.00% <100.00%> (ø)` | |
| [xknx/remote\_value/remote\_value\_color\_rgbw.py](https://app.codecov.io/gh/XKNX/xknx/pull/1465?src=pr&el=tree&filepath=xknx%2Fremote_value%2Fremote_value_color_rgbw.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9yZW1vdGVfdmFsdWUvcmVtb3RlX3ZhbHVlX2NvbG9yX3JnYncucHk=) | `100.00% <100.00%> (ø)` | |
| [xknx/devices/light.py](https://app.codecov.io/gh/XKNX/xknx/pull/1465?src=pr&el=tree&filepath=xknx%2Fdevices%2Flight.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9kZXZpY2VzL2xpZ2h0LnB5) | `99.19% <87.50%> (-0.41%)` | :arrow_down: |

</details>

Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 45759672..f017803e 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -14,6 +14,7 @@ nav_order: 2
 - Added or refactored DPTs and dataclasses:
   - 232.600 - DPTColorRGB - RGBColor
   - 242.600 - DPTColorXYY - XYYColor
+  - 251.600 - DPTColorRGBW - RGBWColor
 
 # 2.12.2 Fix thread leak 2024-03-05
 
diff --git a/xknx/devices/light.py b/xknx/devices/light.py
index 58f10cda..7e6ad26c 100644
--- a/xknx/devices/light.py
+++ b/xknx/devices/light.py
@@ -20,7 +20,7 @@ from itertools import chain
 import logging
 from typing import TYPE_CHECKING, Any, Callable, cast
 
-from xknx.dpt.dpt_color import RGBColor, XYYColor
+from xknx.dpt.dpt_color import RGBColor, RGBWColor, XYYColor
 from xknx.remote_value import (
     GroupAddressesType,
     RemoteValue,
@@ -465,7 +465,16 @@ class Light(Device):
         if self.supports_rgbw and self.rgbw.initialized:
             if not self.rgbw.value:
                 return None, None
-            return self.rgbw.value[:3], self.rgbw.value[3]
+            if (
+                self.rgbw.value.red is not None
+                and self.rgbw.value.green is not None
+                and self.rgbw.value.blue is not None
+            ):
+                return (
+                    (self.rgbw.value.red, self.rgbw.value.green, self.rgbw.value.blue),
+                    self.rgbw.value.white,
+                )
+            return None, self.rgbw.value.white
         if self.color.initialized:
             if self.color.value is None:
                 return None, None
@@ -496,7 +505,7 @@ class Light(Device):
         if white is not None:
             if self.supports_rgbw:
                 if self.rgbw.initialized:
-                    await self.rgbw.set((*color, white))
+                    await self.rgbw.set(RGBWColor(*color, white))
                     return
                 if all(
                     c.brightness.initialized for c in self._iter_individual_colors()
@@ -555,16 +564,10 @@ class Light(Device):
     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
+        if self._xyy_color_valid is not None and new_xyy is not None:
+            self._xyy_color_valid = self._xyy_color_valid | new_xyy
         else:
-            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:
-                new_brightness = self._xyy_color_valid.brightness
-            self._xyy_color_valid = XYYColor(color=new_color, brightness=new_brightness)
+            self._xyy_color_valid = new_xyy
         await self.after_update()
 
     @property
diff --git a/xknx/dpt/__init__.py b/xknx/dpt/__init__.py
index ba7020e7..8ef40f0b 100644
--- a/xknx/dpt/__init__.py
+++ b/xknx/dpt/__init__.py
@@ -183,7 +183,14 @@ from .dpt_4byte_int import (
     DPTVolumeLiquidLitre,
     DPTVolumeM3,
 )
-from .dpt_color import DPTColorXYY, DPTColorRGB, RGBColor, XYYColor
+from .dpt_color import (
+    DPTColorXYY,
+    DPTColorRGB,
+    RGBColor,
+    XYYColor,
+    RGBWColor,
+    DPTColorRGBW,
+)
 from .dpt_date import DPTDate
 from .dpt_datetime import DPTDateTime
 from .dpt_hvac_mode import DPTControllerStatus, DPTHVACContrMode, DPTHVACMode
diff --git a/xknx/dpt/dpt_color.py b/xknx/dpt/dpt_color.py
index 5476179c..51da3800 100644
--- a/xknx/dpt/dpt_color.py
+++ b/xknx/dpt/dpt_color.py
@@ -77,6 +77,105 @@ class DPTColorRGB(DPTComplex[RGBColor]):
         return DPTArray(colors)
 
 
+@dataclass
+class RGBWColor(DPTComplexData):
+    """
+    Representation of RGBW color.
+
+    `red`: int 0..255; None if invalid
+    `green`: int 0..255; None if invalid
+    `blue`: int 0..255; None if invalid
+    `white`: int 0..255; None if invalid
+    """
+
+    red: int | None = None
+    green: int | None = None
+    blue: int | None = None
+    white: int | None = None
+
+    @classmethod
+    def from_dict(cls, data: Mapping[str, int]) -> RGBWColor:
+        """Init from a dictionary."""
+        result = {}
+        for color in ("red", "green", "blue", "white"):
+            try:
+                value = data.get(color)
+                result[color] = int(value) if value is not None else None
+            except AttributeError as err:
+                raise ValueError(f"Invalid value for color {color}: {err}") from err
+            except ValueError as err:
+                raise ValueError(f"Invalid value for color {color}: {err}") from err
+        return cls(**result)
+
+    def as_dict(self) -> dict[str, int | None]:
+        """Create a JSON serializable dictionary."""
+        return {
+            "red": self.red,
+            "green": self.green,
+            "blue": self.blue,
+            "white": self.white,
+        }
+
+    def __or__(self, other: RGBWColor) -> RGBWColor:
+        """Merge two RGBWColor objects using only valid values."""
+        return RGBWColor(
+            red=other.red if other.red is not None else self.red,
+            green=other.green if other.green is not None else self.green,
+            blue=other.blue if other.blue is not None else self.blue,
+            white=other.white if other.white is not None else self.white,
+        )
+
+
+class DPTColorRGBW(DPTComplex[RGBWColor]):
+    """Abstraction for KNX 6 octet color RGBW (DPT 251.600)."""
+
+    data_type = RGBWColor
+    payload_type = DPTArray
+    payload_length = 6
+    dpt_main_number = 251
+    dpt_sub_number = 600
+    value_type = "color_rgbw"
+
+    @classmethod
+    def from_knx(cls, payload: DPTArray | DPTBinary) -> RGBWColor:
+        """Parse/deserialize from KNX/IP raw data."""
+        raw = cls.validate_payload(payload)
+        red_valid = raw[5] >> 3 & 0b1
+        green_valid = raw[5] >> 2 & 0b1
+        blue_valid = raw[5] >> 1 & 0b1
+        white_valid = raw[5] & 0b1
+        return RGBWColor(
+            red=raw[0] if red_valid else None,
+            green=raw[1] if green_valid else None,
+            blue=raw[2] if blue_valid else None,
+            white=raw[3] if white_valid else None,
+        )
+
+    @classmethod
+    def _to_knx(cls, value: RGBWColor) -> DPTArray:
+        """Serialize to KNX/IP raw data."""
+        for color in (value.red, value.green, value.blue, value.white):
+            if color is None:
+                continue
+            if not 0 <= color <= 255:
+                raise ValueError(f"Color value out of range 0..255: {value}")
+        return DPTArray(
+            (
+                value.red if value.red is not None else 0x00,
+                value.green if value.green is not None else 0x00,
+                value.blue if value.blue is not None else 0x00,
+                value.white if value.white is not None else 0x00,
+                0x00,  # reserved
+                (
+                    (value.red is not None) << 3
+                    | (value.green is not None) << 2
+                    | (value.blue is not None) << 1
+                    | (value.white is not None)
+                ),
+            )
+        )
+
+
 @dataclass
 class XYYColor(DPTComplexData):
     """
@@ -121,6 +220,15 @@ class XYYColor(DPTComplexData):
             "brightness": self.brightness,
         }
 
+    def __or__(self, other: XYYColor) -> XYYColor:
+        """Merge two XYYColor objects using only valid values."""
+        return XYYColor(
+            color=other.color if other.color is not None else self.color,
+            brightness=other.brightness
+            if other.brightness is not None
+            else self.brightness,
+        )
+
 
 class DPTColorXYY(DPTComplex[XYYColor]):
     """Abstraction for KNX 6 octet color xyY (DPT 242.600)."""
diff --git a/xknx/remote_value/remote_value_color_rgbw.py b/xknx/remote_value/remote_value_color_rgbw.py
index c6e583ad..a3c56ce5 100644
--- a/xknx/remote_value/remote_value_color_rgbw.py
+++ b/xknx/remote_value/remote_value_color_rgbw.py
@@ -6,11 +6,10 @@ DPT 251.600.
 
 from __future__ import annotations
 
-from collections.abc import Sequence
 from typing import TYPE_CHECKING
 
 from xknx.dpt import DPTArray, DPTBinary
-from xknx.exceptions import ConversionError, CouldNotParseTelegram
+from xknx.dpt.dpt_color import DPTColorRGBW, RGBWColor
 
 from .remote_value import AsyncCallbackType, GroupAddressesType, RemoteValue
 
@@ -18,7 +17,7 @@ if TYPE_CHECKING:
     from xknx.xknx import XKNX
 
 
-class RemoteValueColorRGBW(RemoteValue[tuple[int, int, int, int]]):
+class RemoteValueColorRGBW(RemoteValue[RGBWColor]):
     """Abstraction for remote value of KNX DPT 251.600 (DPT_Color_RGBW)."""
 
     def __init__(
@@ -41,73 +40,18 @@ class RemoteValueColorRGBW(RemoteValue[tuple[int, int, int, int]]):
             feature_name=feature_name,
             after_update_cb=after_update_cb,
         )
-        self.previous_value: tuple[int, int, int, int] = (0, 0, 0, 0)
+        self._valid_value = RGBWColor()
 
-    def to_knx(self, value: Sequence[int]) -> DPTArray:
-        """
-        Convert value (4-6 bytes) to payload (6 bytes).
-
-        * Structure of DPT 251.600
-        ** Byte 0: R value
-        ** Byte 1: G value
-        ** Byte 2: B value
-        ** Byte 3: W value
-        ** Byte 4: 0x00 (reserved)
-        ** Byte 5:
-        *** Bit 0: W value valid?
-        *** Bit 1: B value valid?
-        *** Bit 2: G value valid?
-        *** Bit 3: R value valid?
-        *** Bit 4-7: 0
+    def to_knx(self, value: RGBWColor) -> DPTArray:
+        """Convert value to payload."""
+        return DPTColorRGBW.to_knx(value)
 
-        In case we receive
-        * > 6 bytes: error
-        * 6 bytes: all bytes are passed through
-        * 5 bytes: 0x00?? fill up to 6 bytes
-        * 4 bytes: 0x000f right padding to 6 bytes
-        * < 4 bytes: error
+    def from_knx(self, payload: DPTArray | DPTBinary) -> RGBWColor:
         """
-        if not isinstance(value, (list, tuple)):
-            raise ConversionError(
-                "Could not serialize RemoteValueColorRGBW (wrong type, expecting list of 4-6 bytes))",
-                value=value,
-                type=type(value),
-            )
-        if not 4 <= len(value) <= 6:
-            raise ConversionError(
-                "Could not serialize value to DPT 251.600 (wrong length, expecting list of 4-6 bytes)",
-                value=value,
-                type=type(value),
-            )
-        rgbw = value[:4]
-        if (
-            any(not isinstance(color, int) for color in rgbw)
-            or any(color < 0 for color in rgbw)
-            or any(color > 255 for color in rgbw)
-        ):
-            raise ConversionError(
-                "Could not serialize DPT 251.600 (wrong RGBW values)", value=value
-            )
-        if len(value) == 4:
-            return DPTArray([*rgbw, 0x00, 0x0F])
-        if len(value) == 5:
-            return DPTArray([*rgbw, 0x00, value[4]])
-        return DPTArray(value)
+        Convert current payload to value.
 
-    def from_knx(self, payload: DPTArray | DPTBinary) -> tuple[int, int, int, int]:
+        If one element is invalid, use the last received value.
         """
-        Convert current payload to value. Always 4 byte (RGBW).
-
-        If one element is invalid, use the previous value. All previous element
-        values are initialized to 0.
-        """
-        if not (isinstance(payload, DPTArray) and len(payload.value) == 6):
-            raise CouldNotParseTelegram("Payload invalid", payload=str(payload))
-
-        _result = list(self.previous_value)
-        for i in range(len(payload.value) - 2):
-            if payload.value[5] & (0x08 >> i):  # R,G,B,W value valid?
-                _result[i] = payload.value[i]
-        result = (_result[0], _result[1], _result[2], _result[3])
-        self.previous_value = result
-        return result
+        new_value = DPTColorRGBW.from_knx(payload)
+        self._valid_value = self._valid_value | new_value
+        return self._valid_value


Test patch:
diff --git a/test/dpt_tests/dpt_color_test.py b/test/dpt_tests/dpt_color_test.py
index 5dd1d004..76f49205 100644
--- a/test/dpt_tests/dpt_color_test.py
+++ b/test/dpt_tests/dpt_color_test.py
@@ -2,7 +2,15 @@
 
 import pytest
 
-from xknx.dpt.dpt_color import DPTArray, DPTColorRGB, DPTColorXYY, RGBColor, XYYColor
+from xknx.dpt.dpt_color import (
+    DPTArray,
+    DPTColorRGB,
+    DPTColorRGBW,
+    DPTColorXYY,
+    RGBColor,
+    RGBWColor,
+    XYYColor,
+)
 from xknx.exceptions import ConversionError, CouldNotParseTelegram
 
 
@@ -120,6 +128,165 @@ class TestDPTColorRGB:
             DPTColorRGB.from_knx(DPTArray((0xFF, 0x4E)))
 
 
+class TestRGBWColor:
+    """Test RGBWColor class."""
+
+    @pytest.mark.parametrize(
+        ("data", "value"),
+        [
+            (
+                {"red": 128, "green": 128, "blue": 128, "white": 128},
+                RGBWColor(128, 128, 128, 128),
+            ),
+            (
+                {"red": 255, "green": 255, "blue": 255, "white": 255},
+                RGBWColor(255, 255, 255, 255),
+            ),
+            ({"red": 128, "green": 128, "blue": 128}, RGBWColor(128, 128, 128)),
+            ({"white": 50}, RGBWColor(None, None, None, 50)),
+            ({}, RGBWColor()),
+            (
+                {"red": None, "green": None, "blue": None, "white": 255},
+                RGBWColor(None, None, None, 255),
+            ),
+            (
+                {"red": 128, "green": 128, "blue": 128, "white": None},
+                RGBWColor(128, 128, 128, None),
+            ),
+            ({"red": None, "white": 255}, RGBWColor(None, None, None, 255)),
+            ({"green": None, "white": 255}, RGBWColor(None, None, None, 255)),
+            ({"red": 128}, RGBWColor(128, None, None, None)),
+            ({"green": 128}, RGBWColor(None, 128, None, None)),
+            ({"blue": 128}, RGBWColor(None, None, 128, None)),
+            ({"red": 128, "white": 128}, RGBWColor(128, None, None, 128)),
+            ({"green": 128, "white": 128}, RGBWColor(None, 128, None, 128)),
+            ({"blue": 128, "white": 128}, RGBWColor(None, None, 128, 128)),
+        ],
+    )
+    def test_dict(self, data, value):
+        """Test from_dict and as_dict methods."""
+        test_value = RGBWColor.from_dict(data)
+        assert test_value == value
+        # fields default to `None`
+        default_dict = {"red": None, "green": None, "blue": None, "white": None}
+        assert value.as_dict() == default_dict | data
+
+    @pytest.mark.parametrize(
+        "data",
+        [
+            # invalid data
+            {"red": "a", "green": 128, "blue": 128, "white": 128},
+            {"red": 128, "green": "a", "blue": 128, "white": 128},
+            {"red": 128, "green": 128, "blue": "a", "white": 128},
+            {"red": 128, "green": 128, "blue": 128, "white": "a"},
+        ],
+    )
+    def test_dict_invalid(self, data):
+        """Test from_dict and as_dict methods."""
+        with pytest.raises(ValueError):
+            RGBWColor.from_dict(data)
+
+    def test_merge(self):
+        """Test merging two RGBWColor objects."""
+        color1 = RGBWColor(1, 1, 1, None)
+        color2 = RGBWColor(None, None, None, 2)
+        color3 = RGBWColor(3, None, None, 3)
+        assert color1 | color2 == RGBWColor(1, 1, 1, 2)
+        assert color2 | color1 == RGBWColor(1, 1, 1, 2)
+        assert color1 | color3 == RGBWColor(3, 1, 1, 3)
+        assert color3 | color1 == RGBWColor(1, 1, 1, 3)
+        assert color2 | color3 == RGBWColor(3, None, None, 3)
+        assert color3 | color2 == RGBWColor(3, None, None, 2)
+
+
+class TestDPTColorRGBW:
+    """Test class for KNX RGBW-color objects."""
+
+    @pytest.mark.parametrize(
+        ("value", "raw"),
+        [
+            (RGBWColor(0, 0, 0, 0), (0x00, 0x00, 0x00, 0x00, 0, 0xF)),  # min values
+            (RGBWColor(255, 255, 255, 255), (0xFF, 0xFF, 0xFF, 0xFF, 0, 0xF)),  # max
+            (RGBWColor(None, 0, 0, 0), (0x00, 0x00, 0x00, 0x00, 0, 0x7)),  # red None
+            (RGBWColor(0, 0, 0, None), (0x00, 0x00, 0x00, 0x00, 0, 0xE)),  # white None
+            (RGBWColor(None, None, None, None), (0x00, 0x00, 0x00, 0x00, 0, 0)),
+            (RGBWColor(128, 128, 128, 128), (0x80, 0x80, 0x80, 0x80, 0, 0b1111)),
+            (RGBWColor(204, 204, 204, 204), (0xCC, 0xCC, 0xCC, 0xCC, 0, 0b1111)),
+        ],
+    )
+    def test_rgbwcolor_value(self, value, raw):
+        """Test DPTColorRGBW parsing and streaming."""
+        knx_value = DPTColorRGBW.to_knx(value)
+        assert knx_value == DPTArray(raw)
+        assert DPTColorRGBW.from_knx(knx_value) == value
+
+    @pytest.mark.parametrize(
+        ("value", "raw"),
+        [
+            (
+                {"red": 128, "green": 128, "blue": 128, "white": 128},
+                (0x80, 0x80, 0x80, 0x80, 0, 0xF),
+            ),
+            (
+                {"red": 204, "green": 204, "blue": 204, "white": 204},
+                (0xCC, 0xCC, 0xCC, 0xCC, 0, 0xF),
+            ),
+            (
+                {"red": 204, "green": 204, "blue": 204},
+                (0xCC, 0xCC, 0xCC, 0x00, 0, 0xE),
+            ),
+        ],
+    )
+    def test_rgbwcolor_to_knx_from_dict(self, value, raw):
+        """Test DPTColorRGBW parsing from a dict."""
+        knx_value = DPTColorRGBW.to_knx(value)
+        assert knx_value == DPTArray(raw)
+
+    @pytest.mark.parametrize(
+        ("red", "green", "blue", "white"),
+        [
+            (-1, 0, 0, 0),
+            (0, -1, 0, 0),
+            (0, 0, -1, 0),
+            (0, 0, 0, -1),
+            (256, 0, 0, 0),
+            (0, 256, 0, 0),
+            (0, 0, 256, 0),
+            (0, 0, 0, 256),
+        ],
+    )
+    def test_rgbwcolor_to_knx_limits(self, red, green, blue, white):
+        """Test initialization of DPTColorRGBW with wrong value."""
+        value = RGBWColor(red=red, green=green, blue=blue, white=white)
+        with pytest.raises(ConversionError):
+            DPTColorRGBW.to_knx(value)
+
+    @pytest.mark.parametrize(
+        "value",
+        [
+            None,
+            (0xFF, 0x4E, 0x12),
+            1,
+            ((0x00, 0xFF, 0x4E), 0x12),
+            ((0xFF, 0x4E), (0x12, 0x00)),
+            ((0, 0), "a"),
+            ((0, 0), 0.4),
+            RGBWColor(red=0, green=0, blue=0, white="a"),
+            RGBWColor(red=4, green=4, blue="a", white=4),
+            RGBWColor(red="a", green=0, blue=0, white=1),
+        ],
+    )
+    def test_rgbwcolor_wrong_value_to_knx(self, value):
+        """Test DPTColorRGBW parsing with wrong value."""
+        with pytest.raises(ConversionError):
+            DPTColorRGBW.to_knx(value)
+
+    def test_rgbwcolor_wrong_value_from_knx(self):
+        """Test DPTColorRGBW parsing with wrong value."""
+        with pytest.raises(CouldNotParseTelegram):
+            DPTColorRGBW.from_knx(DPTArray((0xFF, 0x4E, 0x12)))
+
+
 class TestXYYColor:
     """Test XYYColor class."""
 
@@ -191,6 +358,18 @@ class TestXYYColor:
         with pytest.raises(ValueError):
             XYYColor.from_dict(data)
 
+    def test_merge(self):
+        """Test merging two XYYColor objects."""
+        color1 = XYYColor((1, 1), None)
+        color2 = XYYColor(None, 2)
+        color3 = XYYColor((3, 3), 3)
+        assert color1 | color2 == XYYColor((1, 1), 2)
+        assert color2 | color1 == XYYColor((1, 1), 2)
+        assert color1 | color3 == XYYColor((3, 3), 3)
+        assert color3 | color1 == XYYColor((1, 1), 3)
+        assert color2 | color3 == XYYColor((3, 3), 3)
+        assert color3 | color2 == XYYColor((3, 3), 2)
+
 
 class TestDPTColorXYY:
     """Test class for KNX xyY-color objects."""
diff --git a/test/remote_value_tests/remote_value_color_rgbw_test.py b/test/remote_value_tests/remote_value_color_rgbw_test.py
index a7365be2..70e04970 100644
--- a/test/remote_value_tests/remote_value_color_rgbw_test.py
+++ b/test/remote_value_tests/remote_value_color_rgbw_test.py
@@ -4,6 +4,7 @@ import pytest
 
 from xknx import XKNX
 from xknx.dpt import DPTArray, DPTBinary
+from xknx.dpt.dpt_color import RGBWColor
 from xknx.exceptions import ConversionError
 from xknx.remote_value import RemoteValueColorRGBW
 from xknx.telegram import GroupAddress, Telegram
@@ -17,15 +18,9 @@ class TestRemoteValueColorRGBW:
         """Test to_knx function with normal operation."""
         xknx = XKNX()
         remote_value = RemoteValueColorRGBW(xknx)
-        input_list = [100, 101, 102, 127]
-        input_tuple = (100, 101, 102, 127)
+        input_value = RGBWColor(100, 101, 102, 127)
         expected = DPTArray((0x64, 0x65, 0x66, 0x7F, 0x00, 0x0F))
-        assert remote_value.to_knx(input_tuple) == expected
-        assert remote_value.to_knx(input_list) == expected
-        assert remote_value.to_knx((*input_tuple, 15)) == expected
-        assert remote_value.to_knx([*input_list, 15]) == expected
-        assert remote_value.to_knx((*input_tuple, 0, 15)) == expected
-        assert remote_value.to_knx([*input_list, 0, 15]) == expected
+        assert remote_value.to_knx(input_value) == expected
 
     def test_from_knx(self):
         """Test from_knx function with normal operation."""
@@ -33,55 +28,39 @@ class TestRemoteValueColorRGBW:
         remote_value = RemoteValueColorRGBW(xknx)
         assert remote_value.from_knx(
             DPTArray((0x64, 0x65, 0x66, 0x7F, 0x00, 0x00))
-        ) == (0, 0, 0, 0)
+        ) == RGBWColor(None, None, None, None)
         assert remote_value.from_knx(
             DPTArray((0x64, 0x65, 0x66, 0x7F, 0x00, 0x0F))
-        ) == (100, 101, 102, 127)
+        ) == RGBWColor(100, 101, 102, 127)
         assert remote_value.from_knx(
             DPTArray((0x64, 0x65, 0x66, 0x7F, 0x00, 0x00))
-        ) == (100, 101, 102, 127)
+        ) == RGBWColor(100, 101, 102, 127)
         assert remote_value.from_knx(
             DPTArray((0xFF, 0x65, 0x66, 0xFF, 0x00, 0x09))
-        ) == (255, 101, 102, 255)
+        ) == RGBWColor(255, 101, 102, 255)
         assert remote_value.from_knx(
             DPTArray((0x64, 0x65, 0x66, 0x7F, 0x00, 0x01))
-        ) == (255, 101, 102, 127)
+        ) == RGBWColor(255, 101, 102, 127)
 
     def test_to_knx_error(self):
         """Test to_knx function with wrong parametern."""
         xknx = XKNX()
         remote_value = RemoteValueColorRGBW(xknx)
         with pytest.raises(ConversionError):
-            remote_value.to_knx((101, 102, 103))
-        with pytest.raises(ConversionError):
-            remote_value.to_knx((0, 0, 15, 101, 102, 103, 104))
-        with pytest.raises(ConversionError):
-            remote_value.to_knx((256, 101, 102, 103))
-        with pytest.raises(ConversionError):
-            remote_value.to_knx((100, 256, 102, 103))
-        with pytest.raises(ConversionError):
-            remote_value.to_knx((100, 101, 256, 103))
-        with pytest.raises(ConversionError):
-            remote_value.to_knx((100, 101, 102, 256))
-        with pytest.raises(ConversionError):
-            remote_value.to_knx((100, -101, 102, 103))
-        with pytest.raises(ConversionError):
-            remote_value.to_knx(("100", 101, 102, 103))
-        with pytest.raises(ConversionError):
-            remote_value.to_knx("100, 101, 102, 103")
+            remote_value.to_knx((101, 102, 103, 104))
 
     async def test_set(self):
         """Test setting value."""
         xknx = XKNX()
         remote_value = RemoteValueColorRGBW(xknx, group_address=GroupAddress("1/2/3"))
-        await remote_value.set((100, 101, 102, 103))
+        await remote_value.set(RGBWColor(100, 101, 102, 103))
         assert xknx.telegrams.qsize() == 1
         telegram = xknx.telegrams.get_nowait()
         assert telegram == Telegram(
             destination_address=GroupAddress("1/2/3"),
             payload=GroupValueWrite(DPTArray((0x64, 0x65, 0x66, 0x67, 0x00, 0x0F))),
         )
-        await remote_value.set((100, 101, 104, 105))
+        await remote_value.set(RGBWColor(100, 101, 104, 105))
         assert xknx.telegrams.qsize() == 1
         telegram = xknx.telegrams.get_nowait()
         assert telegram == Telegram(
@@ -98,7 +77,7 @@ class TestRemoteValueColorRGBW:
             payload=GroupValueWrite(DPTArray((0x64, 0x65, 0x66, 0x67, 0x00, 0x0F))),
         )
         await remote_value.process(telegram)
-        assert remote_value.value == (100, 101, 102, 103)
+        assert remote_value.value == RGBWColor(100, 101, 102, 103)
 
     async def test_to_process_error(self):
         """Test process erroneous telegram."""