diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index f925f0a15a4..cdff3105ec3 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -7,11 +7,17 @@ from pyhap.const import CATEGORY_LIGHTBULB from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, + ATTR_COLOR_MODE, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_MAX_MIREDS, ATTR_MIN_MIREDS, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + ATTR_RGBWW_COLOR, ATTR_SUPPORTED_COLOR_MODES, + COLOR_MODE_RGBW, + COLOR_MODE_RGBWW, DOMAIN, brightness_supported, color_supported, @@ -26,6 +32,7 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.helpers.event import async_call_later from homeassistant.util.color import ( + color_hsv_to_RGB, color_temperature_mired_to_kelvin, color_temperature_to_hs, ) @@ -49,6 +56,9 @@ RGB_COLOR = "rgb_color" CHANGE_COALESCE_TIME_WINDOW = 0.01 +COLOR_MODES_WITH_WHITES = {COLOR_MODE_RGBW, COLOR_MODE_RGBWW} + + @TYPES.register("Light") class Light(HomeAccessory): """Generate a Light accessory for a light entity. @@ -66,7 +76,9 @@ class Light(HomeAccessory): state = self.hass.states.get(self.entity_id) attributes = state.attributes - color_modes = attributes.get(ATTR_SUPPORTED_COLOR_MODES) + self.color_modes = color_modes = ( + attributes.get(ATTR_SUPPORTED_COLOR_MODES) or [] + ) self.color_supported = color_supported(color_modes) self.color_temp_supported = color_temp_supported(color_modes) self.brightness_supported = brightness_supported(color_modes) @@ -138,12 +150,13 @@ class Light(HomeAccessory): service = SERVICE_TURN_OFF events.append(f"Set state to {char_values[CHAR_ON]}") + brightness_pct = None if CHAR_BRIGHTNESS in char_values: if char_values[CHAR_BRIGHTNESS] == 0: events[-1] = "Set state to 0" service = SERVICE_TURN_OFF else: - params[ATTR_BRIGHTNESS_PCT] = char_values[CHAR_BRIGHTNESS] + brightness_pct = char_values[CHAR_BRIGHTNESS] events.append(f"brightness at {char_values[CHAR_BRIGHTNESS]}%") if service == SERVICE_TURN_OFF: @@ -156,13 +169,36 @@ class Light(HomeAccessory): params[ATTR_COLOR_TEMP] = char_values[CHAR_COLOR_TEMPERATURE] events.append(f"color temperature at {params[ATTR_COLOR_TEMP]}") - elif CHAR_HUE in char_values or CHAR_SATURATION in char_values: - color = params[ATTR_HS_COLOR] = ( + elif ( + CHAR_HUE in char_values + or CHAR_SATURATION in char_values + # If we are adjusting brightness we need to send the full RGBW/RGBWW values + # since HomeKit does not support RGBW/RGBWW + or brightness_pct + and COLOR_MODES_WITH_WHITES.intersection(self.color_modes) + ): + hue_sat = ( char_values.get(CHAR_HUE, self.char_hue.value), char_values.get(CHAR_SATURATION, self.char_saturation.value), ) - _LOGGER.debug("%s: Set hs_color to %s", self.entity_id, color) - events.append(f"set color at {color}") + _LOGGER.debug("%s: Set hs_color to %s", self.entity_id, hue_sat) + events.append(f"set color at {hue_sat}") + # HomeKit doesn't support RGBW/RGBWW so we need to remove any white values + if COLOR_MODE_RGBWW in self.color_modes: + val = brightness_pct or self.char_brightness.value + params[ATTR_RGBWW_COLOR] = (*color_hsv_to_RGB(*hue_sat, val), 0, 0) + elif COLOR_MODE_RGBW in self.color_modes: + val = brightness_pct or self.char_brightness.value + params[ATTR_RGBW_COLOR] = (*color_hsv_to_RGB(*hue_sat, val), 0) + else: + params[ATTR_HS_COLOR] = hue_sat + + if ( + brightness_pct + and ATTR_RGBWW_COLOR not in params + and ATTR_RGBW_COLOR not in params + ): + params[ATTR_BRIGHTNESS_PCT] = brightness_pct self.async_call_service(DOMAIN, service, params, ", ".join(events)) @@ -172,11 +208,21 @@ class Light(HomeAccessory): # Handle State state = new_state.state attributes = new_state.attributes + color_mode = attributes.get(ATTR_COLOR_MODE) self.char_on.set_value(int(state == STATE_ON)) # Handle Brightness if self.brightness_supported: - brightness = attributes.get(ATTR_BRIGHTNESS) + if ( + color_mode + and COLOR_MODES_WITH_WHITES.intersection({color_mode}) + and (rgb_color := attributes.get(ATTR_RGB_COLOR)) + ): + # HomeKit doesn't support RGBW/RGBWW so we need to + # give it the color brightness only + brightness = max(rgb_color) + else: + brightness = attributes.get(ATTR_BRIGHTNESS) if isinstance(brightness, (int, float)): brightness = round(brightness / 255 * 100, 0) # The homeassistant component might report its brightness as 0 but is diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index 9c0d45126fc..8e7b60b0a47 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -13,11 +13,18 @@ from homeassistant.components.homekit.type_lights import ( from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, + ATTR_COLOR_MODE, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_MAX_MIREDS, ATTR_MIN_MIREDS, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + ATTR_RGBWW_COLOR, ATTR_SUPPORTED_COLOR_MODES, + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_RGBW, + COLOR_MODE_RGBWW, DOMAIN, ) from homeassistant.const import ( @@ -565,6 +572,244 @@ async def test_light_restore(hass, hk_driver, events): assert acc.char_on.value == 0 +@pytest.mark.parametrize( + "supported_color_modes, state_props, turn_on_props, turn_on_props_with_brightness", + [ + [ + [COLOR_MODE_COLOR_TEMP, COLOR_MODE_RGBW], + { + ATTR_RGBW_COLOR: (128, 50, 0, 255), + ATTR_RGB_COLOR: (128, 50, 0), + ATTR_HS_COLOR: (23.438, 100.0), + ATTR_BRIGHTNESS: 255, + ATTR_COLOR_MODE: COLOR_MODE_RGBW, + }, + {ATTR_RGBW_COLOR: (31, 127, 71, 0)}, + {ATTR_RGBW_COLOR: (15, 63, 35, 0)}, + ], + [ + [COLOR_MODE_COLOR_TEMP, COLOR_MODE_RGBWW], + { + ATTR_RGBWW_COLOR: (128, 50, 0, 255, 255), + ATTR_RGB_COLOR: (128, 50, 0), + ATTR_HS_COLOR: (23.438, 100.0), + ATTR_BRIGHTNESS: 255, + ATTR_COLOR_MODE: COLOR_MODE_RGBWW, + }, + {ATTR_RGBWW_COLOR: (31, 127, 71, 0, 0)}, + {ATTR_RGBWW_COLOR: (15, 63, 35, 0, 0)}, + ], + ], +) +async def test_light_rgb_with_white( + hass, + hk_driver, + events, + supported_color_modes, + state_props, + turn_on_props, + turn_on_props_with_brightness, +): + """Test lights with RGBW/RGBWW.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, **state_props}, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_hue.value == 23 + assert acc.char_saturation.value == 100 + + await acc.run() + await hass.async_block_till_done() + assert acc.char_hue.value == 23 + assert acc.char_saturation.value == 100 + assert acc.char_brightness.value == 50 + + # Set from HomeKit + call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") + + char_hue_iid = acc.char_hue.to_HAP()[HAP_REPR_IID] + char_saturation_iid = acc.char_saturation.to_HAP()[HAP_REPR_IID] + char_brightness_iid = acc.char_brightness.to_HAP()[HAP_REPR_IID] + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 145, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 75, + }, + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on + assert call_turn_on[-1].data[ATTR_ENTITY_ID] == entity_id + for k, v in turn_on_props.items(): + assert call_turn_on[-1].data[k] == v + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] == "set color at (145, 75)" + assert acc.char_brightness.value == 50 + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 145, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 75, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_brightness_iid, + HAP_REPR_VALUE: 25, + }, + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on + assert call_turn_on[-1].data[ATTR_ENTITY_ID] == entity_id + for k, v in turn_on_props_with_brightness.items(): + assert call_turn_on[-1].data[k] == v + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] == "brightness at 25%, set color at (145, 75)" + assert acc.char_brightness.value == 25 + + +@pytest.mark.parametrize( + "supported_color_modes, state_props, turn_on_props, turn_on_props_with_brightness", + [ + [ + [COLOR_MODE_COLOR_TEMP, COLOR_MODE_RGBW], + { + ATTR_RGBW_COLOR: (128, 50, 0, 255), + ATTR_RGB_COLOR: (128, 50, 0), + ATTR_HS_COLOR: (23.438, 100.0), + ATTR_BRIGHTNESS: 255, + ATTR_COLOR_MODE: COLOR_MODE_RGBW, + }, + {ATTR_RGBW_COLOR: (31, 127, 71, 0)}, + {ATTR_COLOR_TEMP: 2700}, + ], + [ + [COLOR_MODE_COLOR_TEMP, COLOR_MODE_RGBWW], + { + ATTR_RGBWW_COLOR: (128, 50, 0, 255, 255), + ATTR_RGB_COLOR: (128, 50, 0), + ATTR_HS_COLOR: (23.438, 100.0), + ATTR_BRIGHTNESS: 255, + ATTR_COLOR_MODE: COLOR_MODE_RGBWW, + }, + {ATTR_RGBWW_COLOR: (31, 127, 71, 0, 0)}, + {ATTR_COLOR_TEMP: 2700}, + ], + ], +) +async def test_light_rgb_with_white_switch_to_temp( + hass, + hk_driver, + events, + supported_color_modes, + state_props, + turn_on_props, + turn_on_props_with_brightness, +): + """Test lights with RGBW/RGBWW that preserves brightness when switching to color temp.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, **state_props}, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_hue.value == 23 + assert acc.char_saturation.value == 100 + + await acc.run() + await hass.async_block_till_done() + assert acc.char_hue.value == 23 + assert acc.char_saturation.value == 100 + assert acc.char_brightness.value == 50 + + # Set from HomeKit + call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") + + char_hue_iid = acc.char_hue.to_HAP()[HAP_REPR_IID] + char_saturation_iid = acc.char_saturation.to_HAP()[HAP_REPR_IID] + char_color_temp_iid = acc.char_color_temp.to_HAP()[HAP_REPR_IID] + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 145, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 75, + }, + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on + assert call_turn_on[-1].data[ATTR_ENTITY_ID] == entity_id + for k, v in turn_on_props.items(): + assert call_turn_on[-1].data[k] == v + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] == "set color at (145, 75)" + assert acc.char_brightness.value == 50 + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_color_temp_iid, + HAP_REPR_VALUE: 2700, + }, + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on + assert call_turn_on[-1].data[ATTR_ENTITY_ID] == entity_id + for k, v in turn_on_props_with_brightness.items(): + assert call_turn_on[-1].data[k] == v + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] == "color temperature at 2700" + assert acc.char_brightness.value == 50 + + async def test_light_set_brightness_and_color(hass, hk_driver, events): """Test light with all chars in one go.""" entity_id = "light.demo"