From 2641e4014a617739bbf220d013b0897a6002f7fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Jan 2024 22:50:26 -1000 Subject: [PATCH] Add color temp support for older HomeKit devices (#107206) --- .../components/homekit_controller/light.py | 41 +++++++++---- .../snapshots/test_diagnostics.ambr | 14 +++++ .../snapshots/test_init.ambr | 60 +++++++++++++++++++ .../test_light_that_changes_features.py | 5 +- .../homekit_controller/test_light.py | 45 +++++++++++++- 5 files changed, 151 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py index f1d36c0293310..fd3bf4f800b0d 100644 --- a/homeassistant/components/homekit_controller/light.py +++ b/homeassistant/components/homekit_controller/light.py @@ -17,6 +17,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.util.color as color_util from . import KNOWN_DEVICES from .connection import HKDevice @@ -94,12 +95,16 @@ def hs_color(self) -> tuple[float, float]: @cached_property def min_mireds(self) -> int: """Return minimum supported color temperature.""" + if not self.service.has(CharacteristicsTypes.COLOR_TEMPERATURE): + return super().min_mireds min_value = self.service[CharacteristicsTypes.COLOR_TEMPERATURE].minValue return int(min_value) if min_value else super().min_mireds @cached_property def max_mireds(self) -> int: """Return the maximum color temperature.""" + if not self.service.has(CharacteristicsTypes.COLOR_TEMPERATURE): + return super().max_mireds max_value = self.service[CharacteristicsTypes.COLOR_TEMPERATURE].maxValue return int(max_value) if max_value else super().max_mireds @@ -135,8 +140,9 @@ def supported_color_modes(self) -> set[ColorMode]: CharacteristicsTypes.SATURATION ): color_modes.add(ColorMode.HS) + color_modes.add(ColorMode.COLOR_TEMP) - if self.service.has(CharacteristicsTypes.COLOR_TEMPERATURE): + elif self.service.has(CharacteristicsTypes.COLOR_TEMPERATURE): color_modes.add(ColorMode.COLOR_TEMP) if not color_modes and self.service.has(CharacteristicsTypes.BRIGHTNESS): @@ -153,23 +159,36 @@ async def async_turn_on(self, **kwargs: Any) -> None: temperature = kwargs.get(ATTR_COLOR_TEMP) brightness = kwargs.get(ATTR_BRIGHTNESS) - characteristics = {} - - if hs_color is not None: - characteristics.update( - { - CharacteristicsTypes.HUE: hs_color[0], - CharacteristicsTypes.SATURATION: hs_color[1], - } - ) + characteristics: dict[str, Any] = {} if brightness is not None: characteristics[CharacteristicsTypes.BRIGHTNESS] = int( brightness * 100 / 255 ) + # If they send both temperature and hs_color, and the device + # does not support both, temperature will win. This is not + # expected to happen in the UI, but it is possible via a manual + # service call. if temperature is not None: - characteristics[CharacteristicsTypes.COLOR_TEMPERATURE] = int(temperature) + if self.service.has(CharacteristicsTypes.COLOR_TEMPERATURE): + characteristics[CharacteristicsTypes.COLOR_TEMPERATURE] = int( + temperature + ) + elif hs_color is None: + # Some HomeKit devices implement color temperature with HS + # since the spec "technically" does not permit the COLOR_TEMPERATURE + # characteristic and the HUE and SATURATION characteristics to be + # present at the same time. + hue_sat = color_util.color_temperature_to_hs( + color_util.color_temperature_mired_to_kelvin(temperature) + ) + characteristics[CharacteristicsTypes.HUE] = hue_sat[0] + characteristics[CharacteristicsTypes.SATURATION] = hue_sat[1] + + if hs_color is not None: + characteristics[CharacteristicsTypes.HUE] = hs_color[0] + characteristics[CharacteristicsTypes.SATURATION] = hs_color[1] characteristics[CharacteristicsTypes.ON] = True diff --git a/tests/components/homekit_controller/snapshots/test_diagnostics.ambr b/tests/components/homekit_controller/snapshots/test_diagnostics.ambr index d3205b09de39f..bda92943cce7d 100644 --- a/tests/components/homekit_controller/snapshots/test_diagnostics.ambr +++ b/tests/components/homekit_controller/snapshots/test_diagnostics.ambr @@ -43,10 +43,17 @@ 'attributes': dict({ 'brightness': None, 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'Koogeek-LS1-20833F Light Strip', 'hs_color': None, + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'rgb_color': None, 'supported_color_modes': list([ + 'color_temp', 'hs', ]), 'supported_features': 0, @@ -360,10 +367,17 @@ 'attributes': dict({ 'brightness': None, 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'Koogeek-LS1-20833F Light Strip', 'hs_color': None, + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'rgb_color': None, 'supported_color_modes': list([ + 'color_temp', 'hs', ]), 'supported_features': 0, diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index 4b4ffeb9aa38b..2f38229aef869 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -1626,7 +1626,12 @@ ]), 'area_id': None, 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'supported_color_modes': list([ + , , ]), }), @@ -1656,10 +1661,17 @@ 'attributes': dict({ 'brightness': None, 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'Aqara Hub-1563 Lightbulb-1563', 'hs_color': None, + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'rgb_color': None, 'supported_color_modes': list([ + , , ]), 'supported_features': , @@ -2014,7 +2026,12 @@ ]), 'area_id': None, 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'supported_color_modes': list([ + , , ]), }), @@ -2044,10 +2061,17 @@ 'attributes': dict({ 'brightness': None, 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'ArloBabyA0 Nightlight', 'hs_color': None, + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'rgb_color': None, 'supported_color_modes': list([ + , , ]), 'supported_features': , @@ -9279,7 +9303,12 @@ ]), 'area_id': None, 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'supported_color_modes': list([ + , , ]), }), @@ -9309,10 +9338,17 @@ 'attributes': dict({ 'brightness': None, 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'Laundry Smoke ED78', 'hs_color': None, + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'rgb_color': None, 'supported_color_modes': list([ + , , ]), 'supported_features': , @@ -11535,7 +11571,12 @@ ]), 'area_id': None, 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'supported_color_modes': list([ + , , ]), }), @@ -11565,10 +11606,17 @@ 'attributes': dict({ 'brightness': None, 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'Koogeek-LS1-20833F Light Strip', 'hs_color': None, + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'rgb_color': None, 'supported_color_modes': list([ + , , ]), 'supported_features': , @@ -16318,7 +16366,12 @@ ]), 'area_id': None, 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'supported_color_modes': list([ + , , ]), }), @@ -16348,17 +16401,24 @@ 'attributes': dict({ 'brightness': 127.5, 'color_mode': , + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'VOCOlinc-Flowerbud-0d324b Mood Light', 'hs_color': tuple( 120.0, 100.0, ), + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'rgb_color': tuple( 0, 255, 0, ), 'supported_color_modes': list([ + , , ]), 'supported_features': , diff --git a/tests/components/homekit_controller/specific_devices/test_light_that_changes_features.py b/tests/components/homekit_controller/specific_devices/test_light_that_changes_features.py index 54dc900c13014..4e62c75d8f2b3 100644 --- a/tests/components/homekit_controller/specific_devices/test_light_that_changes_features.py +++ b/tests/components/homekit_controller/specific_devices/test_light_that_changes_features.py @@ -39,4 +39,7 @@ async def test_light_add_feature_at_runtime( await device_config_changed(hass, accessories) light_state = hass.states.get("light.laundry_smoke_ed78") - assert light_state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.HS] + assert light_state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ] diff --git a/tests/components/homekit_controller/test_light.py b/tests/components/homekit_controller/test_light.py index 72bf579b36ef9..c7f168b2abe52 100644 --- a/tests/components/homekit_controller/test_light.py +++ b/tests/components/homekit_controller/test_light.py @@ -74,6 +74,22 @@ async def test_switch_change_light_state(hass: HomeAssistant) -> None: }, ) + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.testdevice", "brightness": 255, "color_temp": 300}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.LIGHTBULB, + { + CharacteristicsTypes.ON: True, + CharacteristicsTypes.BRIGHTNESS: 100, + CharacteristicsTypes.HUE: 27, + CharacteristicsTypes.SATURATION: 49, + }, + ) + await hass.services.async_call( "light", "turn_off", {"entity_id": "light.testdevice"}, blocking=True ) @@ -176,7 +192,10 @@ async def test_switch_read_light_state_hs(hass: HomeAssistant) -> None: state = await helper.poll_and_get_state() assert state.state == "off" assert state.attributes[ATTR_COLOR_MODE] is None - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.HS] + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ] assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 # Simulate that someone switched on the device in the real world not via HA @@ -193,7 +212,10 @@ async def test_switch_read_light_state_hs(hass: HomeAssistant) -> None: assert state.attributes["brightness"] == 255 assert state.attributes["hs_color"] == (4, 5) assert state.attributes[ATTR_COLOR_MODE] == ColorMode.HS - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.HS] + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ] assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 # Simulate that device switched off in the real world not via HA @@ -205,6 +227,25 @@ async def test_switch_read_light_state_hs(hass: HomeAssistant) -> None: ) assert state.state == "off" + # Simulate that device switched on in the real world not via HA + state = await helper.async_update( + ServicesTypes.LIGHTBULB, + { + CharacteristicsTypes.ON: True, + CharacteristicsTypes.HUE: 6, + CharacteristicsTypes.SATURATION: 7, + }, + ) + assert state.state == "on" + assert state.attributes["brightness"] == 255 + assert state.attributes["hs_color"] == (6, 7) + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.HS + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ] + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + async def test_switch_push_light_state_hs(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit light accessory."""