diff --git a/tests/data/devices/ericsity-gl-c-009p-0x25013001.json b/tests/data/devices/ericsity-gl-c-009p-0x25013001.json index 8a0cf6176..a43aa676e 100644 --- a/tests/data/devices/ericsity-gl-c-009p-0x25013001.json +++ b/tests/data/devices/ericsity-gl-c-009p-0x25013001.json @@ -386,16 +386,15 @@ "on": false, "brightness": 254, "xy_color": null, - "color_temp": 500, + "color_temp": null, "effect_list": [ "off" ], "effect": "off", "supported_features": 40, - "color_mode": "color_temp", + "color_mode": "brightness", "supported_color_modes": [ "brightness", - "color_temp", "onoff" ], "off_with_transition": false, diff --git a/tests/data/devices/gledopto-gl-c-009p-0x17013001.json b/tests/data/devices/gledopto-gl-c-009p-0x17013001.json index 67000a6cb..86e9f6817 100644 --- a/tests/data/devices/gledopto-gl-c-009p-0x17013001.json +++ b/tests/data/devices/gledopto-gl-c-009p-0x17013001.json @@ -355,16 +355,15 @@ "on": false, "brightness": 143, "xy_color": null, - "color_temp": 158, + "color_temp": null, "effect_list": [ "off" ], "effect": "off", "supported_features": 40, - "color_mode": "color_temp", + "color_mode": "brightness", "supported_color_modes": [ "brightness", - "color_temp", "onoff" ], "off_with_transition": false, diff --git a/tests/data/devices/gledopto-gl-sd-301p-0x26013001.json b/tests/data/devices/gledopto-gl-sd-301p-0x26013001.json index cb5b5da6d..210b6ea04 100644 --- a/tests/data/devices/gledopto-gl-sd-301p-0x26013001.json +++ b/tests/data/devices/gledopto-gl-sd-301p-0x26013001.json @@ -355,16 +355,15 @@ "on": true, "brightness": 254, "xy_color": null, - "color_temp": 495, + "color_temp": null, "effect_list": [ "off" ], "effect": "off", "supported_features": 40, - "color_mode": "color_temp", + "color_mode": "brightness", "supported_color_modes": [ "brightness", - "color_temp", "onoff" ], "off_with_transition": false, diff --git a/tests/data/devices/homr-hrmsn01.json b/tests/data/devices/homr-hrmsn01.json index d14dfabf7..e657bde01 100644 --- a/tests/data/devices/homr-hrmsn01.json +++ b/tests/data/devices/homr-hrmsn01.json @@ -508,7 +508,7 @@ 0.0, 0.0 ], - "color_temp": 0, + "color_temp": null, "effect_list": [ "off" ], @@ -517,7 +517,6 @@ "color_mode": "xy", "supported_color_modes": [ "brightness", - "color_temp", "onoff", "xy" ], diff --git a/tests/data/devices/ledvance-outdoor-accent-light-rgb-0x00102428.json b/tests/data/devices/ledvance-outdoor-accent-light-rgb-0x00102428.json index a6d597859..40564fd7c 100644 --- a/tests/data/devices/ledvance-outdoor-accent-light-rgb-0x00102428.json +++ b/tests/data/devices/ledvance-outdoor-accent-light-rgb-0x00102428.json @@ -365,16 +365,15 @@ 0.16398870832379644, 0.13199053940642405 ], - "color_temp": 1901, + "color_temp": null, "effect_list": [ "off" ], "effect": "off", "supported_features": 40, - "color_mode": "color_temp", + "color_mode": "xy", "supported_color_modes": [ "brightness", - "color_temp", "onoff", "xy" ], diff --git a/tests/data/devices/sonoff-dongle-e-r.json b/tests/data/devices/sonoff-dongle-e-r.json index a3a6e4fed..8c222e6c8 100644 --- a/tests/data/devices/sonoff-dongle-e-r.json +++ b/tests/data/devices/sonoff-dongle-e-r.json @@ -500,16 +500,15 @@ "on": false, "brightness": 51, "xy_color": null, - "color_temp": 17476, + "color_temp": null, "effect_list": [ "off" ], "effect": "off", "supported_features": 40, - "color_mode": "color_temp", + "color_mode": "brightness", "supported_color_modes": [ "brightness", - "color_temp", "onoff" ], "off_with_transition": false, diff --git a/tests/test_light.py b/tests/test_light.py index a7dcc4419..3f58cad27 100644 --- a/tests/test_light.py +++ b/tests/test_light.py @@ -11,7 +11,7 @@ from unittest.mock import AsyncMock, call, patch, sentinel import pytest -from zigpy.profiles import zha +from zigpy.profiles import zha, zll from zigpy.zcl.clusters import general, lighting import zigpy.zcl.foundation as zcl_f import zigpy.zdo.types as zdo_t @@ -2248,6 +2248,187 @@ async def test_poll_updates_color_mode_on_dual_mode_light( assert entity.state["color_temp"] is None +_ZHA_COLOR = (zha.PROFILE_ID, zha.DeviceType.COLOR_DIMMABLE_LIGHT) +_ZHA_CT = (zha.PROFILE_ID, zha.DeviceType.COLOR_TEMPERATURE_LIGHT) +_ZHA_EXTENDED = (zha.PROFILE_ID, zha.DeviceType.EXTENDED_COLOR_LIGHT) +_ZLL_CT = (zll.PROFILE_ID, zll.DeviceType.COLOR_TEMPERATURE_LIGHT) +_ZLL_EXTENDED = (zll.PROFILE_ID, zll.DeviceType.EXTENDED_COLOR_LIGHT) +_ZCL_CT: lighting.ColorMode = lighting.ColorMode.Color_temperature + + +def _create_color_light( + zha_gateway: Gateway, + *, + endpoint: tuple[int, int] = _ZHA_COLOR, + color_attributes: dict[str, Any] | None = None, +) -> tuple[Any, Any]: + profile_id, device_type = endpoint + zigpy_device = create_mock_zigpy_device( + zha_gateway, + { + 1: { + **LIGHT_COLOR[1], + SIG_EP_PROFILE: profile_id, + SIG_EP_TYPE: device_type, + } + }, + ) + color_cluster = zigpy_device.endpoints[1].light_color + color_cluster.PLUGGED_ATTR_READS = color_attributes or {} + update_attribute_cache(color_cluster) + return zigpy_device, color_cluster + + +async def test_explicit_negative_color_capability_suppresses_color_temperature( + zha_gateway: Gateway, +) -> None: + """Test an explicit bitmap without CT overrides fallback signals.""" + zigpy_device, color_cluster = _create_color_light( + zha_gateway, + endpoint=_ZHA_EXTENDED, + color_attributes={ + "color_capabilities": lighting.ColorCapabilities.XY_attributes, + "color_mode": _ZCL_CT, + "color_temperature": 250, + }, + ) + level_cluster = zigpy_device.endpoints[1].level + zha_device = await join_zigpy_device(zha_gateway, zigpy_device) + entity = get_entity(zha_device, platform=Platform.LIGHT) + + assert entity.supported_color_modes == {ColorMode.XY} + assert entity.state["color_temp"] is None + + level_cluster.PLUGGED_ATTR_READS = {"current_level": 180} + color_cluster.PLUGGED_ATTR_READS = { + "color_mode": lighting.ColorMode.X_and_Y, + "color_temperature": 275, + "current_x": 30000, + "current_y": 25000, + } + color_cluster.read_attributes.reset_mock() + await entity.async_update() + + polled_attributes = color_cluster.read_attributes.await_args.args[0] + assert "color_temperature" not in polled_attributes + assert entity.state["color_mode"] == ColorMode.XY + assert entity.state["xy_color"] == (30000 / 65535, 25000 / 65535) + assert entity.state["brightness"] == 180 + + +async def test_recompute_capabilities_clears_unsupported_color_temperature( + zha_gateway: Gateway, +) -> None: + """Test recomputing capabilities clears stale color temperature state.""" + zigpy_device, color_cluster = _create_color_light( + zha_gateway, + color_attributes={ + "color_mode": _ZCL_CT, + "color_temperature": 250, + }, + ) + zha_device = await join_zigpy_device(zha_gateway, zigpy_device) + entity = get_entity(zha_device, platform=Platform.LIGHT) + + assert entity.supported_color_modes == {ColorMode.COLOR_TEMP, ColorMode.XY} + assert entity.state["color_temp"] == 250 + + color_cluster._attr_cache.set_value( + lighting.Color.AttributeDefs.color_capabilities, + lighting.ColorCapabilities.XY_attributes, + ) + entity.recompute_capabilities() + + assert entity.supported_color_modes == {ColorMode.XY} + assert entity.state["color_temp"] is None + + +@pytest.mark.parametrize( + ( + "capability_state", + "endpoint", + "color_attributes", + "expected_color_temp_support", + ), + ( + pytest.param("none", _ZHA_COLOR, {"color_mode": _ZCL_CT}, True, id="none"), + pytest.param( + "unsupported", + _ZHA_COLOR, + {"color_mode": _ZCL_CT}, + True, + id="unsupported", + ), + pytest.param("absent", _ZLL_CT, {}, True, id="zll-color-temperature"), + pytest.param("absent", _ZLL_EXTENDED, {}, True, id="zll-extended"), + pytest.param( + "absent", + _ZHA_COLOR, + {"color_temperature": 250}, + False, + id="stale-temperature", + ), + ), +) +async def test_color_temperature_capability_evidence( + zha_gateway: Gateway, + capability_state: str, + endpoint: tuple[int, int], + color_attributes: dict[str, Any], + expected_color_temp_support: bool, +) -> None: + """Test the authoritative signals for color temperature support.""" + zigpy_device, color_cluster = _create_color_light( + zha_gateway, + endpoint=endpoint, + color_attributes=color_attributes, + ) + + capability_attr = lighting.Color.AttributeDefs.color_capabilities + if capability_state == "none": + color_cluster._attr_cache.set_value(capability_attr, None) + elif capability_state == "unsupported": + color_cluster._attr_cache.set_value( + capability_attr, lighting.ColorCapabilities.XY_attributes + ) + color_cluster.add_unsupported_attribute(capability_attr) + + zha_device = await join_zigpy_device(zha_gateway, zigpy_device) + entity = get_entity(zha_device, platform=Platform.LIGHT) + + assert ( + ColorMode.COLOR_TEMP in entity.supported_color_modes + ) is expected_color_temp_support + assert ColorMode.XY in entity.supported_color_modes + assert "colorloop" not in entity.state["effect_list"] + if not expected_color_temp_support: + assert entity.state["color_temp"] is None + + +async def test_missing_color_attribute_definitions(zha_gateway: Gateway) -> None: + """Test a partial color cluster remains usable.""" + zigpy_device, color_cluster = _create_color_light( + zha_gateway, + endpoint=_ZHA_CT, + ) + missing_attributes = {"color_capabilities", "color_mode", "color_temperature"} + color_cluster.attributes_by_name = { + name: attribute + for name, attribute in color_cluster.attributes_by_name.items() + if name not in missing_attributes + } + + zha_device = await join_zigpy_device(zha_gateway, zigpy_device) + entity = get_entity(zha_device, platform=Platform.LIGHT) + assert entity.supported_color_modes == {ColorMode.COLOR_TEMP, ColorMode.XY} + + color_cluster.read_attributes.reset_mock() + await entity.async_update() + + polled_attributes = color_cluster.read_attributes.await_args.args[0] + assert set(polled_attributes) == {"current_x", "current_y"} + + async def test_turn_on_cancellation_cleans_up_transition_flag( zha_gateway: Gateway, ) -> None: diff --git a/zha/application/platforms/light/__init__.py b/zha/application/platforms/light/__init__.py index c6f1249f4..c4ae3d8b4 100644 --- a/zha/application/platforms/light/__init__.py +++ b/zha/application/platforms/light/__init__.py @@ -15,6 +15,7 @@ import logging from typing import TYPE_CHECKING, Any +from zigpy.profiles import zha, zll from zigpy.zcl import ( AttributeReadEvent, AttributeReportedEvent, @@ -87,6 +88,15 @@ _LOGGER = logging.getLogger(__name__) +_COLOR_TEMP_PROFILE_DEVICE_TYPES = frozenset( + { + (zha.PROFILE_ID, zha.DeviceType.COLOR_TEMPERATURE_LIGHT), + (zha.PROFILE_ID, zha.DeviceType.EXTENDED_COLOR_LIGHT), + (zll.PROFILE_ID, zll.DeviceType.COLOR_TEMPERATURE_LIGHT), + (zll.PROFILE_ID, zll.DeviceType.EXTENDED_COLOR_LIGHT), + } +) + @dataclass(frozen=True, kw_only=True) class LightEntityInfo(BaseEntityInfo): @@ -283,36 +293,65 @@ def __init__(self, *args, **kwargs): self._internal_supported_color_modes: set[ColorMode] = set() @property - def _color_capabilities(self) -> ColorCapabilities: + def _color_capabilities(self) -> ColorCapabilities | None: """Return ZCL color capabilities of the light.""" if self._color_cluster is None: return Color.ColorCapabilities.XY_attributes - color_capabilities = self._color_cluster.get( - Color.AttributeDefs.color_capabilities.name + color_capabilities_name = Color.AttributeDefs.color_capabilities.name + color_capabilities_attr = self._color_cluster.attributes_by_name.get( + color_capabilities_name ) + if color_capabilities_attr is None: + return None + if self._color_cluster.is_attribute_unsupported(color_capabilities_attr): + return None + color_capabilities = self._color_cluster.get(color_capabilities_name) if color_capabilities is None: - return Color.ColorCapabilities.XY_attributes + return None return Color.ColorCapabilities(color_capabilities) @property def _color_temperature(self) -> int | None: if self._color_cluster is None: return None - return self._color_cluster.get(Color.AttributeDefs.color_temperature.name) + color_temperature_name = Color.AttributeDefs.color_temperature.name + if color_temperature_name not in self._color_cluster.attributes_by_name: + return None + return self._color_cluster.get(color_temperature_name) @property def _color_xy_supported(self) -> bool: - return Color.ColorCapabilities.XY_attributes in self._color_capabilities + color_capabilities = self._color_capabilities + return color_capabilities is None or ( + Color.ColorCapabilities.XY_attributes in color_capabilities + ) @property def _color_temp_supported(self) -> bool: + color_capabilities = self._color_capabilities + if color_capabilities is not None: + return Color.ColorCapabilities.Color_temperature in color_capabilities + + assert self._color_cluster is not None + zigpy_endpoint = self._color_cluster.endpoint + color_mode_name = Color.AttributeDefs.color_mode.name + reported_color_mode = ( + self._color_cluster.get(color_mode_name) + if color_mode_name in self._color_cluster.attributes_by_name + else None + ) return ( - Color.ColorCapabilities.Color_temperature in self._color_capabilities - ) or self._color_temperature is not None + reported_color_mode == Color.ColorMode.Color_temperature + or (zigpy_endpoint.profile_id, zigpy_endpoint.device_type) + in _COLOR_TEMP_PROFILE_DEVICE_TYPES + ) @property def _color_loop_supported(self) -> bool: - return Color.ColorCapabilities.Color_loop in self._color_capabilities + color_capabilities = self._color_capabilities + return color_capabilities is not None and ( + Color.ColorCapabilities.Color_loop in color_capabilities + ) @property def _color_options(self) -> Options: @@ -1061,6 +1100,7 @@ def recompute_capabilities(self) -> None: self._min_mireds: int = self._color_min_mireds self._max_mireds: int = self._color_max_mireds + self._color_temp = None if self._color_temp_supported: self._internal_supported_color_modes.add(ColorMode.COLOR_TEMP) self._color_temp = self._color_temperature @@ -1090,8 +1130,10 @@ def recompute_capabilities(self) -> None: self._color_mode = next(iter(supported_color_modes)) else: # Light supports color_temp + xy, determine which mode the light is in assert self._color_cluster is not None + color_mode_name = Color.AttributeDefs.color_mode.name if ( - self._color_cluster.get(Color.AttributeDefs.color_mode.name) + color_mode_name in self._color_cluster.attributes_by_name + and self._color_cluster.get(color_mode_name) == Color.ColorMode.Color_temperature ): self._color_mode = ColorMode.COLOR_TEMP @@ -1247,7 +1289,16 @@ async def async_update(self) -> None: Color.AttributeDefs.current_x.name, Color.AttributeDefs.current_y.name, ] - if self._color_temp_supported: + if ( + Color.AttributeDefs.color_mode.name + not in self._color_cluster.attributes_by_name + ): + attributes.remove(Color.AttributeDefs.color_mode.name) + if ( + self._color_temp_supported + and Color.AttributeDefs.color_temperature.name + in self._color_cluster.attributes_by_name + ): attributes.append(Color.AttributeDefs.color_temperature.name) if self._color_loop_supported: attributes.append(Color.AttributeDefs.color_loop_active.name)