diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index e170d8b3948..f9fb8b8fb6d 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -1,7 +1,8 @@ """Support for ESPHome lights.""" from __future__ import annotations -from typing import Any, cast +from functools import lru_cache +from typing import TYPE_CHECKING, Any, cast from aioesphomeapi import ( APIVersion, @@ -111,6 +112,7 @@ def _mired_to_kelvin(mired_temperature: float) -> int: return round(1000000 / mired_temperature) +@lru_cache def _color_mode_to_ha(mode: int) -> str: """Convert an esphome color mode to a HA color mode constant. @@ -134,20 +136,34 @@ def _color_mode_to_ha(mode: int) -> str: return candidates[-1][0] +@lru_cache def _filter_color_modes( supported: list[int], features: LightColorCapability -) -> list[int]: +) -> tuple[int, ...]: """Filter the given supported color modes. Excluding all values that don't have the requested features. """ - return [mode for mode in supported if (mode & features) == features] + features_value = features.value + return tuple( + mode for mode in supported if (mode & features_value) == features_value + ) + + +@lru_cache +def _least_complex_color_mode(color_modes: tuple[int, ...]) -> int: + """Return the color mode with the least complexity.""" + # popcount with bin() function because it appears + # to be the best way: https://stackoverflow.com/a/9831671 + color_modes_list = list(color_modes) + color_modes_list.sort(key=lambda mode: bin(mode).count("1")) + return color_modes_list[0] class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): """A light implementation for ESPHome.""" - _native_supported_color_modes: list[int] + _native_supported_color_modes: tuple[int, ...] _supports_color_mode = False @property @@ -231,10 +247,10 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): 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 - ) + if color_temp_modes := _filter_color_modes( + color_modes, LightColorCapability.COLOR_TEMPERATURE + ): + color_modes = color_temp_modes else: color_modes = _filter_color_modes( color_modes, LightColorCapability.COLD_WARM_WHITE @@ -267,10 +283,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): else: # otherwise try the color mode with the least complexity # (fewest capabilities set) - # popcount with bin() function because it appears - # to be the best way: https://stackoverflow.com/a/9831671 - color_modes.sort(key=lambda mode: bin(mode).count("1")) - data["color_mode"] = color_modes[0] + data["color_mode"] = _least_complex_color_mode(color_modes) await self._client.light_command(**data) @@ -294,9 +307,10 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): def color_mode(self) -> str | None: """Return the color mode of the light.""" if not self._supports_color_mode: - if not (supported := self.supported_color_modes): - return None - return next(iter(supported)) + supported_color_modes = self.supported_color_modes + if TYPE_CHECKING: + assert supported_color_modes is not None + return next(iter(supported_color_modes)) return _color_mode_to_ha(self._state.color_mode) @@ -374,8 +388,8 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): super()._on_static_info_update(static_info) static_info = self._static_info self._supports_color_mode = self._api_version >= APIVersion(1, 6) - self._native_supported_color_modes = static_info.supported_color_modes_compat( - self._api_version + self._native_supported_color_modes = tuple( + static_info.supported_color_modes_compat(self._api_version) ) flags = LightEntityFeature.FLASH diff --git a/tests/components/esphome/test_light.py b/tests/components/esphome/test_light.py index 99058ad3ed4..3d0c1cc63eb 100644 --- a/tests/components/esphome/test_light.py +++ b/tests/components/esphome/test_light.py @@ -29,6 +29,7 @@ from homeassistant.components.light import ( ATTR_RGBWW_COLOR, ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, + ATTR_WHITE, DOMAIN as LIGHT_DOMAIN, FLASH_LONG, FLASH_SHORT, @@ -317,6 +318,68 @@ async def test_light_legacy_white_converted_to_brightness( mock_client.light_command.reset_mock() +async def test_light_legacy_white_with_rgb( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic light entity with rgb and white.""" + mock_client.api_version = APIVersion(1, 7) + color_mode = ( + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.WHITE + ) + color_mode_2 = ( + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB + ) + entity_info = [ + LightInfo( + object_id="mylight", + key=1, + name="my light", + unique_id="my_light", + min_mireds=153, + max_mireds=400, + supported_color_modes=[color_mode, color_mode_2], + ) + ] + states = [LightState(key=1, state=True, brightness=100)] + 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_mylight") + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.RGB, + ColorMode.WHITE, + ] + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_WHITE: 60}, + blocking=True, + ) + mock_client.light_command.assert_has_calls( + [ + call( + key=1, + state=True, + brightness=pytest.approx(0.23529411764705882), + white=1.0, + color_mode=color_mode, + ) + ] + ) + mock_client.light_command.reset_mock() + + async def test_light_brightness_on_off_with_unknown_color_mode( hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry ) -> None: @@ -1676,3 +1739,139 @@ async def test_light_effects( ] ) mock_client.light_command.reset_mock() + + +async def test_only_cold_warm_white_support( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic light entity with only cold warm white support.""" + mock_client.api_version = APIVersion(1, 7) + color_modes = ( + LightColorCapability.COLD_WARM_WHITE + | LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + ) + entity_info = [ + LightInfo( + object_id="mylight", + key=1, + name="my light", + unique_id="my_light", + min_mireds=153, + max_mireds=400, + supported_color_modes=[color_modes], + ) + ] + states = [ + LightState( + key=1, + state=True, + color_brightness=1, + brightness=100, + red=1, + green=1, + blue=1, + warm_white=1, + cold_white=1, + color_mode=color_modes, + ) + ] + 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_mylight") + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.COLOR_TEMP] + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 0 + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_mylight"}, + blocking=True, + ) + mock_client.light_command.assert_has_calls( + [call(key=1, state=True, color_mode=color_modes)] + ) + mock_client.light_command.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, + blocking=True, + ) + mock_client.light_command.assert_has_calls( + [ + call( + key=1, + state=True, + color_mode=color_modes, + brightness=pytest.approx(0.4980392156862745), + ) + ] + ) + mock_client.light_command.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_COLOR_TEMP_KELVIN: 2500}, + blocking=True, + ) + mock_client.light_command.assert_has_calls( + [ + call( + key=1, + state=True, + color_mode=color_modes, + color_temperature=400.0, + ) + ] + ) + mock_client.light_command.reset_mock() + + +async def test_light_no_color_modes( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic light entity with no color modes.""" + mock_client.api_version = APIVersion(1, 7) + color_mode = 0 + entity_info = [ + LightInfo( + object_id="mylight", + key=1, + name="my light", + unique_id="my_light", + min_mireds=153, + max_mireds=400, + supported_color_modes=[color_mode], + ) + ] + states = [LightState(key=1, state=True, brightness=100)] + 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_mylight") + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.UNKNOWN] + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_mylight"}, + blocking=True, + ) + mock_client.light_command.assert_has_calls([call(key=1, state=True, color_mode=0)]) + mock_client.light_command.reset_mock()