Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions tests/test_light.py
Original file line number Diff line number Diff line change
Expand Up @@ -2110,6 +2110,144 @@ 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 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 = {
"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_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:
Expand Down
21 changes: 15 additions & 6 deletions zha/application/platforms/light/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,8 @@ 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:
# 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:
self._effect = effect
Expand Down Expand Up @@ -1085,14 +1086,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:
self._color_mode = ColorMode.COLOR_TEMP
# 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:
effective_mode = next(iter(self._supported_color_modes))
Comment on lines 1088 to +1092
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new comment says to use the only supported mode “regardless of what the device reports”, but this logic only runs when results.get("color_mode") is not None. If the color_mode read fails/returns no value while other attributes (e.g., color_temperature) do, polling will currently skip updating state entirely; either adjust the comment to reflect the guard, or consider deriving effective_mode from _supported_color_modes even when color_mode is missing so polling can still apply updates.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

@TheJulianJES TheJulianJES Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I guess this is valid somewhat...

I guess this also brings into question if there's a non-functional Color cluster on the device. Instead of having XY and/or COLOR_TEMP as supported modes, we'd just have ONOFF or BRIGHTNESS.
This would still work with the current logic, we would just re-set the color_mode to ONOFF or BRIGHTNESS during every poll. Shouldn't matter..

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly, I think current behavior is fine. A light with color_mode being unsupported somehow still supporting color/temp is something we don't need to care about when polling IMO.

elif color_mode == Color.ColorMode.Color_temperature:
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 and color_mode:
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:
Expand Down
Loading