From 983ff10541936586716a0e7db39c65c559a51d5f Mon Sep 17 00:00:00 2001 From: Daniel Kent <129895318+danielkent-net@users.noreply.github.com> Date: Fri, 23 Jun 2023 09:08:28 -0400 Subject: [PATCH] Fix ESPHome color temperature precision for light entities (#91424) Co-authored-by: J. Nick Koston --- homeassistant/components/esphome/light.py | 35 +++++++-- tests/components/esphome/test_light.py | 95 +++++++++++++++++++++-- 2 files changed, 119 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index b44bac0b933..a17c49caa73 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -13,7 +13,7 @@ from aioesphomeapi import ( from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, ATTR_RGB_COLOR, @@ -98,6 +98,20 @@ _COLOR_MODE_MAPPING = { } +def _mired_to_kelvin(mired_temperature: float) -> int: + """Convert absolute mired shift to degrees kelvin. + + This function rounds the converted value instead of flooring the value as + is done in homeassistant.util.color.color_temperature_mired_to_kelvin(). + + If the value of mired_temperature is less than or equal to zero, return + the original value to avoid a divide by zero. + """ + if mired_temperature <= 0: + return round(mired_temperature) + return round(1000000 / mired_temperature) + + def _color_mode_to_ha(mode: int) -> str: """Convert an esphome color mode to a HA color mode constant. @@ -198,8 +212,9 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): # need to convert cw+ww part to white+color_temp white = data["white"] = max(cw, ww) if white != 0: - min_ct = self.min_mireds - max_ct = self.max_mireds + static_info = self._static_info + min_ct = static_info.min_mireds + max_ct = static_info.max_mireds ct_ratio = ww / (cw + ww) data["color_temperature"] = min_ct + ct_ratio * (max_ct - min_ct) color_modes = _filter_color_modes( @@ -216,8 +231,9 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): if (transition := kwargs.get(ATTR_TRANSITION)) is not None: data["transition_length"] = transition - if (color_temp := kwargs.get(ATTR_COLOR_TEMP)) is not None: - data["color_temperature"] = color_temp + if (color_temp_k := kwargs.get(ATTR_COLOR_TEMP_KELVIN)) is not None: + # Do not use kelvin_to_mired here to prevent precision loss + data["color_temperature"] = 1000000.0 / color_temp_k if _filter_color_modes(color_modes, LightColorCapability.COLOR_TEMPERATURE): color_modes = _filter_color_modes( color_modes, LightColorCapability.COLOR_TEMPERATURE @@ -349,6 +365,12 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): """Return the CT color value in mireds.""" return round(self._state.color_temperature) + @property + @esphome_state_property + def color_temp_kelvin(self) -> int: + """Return the CT color value in Kelvin.""" + return _mired_to_kelvin(self._state.color_temperature) + @property @esphome_state_property def effect(self) -> str | None: @@ -385,3 +407,6 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): self._attr_effect_list = static_info.effects self._attr_min_mireds = round(static_info.min_mireds) self._attr_max_mireds = round(static_info.max_mireds) + if ColorMode.COLOR_TEMP in supported: + self._attr_min_color_temp_kelvin = _mired_to_kelvin(static_info.max_mireds) + self._attr_max_color_temp_kelvin = _mired_to_kelvin(static_info.min_mireds) diff --git a/tests/components/esphome/test_light.py b/tests/components/esphome/test_light.py index df307259e53..a8430be6b49 100644 --- a/tests/components/esphome/test_light.py +++ b/tests/components/esphome/test_light.py @@ -76,8 +76,8 @@ async def test_light_color_temp( key=1, name="my light", unique_id="my_light", - min_mireds=153, - max_mireds=400, + min_mireds=153.846161, + max_mireds=370.370361, supported_color_modes=[ LightColorCapability.COLOR_TEMPERATURE | LightColorCapability.ON_OFF @@ -90,7 +90,7 @@ async def test_light_color_temp( key=1, state=True, brightness=100, - color_temperature=153, + color_temperature=153.846161, color_mode=LightColorCapability.COLOR_TEMPERATURE, ) ] @@ -106,10 +106,93 @@ async def test_light_color_temp( assert state.state == STATE_ON attributes = state.attributes - assert attributes[ATTR_MAX_MIREDS] == 400 assert attributes[ATTR_MIN_MIREDS] == 153 - assert attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 2500 - assert attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 6535 + assert attributes[ATTR_MAX_MIREDS] == 370 + + assert attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 2700 + assert attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 6500 + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_my_light"}, + blocking=True, + ) + mock_client.light_command.assert_has_calls( + [ + call( + key=1, + state=True, + color_mode=LightColorCapability.COLOR_TEMPERATURE + | LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS, + ) + ] + ) + mock_client.light_command.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.test_my_light"}, + blocking=True, + ) + mock_client.light_command.assert_has_calls([call(key=1, state=False)]) + mock_client.light_command.reset_mock() + + +async def test_light_color_temp_legacy( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a legacy light entity that does supports color temp.""" + mock_client.api_version = APIVersion(1, 7) + entity_info = [ + LightInfo( + object_id="mylight", + key=1, + name="my light", + unique_id="my_light", + min_mireds=153.846161, + max_mireds=370.370361, + supported_color_modes=[ + LightColorCapability.COLOR_TEMPERATURE + | LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + ], + legacy_supports_brightness=True, + legacy_supports_color_temperature=True, + ) + ] + states = [ + LightState( + key=1, + state=True, + brightness=100, + red=1, + green=1, + blue=1, + white=1, + cold_white=1, + color_temperature=153.846161, + color_mode=19, + ) + ] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("light.test_my_light") + assert state is not None + assert state.state == STATE_ON + attributes = state.attributes + + assert attributes[ATTR_MIN_MIREDS] == 153 + assert attributes[ATTR_MAX_MIREDS] == 370 + + assert attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 2700 + assert attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 6500 await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON,