Repository: XKNX/xknx
Pull request #742 (2021-07-25 05:25:38)

Light: add hs-color
<!--
  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.
-->
Add HS-color to lights.

see #698

## 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)
- [ ] This change requires a documentation update

## Checklist:

- [x] 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



Patch:
diff --git a/changelog.md b/changelog.md
index 865112aa..120e88bd 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,5 +1,11 @@
 # Changelog
 
+## unreleased changes
+
+### Devices
+
+- Light: Support for HS-color (DPT 5.003 hue and 5.001 saturation)
+
 ## 0.18.8 Position-only cover 2021-06.30
 
 ### Devices
diff --git a/docs/light.md b/docs/light.md
index ecfbfb11..2eb879a0 100644
--- a/docs/light.md
+++ b/docs/light.md
@@ -23,6 +23,10 @@ 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_hue` KNX group address to set the current hue. *DPT 5.003*
+- `group_address_hue_state` KNX group address for the current hue. *DPT 5.003*
+- `group_address_saturation` KNX group address to set the current saturation. *DPT 5.001*
+- `group_address_saturation_state` KNX group address for the current saturation. *DPT 5.001*
 - `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*
@@ -166,3 +170,24 @@ print(light.supports_color_temperature)
 await light.sync()
 
 ```
+
+## [](#header-2)Example: HSV-color light
+
+```python
+light = Light(
+    xknx,
+    "Hue and saturation",
+    group_address_switch="1/1/1",
+    group_address_switch_state='1/2/1',
+    group_address_brightness='1/1/2',
+    group_address_brightness_state='1/2/2',
+    group_address_hue="1/1/3",
+    group_address_hue_state="1/2/3",
+    group_address_saturation="1/1/4",
+    group_address_saturation_state="1/2/4",
+)
+print(light.supports_brightness)
+print(light.supports_hs_color)
+
+await light.set_hs_color((25,40))
+```
diff --git a/xknx/devices/light.py b/xknx/devices/light.py
index 862f9d39..9513b4fe 100644
--- a/xknx/devices/light.py
+++ b/xknx/devices/light.py
@@ -24,6 +24,7 @@ from xknx.remote_value import (
     RemoteValueColorRGBW,
     RemoteValueColorXYY,
     RemoteValueDpt2ByteUnsigned,
+    RemoteValueNumeric,
     RemoteValueScaling,
     RemoteValueSwitch,
 )
@@ -126,6 +127,10 @@ 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_hue: GroupAddressesType | None = None,
+        group_address_hue_state: GroupAddressesType | None = None,
+        group_address_saturation: GroupAddressesType | None = None,
+        group_address_saturation_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,
@@ -196,6 +201,28 @@ class Light(Device):
             after_update_cb=self.after_update,
         )
 
+        self.hue = RemoteValueNumeric(
+            xknx,
+            group_address_hue,
+            group_address_hue_state,
+            sync_state=sync_state,
+            value_type="angle",
+            device_name=self.name,
+            feature_name="Hue",
+            after_update_cb=self.after_update,
+        )
+
+        self.saturation = RemoteValueNumeric(
+            xknx,
+            group_address_saturation,
+            group_address_saturation_state,
+            sync_state=sync_state,
+            value_type="percent",
+            device_name=self.name,
+            feature_name="Saturation",
+            after_update_cb=self.after_update,
+        )
+
         self._xyy_color_valid: XYYColor | None = None
         self.xyy_color = RemoteValueColorXYY(
             xknx,
@@ -285,6 +312,8 @@ class Light(Device):
         yield self.brightness
         yield self.color
         yield self.rgbw
+        yield self.hue
+        yield self.saturation
         yield self.xyy_color
         yield self.tunable_white
         yield self.color_temperature
@@ -315,6 +344,11 @@ class Light(Device):
             c.brightness.initialized for c in self._iter_individual_colors()
         )
 
+    @property
+    def supports_hs_color(self) -> bool:
+        """Return if light supports HS-color."""
+        return self.hue.initialized and self.saturation.initialized
+
     @property
     def supports_xyy_color(self) -> bool:
         """Return if light supports xyY-color."""
@@ -426,6 +460,36 @@ class Light(Device):
                     return
             logger.warning("Colors not supported for device %s", self.get_name())
 
+    @property
+    def current_hs_color(self) -> tuple[float, float] | None:
+        """Return current HS-color of the light.
+
+        Hue is scaled 0-360 (265 possible values from KNX)
+        Sat is scaled 0-100
+        """
+        if (hue := self.hue.value) is not None and (
+            (saturation := self.saturation.value) is not None
+        ):
+            return (hue, saturation)
+        return None
+
+    async def set_hs_color(self, hs_color: tuple[float, float]) -> None:
+        """Set HS-color of the light."""
+        if not self.supports_hs_color:
+            logger.warning("HS-color not supported for device %s", self.get_name())
+            return
+        value_sent = False
+        if (hue := hs_color[0]) != self.hue.value:
+            await self.hue.set(hue)
+            value_sent = True
+        if (saturation := hs_color[1]) != self.saturation.value:
+            await self.saturation.set(saturation)
+            value_sent = True
+        if not value_sent:
+            # at least one value shall be sent to enable turn-on by hs_color
+            await self.hue.set(hue)
+            await self.saturation.set(saturation)
+
     async def _xyy_color_from_rv(self) -> None:
         """Update the current xyY-color from RemoteValue (Callback)."""
         new_xyy = self.xyy_color.value
@@ -500,6 +564,18 @@ class Light(Device):
             "" if not self.supports_rgbw else f" rgbw={self.rgbw.group_addr_str()}"
         )
 
+        str_hue = (
+            ""
+            if not self.hue.initialized
+            else f" brightness={self.hue.group_addr_str()}"
+        )
+
+        str_saturation = (
+            ""
+            if not self.saturation.initialized
+            else f" brightness={self.saturation.group_addr_str()}"
+        )
+
         str_xyy_color = (
             ""
             if not self.supports_xyy_color
@@ -561,21 +637,26 @@ class Light(Device):
             else f" white_brightness={self.white.brightness.group_addr_str()}"
         )
 
-        return '<Light name="{}" ' "switch={}{}{}{}{}{}{}{}{}{}{}{}{}{}{} />".format(
-            self.name,
-            self.switch.group_addr_str(),
-            str_brightness,
-            str_color,
-            str_rgbw,
-            str_xyy_color,
-            str_tunable_white,
-            str_color_temperature,
-            str_red_state,
-            str_red_brightness,
-            str_green_state,
-            str_green_brightness,
-            str_blue_state,
-            str_blue_brightness,
-            str_white_state,
-            str_white_brightness,
+        return (
+            '<Light name="{}" '
+            "switch={}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{} />".format(
+                self.name,
+                self.switch.group_addr_str(),
+                str_brightness,
+                str_color,
+                str_rgbw,
+                str_hue,
+                str_saturation,
+                str_xyy_color,
+                str_tunable_white,
+                str_color_temperature,
+                str_red_state,
+                str_red_brightness,
+                str_green_state,
+                str_green_brightness,
+                str_blue_state,
+                str_blue_brightness,
+                str_white_state,
+                str_white_brightness,
+            )
         )


Test patch:
diff --git a/test/devices_tests/light_test.py b/test/devices_tests/light_test.py
index 99c27c49..86f7d384 100644
--- a/test/devices_tests/light_test.py
+++ b/test/devices_tests/light_test.py
@@ -189,6 +189,37 @@ class TestLight:
         )
         assert not light.supports_rgbw
 
+    def test_supports_hs_color_true(self):
+        """Test supports_hs_color true."""
+        xknx = XKNX()
+        light = Light(
+            xknx,
+            "Hue and saturation",
+            group_address_switch="1/6/4",
+            group_address_hue="1/6/5",
+            group_address_saturation="1/6/6",
+        )
+        assert light.supports_hs_color
+
+    def test_supports_hs_color_false(self):
+        """Test supports_hs_color false."""
+        xknx = XKNX()
+        light_hue = Light(
+            xknx,
+            "Light hue only",
+            group_address_switch="1/6/4",
+            group_address_hue="1/6/5",
+        )
+        assert not light_hue.supports_hs_color
+
+        light_saturation = Light(
+            xknx,
+            "Light saturation only",
+            group_address_switch="1/6/4",
+            group_address_saturation="1/6/5",
+        )
+        assert not light_saturation.supports_hs_color
+
     def test_supports_xyy_color_true(self):
         """Test supports_xyy_color true."""
         xknx = XKNX()
@@ -250,7 +281,7 @@ class TestLight:
     # SYNC
     #
     async def test_sync(self):
-        """Test sync function / sending group reads to KNX bus. Testing with a Light without dimm functionality."""
+        """Test sync function / sending group reads to KNX bus."""
         xknx = XKNX()
         light = Light(
             xknx,
@@ -905,7 +936,93 @@ class TestLight:
             )
 
     #
-    # TEST SET COLOR AS RGBW
+    # TEST SET COLOR AS HS
+    #
+    async def test_set_hs_color(self):
+        """Test setting HS value of a Light."""
+        xknx = XKNX()
+        light = Light(
+            xknx,
+            name="TestLight",
+            group_address_switch="1/2/3",
+            group_address_hue="1/2/4",
+            group_address_saturation="1/2/5",
+        )
+        await light.set_hs_color((359, 99))
+        assert xknx.telegrams.qsize() == 2
+
+        telegram = xknx.telegrams.get_nowait()
+        assert telegram == Telegram(
+            destination_address=GroupAddress("1/2/4"),
+            payload=GroupValueWrite(DPTArray((0xFE,))),
+        )
+        await xknx.devices.process(telegram)
+
+        telegram = xknx.telegrams.get_nowait()
+        assert telegram == Telegram(
+            destination_address=GroupAddress("1/2/5"),
+            payload=GroupValueWrite(DPTArray((0xFC,))),
+        )
+        await xknx.devices.process(telegram)
+
+        assert light.current_hs_color == (359, 99)
+
+        # change only one
+        await light.set_hs_color((18, 99))
+        assert xknx.telegrams.qsize() == 1
+        telegram = xknx.telegrams.get_nowait()
+        assert telegram == Telegram(
+            destination_address=GroupAddress("1/2/4"),
+            payload=GroupValueWrite(DPTArray((0x0D,))),
+        )
+        await xknx.devices.process(telegram)
+
+        await light.set_hs_color((18, 3))
+        assert xknx.telegrams.qsize() == 1
+        telegram = xknx.telegrams.get_nowait()
+        assert telegram == Telegram(
+            destination_address=GroupAddress("1/2/5"),
+            payload=GroupValueWrite(DPTArray((0x08,))),
+        )
+        await xknx.devices.process(telegram)
+
+        # call set_hs_color with current color shall trigger both values
+        await light.set_hs_color((18, 3))
+        assert xknx.telegrams.qsize() == 2
+
+        telegram = xknx.telegrams.get_nowait()
+        assert telegram == Telegram(
+            destination_address=GroupAddress("1/2/4"),
+            payload=GroupValueWrite(DPTArray((0x0D,))),
+        )
+        await xknx.devices.process(telegram)
+
+        telegram = xknx.telegrams.get_nowait()
+        assert telegram == Telegram(
+            destination_address=GroupAddress("1/2/5"),
+            payload=GroupValueWrite(DPTArray((0x08,))),
+        )
+        await xknx.devices.process(telegram)
+
+    async def test_set_hs_color_not_possible(self):
+        """Test setting HS value of a light not supporting it."""
+        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_hs_color((22, 25))
+
+            assert xknx.telegrams.qsize() == 0
+            mock_warn.assert_called_with(
+                "HS-color not supported for device %s", "TestLight"
+            )
+
+    #
+    # TEST SET COLOR AS XYY
     #
     async def test_set_xyy_color(self):
         """Test setting XYY value of a Light."""
@@ -927,7 +1044,7 @@ class TestLight:
         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."""
+        """Test setting XYY value of a light not supporting it."""
         xknx = XKNX()
         light = Light(
             xknx,
@@ -1251,6 +1368,36 @@ class TestLight:
         await light.process(telegram)
         assert light.current_color == (None, 42)
 
+    async def test_process_hs_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_hue="1/2/4",
+            group_address_hue_state="1/4/4",
+            group_address_saturation="1/2/5",
+            group_address_saturation_state="1/4/5",
+        )
+        assert light.current_hs_color is None
+        # initialize hue
+        await light.process(
+            Telegram(
+                destination_address=GroupAddress("1/4/4"),
+                payload=GroupValueWrite(DPTArray((0x2E,))),
+            )
+        )
+        assert light.current_hs_color is None
+        # initialize saturation
+        await light.process(
+            Telegram(
+                destination_address=GroupAddress("1/4/5"),
+                payload=GroupValueWrite(DPTArray((0x55,))),
+            )
+        )
+        assert light.current_hs_color == (65, 33)
+
     async def test_process_xyy_color(self):
         """Test process / reading telegrams from telegram queue. Test if color is processed."""
         xknx = XKNX()
@@ -1427,6 +1574,10 @@ 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_hue="1/7/81",
+            group_address_hue_state="1/7/82",
+            group_address_saturation="1/7/83",
+            group_address_saturation_state="1/7/84",
             group_address_xyy_color="1/7/13",
             group_address_xyy_color_state="1/7/14",
             group_address_switch_red="1/1/1",
@@ -1458,9 +1609,16 @@ 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"))
+        # hue
+        assert light.has_group_address(GroupAddress("1/7/81"))
+        assert light.has_group_address(GroupAddress("1/7/82"))
+        # saturation
+        assert light.has_group_address(GroupAddress("1/7/83"))
+        assert light.has_group_address(GroupAddress("1/7/84"))
+        # xyy
         assert light.has_group_address(GroupAddress("1/7/13"))
         assert light.has_group_address(GroupAddress("1/7/14"))
-
+        # individual
         assert light.has_group_address(GroupAddress("1/1/1"))
         assert light.has_group_address(GroupAddress("1/1/2"))
         assert light.has_group_address(GroupAddress("1/1/3"))