Use average color for Hue light group state (#149499)

pull/151560/head
Phil Male 2025-09-01 14:14:50 +01:00 committed by GitHub
parent ad154dce40
commit 36483dd785
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 426 additions and 14 deletions

View File

@ -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:

View File

@ -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(