From 46159c3f18bf3f405bced2f5280003e905748e1b Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Wed, 1 Sep 2021 10:03:41 +0200 Subject: [PATCH] ESPHome light color mode use capabilities (#55206) Co-authored-by: Oxan van Leeuwen --- homeassistant/components/esphome/light.py | 195 +++++++++++++----- .../components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 150 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index 73339769121..9e7f544f610 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any, cast -from aioesphomeapi import APIVersion, LightColorMode, LightInfo, LightState +from aioesphomeapi import APIVersion, LightColorCapability, LightInfo, LightState from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -34,12 +34,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ( - EsphomeEntity, - EsphomeEnumMapper, - esphome_state_property, - platform_async_setup_entry, -) +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry FLASH_LENGTHS = {FLASH_SHORT: 2, FLASH_LONG: 10} @@ -59,20 +54,81 @@ async def async_setup_entry( ) -_COLOR_MODES: EsphomeEnumMapper[LightColorMode, str] = EsphomeEnumMapper( - { - LightColorMode.UNKNOWN: COLOR_MODE_UNKNOWN, - LightColorMode.ON_OFF: COLOR_MODE_ONOFF, - LightColorMode.BRIGHTNESS: COLOR_MODE_BRIGHTNESS, - LightColorMode.WHITE: COLOR_MODE_WHITE, - LightColorMode.COLOR_TEMPERATURE: COLOR_MODE_COLOR_TEMP, - LightColorMode.COLD_WARM_WHITE: COLOR_MODE_COLOR_TEMP, - LightColorMode.RGB: COLOR_MODE_RGB, - LightColorMode.RGB_WHITE: COLOR_MODE_RGBW, - LightColorMode.RGB_COLOR_TEMPERATURE: COLOR_MODE_RGBWW, - LightColorMode.RGB_COLD_WARM_WHITE: COLOR_MODE_RGBWW, - } -) +_COLOR_MODE_MAPPING = { + COLOR_MODE_ONOFF: [ + LightColorCapability.ON_OFF, + ], + COLOR_MODE_BRIGHTNESS: [ + LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + # for compatibility with older clients (2021.8.x) + LightColorCapability.BRIGHTNESS, + ], + COLOR_MODE_COLOR_TEMP: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.COLOR_TEMPERATURE, + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.COLD_WARM_WHITE, + ], + COLOR_MODE_RGB: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB, + ], + COLOR_MODE_RGBW: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB + | LightColorCapability.WHITE, + ], + COLOR_MODE_RGBWW: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB + | LightColorCapability.WHITE + | LightColorCapability.COLOR_TEMPERATURE, + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB + | LightColorCapability.COLD_WARM_WHITE, + ], + COLOR_MODE_WHITE: [ + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.WHITE + ], +} + + +def _color_mode_to_ha(mode: int) -> str: + """Convert an esphome color mode to a HA color mode constant. + + Choses the color mode that best matches the feature-set. + """ + candidates = [] + for ha_mode, cap_lists in _COLOR_MODE_MAPPING.items(): + for caps in cap_lists: + if caps == mode: + # exact match + return ha_mode + if (mode & caps) == caps: + # all requirements met + candidates.append((ha_mode, caps)) + + if not candidates: + return COLOR_MODE_UNKNOWN + + # choose the color mode with the most bits set + candidates.sort(key=lambda key: bin(key[1]).count("1")) + return candidates[-1][0] + + +def _filter_color_modes( + supported: list[int], features: LightColorCapability +) -> list[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] # https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property @@ -95,10 +151,17 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" data: dict[str, Any] = {"key": self._static_info.key, "state": True} + # The list of color modes that would fit this service call + color_modes = self._native_supported_color_modes + try_keep_current_mode = True + # rgb/brightness input is in range 0-255, but esphome uses 0-1 if (brightness_ha := kwargs.get(ATTR_BRIGHTNESS)) is not None: data["brightness"] = brightness_ha / 255 + color_modes = _filter_color_modes( + color_modes, LightColorCapability.BRIGHTNESS + ) if (rgb_ha := kwargs.get(ATTR_RGB_COLOR)) is not None: rgb = tuple(x / 255 for x in rgb_ha) @@ -106,8 +169,8 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): # normalize rgb data["rgb"] = tuple(x / (color_bri or 1) for x in rgb) data["color_brightness"] = color_bri - if self._supports_color_mode: - data["color_mode"] = LightColorMode.RGB + color_modes = _filter_color_modes(color_modes, LightColorCapability.RGB) + try_keep_current_mode = False if (rgbw_ha := kwargs.get(ATTR_RGBW_COLOR)) is not None: # pylint: disable=invalid-name @@ -117,8 +180,10 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): data["rgb"] = tuple(x / (color_bri or 1) for x in rgb) data["white"] = w data["color_brightness"] = color_bri - if self._supports_color_mode: - data["color_mode"] = LightColorMode.RGB_WHITE + color_modes = _filter_color_modes( + color_modes, LightColorCapability.RGB | LightColorCapability.WHITE + ) + try_keep_current_mode = False if (rgbww_ha := kwargs.get(ATTR_RGBWW_COLOR)) is not None: # pylint: disable=invalid-name @@ -126,14 +191,14 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): color_bri = max(rgb) # normalize rgb data["rgb"] = tuple(x / (color_bri or 1) for x in rgb) - modes = self._native_supported_color_modes - if ( - self._supports_color_mode - and LightColorMode.RGB_COLD_WARM_WHITE in modes - ): + color_modes = _filter_color_modes(color_modes, LightColorCapability.RGB) + if _filter_color_modes(color_modes, LightColorCapability.COLD_WARM_WHITE): + # Device supports setting cwww values directly data["cold_white"] = cw data["warm_white"] = ww - target_mode = LightColorMode.RGB_COLD_WARM_WHITE + color_modes = _filter_color_modes( + color_modes, LightColorCapability.COLD_WARM_WHITE + ) else: # need to convert cw+ww part to white+color_temp white = data["white"] = max(cw, ww) @@ -142,11 +207,13 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): max_ct = self.max_mireds ct_ratio = ww / (cw + ww) data["color_temperature"] = min_ct + ct_ratio * (max_ct - min_ct) - target_mode = LightColorMode.RGB_COLOR_TEMPERATURE + color_modes = _filter_color_modes( + color_modes, + LightColorCapability.COLOR_TEMPERATURE | LightColorCapability.WHITE, + ) + try_keep_current_mode = False data["color_brightness"] = color_bri - if self._supports_color_mode: - data["color_mode"] = target_mode if (flash := kwargs.get(ATTR_FLASH)) is not None: data["flash_length"] = FLASH_LENGTHS[flash] @@ -156,12 +223,15 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): if (color_temp := kwargs.get(ATTR_COLOR_TEMP)) is not None: data["color_temperature"] = color_temp - if self._supports_color_mode: - supported_modes = self._native_supported_color_modes - if LightColorMode.COLOR_TEMPERATURE in supported_modes: - data["color_mode"] = LightColorMode.COLOR_TEMPERATURE - elif LightColorMode.COLD_WARM_WHITE in supported_modes: - data["color_mode"] = LightColorMode.COLD_WARM_WHITE + if _filter_color_modes(color_modes, LightColorCapability.COLOR_TEMPERATURE): + color_modes = _filter_color_modes( + color_modes, LightColorCapability.COLOR_TEMPERATURE + ) + else: + color_modes = _filter_color_modes( + color_modes, LightColorCapability.COLD_WARM_WHITE + ) + try_keep_current_mode = False if (effect := kwargs.get(ATTR_EFFECT)) is not None: data["effect"] = effect @@ -171,7 +241,30 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): # HA only sends `white` in turn_on, and reads total brightness through brightness property data["brightness"] = white_ha / 255 data["white"] = 1.0 - data["color_mode"] = LightColorMode.WHITE + color_modes = _filter_color_modes( + color_modes, + LightColorCapability.BRIGHTNESS | LightColorCapability.WHITE, + ) + try_keep_current_mode = False + + if self._supports_color_mode and color_modes: + # 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] + if self._supports_color_mode and color_modes: + if ( + try_keep_current_mode + and self._state is not None + and self._state.color_mode in color_modes + ): + # if possible, stay with the color mode that is already set + data["color_mode"] = self._state.color_mode + 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] await self._client.light_command(**data) @@ -198,7 +291,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): return None return next(iter(supported)) - return _COLOR_MODES.from_esphome(self._state.color_mode) + return _color_mode_to_ha(self._state.color_mode) @esphome_state_property def rgb_color(self) -> tuple[int, int, int] | None: @@ -227,9 +320,8 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): def rgbww_color(self) -> tuple[int, int, int, int, int] | None: """Return the rgbww color value [int, int, int, int, int].""" rgb = cast("tuple[int, int, int]", self.rgb_color) - if ( - not self._supports_color_mode - or self._state.color_mode != LightColorMode.RGB_COLD_WARM_WHITE + if not _filter_color_modes( + self._native_supported_color_modes, LightColorCapability.COLD_WARM_WHITE ): # Try to reverse white + color temp to cwww min_ct = self._static_info.min_mireds @@ -262,7 +354,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): return self._state.effect @property - def _native_supported_color_modes(self) -> list[LightColorMode]: + def _native_supported_color_modes(self) -> list[int]: return self._static_info.supported_color_modes_compat(self._api_version) @property @@ -272,7 +364,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): # All color modes except UNKNOWN,ON_OFF support transition modes = self._native_supported_color_modes - if any(m not in (LightColorMode.UNKNOWN, LightColorMode.ON_OFF) for m in modes): + if any(m not in (0, LightColorCapability.ON_OFF) for m in modes): flags |= SUPPORT_TRANSITION if self._static_info.effects: flags |= SUPPORT_EFFECT @@ -281,7 +373,14 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): @property def supported_color_modes(self) -> set[str] | None: """Flag supported color modes.""" - return set(map(_COLOR_MODES.from_esphome, self._native_supported_color_modes)) + supported = set(map(_color_mode_to_ha, self._native_supported_color_modes)) + if COLOR_MODE_ONOFF in supported and len(supported) > 1: + supported.remove(COLOR_MODE_ONOFF) + if COLOR_MODE_BRIGHTNESS in supported and len(supported) > 1: + supported.remove(COLOR_MODE_BRIGHTNESS) + if COLOR_MODE_WHITE in supported and len(supported) == 1: + supported.remove(COLOR_MODE_WHITE) + return supported @property def effect_list(self) -> list[str]: diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 96ac632d990..a78d2efb763 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -3,7 +3,7 @@ "name": "ESPHome", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/esphome", - "requirements": ["aioesphomeapi==7.0.0"], + "requirements": ["aioesphomeapi==8.0.0"], "zeroconf": ["_esphomelib._tcp.local."], "codeowners": ["@OttoWinter", "@jesserockz"], "after_dependencies": ["zeroconf", "tag"], diff --git a/requirements_all.txt b/requirements_all.txt index c9bd684bfd9..179017806ea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -164,7 +164,7 @@ aioeagle==1.1.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==7.0.0 +aioesphomeapi==8.0.0 # homeassistant.components.flo aioflo==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eb8ebeaea65..22f16c40b79 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -106,7 +106,7 @@ aioeagle==1.1.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==7.0.0 +aioesphomeapi==8.0.0 # homeassistant.components.flo aioflo==0.4.1