From 04c6b9c51963418ffebddc7753939700fbea7e42 Mon Sep 17 00:00:00 2001
From: "David F. Mulcahey" <david.mulcahey@me.com>
Date: Thu, 21 Jul 2022 17:54:50 -0400
Subject: [PATCH] ZHA light entity cleanup  (#75573)

* use base class attributes

* initial hue and saturation support

* spec is 65536 not 65535

* fixes

* enhanced current hue

* fix comparison

* clean up

* fix channel test

* oops

* report enhanced current hue
---
 .../components/zha/core/channels/lighting.py  |  71 ++++
 homeassistant/components/zha/light.py         | 340 ++++++++++--------
 tests/components/zha/test_channels.py         |  13 +-
 tests/components/zha/test_light.py            |  23 +-
 4 files changed, 283 insertions(+), 164 deletions(-)

diff --git a/homeassistant/components/zha/core/channels/lighting.py b/homeassistant/components/zha/core/channels/lighting.py
index 99e6101b0bd..36bb0beb17d 100644
--- a/homeassistant/components/zha/core/channels/lighting.py
+++ b/homeassistant/components/zha/core/channels/lighting.py
@@ -31,6 +31,9 @@ class ColorChannel(ZigbeeChannel):
     REPORT_CONFIG = (
         AttrReportConfig(attr="current_x", config=REPORT_CONFIG_DEFAULT),
         AttrReportConfig(attr="current_y", config=REPORT_CONFIG_DEFAULT),
+        AttrReportConfig(attr="current_hue", config=REPORT_CONFIG_DEFAULT),
+        AttrReportConfig(attr="enhanced_current_hue", config=REPORT_CONFIG_DEFAULT),
+        AttrReportConfig(attr="current_saturation", config=REPORT_CONFIG_DEFAULT),
         AttrReportConfig(attr="color_temperature", config=REPORT_CONFIG_DEFAULT),
     )
     MAX_MIREDS: int = 500
@@ -52,6 +55,14 @@ class ColorChannel(ZigbeeChannel):
             return self.CAPABILITIES_COLOR_XY | self.CAPABILITIES_COLOR_TEMP
         return self.CAPABILITIES_COLOR_XY
 
+    @property
+    def zcl_color_capabilities(self) -> lighting.Color.ColorCapabilities:
+        """Return ZCL color capabilities of the light."""
+        color_capabilities = self.cluster.get("color_capabilities")
+        if color_capabilities is None:
+            return lighting.Color.ColorCapabilities(self.CAPABILITIES_COLOR_XY)
+        return lighting.Color.ColorCapabilities(color_capabilities)
+
     @property
     def color_mode(self) -> int | None:
         """Return cached value of the color_mode attribute."""
@@ -77,6 +88,21 @@ class ColorChannel(ZigbeeChannel):
         """Return cached value of the current_y attribute."""
         return self.cluster.get("current_y")
 
+    @property
+    def current_hue(self) -> int | None:
+        """Return cached value of the current_hue attribute."""
+        return self.cluster.get("current_hue")
+
+    @property
+    def enhanced_current_hue(self) -> int | None:
+        """Return cached value of the enhanced_current_hue attribute."""
+        return self.cluster.get("enhanced_current_hue")
+
+    @property
+    def current_saturation(self) -> int | None:
+        """Return cached value of the current_saturation attribute."""
+        return self.cluster.get("current_saturation")
+
     @property
     def min_mireds(self) -> int:
         """Return the coldest color_temp that this channel supports."""
@@ -86,3 +112,48 @@ class ColorChannel(ZigbeeChannel):
     def max_mireds(self) -> int:
         """Return the warmest color_temp that this channel supports."""
         return self.cluster.get("color_temp_physical_max", self.MAX_MIREDS)
+
+    @property
+    def hs_supported(self) -> bool:
+        """Return True if the channel supports hue and saturation."""
+        return (
+            self.zcl_color_capabilities is not None
+            and lighting.Color.ColorCapabilities.Hue_and_saturation
+            in self.zcl_color_capabilities
+        )
+
+    @property
+    def enhanced_hue_supported(self) -> bool:
+        """Return True if the channel supports enhanced hue and saturation."""
+        return (
+            self.zcl_color_capabilities is not None
+            and lighting.Color.ColorCapabilities.Enhanced_hue
+            in self.zcl_color_capabilities
+        )
+
+    @property
+    def xy_supported(self) -> bool:
+        """Return True if the channel supports xy."""
+        return (
+            self.zcl_color_capabilities is not None
+            and lighting.Color.ColorCapabilities.XY_attributes
+            in self.zcl_color_capabilities
+        )
+
+    @property
+    def color_temp_supported(self) -> bool:
+        """Return True if the channel supports color temperature."""
+        return (
+            self.zcl_color_capabilities is not None
+            and lighting.Color.ColorCapabilities.Color_temperature
+            in self.zcl_color_capabilities
+        )
+
+    @property
+    def color_loop_supported(self) -> bool:
+        """Return True if the channel supports color loop."""
+        return (
+            self.zcl_color_capabilities is not None
+            and lighting.Color.ColorCapabilities.Color_loop
+            in self.zcl_color_capabilities
+        )
diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py
index cc8dc475c43..8aab3d20a5c 100644
--- a/homeassistant/components/zha/light.py
+++ b/homeassistant/components/zha/light.py
@@ -15,15 +15,6 @@ from zigpy.zcl.foundation import Status
 
 from homeassistant.components import light
 from homeassistant.components.light import (
-    ATTR_BRIGHTNESS,
-    ATTR_COLOR_MODE,
-    ATTR_COLOR_TEMP,
-    ATTR_EFFECT,
-    ATTR_EFFECT_LIST,
-    ATTR_HS_COLOR,
-    ATTR_MAX_MIREDS,
-    ATTR_MIN_MIREDS,
-    ATTR_SUPPORTED_COLOR_MODES,
     ColorMode,
     brightness_supported,
     filter_supported_color_modes,
@@ -43,7 +34,6 @@ from homeassistant.helpers.dispatcher import (
 )
 from homeassistant.helpers.entity_platform import AddEntitiesCallback
 from homeassistant.helpers.event import async_track_time_interval
-import homeassistant.util.color as color_util
 
 from .core import discovery, helpers
 from .core.const import (
@@ -69,10 +59,6 @@ if TYPE_CHECKING:
 
 _LOGGER = logging.getLogger(__name__)
 
-CAPABILITIES_COLOR_LOOP = 0x4
-CAPABILITIES_COLOR_XY = 0x08
-CAPABILITIES_COLOR_TEMP = 0x10
-
 DEFAULT_MIN_BRIGHTNESS = 2
 
 UPDATE_COLORLOOP_ACTION = 0x1
@@ -88,7 +74,7 @@ GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, Platform.LIGHT)
 PARALLEL_UPDATES = 0
 SIGNAL_LIGHT_GROUP_STATE_CHANGED = "zha_light_group_state_changed"
 
-COLOR_MODES_GROUP_LIGHT = {ColorMode.COLOR_TEMP, ColorMode.HS}
+COLOR_MODES_GROUP_LIGHT = {ColorMode.COLOR_TEMP, ColorMode.XY}
 SUPPORT_GROUP_LIGHT = (
     light.LightEntityFeature.EFFECT
     | light.LightEntityFeature.FLASH
@@ -123,24 +109,18 @@ class BaseLight(LogMixin, light.LightEntity):
     def __init__(self, *args, **kwargs):
         """Initialize the light."""
         super().__init__(*args, **kwargs)
-        self._available: bool = False
-        self._brightness: int | None = None
+        self._attr_min_mireds: int | None = 153
+        self._attr_max_mireds: int | None = 500
+        self._attr_color_mode = ColorMode.UNKNOWN  # Set by sub classes
+        self._attr_supported_features: int = 0
+        self._attr_state: bool | None
         self._off_with_transition: bool = False
         self._off_brightness: int | None = None
-        self._hs_color: tuple[float, float] | None = None
-        self._color_temp: int | None = None
-        self._min_mireds: int | None = 153
-        self._max_mireds: int | None = 500
-        self._effect_list: list[str] | None = None
-        self._effect: str | None = None
-        self._supported_features: int = 0
-        self._state: bool = False
+        self._zha_config_transition = self._DEFAULT_MIN_TRANSITION_TIME
         self._on_off_channel = None
         self._level_channel = None
         self._color_channel = None
         self._identify_channel = None
-        self._zha_config_transition = self._DEFAULT_MIN_TRANSITION_TIME
-        self._attr_color_mode = ColorMode.UNKNOWN  # Set by sub classes
 
     @property
     def extra_state_attributes(self) -> dict[str, Any]:
@@ -154,24 +134,9 @@ class BaseLight(LogMixin, light.LightEntity):
     @property
     def is_on(self) -> bool:
         """Return true if entity is on."""
-        if self._state is None:
+        if self._attr_state is None:
             return False
-        return self._state
-
-    @property
-    def brightness(self):
-        """Return the brightness of this light."""
-        return self._brightness
-
-    @property
-    def min_mireds(self):
-        """Return the coldest color_temp that this light supports."""
-        return self._min_mireds
-
-    @property
-    def max_mireds(self):
-        """Return the warmest color_temp that this light supports."""
-        return self._max_mireds
+        return self._attr_state
 
     @callback
     def set_level(self, value):
@@ -182,34 +147,9 @@ class BaseLight(LogMixin, light.LightEntity):
         level
         """
         value = max(0, min(254, value))
-        self._brightness = value
+        self._attr_brightness = value
         self.async_write_ha_state()
 
-    @property
-    def hs_color(self):
-        """Return the hs color value [int, int]."""
-        return self._hs_color
-
-    @property
-    def color_temp(self):
-        """Return the CT color value in mireds."""
-        return self._color_temp
-
-    @property
-    def effect_list(self):
-        """Return the list of supported effects."""
-        return self._effect_list
-
-    @property
-    def effect(self):
-        """Return the current effect."""
-        return self._effect
-
-    @property
-    def supported_features(self):
-        """Flag supported features."""
-        return self._supported_features
-
     async def async_turn_on(self, **kwargs):
         """Turn the entity on."""
         transition = kwargs.get(light.ATTR_TRANSITION)
@@ -222,6 +162,7 @@ class BaseLight(LogMixin, light.LightEntity):
         effect = kwargs.get(light.ATTR_EFFECT)
         flash = kwargs.get(light.ATTR_FLASH)
         temperature = kwargs.get(light.ATTR_COLOR_TEMP)
+        xy_color = kwargs.get(light.ATTR_XY_COLOR)
         hs_color = kwargs.get(light.ATTR_HS_COLOR)
 
         # If the light is currently off but a turn_on call with a color/temperature is sent,
@@ -235,19 +176,26 @@ class BaseLight(LogMixin, light.LightEntity):
         new_color_provided_while_off = (
             not isinstance(self, LightGroup)
             and not self._FORCE_ON
-            and not self._state
+            and not self._attr_state
             and (
                 (
                     temperature is not None
                     and (
-                        self._color_temp != temperature
+                        self._attr_color_temp != temperature
                         or self._attr_color_mode != ColorMode.COLOR_TEMP
                     )
                 )
+                or (
+                    xy_color is not None
+                    and (
+                        self._attr_xy_color != xy_color
+                        or self._attr_color_mode != ColorMode.XY
+                    )
+                )
                 or (
                     hs_color is not None
                     and (
-                        self.hs_color != hs_color
+                        self._attr_hs_color != hs_color
                         or self._attr_color_mode != ColorMode.HS
                     )
                 )
@@ -265,7 +213,7 @@ class BaseLight(LogMixin, light.LightEntity):
         if brightness is not None:
             level = min(254, brightness)
         else:
-            level = self._brightness or 254
+            level = self._attr_brightness or 254
 
         t_log = {}
 
@@ -280,7 +228,7 @@ class BaseLight(LogMixin, light.LightEntity):
                 self.debug("turned on: %s", t_log)
                 return
             # Currently only setting it to "on", as the correct level state will be set at the second move_to_level call
-            self._state = True
+            self._attr_state = True
 
         if (
             (brightness is not None or transition)
@@ -294,9 +242,9 @@ class BaseLight(LogMixin, light.LightEntity):
             if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
                 self.debug("turned on: %s", t_log)
                 return
-            self._state = bool(level)
+            self._attr_state = bool(level)
             if level:
-                self._brightness = level
+                self._attr_brightness = level
 
         if (
             brightness is None
@@ -310,7 +258,7 @@ class BaseLight(LogMixin, light.LightEntity):
             if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
                 self.debug("turned on: %s", t_log)
                 return
-            self._state = True
+            self._attr_state = True
 
         if temperature is not None:
             result = await self._color_channel.move_to_color_temp(
@@ -324,11 +272,39 @@ class BaseLight(LogMixin, light.LightEntity):
                 self.debug("turned on: %s", t_log)
                 return
             self._attr_color_mode = ColorMode.COLOR_TEMP
-            self._color_temp = temperature
-            self._hs_color = None
+            self._attr_color_temp = temperature
+            self._attr_xy_color = None
+            self._attr_hs_color = None
 
         if hs_color is not None:
-            xy_color = color_util.color_hs_to_xy(*hs_color)
+            if self._color_channel.enhanced_hue_supported:
+                result = await self._color_channel.enhanced_move_to_hue_and_saturation(
+                    int(hs_color[0] * 65535 / 360),
+                    int(hs_color[1] * 2.54),
+                    self._DEFAULT_MIN_TRANSITION_TIME
+                    if new_color_provided_while_off
+                    else duration,
+                )
+                t_log["enhanced_move_to_hue_and_saturation"] = result
+            else:
+                result = await self._color_channel.move_to_hue_and_saturation(
+                    int(hs_color[0] * 254 / 360),
+                    int(hs_color[1] * 2.54),
+                    self._DEFAULT_MIN_TRANSITION_TIME
+                    if new_color_provided_while_off
+                    else duration,
+                )
+                t_log["move_to_hue_and_saturation"] = result
+            if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
+                self.debug("turned on: %s", t_log)
+                return
+            self._attr_color_mode = ColorMode.HS
+            self._attr_hs_color = hs_color
+            self._attr_xy_color = None
+            self._attr_color_temp = None
+            xy_color = None  # don't set xy_color if it is also present
+
+        if xy_color is not None:
             result = await self._color_channel.move_to_color(
                 int(xy_color[0] * 65535),
                 int(xy_color[1] * 65535),
@@ -340,9 +316,10 @@ class BaseLight(LogMixin, light.LightEntity):
             if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
                 self.debug("turned on: %s", t_log)
                 return
-            self._attr_color_mode = ColorMode.HS
-            self._hs_color = hs_color
-            self._color_temp = None
+            self._attr_color_mode = ColorMode.XY
+            self._attr_xy_color = xy_color
+            self._attr_color_temp = None
+            self._attr_hs_color = None
 
         if new_color_provided_while_off:
             # The light is has the correct color, so we can now transition it to the correct brightness level.
@@ -351,9 +328,9 @@ class BaseLight(LogMixin, light.LightEntity):
             if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
                 self.debug("turned on: %s", t_log)
                 return
-            self._state = bool(level)
+            self._attr_state = bool(level)
             if level:
-                self._brightness = level
+                self._attr_brightness = level
 
         if effect == light.EFFECT_COLORLOOP:
             result = await self._color_channel.color_loop_set(
@@ -366,9 +343,10 @@ class BaseLight(LogMixin, light.LightEntity):
                 0,  # no hue
             )
             t_log["color_loop_set"] = result
-            self._effect = light.EFFECT_COLORLOOP
+            self._attr_effect = light.EFFECT_COLORLOOP
         elif (
-            self._effect == light.EFFECT_COLORLOOP and effect != light.EFFECT_COLORLOOP
+            self._attr_effect == light.EFFECT_COLORLOOP
+            and effect != light.EFFECT_COLORLOOP
         ):
             result = await self._color_channel.color_loop_set(
                 UPDATE_COLORLOOP_ACTION,
@@ -378,7 +356,7 @@ class BaseLight(LogMixin, light.LightEntity):
                 0x0,  # update action only, action off, no dir, time, hue
             )
             t_log["color_loop_set"] = result
-            self._effect = None
+            self._attr_effect = None
 
         if flash is not None:
             result = await self._identify_channel.trigger_effect(
@@ -406,12 +384,12 @@ class BaseLight(LogMixin, light.LightEntity):
         self.debug("turned off: %s", result)
         if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
             return
-        self._state = False
+        self._attr_state = False
 
         if supports_level:
             # store current brightness so that the next turn_on uses it.
             self._off_with_transition = transition is not None
-            self._off_brightness = self._brightness
+            self._off_brightness = self._attr_brightness
 
         self.async_write_ha_state()
 
@@ -427,44 +405,59 @@ class Light(BaseLight, ZhaEntity):
         """Initialize the ZHA light."""
         super().__init__(unique_id, zha_device, channels, **kwargs)
         self._on_off_channel = self.cluster_channels[CHANNEL_ON_OFF]
-        self._state = bool(self._on_off_channel.on_off)
+        self._attr_state = bool(self._on_off_channel.on_off)
         self._level_channel = self.cluster_channels.get(CHANNEL_LEVEL)
         self._color_channel = self.cluster_channels.get(CHANNEL_COLOR)
         self._identify_channel = self.zha_device.channels.identify_ch
         if self._color_channel:
-            self._min_mireds: int | None = self._color_channel.min_mireds
-            self._max_mireds: int | None = self._color_channel.max_mireds
+            self._attr_min_mireds: int = self._color_channel.min_mireds
+            self._attr_max_mireds: int = self._color_channel.max_mireds
         self._cancel_refresh_handle = None
         effect_list = []
 
         self._attr_supported_color_modes = {ColorMode.ONOFF}
         if self._level_channel:
             self._attr_supported_color_modes.add(ColorMode.BRIGHTNESS)
-            self._supported_features |= light.LightEntityFeature.TRANSITION
-            self._brightness = self._level_channel.current_level
+            self._attr_supported_features |= light.LightEntityFeature.TRANSITION
+            self._attr_brightness = self._level_channel.current_level
 
         if self._color_channel:
-            color_capabilities = self._color_channel.color_capabilities
-            if color_capabilities & CAPABILITIES_COLOR_TEMP:
+            if self._color_channel.color_temp_supported:
                 self._attr_supported_color_modes.add(ColorMode.COLOR_TEMP)
-                self._color_temp = self._color_channel.color_temperature
+                self._attr_color_temp = self._color_channel.color_temperature
 
-            if color_capabilities & CAPABILITIES_COLOR_XY:
-                self._attr_supported_color_modes.add(ColorMode.HS)
+            if (
+                self._color_channel.xy_supported
+                and not self._color_channel.hs_supported
+            ):
+                self._attr_supported_color_modes.add(ColorMode.XY)
                 curr_x = self._color_channel.current_x
                 curr_y = self._color_channel.current_y
                 if curr_x is not None and curr_y is not None:
-                    self._hs_color = color_util.color_xy_to_hs(
-                        float(curr_x / 65535), float(curr_y / 65535)
+                    self._attr_xy_color = (curr_x / 65535, curr_y / 65535)
+                else:
+                    self._attr_xy_color = (0, 0)
+
+            if self._color_channel.hs_supported:
+                self._attr_supported_color_modes.add(ColorMode.HS)
+                if self._color_channel.enhanced_hue_supported:
+                    curr_hue = self._color_channel.enhanced_current_hue * 65535 / 360
+                else:
+                    curr_hue = self._color_channel.current_hue * 254 / 360
+                curr_saturation = self._color_channel.current_saturation
+                if curr_hue is not None and curr_saturation is not None:
+                    self._attr_hs_color = (
+                        int(curr_hue),
+                        int(curr_saturation * 2.54),
                     )
                 else:
-                    self._hs_color = (0, 0)
+                    self._attr_hs_color = (0, 0)
 
-            if color_capabilities & CAPABILITIES_COLOR_LOOP:
-                self._supported_features |= light.LightEntityFeature.EFFECT
+            if self._color_channel.color_loop_supported:
+                self._attr_supported_features |= light.LightEntityFeature.EFFECT
                 effect_list.append(light.EFFECT_COLORLOOP)
                 if self._color_channel.color_loop_active == 1:
-                    self._effect = light.EFFECT_COLORLOOP
+                    self._attr_effect = light.EFFECT_COLORLOOP
         self._attr_supported_color_modes = filter_supported_color_modes(
             self._attr_supported_color_modes
         )
@@ -475,13 +468,13 @@ class Light(BaseLight, ZhaEntity):
             if self._color_channel.color_mode == Color.ColorMode.Color_temperature:
                 self._attr_color_mode = ColorMode.COLOR_TEMP
             else:
-                self._attr_color_mode = ColorMode.HS
+                self._attr_color_mode = ColorMode.XY
 
         if self._identify_channel:
-            self._supported_features |= light.LightEntityFeature.FLASH
+            self._attr_supported_features |= light.LightEntityFeature.FLASH
 
         if effect_list:
-            self._effect_list = effect_list
+            self._attr_effect_list = effect_list
 
         self._zha_config_transition = async_get_zha_config_value(
             zha_device.gateway.config_entry,
@@ -493,7 +486,7 @@ class Light(BaseLight, ZhaEntity):
     @callback
     def async_set_state(self, attr_id, attr_name, value):
         """Set the state."""
-        self._state = bool(value)
+        self._attr_state = bool(value)
         if value:
             self._off_with_transition = False
             self._off_brightness = None
@@ -529,9 +522,9 @@ class Light(BaseLight, ZhaEntity):
     @callback
     def async_restore_last_state(self, last_state):
         """Restore previous state."""
-        self._state = last_state.state == STATE_ON
+        self._attr_state = last_state.state == STATE_ON
         if "brightness" in last_state.attributes:
-            self._brightness = last_state.attributes["brightness"]
+            self._attr_brightness = last_state.attributes["brightness"]
         if "off_with_transition" in last_state.attributes:
             self._off_with_transition = last_state.attributes["off_with_transition"]
         if "off_brightness" in last_state.attributes:
@@ -539,15 +532,17 @@ class Light(BaseLight, ZhaEntity):
         if "color_mode" in last_state.attributes:
             self._attr_color_mode = ColorMode(last_state.attributes["color_mode"])
         if "color_temp" in last_state.attributes:
-            self._color_temp = last_state.attributes["color_temp"]
+            self._attr_color_temp = last_state.attributes["color_temp"]
+        if "xy_color" in last_state.attributes:
+            self._attr_xy_color = last_state.attributes["xy_color"]
         if "hs_color" in last_state.attributes:
-            self._hs_color = last_state.attributes["hs_color"]
+            self._attr_hs_color = last_state.attributes["hs_color"]
         if "effect" in last_state.attributes:
-            self._effect = last_state.attributes["effect"]
+            self._attr_effect = last_state.attributes["effect"]
 
     async def async_get_state(self):
         """Attempt to retrieve the state from the light."""
-        if not self.available:
+        if not self._attr_available:
             return
         self.debug("polling current state")
         if self._on_off_channel:
@@ -555,21 +550,32 @@ class Light(BaseLight, ZhaEntity):
                 "on_off", from_cache=False
             )
             if state is not None:
-                self._state = state
+                self._attr_state = state
         if self._level_channel:
             level = await self._level_channel.get_attribute_value(
                 "current_level", from_cache=False
             )
             if level is not None:
-                self._brightness = level
+                self._attr_brightness = level
         if self._color_channel:
             attributes = [
                 "color_mode",
-                "color_temperature",
                 "current_x",
                 "current_y",
-                "color_loop_active",
             ]
+            if self._color_channel.enhanced_hue_supported:
+                attributes.append("enhanced_current_hue")
+                attributes.append("current_saturation")
+            if (
+                self._color_channel.hs_supported
+                and not self._color_channel.enhanced_hue_supported
+            ):
+                attributes.append("current_hue")
+                attributes.append("current_saturation")
+            if self._color_channel.color_temp_supported:
+                attributes.append("color_temperature")
+            if self._color_channel.color_loop_supported:
+                attributes.append("color_loop_active")
 
             results = await self._color_channel.get_attributes(
                 attributes, from_cache=False, only_cache=False
@@ -580,24 +586,40 @@ class Light(BaseLight, ZhaEntity):
                     self._attr_color_mode = ColorMode.COLOR_TEMP
                     color_temp = results.get("color_temperature")
                     if color_temp is not None and color_mode:
-                        self._color_temp = color_temp
-                        self._hs_color = None
-                else:
+                        self._attr_color_temp = color_temp
+                        self._attr_xy_color = None
+                        self._attr_hs_color = None
+                elif color_mode == Color.ColorMode.Hue_and_saturation:
                     self._attr_color_mode = ColorMode.HS
+                    if self._color_channel.enhanced_hue_supported:
+                        current_hue = results.get("enhanced_current_hue")
+                    else:
+                        current_hue = results.get("current_hue")
+                    current_saturation = results.get("current_saturation")
+                    if current_hue is not None and current_saturation is not None:
+                        self._attr_hs_color = (
+                            int(current_hue * 360 / 65535)
+                            if self._color_channel.enhanced_hue_supported
+                            else int(current_hue * 360 / 254),
+                            int(current_saturation / 254),
+                        )
+                        self._attr_xy_color = None
+                        self._attr_color_temp = None
+                else:
+                    self._attr_color_mode = Color.ColorMode.X_and_Y
                     color_x = results.get("current_x")
                     color_y = results.get("current_y")
                     if color_x is not None and color_y is not None:
-                        self._hs_color = color_util.color_xy_to_hs(
-                            float(color_x / 65535), float(color_y / 65535)
-                        )
-                        self._color_temp = None
+                        self._attr_xy_color = (color_x / 65535, color_y / 65535)
+                        self._attr_color_temp = None
+                        self._attr_hs_color = None
 
             color_loop_active = results.get("color_loop_active")
             if color_loop_active is not None:
                 if color_loop_active == 1:
-                    self._effect = light.EFFECT_COLORLOOP
+                    self._attr_effect = light.EFFECT_COLORLOOP
                 else:
-                    self._effect = None
+                    self._attr_effect = None
 
     async def async_update(self):
         """Update to the latest state."""
@@ -671,6 +693,12 @@ class LightGroup(BaseLight, ZhaGroupEntity):
         )
         self._attr_color_mode = None
 
+    # remove this when all ZHA platforms and base entities are updated
+    @property
+    def available(self) -> bool:
+        """Return entity availability."""
+        return self._attr_available
+
     async def async_added_to_hass(self):
         """Run when about to be added to hass."""
         await super().async_added_to_hass()
@@ -700,39 +728,49 @@ class LightGroup(BaseLight, ZhaGroupEntity):
         states: list[State] = list(filter(None, all_states))
         on_states = [state for state in states if state.state == STATE_ON]
 
-        self._state = len(on_states) > 0
-        self._available = any(state.state != STATE_UNAVAILABLE for state in states)
+        self._attr_state = len(on_states) > 0
+        self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states)
+
+        self._attr_brightness = helpers.reduce_attribute(
+            on_states, light.ATTR_BRIGHTNESS
+        )
 
-        self._brightness = helpers.reduce_attribute(on_states, ATTR_BRIGHTNESS)
+        self._attr_xy_color = helpers.reduce_attribute(
+            on_states, light.ATTR_XY_COLOR, reduce=helpers.mean_tuple
+        )
 
-        self._hs_color = helpers.reduce_attribute(
-            on_states, ATTR_HS_COLOR, reduce=helpers.mean_tuple
+        self._attr_hs_color = helpers.reduce_attribute(
+            on_states, light.ATTR_HS_COLOR, reduce=helpers.mean_tuple
         )
 
-        self._color_temp = helpers.reduce_attribute(on_states, ATTR_COLOR_TEMP)
-        self._min_mireds = helpers.reduce_attribute(
-            states, ATTR_MIN_MIREDS, default=153, reduce=min
+        self._attr_color_temp = helpers.reduce_attribute(
+            on_states, light.ATTR_COLOR_TEMP
+        )
+        self._attr_min_mireds = helpers.reduce_attribute(
+            states, light.ATTR_MIN_MIREDS, default=153, reduce=min
         )
-        self._max_mireds = helpers.reduce_attribute(
-            states, ATTR_MAX_MIREDS, default=500, reduce=max
+        self._attr_max_mireds = helpers.reduce_attribute(
+            states, light.ATTR_MAX_MIREDS, default=500, reduce=max
         )
 
-        self._effect_list = None
-        all_effect_lists = list(helpers.find_state_attributes(states, ATTR_EFFECT_LIST))
+        self._attr_effect_list = None
+        all_effect_lists = list(
+            helpers.find_state_attributes(states, light.ATTR_EFFECT_LIST)
+        )
         if all_effect_lists:
             # Merge all effects from all effect_lists with a union merge.
-            self._effect_list = list(set().union(*all_effect_lists))
+            self._attr_effect_list = list(set().union(*all_effect_lists))
 
-        self._effect = None
-        all_effects = list(helpers.find_state_attributes(on_states, ATTR_EFFECT))
+        self._attr_effect = None
+        all_effects = list(helpers.find_state_attributes(on_states, light.ATTR_EFFECT))
         if all_effects:
             # Report the most common effect.
             effects_count = Counter(itertools.chain(all_effects))
-            self._effect = effects_count.most_common(1)[0][0]
+            self._attr_effect = effects_count.most_common(1)[0][0]
 
         self._attr_color_mode = None
         all_color_modes = list(
-            helpers.find_state_attributes(on_states, ATTR_COLOR_MODE)
+            helpers.find_state_attributes(on_states, light.ATTR_COLOR_MODE)
         )
         if all_color_modes:
             # Report the most common color mode, select brightness and onoff last
@@ -745,7 +783,7 @@ class LightGroup(BaseLight, ZhaGroupEntity):
 
         self._attr_supported_color_modes = None
         all_supported_color_modes = list(
-            helpers.find_state_attributes(states, ATTR_SUPPORTED_COLOR_MODES)
+            helpers.find_state_attributes(states, light.ATTR_SUPPORTED_COLOR_MODES)
         )
         if all_supported_color_modes:
             # Merge all color modes.
@@ -753,14 +791,14 @@ class LightGroup(BaseLight, ZhaGroupEntity):
                 set[str], set().union(*all_supported_color_modes)
             )
 
-        self._supported_features = 0
+        self._attr_supported_features = 0
         for support in helpers.find_state_attributes(states, ATTR_SUPPORTED_FEATURES):
             # Merge supported features by emulating support for every feature
             # we find.
-            self._supported_features |= support
+            self._attr_supported_features |= support
         # Bitwise-and the supported features with the GroupedLight's features
         # so that we don't break in the future when a new feature is added.
-        self._supported_features &= SUPPORT_GROUP_LIGHT
+        self._attr_supported_features &= SUPPORT_GROUP_LIGHT
 
     async def _force_member_updates(self):
         """Force the update of member entities to ensure the states are correct for bulbs that don't report their state."""
diff --git a/tests/components/zha/test_channels.py b/tests/components/zha/test_channels.py
index 7701992cab4..6aba5500a2a 100644
--- a/tests/components/zha/test_channels.py
+++ b/tests/components/zha/test_channels.py
@@ -151,7 +151,18 @@ async def poll_control_device(zha_device_restored, zigpy_device_mock):
             },
         ),
         (0x0202, 1, {"fan_mode"}),
-        (0x0300, 1, {"current_x", "current_y", "color_temperature"}),
+        (
+            0x0300,
+            1,
+            {
+                "current_x",
+                "current_y",
+                "color_temperature",
+                "current_hue",
+                "enhanced_current_hue",
+                "current_saturation",
+            },
+        ),
         (0x0400, 1, {"measured_value"}),
         (0x0401, 1, {"level_status"}),
         (0x0402, 1, {"measured_value"}),
diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py
index 982ff622341..f892448dc69 100644
--- a/tests/components/zha/test_light.py
+++ b/tests/components/zha/test_light.py
@@ -15,11 +15,7 @@ from homeassistant.components.light import (
     ColorMode,
 )
 from homeassistant.components.zha.core.group import GroupMember
-from homeassistant.components.zha.light import (
-    CAPABILITIES_COLOR_TEMP,
-    CAPABILITIES_COLOR_XY,
-    FLASH_EFFECTS,
-)
+from homeassistant.components.zha.light import FLASH_EFFECTS
 from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform
 import homeassistant.util.dt as dt_util
 
@@ -148,7 +144,8 @@ async def device_light_1(hass, zigpy_device_mock, zha_device_joined):
     )
     color_cluster = zigpy_device.endpoints[1].light_color
     color_cluster.PLUGGED_ATTR_READS = {
-        "color_capabilities": CAPABILITIES_COLOR_TEMP | CAPABILITIES_COLOR_XY
+        "color_capabilities": lighting.Color.ColorCapabilities.Color_temperature
+        | lighting.Color.ColorCapabilities.XY_attributes
     }
     zha_device = await zha_device_joined(zigpy_device)
     zha_device.available = True
@@ -180,7 +177,8 @@ async def device_light_2(hass, zigpy_device_mock, zha_device_joined):
     )
     color_cluster = zigpy_device.endpoints[1].light_color
     color_cluster.PLUGGED_ATTR_READS = {
-        "color_capabilities": CAPABILITIES_COLOR_TEMP | CAPABILITIES_COLOR_XY
+        "color_capabilities": lighting.Color.ColorCapabilities.Color_temperature
+        | lighting.Color.ColorCapabilities.XY_attributes
     }
     zha_device = await zha_device_joined(zigpy_device)
     zha_device.available = True
@@ -239,7 +237,8 @@ async def eWeLink_light(hass, zigpy_device_mock, zha_device_joined):
     )
     color_cluster = zigpy_device.endpoints[1].light_color
     color_cluster.PLUGGED_ATTR_READS = {
-        "color_capabilities": CAPABILITIES_COLOR_TEMP | CAPABILITIES_COLOR_XY
+        "color_capabilities": lighting.Color.ColorCapabilities.Color_temperature
+        | lighting.Color.ColorCapabilities.XY_attributes
     }
     zha_device = await zha_device_joined(zigpy_device)
     zha_device.available = True
@@ -302,7 +301,7 @@ async def test_light_refresh(hass, zigpy_device_mock, zha_device_joined_restored
 )
 @pytest.mark.parametrize(
     "device, reporting",
-    [(LIGHT_ON_OFF, (1, 0, 0)), (LIGHT_LEVEL, (1, 1, 0)), (LIGHT_COLOR, (1, 1, 3))],
+    [(LIGHT_ON_OFF, (1, 0, 0)), (LIGHT_LEVEL, (1, 1, 0)), (LIGHT_COLOR, (1, 1, 6))],
 )
 async def test_light(
     hass, zigpy_device_mock, zha_device_joined_restored, device, reporting
@@ -1400,7 +1399,7 @@ async def test_zha_group_light_entity(
     assert group_state.state == STATE_OFF
     assert group_state.attributes["supported_color_modes"] == [
         ColorMode.COLOR_TEMP,
-        ColorMode.HS,
+        ColorMode.XY,
     ]
     # Light which is off has no color mode
     assert "color_mode" not in group_state.attributes
@@ -1431,9 +1430,9 @@ async def test_zha_group_light_entity(
     assert group_state.state == STATE_ON
     assert group_state.attributes["supported_color_modes"] == [
         ColorMode.COLOR_TEMP,
-        ColorMode.HS,
+        ColorMode.XY,
     ]
-    assert group_state.attributes["color_mode"] == ColorMode.HS
+    assert group_state.attributes["color_mode"] == ColorMode.XY
 
     # test long flashing the lights from the HA
     await async_test_flash_from_hass(
-- 
GitLab