Home Assistant Unofficial Reference 2024.12.1
light.py
Go to the documentation of this file.
1 """Support for ESPHome lights."""
2 
3 from __future__ import annotations
4 
5 from functools import lru_cache, partial
6 from typing import TYPE_CHECKING, Any, cast
7 
8 from aioesphomeapi import (
9  APIVersion,
10  EntityInfo,
11  LightColorCapability,
12  LightInfo,
13  LightState,
14 )
15 
17  ATTR_BRIGHTNESS,
18  ATTR_COLOR_TEMP_KELVIN,
19  ATTR_EFFECT,
20  ATTR_FLASH,
21  ATTR_RGB_COLOR,
22  ATTR_RGBW_COLOR,
23  ATTR_RGBWW_COLOR,
24  ATTR_TRANSITION,
25  ATTR_WHITE,
26  FLASH_LONG,
27  FLASH_SHORT,
28  ColorMode,
29  LightEntity,
30  LightEntityFeature,
31 )
32 from homeassistant.core import callback
33 
34 from .entity import (
35  EsphomeEntity,
36  convert_api_error_ha_error,
37  esphome_state_property,
38  platform_async_setup_entry,
39 )
40 
41 FLASH_LENGTHS = {FLASH_SHORT: 2, FLASH_LONG: 10}
42 
43 
44 _COLOR_MODE_MAPPING = {
45  ColorMode.ONOFF: [
46  LightColorCapability.ON_OFF,
47  ],
48  ColorMode.BRIGHTNESS: [
49  LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS,
50  # for compatibility with older clients (2021.8.x)
51  LightColorCapability.BRIGHTNESS,
52  ],
53  ColorMode.COLOR_TEMP: [
54  LightColorCapability.ON_OFF
55  | LightColorCapability.BRIGHTNESS
56  | LightColorCapability.COLOR_TEMPERATURE,
57  LightColorCapability.ON_OFF
58  | LightColorCapability.BRIGHTNESS
59  | LightColorCapability.COLD_WARM_WHITE,
60  ],
61  ColorMode.RGB: [
62  LightColorCapability.ON_OFF
63  | LightColorCapability.BRIGHTNESS
64  | LightColorCapability.RGB,
65  ],
66  ColorMode.RGBW: [
67  LightColorCapability.ON_OFF
68  | LightColorCapability.BRIGHTNESS
69  | LightColorCapability.RGB
70  | LightColorCapability.WHITE,
71  ],
72  ColorMode.RGBWW: [
73  LightColorCapability.ON_OFF
74  | LightColorCapability.BRIGHTNESS
75  | LightColorCapability.RGB
76  | LightColorCapability.WHITE
77  | LightColorCapability.COLOR_TEMPERATURE,
78  LightColorCapability.ON_OFF
79  | LightColorCapability.BRIGHTNESS
80  | LightColorCapability.RGB
81  | LightColorCapability.COLD_WARM_WHITE,
82  ],
83  ColorMode.WHITE: [
84  LightColorCapability.ON_OFF
85  | LightColorCapability.BRIGHTNESS
86  | LightColorCapability.WHITE
87  ],
88 }
89 
90 
91 def _mired_to_kelvin(mired_temperature: float) -> int:
92  """Convert absolute mired shift to degrees kelvin.
93 
94  This function rounds the converted value instead of flooring the value as
95  is done in homeassistant.util.color.color_temperature_mired_to_kelvin().
96 
97  If the value of mired_temperature is less than or equal to zero, return
98  the original value to avoid a divide by zero.
99  """
100  if mired_temperature <= 0:
101  return round(mired_temperature)
102  return round(1000000 / mired_temperature)
103 
104 
105 @lru_cache
106 def _color_mode_to_ha(mode: int) -> str:
107  """Convert an esphome color mode to a HA color mode constant.
108 
109  Choses the color mode that best matches the feature-set.
110  """
111  candidates = []
112  for ha_mode, cap_lists in _COLOR_MODE_MAPPING.items():
113  for caps in cap_lists:
114  if caps == mode:
115  # exact match
116  return ha_mode
117  if (mode & caps) == caps:
118  # all requirements met
119  candidates.append((ha_mode, caps))
120 
121  if not candidates:
122  return ColorMode.UNKNOWN
123 
124  # choose the color mode with the most bits set
125  candidates.sort(key=lambda key: key[1].bit_count())
126  return candidates[-1][0]
127 
128 
129 @lru_cache
131  supported: list[int], features: LightColorCapability
132 ) -> tuple[int, ...]:
133  """Filter the given supported color modes.
134 
135  Excluding all values that don't have the requested features.
136  """
137  features_value = features.value
138  return tuple(
139  mode for mode in supported if (mode & features_value) == features_value
140  )
141 
142 
143 @lru_cache
144 def _least_complex_color_mode(color_modes: tuple[int, ...]) -> int:
145  """Return the color mode with the least complexity."""
146  # popcount with bin() function because it appears
147  # to be the best way: https://stackoverflow.com/a/9831671
148  color_modes_list = list(color_modes)
149  color_modes_list.sort(key=lambda mode: (mode).bit_count())
150  return color_modes_list[0]
151 
152 
153 class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
154  """A light implementation for ESPHome."""
155 
156  _native_supported_color_modes: tuple[int, ...]
157  _supports_color_mode = False
158 
159  @property
160  @esphome_state_property
161  def is_on(self) -> bool | None:
162  """Return true if the light is on."""
163  return self._state_state.state
164 
165  @convert_api_error_ha_error
166  async def async_turn_on(self, **kwargs: Any) -> None:
167  """Turn the entity on."""
168  data: dict[str, Any] = {"key": self._key_key, "state": True}
169  # The list of color modes that would fit this service call
170  color_modes = self._native_supported_color_modes_native_supported_color_modes
171  try_keep_current_mode = True
172 
173  # rgb/brightness input is in range 0-255, but esphome uses 0-1
174 
175  if (brightness_ha := kwargs.get(ATTR_BRIGHTNESS)) is not None:
176  data["brightness"] = brightness_ha / 255
177  color_modes = _filter_color_modes(
178  color_modes, LightColorCapability.BRIGHTNESS
179  )
180 
181  if (rgb_ha := kwargs.get(ATTR_RGB_COLOR)) is not None:
182  rgb = tuple(x / 255 for x in rgb_ha)
183  color_bri = max(rgb)
184  # normalize rgb
185  data["rgb"] = tuple(x / (color_bri or 1) for x in rgb)
186  data["color_brightness"] = color_bri
187  color_modes = _filter_color_modes(color_modes, LightColorCapability.RGB)
188  try_keep_current_mode = False
189 
190  if (rgbw_ha := kwargs.get(ATTR_RGBW_COLOR)) is not None:
191  *rgb, w = tuple(x / 255 for x in rgbw_ha) # type: ignore[assignment]
192  color_bri = max(rgb)
193  # normalize rgb
194  data["rgb"] = tuple(x / (color_bri or 1) for x in rgb)
195  data["white"] = w
196  data["color_brightness"] = color_bri
197  color_modes = _filter_color_modes(
198  color_modes, LightColorCapability.RGB | LightColorCapability.WHITE
199  )
200  try_keep_current_mode = False
201 
202  if (rgbww_ha := kwargs.get(ATTR_RGBWW_COLOR)) is not None:
203  *rgb, cw, ww = tuple(x / 255 for x in rgbww_ha) # type: ignore[assignment]
204  color_bri = max(rgb)
205  # normalize rgb
206  data["rgb"] = tuple(x / (color_bri or 1) for x in rgb)
207  color_modes = _filter_color_modes(color_modes, LightColorCapability.RGB)
208  if _filter_color_modes(color_modes, LightColorCapability.COLD_WARM_WHITE):
209  # Device supports setting cwww values directly
210  data["cold_white"] = cw
211  data["warm_white"] = ww
212  color_modes = _filter_color_modes(
213  color_modes, LightColorCapability.COLD_WARM_WHITE
214  )
215  else:
216  # need to convert cw+ww part to white+color_temp
217  white = data["white"] = max(cw, ww)
218  if white != 0:
219  static_info = self._static_info_static_info
220  min_ct = static_info.min_mireds
221  max_ct = static_info.max_mireds
222  ct_ratio = ww / (cw + ww)
223  data["color_temperature"] = min_ct + ct_ratio * (max_ct - min_ct)
224  color_modes = _filter_color_modes(
225  color_modes,
226  LightColorCapability.COLOR_TEMPERATURE | LightColorCapability.WHITE,
227  )
228  try_keep_current_mode = False
229 
230  data["color_brightness"] = color_bri
231 
232  if (flash := kwargs.get(ATTR_FLASH)) is not None:
233  data["flash_length"] = FLASH_LENGTHS[flash]
234 
235  if (transition := kwargs.get(ATTR_TRANSITION)) is not None:
236  data["transition_length"] = transition
237 
238  if (color_temp_k := kwargs.get(ATTR_COLOR_TEMP_KELVIN)) is not None:
239  # Do not use kelvin_to_mired here to prevent precision loss
240  data["color_temperature"] = 1000000.0 / color_temp_k
241  if color_temp_modes := _filter_color_modes(
242  color_modes, LightColorCapability.COLOR_TEMPERATURE
243  ):
244  color_modes = color_temp_modes
245  else:
246  color_modes = _filter_color_modes(
247  color_modes, LightColorCapability.COLD_WARM_WHITE
248  )
249  try_keep_current_mode = False
250 
251  if (effect := kwargs.get(ATTR_EFFECT)) is not None:
252  data["effect"] = effect
253 
254  if (white_ha := kwargs.get(ATTR_WHITE)) is not None:
255  # ESPHome multiplies brightness and white together for final brightness
256  # HA only sends `white` in turn_on, and reads total brightness
257  # through brightness property.
258  data["brightness"] = white_ha / 255
259  data["white"] = 1.0
260  color_modes = _filter_color_modes(
261  color_modes,
262  LightColorCapability.BRIGHTNESS | LightColorCapability.WHITE,
263  )
264  try_keep_current_mode = False
265 
266  if self._supports_color_mode_supports_color_mode_supports_color_mode and color_modes:
267  if (
268  try_keep_current_mode
269  and self._state_state is not None
270  and self._state_state.color_mode in color_modes
271  ):
272  # if possible, stay with the color mode that is already set
273  data["color_mode"] = self._state_state.color_mode
274  else:
275  # otherwise try the color mode with the least complexity
276  # (fewest capabilities set)
277  data["color_mode"] = _least_complex_color_mode(color_modes)
278 
279  self._client_client.light_command(**data)
280 
281  @convert_api_error_ha_error
282  async def async_turn_off(self, **kwargs: Any) -> None:
283  """Turn the entity off."""
284  data: dict[str, Any] = {"key": self._key_key, "state": False}
285  if ATTR_FLASH in kwargs:
286  data["flash_length"] = FLASH_LENGTHS[kwargs[ATTR_FLASH]]
287  if ATTR_TRANSITION in kwargs:
288  data["transition_length"] = kwargs[ATTR_TRANSITION]
289  self._client_client.light_command(**data)
290 
291  @property
292  @esphome_state_property
293  def brightness(self) -> int | None:
294  """Return the brightness of this light between 0..255."""
295  return round(self._state_state.brightness * 255)
296 
297  @property
298  @esphome_state_property
299  def color_mode(self) -> str | None:
300  """Return the color mode of the light."""
301  if not self._supports_color_mode_supports_color_mode_supports_color_mode:
302  supported_color_modes = self.supported_color_modessupported_color_modes
303  if TYPE_CHECKING:
304  assert supported_color_modes is not None
305  return next(iter(supported_color_modes))
306 
307  return _color_mode_to_ha(self._state_state.color_mode)
308 
309  @property
310  @esphome_state_property
311  def rgb_color(self) -> tuple[int, int, int] | None:
312  """Return the rgb color value [int, int, int]."""
313  state = self._state_state
314  if not self._supports_color_mode_supports_color_mode_supports_color_mode:
315  return (
316  round(state.red * 255),
317  round(state.green * 255),
318  round(state.blue * 255),
319  )
320 
321  return (
322  round(state.red * state.color_brightness * 255),
323  round(state.green * state.color_brightness * 255),
324  round(state.blue * state.color_brightness * 255),
325  )
326 
327  @property
328  @esphome_state_property
329  def rgbw_color(self) -> tuple[int, int, int, int] | None:
330  """Return the rgbw color value [int, int, int, int]."""
331  white = round(self._state_state.white * 255)
332  rgb = cast("tuple[int, int, int]", self.rgb_colorrgb_colorrgb_color)
333  return (*rgb, white)
334 
335  @property
336  @esphome_state_property
337  def rgbww_color(self) -> tuple[int, int, int, int, int] | None:
338  """Return the rgbww color value [int, int, int, int, int]."""
339  state = self._state_state
340  rgb = cast("tuple[int, int, int]", self.rgb_colorrgb_colorrgb_color)
341  if not _filter_color_modes(
342  self._native_supported_color_modes_native_supported_color_modes, LightColorCapability.COLD_WARM_WHITE
343  ):
344  # Try to reverse white + color temp to cwww
345  static_info = self._static_info_static_info
346  min_ct = static_info.min_mireds
347  max_ct = static_info.max_mireds
348  color_temp = min(max(state.color_temperature, min_ct), max_ct)
349  white = state.white
350 
351  ww_frac = (color_temp - min_ct) / (max_ct - min_ct)
352  cw_frac = 1 - ww_frac
353 
354  return (
355  *rgb,
356  round(white * cw_frac / max(cw_frac, ww_frac) * 255),
357  round(white * ww_frac / max(cw_frac, ww_frac) * 255),
358  )
359  return (
360  *rgb,
361  round(state.cold_white * 255),
362  round(state.warm_white * 255),
363  )
364 
365  @property
366  @esphome_state_property
367  def color_temp_kelvin(self) -> int:
368  """Return the CT color value in Kelvin."""
369  return _mired_to_kelvin(self._state_state.color_temperature)
370 
371  @property
372  @esphome_state_property
373  def effect(self) -> str | None:
374  """Return the current effect."""
375  return self._state_state.effect
376 
377  @callback
378  def _on_static_info_update(self, static_info: EntityInfo) -> None:
379  """Set attrs from static info."""
380  super()._on_static_info_update(static_info)
381  static_info = self._static_info_static_info
382  self._supports_color_mode_supports_color_mode_supports_color_mode = self._api_version_api_version >= APIVersion(1, 6)
383  self._native_supported_color_modes_native_supported_color_modes = tuple(
384  static_info.supported_color_modes_compat(self._api_version_api_version)
385  )
386  flags = LightEntityFeature.FLASH
387 
388  # All color modes except UNKNOWN,ON_OFF support transition
389  modes = self._native_supported_color_modes_native_supported_color_modes
390  if any(m not in (0, LightColorCapability.ON_OFF) for m in modes):
391  flags |= LightEntityFeature.TRANSITION
392  if static_info.effects:
393  flags |= LightEntityFeature.EFFECT
394  self._attr_supported_features_attr_supported_features = flags
395 
396  supported = set(map(_color_mode_to_ha, self._native_supported_color_modes_native_supported_color_modes))
397 
398  # If we don't know the supported color modes, ESPHome lights
399  # are always at least ONOFF so we can safely discard UNKNOWN
400  supported.discard(ColorMode.UNKNOWN)
401 
402  if ColorMode.ONOFF in supported and len(supported) > 1:
403  supported.remove(ColorMode.ONOFF)
404  if ColorMode.BRIGHTNESS in supported and len(supported) > 1:
405  supported.remove(ColorMode.BRIGHTNESS)
406  if ColorMode.WHITE in supported and len(supported) == 1:
407  supported.remove(ColorMode.WHITE)
408 
409  # If we don't know the supported color modes, its a very old
410  # legacy device, and since ESPHome lights are always at least ONOFF
411  # we can safely assume that it supports ONOFF
412  if not supported:
413  supported.add(ColorMode.ONOFF)
414 
415  self._attr_supported_color_modes_attr_supported_color_modes = supported
416  self._attr_effect_list_attr_effect_list = static_info.effects
417  self._attr_min_mireds_attr_min_mireds = round(static_info.min_mireds)
418  self._attr_max_mireds_attr_max_mireds = round(static_info.max_mireds)
419  if ColorMode.COLOR_TEMP in supported:
420  self._attr_min_color_temp_kelvin_attr_min_color_temp_kelvin = _mired_to_kelvin(static_info.max_mireds)
421  self._attr_max_color_temp_kelvin_attr_max_color_temp_kelvin = _mired_to_kelvin(static_info.min_mireds)
422 
423 
424 async_setup_entry = partial(
425  platform_async_setup_entry,
426  info_type=LightInfo,
427  entity_type=EsphomeLight,
428  state_type=LightState,
429 )
tuple[int, int, int, int]|None rgbw_color(self)
Definition: light.py:329
tuple[int, int, int]|None rgb_color(self)
Definition: light.py:311
None _on_static_info_update(self, EntityInfo static_info)
Definition: light.py:378
tuple[int, int, int, int, int]|None rgbww_color(self)
Definition: light.py:337
tuple[int, int, int]|None rgb_color(self)
Definition: __init__.py:957
set[ColorMode]|set[str]|None supported_color_modes(self)
Definition: __init__.py:1302
tuple[int,...] _filter_color_modes(list[int] supported, LightColorCapability features)
Definition: light.py:132
int _least_complex_color_mode(tuple[int,...] color_modes)
Definition: light.py:144
int _mired_to_kelvin(float mired_temperature)
Definition: light.py:91