From dc9ac69172eef946c5a455eded770105769c7eed Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 12 Mar 2026 02:06:33 +0100 Subject: [PATCH 1/8] WIP: Just guard `color_mode` in polling --- zha/application/platforms/light/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/zha/application/platforms/light/__init__.py b/zha/application/platforms/light/__init__.py index 2bb5e6bd9..aa6e5bfd0 100644 --- a/zha/application/platforms/light/__init__.py +++ b/zha/application/platforms/light/__init__.py @@ -258,7 +258,7 @@ def restore_external_state_attributes( self._color_temp = color_temp if xy_color is not None: self._xy_color = xy_color - if color_mode is not None: + if color_mode is not None and color_mode in self._supported_color_modes: self._color_mode = color_mode if effect is not None: self._effect = effect @@ -1086,13 +1086,15 @@ async def async_update(self) -> None: if (color_mode := results.get("color_mode")) is not None: if color_mode == Color.ColorMode.Color_temperature: - self._color_mode = ColorMode.COLOR_TEMP + if ColorMode.COLOR_TEMP in self._supported_color_modes: + self._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._xy_color = None else: - self._color_mode = ColorMode.XY + if ColorMode.XY in self._supported_color_modes: + self._color_mode = ColorMode.XY color_x = results.get("current_x") color_y = results.get("current_y") if color_x is not None and color_y is not None: From 0ad05d54b918a8754bc5bc82ee9b01565363ff25 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 12 Mar 2026 02:11:34 +0100 Subject: [PATCH 2/8] WIP: Move guard to outside --- zha/application/platforms/light/__init__.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/zha/application/platforms/light/__init__.py b/zha/application/platforms/light/__init__.py index aa6e5bfd0..be18a7307 100644 --- a/zha/application/platforms/light/__init__.py +++ b/zha/application/platforms/light/__init__.py @@ -1088,13 +1088,12 @@ async def async_update(self) -> None: if color_mode == Color.ColorMode.Color_temperature: if ColorMode.COLOR_TEMP in self._supported_color_modes: self._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._xy_color = None - else: - if ColorMode.XY in self._supported_color_modes: - self._color_mode = ColorMode.XY + color_temp = results.get("color_temperature") + if color_temp is not None and color_mode: + self._color_temp = color_temp + self._xy_color = None + elif ColorMode.XY in self._supported_color_modes: + self._color_mode = ColorMode.XY color_x = results.get("current_x") color_y = results.get("current_y") if color_x is not None and color_y is not None: From acc3160647322ba857f01fe1f7d4af78ee8d7547 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 12 Mar 2026 02:15:15 +0100 Subject: [PATCH 3/8] WIP: Try to also update color temp for broken lights --- zha/application/platforms/light/__init__.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/zha/application/platforms/light/__init__.py b/zha/application/platforms/light/__init__.py index be18a7307..7d1b69e65 100644 --- a/zha/application/platforms/light/__init__.py +++ b/zha/application/platforms/light/__init__.py @@ -1085,20 +1085,22 @@ async def async_update(self) -> None: return # type: ignore[unreachable] if (color_mode := results.get("color_mode")) is not None: - if color_mode == Color.ColorMode.Color_temperature: - if ColorMode.COLOR_TEMP in self._supported_color_modes: - self._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._xy_color = None - elif ColorMode.XY in self._supported_color_modes: + if ( + color_mode != Color.ColorMode.Color_temperature + and ColorMode.XY in self._supported_color_modes + ): self._color_mode = ColorMode.XY 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._xy_color = (color_x / 65535, color_y / 65535) self._color_temp = None + elif ColorMode.COLOR_TEMP in self._supported_color_modes: + self._color_mode = ColorMode.COLOR_TEMP + color_temp = results.get("color_temperature") + if color_temp is not None: + self._color_temp = color_temp + self._xy_color = None color_loop_active = results.get("color_loop_active") if color_loop_active is not None: From d21c88680685f839d6b8ba0daf4d394258c43ce3 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 12 Mar 2026 02:17:00 +0100 Subject: [PATCH 4/8] WIP: Ignore `color_mode` when polling if only one is known to be supported --- zha/application/platforms/light/__init__.py | 32 ++++++++++++++------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/zha/application/platforms/light/__init__.py b/zha/application/platforms/light/__init__.py index 7d1b69e65..79ee8a1e2 100644 --- a/zha/application/platforms/light/__init__.py +++ b/zha/application/platforms/light/__init__.py @@ -1085,22 +1085,34 @@ async def async_update(self) -> None: return # type: ignore[unreachable] if (color_mode := results.get("color_mode")) is not None: - if ( - color_mode != Color.ColorMode.Color_temperature - and ColorMode.XY in self._supported_color_modes - ): + if len(self._supported_color_modes) == 1: + # Only one color mode supported, use it regardless of + # what the device reports + supported_mode = next(iter(self._supported_color_modes)) + if supported_mode == ColorMode.COLOR_TEMP: + color_temp = results.get("color_temperature") + if color_temp is not None: + self._color_temp = color_temp + self._xy_color = None + elif supported_mode == ColorMode.XY: + 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._xy_color = (color_x / 65535, color_y / 65535) + self._color_temp = None + elif color_mode == Color.ColorMode.Color_temperature: + self._color_mode = ColorMode.COLOR_TEMP + color_temp = results.get("color_temperature") + if color_temp is not None: + self._color_temp = color_temp + self._xy_color = None + else: self._color_mode = ColorMode.XY 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._xy_color = (color_x / 65535, color_y / 65535) self._color_temp = None - elif ColorMode.COLOR_TEMP in self._supported_color_modes: - self._color_mode = ColorMode.COLOR_TEMP - color_temp = results.get("color_temperature") - if color_temp is not None: - self._color_temp = color_temp - self._xy_color = None color_loop_active = results.get("color_loop_active") if color_loop_active is not None: From 837471527c4d50d7ed6ee5b327af40e6c77ce6ab Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 12 Mar 2026 02:19:16 +0100 Subject: [PATCH 5/8] WIP: Refactor to usable state --- zha/application/platforms/light/__init__.py | 27 ++++++++------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/zha/application/platforms/light/__init__.py b/zha/application/platforms/light/__init__.py index 79ee8a1e2..963d57911 100644 --- a/zha/application/platforms/light/__init__.py +++ b/zha/application/platforms/light/__init__.py @@ -1085,29 +1085,22 @@ async def async_update(self) -> None: return # type: ignore[unreachable] if (color_mode := results.get("color_mode")) is not None: + # Determine the effective color mode: if only one mode is + # supported, use it regardless of what the device reports if len(self._supported_color_modes) == 1: - # Only one color mode supported, use it regardless of - # what the device reports - supported_mode = next(iter(self._supported_color_modes)) - if supported_mode == ColorMode.COLOR_TEMP: - color_temp = results.get("color_temperature") - if color_temp is not None: - self._color_temp = color_temp - self._xy_color = None - elif supported_mode == ColorMode.XY: - 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._xy_color = (color_x / 65535, color_y / 65535) - self._color_temp = None + effective_mode = next(iter(self._supported_color_modes)) elif color_mode == Color.ColorMode.Color_temperature: - self._color_mode = ColorMode.COLOR_TEMP + effective_mode = ColorMode.COLOR_TEMP + else: + effective_mode = ColorMode.XY + self._color_mode = effective_mode + + if effective_mode == ColorMode.COLOR_TEMP: color_temp = results.get("color_temperature") if color_temp is not None: self._color_temp = color_temp self._xy_color = None - else: - self._color_mode = ColorMode.XY + elif effective_mode == ColorMode.XY: color_x = results.get("current_x") color_y = results.get("current_y") if color_x is not None and color_y is not None: From fe5194b8f6ae3529ac0eda72d178d4a03b4e4409 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 12 Mar 2026 02:25:28 +0100 Subject: [PATCH 6/8] Add comment on why we check modes during restore --- zha/application/platforms/light/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/zha/application/platforms/light/__init__.py b/zha/application/platforms/light/__init__.py index 963d57911..aa41c148c 100644 --- a/zha/application/platforms/light/__init__.py +++ b/zha/application/platforms/light/__init__.py @@ -258,6 +258,7 @@ def restore_external_state_attributes( self._color_temp = color_temp if xy_color is not None: self._xy_color = xy_color + # Older persisted states may contain a color_mode not in supported modes if color_mode is not None and color_mode in self._supported_color_modes: self._color_mode = color_mode if effect is not None: From afc7e52dc10ca9515dbafe9f85178f4ba889b1a7 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 12 Mar 2026 02:41:12 +0100 Subject: [PATCH 7/8] Add tests --- tests/test_light.py | 91 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/tests/test_light.py b/tests/test_light.py index 9b0844752..fbd9753e8 100644 --- a/tests/test_light.py +++ b/tests/test_light.py @@ -2110,6 +2110,97 @@ async def test_light_state_restoration(zha_gateway: Gateway) -> None: assert entity.state["effect"] == "colorloop" +async def test_light_state_restoration_unsupported_color_mode( + zha_gateway: Gateway, +) -> None: + """Test that restoring an unsupported color_mode is ignored.""" + zigpy_device = create_mock_zigpy_device(zha_gateway, LIGHT_COLOR) + color_cluster = zigpy_device.endpoints[1].light_color + color_cluster.PLUGGED_ATTR_READS = { + "color_capabilities": lighting.Color.ColorCapabilities.Color_temperature, + "color_temperature": 250, + "color_temp_physical_min": 153, + "color_temp_physical_max": 500, + } + update_attribute_cache(color_cluster) + 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} + assert entity.state["color_mode"] == ColorMode.COLOR_TEMP + + # Attempt to restore XY color_mode on a color_temp-only light + entity.restore_external_state_attributes( + state=True, + off_with_transition=False, + off_brightness=None, + brightness=100, + color_temp=300, + xy_color=None, + color_mode=ColorMode.XY, + effect=None, + ) + + # color_mode should remain COLOR_TEMP since XY is not supported + assert entity.state["color_mode"] == ColorMode.COLOR_TEMP + assert entity.state["color_temp"] == 300 + + +async def test_color_temp_only_light_ignores_incorrect_color_mode( + zha_gateway: Gateway, +) -> None: + """Test that a color_temp-only light ignores incorrect color_mode reports from the device.""" + zigpy_device = create_mock_zigpy_device(zha_gateway, LIGHT_COLOR) + color_cluster = zigpy_device.endpoints[1].light_color + color_cluster.PLUGGED_ATTR_READS = { + "color_capabilities": lighting.Color.ColorCapabilities.Color_temperature, + "color_temperature": 250, + "color_temp_physical_min": 153, + "color_temp_physical_max": 500, + } + update_attribute_cache(color_cluster) + 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} + assert entity.state["color_mode"] == ColorMode.COLOR_TEMP + assert entity.state["color_temp"] == 250 + + # Simulate the device incorrectly reporting XY color mode during a poll + color_cluster.PLUGGED_ATTR_READS = { + "color_capabilities": lighting.Color.ColorCapabilities.Color_temperature, + "color_mode": lighting.Color.ColorMode.X_and_Y, + "color_temperature": 300, + "color_temp_physical_min": 153, + "color_temp_physical_max": 500, + } + update_attribute_cache(color_cluster) + + # Trigger a poll + await entity.async_update() + + # color_mode should remain COLOR_TEMP and color_temp should be updated + assert entity.state["color_mode"] == ColorMode.COLOR_TEMP + assert entity.state["color_temp"] == 300 + assert entity.state["xy_color"] is None + + # Same test with Hue_and_saturation mode + color_cluster.PLUGGED_ATTR_READS = { + "color_capabilities": lighting.Color.ColorCapabilities.Color_temperature, + "color_mode": lighting.Color.ColorMode.Hue_and_saturation, + "color_temperature": 400, + "color_temp_physical_min": 153, + "color_temp_physical_max": 500, + } + update_attribute_cache(color_cluster) + + await entity.async_update() + + assert entity.state["color_mode"] == ColorMode.COLOR_TEMP + assert entity.state["color_temp"] == 400 + assert entity.state["xy_color"] is None + + async def test_turn_on_cancellation_cleans_up_transition_flag( zha_gateway: Gateway, ) -> None: From 43a1a51187f2c23d2ad2ff80a176f855a4237e99 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 12 Mar 2026 02:55:09 +0100 Subject: [PATCH 8/8] Add more tests --- tests/test_light.py | 49 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/tests/test_light.py b/tests/test_light.py index fbd9753e8..b59e452ce 100644 --- a/tests/test_light.py +++ b/tests/test_light.py @@ -2149,7 +2149,7 @@ async def test_light_state_restoration_unsupported_color_mode( async def test_color_temp_only_light_ignores_incorrect_color_mode( zha_gateway: Gateway, ) -> None: - """Test that a color_temp-only light ignores incorrect color_mode reports from the device.""" + """Test color_temp-only light ignores incorrect color_mode reads when polling.""" zigpy_device = create_mock_zigpy_device(zha_gateway, LIGHT_COLOR) color_cluster = zigpy_device.endpoints[1].light_color color_cluster.PLUGGED_ATTR_READS = { @@ -2201,6 +2201,53 @@ async def test_color_temp_only_light_ignores_incorrect_color_mode( assert entity.state["xy_color"] is None +async def test_poll_updates_color_mode_on_dual_mode_light( + zha_gateway: Gateway, +) -> None: + """Test polling XY + COLOR_TEMP light updates color_mode and attribute values.""" + device = await device_light_1_mock(zha_gateway) + entity = get_entity(device, platform=Platform.LIGHT) + color_cluster = device.device.endpoints[1].light_color + + assert entity.supported_color_modes == {ColorMode.COLOR_TEMP, ColorMode.XY} + + # Poll with color_temp mode + color_cluster.PLUGGED_ATTR_READS = { + "color_capabilities": ( + lighting.Color.ColorCapabilities.Color_temperature + | lighting.Color.ColorCapabilities.XY_attributes + ), + "color_mode": lighting.Color.ColorMode.Color_temperature, + "color_temperature": 350, + "current_x": 20000, + "current_y": 20000, + } + update_attribute_cache(color_cluster) + await entity.async_update() + + assert entity.state["color_mode"] == ColorMode.COLOR_TEMP + assert entity.state["color_temp"] == 350 + assert entity.state["xy_color"] is None + + # Poll with XY mode + color_cluster.PLUGGED_ATTR_READS = { + "color_capabilities": ( + lighting.Color.ColorCapabilities.Color_temperature + | lighting.Color.ColorCapabilities.XY_attributes + ), + "color_mode": lighting.Color.ColorMode.X_and_Y, + "color_temperature": 350, + "current_x": 30000, + "current_y": 25000, + } + update_attribute_cache(color_cluster) + await entity.async_update() + + assert entity.state["color_mode"] == ColorMode.XY + assert entity.state["xy_color"] == (30000 / 65535, 25000 / 65535) + assert entity.state["color_temp"] is None + + async def test_turn_on_cancellation_cleans_up_transition_flag( zha_gateway: Gateway, ) -> None: