From 36483dd7854ec8c3bb899fc790b8cf2ba6a37045 Mon Sep 17 00:00:00 2001 From: Phil Male Date: Mon, 1 Sep 2025 14:14:50 +0100 Subject: [PATCH] Use average color for Hue light group state (#149499) --- homeassistant/components/hue/v2/group.py | 79 ++++- tests/components/hue/test_light_v2.py | 361 ++++++++++++++++++++++- 2 files changed, 426 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index 4db9bc16ca89..41956824ab22 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -226,15 +226,26 @@ class GroupedHueLight(HueBaseEntity, LightEntity): lights_with_color_support = 0 lights_with_color_temp_support = 0 lights_with_dimming_support = 0 + lights_on_with_dimming_support = 0 total_brightness = 0 all_lights = self.controller.get_lights(self.resource.id) lights_in_colortemp_mode = 0 + lights_in_xy_mode = 0 lights_in_dynamic_mode = 0 + # accumulate color values + xy_total_x = 0.0 + xy_total_y = 0.0 + xy_count = 0 + temp_total = 0.0 + # loop through all lights to find capabilities for light in all_lights: + # reset per-light colortemp on flag + light_in_colortemp_mode = False + # check if light has color temperature if color_temp := light.color_temperature: lights_with_color_temp_support += 1 - # we assume mired values from the first capable light + # default to mired values from the last capable light self._attr_color_temp_kelvin = ( color_util.color_temperature_mired_to_kelvin(color_temp.mirek) if color_temp.mirek @@ -250,15 +261,39 @@ class GroupedHueLight(HueBaseEntity, LightEntity): color_temp.mirek_schema.mirek_minimum ) ) - if color_temp.mirek is not None and color_temp.mirek_valid: + # counters for color mode vote and average temp + if ( + light.on.on + and color_temp.mirek is not None + and color_temp.mirek_valid + ): lights_in_colortemp_mode += 1 + light_in_colortemp_mode = True + temp_total += color_util.color_temperature_mired_to_kelvin( + color_temp.mirek + ) + # check if light has color xy if color := light.color: lights_with_color_support += 1 - # we assume xy values from the first capable light + # default to xy values from the last capable light self._attr_xy_color = (color.xy.x, color.xy.y) + # counters for color mode vote and average xy color + if light.on.on: + xy_total_x += color.xy.x + xy_total_y += color.xy.y + xy_count += 1 + # only count for colour mode vote if + # this light is not in colortemp mode + if not light_in_colortemp_mode: + lights_in_xy_mode += 1 + # check if light has dimming if dimming := light.dimming: lights_with_dimming_support += 1 - total_brightness += dimming.brightness + # accumulate brightness values + if light.on.on: + total_brightness += dimming.brightness + lights_on_with_dimming_support += 1 + # check if light is in dynamic mode if ( light.dynamics and light.dynamics.status == DynamicStatus.DYNAMIC_PALETTE @@ -266,10 +301,11 @@ class GroupedHueLight(HueBaseEntity, LightEntity): lights_in_dynamic_mode += 1 # this is a bit hacky because light groups may contain lights - # of different capabilities. We set a colormode as supported - # if any of the lights support it + # of different capabilities # this means that the state is derived from only some of the lights # and will never be 100% accurate but it will be close + + # assign group color support modes based on light capabilities if lights_with_color_support > 0: supported_color_modes.add(ColorMode.XY) if lights_with_color_temp_support > 0: @@ -278,19 +314,38 @@ class GroupedHueLight(HueBaseEntity, LightEntity): if len(supported_color_modes) == 0: # only add color mode brightness if no color variants supported_color_modes.add(ColorMode.BRIGHTNESS) - self._brightness_pct = total_brightness / lights_with_dimming_support - self._attr_brightness = round( - ((total_brightness / lights_with_dimming_support) / 100) * 255 - ) + # as we have brightness support, set group brightness values + if lights_on_with_dimming_support > 0: + self._brightness_pct = total_brightness / lights_on_with_dimming_support + self._attr_brightness = round( + ((total_brightness / lights_on_with_dimming_support) / 100) * 255 + ) else: supported_color_modes.add(ColorMode.ONOFF) self._dynamic_mode_active = lights_in_dynamic_mode > 0 self._attr_supported_color_modes = supported_color_modes - # pick a winner for the current colormode - if lights_with_color_temp_support > 0 and lights_in_colortemp_mode > 0: + # set the group color values if there are any color lights on + if xy_count > 0: + self._attr_xy_color = ( + round(xy_total_x / xy_count, 5), + round(xy_total_y / xy_count, 5), + ) + if lights_in_colortemp_mode > 0: + avg_temp = temp_total / lights_in_colortemp_mode + self._attr_color_temp_kelvin = round(avg_temp) + # pick a winner for the current color mode based on the majority of on lights + # if there is no winner pick the highest mode from group capabilities + if lights_in_xy_mode > 0 and lights_in_xy_mode >= lights_in_colortemp_mode: + self._attr_color_mode = ColorMode.XY + elif ( + lights_in_colortemp_mode > 0 + and lights_in_colortemp_mode > lights_in_xy_mode + ): self._attr_color_mode = ColorMode.COLOR_TEMP elif lights_with_color_support > 0: self._attr_color_mode = ColorMode.XY + elif lights_with_color_temp_support > 0: + self._attr_color_mode = ColorMode.COLOR_TEMP elif lights_with_dimming_support > 0: self._attr_color_mode = ColorMode.BRIGHTNESS else: diff --git a/tests/components/hue/test_light_v2.py b/tests/components/hue/test_light_v2.py index 83b2bd48b3cd..13cfe3995de9 100644 --- a/tests/components/hue/test_light_v2.py +++ b/tests/components/hue/test_light_v2.py @@ -518,9 +518,8 @@ async def test_grouped_lights( } mock_bridge_v2.api.emit_event("update", event) await hass.async_block_till_done() - await hass.async_block_till_done() - # the light should now be on and have the properties we've set + # The light should now be on and have the properties we've set test_light = hass.states.get(test_light_id) assert test_light is not None assert test_light.state == "on" @@ -528,6 +527,364 @@ async def test_grouped_lights( assert test_light.attributes["brightness"] == 255 assert test_light.attributes["xy_color"] == (0.123, 0.123) + # While we have a group on, test the color aggregation logic, XY first + + # Turn off one of the bulbs in the group + # "hue_light_with_color_and_color_temperature_1" corresponds to "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1" + mock_bridge_v2.mock_requests.clear() + single_light_id = "light.hue_light_with_color_and_color_temperature_1" + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": single_light_id}, + blocking=True, + ) + event = { + "id": "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1", + "type": "light", + "on": {"on": False}, + } + mock_bridge_v2.api.emit_event("update", event) + await hass.async_block_till_done() + + # The group should still show the same XY color since other lights maintain their color + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.state == "on" + assert test_light.attributes["xy_color"] == (0.123, 0.123) + + # Turn the light back on with a white XY color (different from the rest of the group) + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": single_light_id, "xy_color": [0.3127, 0.3290]}, + blocking=True, + ) + event = { + "id": "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1", + "type": "light", + "on": {"on": True}, + "color": {"xy": {"x": 0.3127, "y": 0.3290}}, + } + mock_bridge_v2.api.emit_event("update", event) + await hass.async_block_till_done() + + # Now the group XY color should be the average of all three lights: + # Light 1: (0.3127, 0.3290) - white + # Light 2: (0.123, 0.123) + # Light 3: (0.123, 0.123) + # Average: ((0.3127 + 0.123 + 0.123) / 3, (0.3290 + 0.123 + 0.123) / 3) + # Average: (0.1862, 0.1917) rounded to 4 decimal places + expected_x = round((0.3127 + 0.123 + 0.123) / 3, 4) + expected_y = round((0.3290 + 0.123 + 0.123) / 3, 4) + + # Check that the group XY color is now the average of all lights + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.state == "on" + group_x, group_y = test_light.attributes["xy_color"] + assert abs(group_x - expected_x) < 0.001 # Allow small floating point differences + assert abs(group_y - expected_y) < 0.001 + + # Test turning off another light in the group, leaving only two lights on - one white and one original color + # "hue_light_with_color_and_color_temperature_2" corresponds to "b3fe71ef-d0ef-48de-9355-d9e604377df0" + second_light_id = "light.hue_light_with_color_and_color_temperature_2" + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": second_light_id}, + blocking=True, + ) + + # Simulate the second light turning off + event = { + "id": "b3fe71ef-d0ef-48de-9355-d9e604377df0", + "type": "light", + "on": {"on": False}, + } + mock_bridge_v2.api.emit_event("update", event) + await hass.async_block_till_done() + + # Now only two lights are on: + # Light 1: (0.3127, 0.3290) - white + # Light 3: (0.123, 0.123) - original color + # Average of remaining lights: ((0.3127 + 0.123) / 2, (0.3290 + 0.123) / 2) + expected_x_two_lights = round((0.3127 + 0.123) / 2, 4) + expected_y_two_lights = round((0.3290 + 0.123) / 2, 4) + + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.state == "on" + # Check that the group color is now the average of only the two remaining lights + group_x, group_y = test_light.attributes["xy_color"] + assert abs(group_x - expected_x_two_lights) < 0.001 + assert abs(group_y - expected_y_two_lights) < 0.001 + + # Test colour temperature aggregation + # Set all three lights to colour temperature mode with different mirek values + for mirek, light_name, light_id in zip( + [300, 250, 200], + [ + "light.hue_light_with_color_and_color_temperature_1", + "light.hue_light_with_color_and_color_temperature_2", + "light.hue_light_with_color_and_color_temperature_gradient", + ], + [ + "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1", + "b3fe71ef-d0ef-48de-9355-d9e604377df0", + "8015b17f-8336-415b-966a-b364bd082397", + ], + strict=True, + ): + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": light_name, + "color_temp": mirek, + }, + blocking=True, + ) + # Emit update event with matching mirek value + mock_bridge_v2.api.emit_event( + "update", + { + "id": light_id, + "type": "light", + "on": {"on": True}, + "color_temperature": {"mirek": mirek, "mirek_valid": True}, + }, + ) + await hass.async_block_till_done() + + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.state == "on" + assert test_light.attributes["color_mode"] == ColorMode.COLOR_TEMP + + # Expected average kelvin calculation: + # 300 mirek ≈ 3333K, 250 mirek ≈ 4000K, 200 mirek ≈ 5000K + expected_avg_kelvin = round((3333 + 4000 + 5000) / 3) + assert abs(test_light.attributes["color_temp_kelvin"] - expected_avg_kelvin) <= 5 + + # Switch light 3 off and check average kelvin temperature of remaining two lights + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": "light.hue_light_with_color_and_color_temperature_gradient"}, + blocking=True, + ) + event = { + "id": "8015b17f-8336-415b-966a-b364bd082397", + "type": "light", + "on": {"on": False}, + } + mock_bridge_v2.api.emit_event("update", event) + await hass.async_block_till_done() + + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.state == "on" + assert test_light.attributes["color_mode"] == ColorMode.COLOR_TEMP + + # Expected average kelvin calculation: + # 300 mirek ≈ 3333K, 250 mirek ≈ 4000K + expected_avg_kelvin = round((3333 + 4000) / 2) + assert abs(test_light.attributes["color_temp_kelvin"] - expected_avg_kelvin) <= 5 + + # Turn light 3 back on in XY mode and verify majority still favours COLOR_TEMP + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": "light.hue_light_with_color_and_color_temperature_gradient", + "xy_color": [0.123, 0.123], + }, + blocking=True, + ) + mock_bridge_v2.api.emit_event( + "update", + { + "id": "8015b17f-8336-415b-966a-b364bd082397", + "type": "light", + "on": {"on": True}, + "color": {"xy": {"x": 0.123, "y": 0.123}}, + "color_temperature": { + "mirek": None, + "mirek_valid": False, + }, + }, + ) + await hass.async_block_till_done() + + test_light = hass.states.get(test_light_id) + assert test_light.attributes["color_mode"] == ColorMode.COLOR_TEMP + + # Switch light 2 to XY mode to flip the majority + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": "light.hue_light_with_color_and_color_temperature_2", + "xy_color": [0.321, 0.321], + }, + blocking=True, + ) + mock_bridge_v2.api.emit_event( + "update", + { + "id": "b3fe71ef-d0ef-48de-9355-d9e604377df0", + "type": "light", + "on": {"on": True}, + "color": {"xy": {"x": 0.321, "y": 0.321}}, + "color_temperature": { + "mirek": None, + "mirek_valid": False, + }, + }, + ) + await hass.async_block_till_done() + + test_light = hass.states.get(test_light_id) + assert test_light.attributes["color_mode"] == ColorMode.XY + + # Test brightness aggregation with different brightness levels + mock_bridge_v2.mock_requests.clear() + + # Set all three lights to different brightness levels + for brightness, light_name, light_id in zip( + [90.0, 60.0, 30.0], + [ + "light.hue_light_with_color_and_color_temperature_1", + "light.hue_light_with_color_and_color_temperature_2", + "light.hue_light_with_color_and_color_temperature_gradient", + ], + [ + "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1", + "b3fe71ef-d0ef-48de-9355-d9e604377df0", + "8015b17f-8336-415b-966a-b364bd082397", + ], + strict=True, + ): + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": light_name, + "brightness": brightness, + }, + blocking=True, + ) + # Emit update event with matching brightness value + mock_bridge_v2.api.emit_event( + "update", + { + "id": light_id, + "type": "light", + "on": {"on": True}, + "dimming": {"brightness": brightness}, + }, + ) + await hass.async_block_till_done() + + # Check that the group brightness is the average of all three lights + # Expected average: (90 + 60 + 30) / 3 = 60% -> 153 (60% of 255) + expected_brightness = round(((90 + 60 + 30) / 3 / 100) * 255) + + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.state == "on" + assert test_light.attributes["brightness"] == expected_brightness + + # Turn off the dimmest light 3 (30% brightness) while keeping the other two on + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": "light.hue_light_with_color_and_color_temperature_gradient"}, + blocking=True, + ) + event = { + "id": "8015b17f-8336-415b-966a-b364bd082397", + "type": "light", + "on": {"on": False}, + } + mock_bridge_v2.api.emit_event("update", event) + await hass.async_block_till_done() + + # Check that the group brightness is now the average of the two remaining lights + # Expected average: (90 + 60) / 2 = 75% -> 191 (75% of 255) + expected_brightness_two_lights = round(((90 + 60) / 2 / 100) * 255) + + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.state == "on" + assert test_light.attributes["brightness"] == expected_brightness_two_lights + + # Turn off light 2 (60% brightness), leaving only the brightest one + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": "light.hue_light_with_color_and_color_temperature_2"}, + blocking=True, + ) + event = { + "id": "b3fe71ef-d0ef-48de-9355-d9e604377df0", + "type": "light", + "on": {"on": False}, + } + mock_bridge_v2.api.emit_event("update", event) + await hass.async_block_till_done() + + # Check that the group brightness is now just the remaining light's brightness + # Expected brightness: 90% -> 230 (round(90 / 100 * 255)) + expected_brightness_one_light = round((90 / 100) * 255) + + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.state == "on" + assert test_light.attributes["brightness"] == expected_brightness_one_light + + # Set all three lights back to 100% brightness for consistency with later tests + for light_name, light_id in zip( + [ + "light.hue_light_with_color_and_color_temperature_1", + "light.hue_light_with_color_and_color_temperature_2", + "light.hue_light_with_color_and_color_temperature_gradient", + ], + [ + "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1", + "b3fe71ef-d0ef-48de-9355-d9e604377df0", + "8015b17f-8336-415b-966a-b364bd082397", + ], + strict=True, + ): + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": light_name, + "brightness": 100.0, + }, + blocking=True, + ) + # Emit update event with matching brightness value + mock_bridge_v2.api.emit_event( + "update", + { + "id": light_id, + "type": "light", + "on": {"on": True}, + "dimming": {"brightness": 100.0}, + }, + ) + await hass.async_block_till_done() + + # Verify group is back to 100% brightness + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.state == "on" + assert test_light.attributes["brightness"] == 255 + # Test calling the turn off service on a grouped light. mock_bridge_v2.mock_requests.clear() await hass.services.async_call(