Home Assistant Unofficial Reference 2024.12.1
light.py
Go to the documentation of this file.
1 """Support for TPLink lights."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Sequence
6 import logging
7 from typing import Any
8 
9 from kasa import Device, DeviceType, LightState, Module
10 from kasa.interfaces import Light, LightEffect
11 from kasa.iot import IotDevice
12 import voluptuous as vol
13 
15  ATTR_BRIGHTNESS,
16  ATTR_COLOR_TEMP_KELVIN,
17  ATTR_EFFECT,
18  ATTR_HS_COLOR,
19  ATTR_TRANSITION,
20  EFFECT_OFF,
21  ColorMode,
22  LightEntity,
23  LightEntityFeature,
24  filter_supported_color_modes,
25 )
26 from homeassistant.core import HomeAssistant, callback
27 from homeassistant.helpers import entity_platform
29 from homeassistant.helpers.entity_platform import AddEntitiesCallback
30 from homeassistant.helpers.typing import VolDictType
31 
32 from . import TPLinkConfigEntry, legacy_device_id
33 from .coordinator import TPLinkDataUpdateCoordinator
34 from .entity import CoordinatedTPLinkEntity, async_refresh_after
35 
36 _LOGGER = logging.getLogger(__name__)
37 
38 SERVICE_RANDOM_EFFECT = "random_effect"
39 SERVICE_SEQUENCE_EFFECT = "sequence_effect"
40 
41 HUE = vol.Range(min=0, max=360)
42 SAT = vol.Range(min=0, max=100)
43 VAL = vol.Range(min=0, max=100)
44 TRANSITION = vol.Range(min=0, max=6000)
45 HSV_SEQUENCE = vol.ExactSequence((HUE, SAT, VAL))
46 
47 BASE_EFFECT_DICT: VolDictType = {
48  vol.Optional("brightness", default=100): vol.All(
49  vol.Coerce(int), vol.Range(min=0, max=100)
50  ),
51  vol.Optional("duration", default=0): vol.All(
52  vol.Coerce(int), vol.Range(min=0, max=5000)
53  ),
54  vol.Optional("transition", default=0): vol.All(vol.Coerce(int), TRANSITION),
55  vol.Optional("segments", default=[0]): vol.All(
56  cv.ensure_list_csv,
57  vol.Length(min=1, max=80),
58  [vol.All(vol.Coerce(int), vol.Range(min=0, max=80))],
59  ),
60 }
61 
62 SEQUENCE_EFFECT_DICT: VolDictType = {
63  **BASE_EFFECT_DICT,
64  vol.Required("sequence"): vol.All(
65  cv.ensure_list,
66  vol.Length(min=1, max=16),
67  [vol.All(vol.Coerce(tuple), HSV_SEQUENCE)],
68  ),
69  vol.Optional("repeat_times", default=0): vol.All(
70  vol.Coerce(int), vol.Range(min=0, max=10)
71  ),
72  vol.Optional("spread", default=1): vol.All(
73  vol.Coerce(int), vol.Range(min=1, max=16)
74  ),
75  vol.Optional("direction", default=4): vol.All(
76  vol.Coerce(int), vol.Range(min=1, max=4)
77  ),
78 }
79 
80 RANDOM_EFFECT_DICT: VolDictType = {
81  **BASE_EFFECT_DICT,
82  vol.Optional("fadeoff", default=0): vol.All(
83  vol.Coerce(int), vol.Range(min=0, max=3000)
84  ),
85  vol.Optional("hue_range"): vol.All(
86  cv.ensure_list_csv, [vol.Coerce(int)], vol.ExactSequence((HUE, HUE))
87  ),
88  vol.Optional("saturation_range"): vol.All(
89  cv.ensure_list_csv, [vol.Coerce(int)], vol.ExactSequence((SAT, SAT))
90  ),
91  vol.Optional("brightness_range"): vol.All(
92  cv.ensure_list_csv, [vol.Coerce(int)], vol.ExactSequence((VAL, VAL))
93  ),
94  vol.Optional("transition_range"): vol.All(
95  cv.ensure_list_csv,
96  [vol.Coerce(int)],
97  vol.ExactSequence((TRANSITION, TRANSITION)),
98  ),
99  vol.Required("init_states"): vol.All(
100  cv.ensure_list_csv, [vol.Coerce(int)], HSV_SEQUENCE
101  ),
102  vol.Optional("random_seed", default=100): vol.All(
103  vol.Coerce(int), vol.Range(min=1, max=600)
104  ),
105  vol.Optional("backgrounds"): vol.All(
106  cv.ensure_list,
107  vol.Length(min=1, max=16),
108  [vol.All(vol.Coerce(tuple), HSV_SEQUENCE)],
109  ),
110 }
111 
112 
113 @callback
115  brightness: int,
116  duration: int,
117  transition: int,
118  segments: list[int],
119 ) -> dict[str, Any]:
120  return {
121  "custom": 1,
122  "id": "yMwcNpLxijmoKamskHCvvravpbnIqAIN",
123  "brightness": brightness,
124  "name": "Custom",
125  "segments": segments,
126  "expansion_strategy": 1,
127  "enable": 1,
128  "duration": duration,
129  "transition": transition,
130  }
131 
132 
134  hass: HomeAssistant,
135  config_entry: TPLinkConfigEntry,
136  async_add_entities: AddEntitiesCallback,
137 ) -> None:
138  """Set up switches."""
139  data = config_entry.runtime_data
140  parent_coordinator = data.parent_coordinator
141  device = parent_coordinator.device
142  entities: list[TPLinkLightEntity | TPLinkLightEffectEntity] = []
143  if effect_module := device.modules.get(Module.LightEffect):
144  entities.append(
146  device,
147  parent_coordinator,
148  light_module=device.modules[Module.Light],
149  effect_module=effect_module,
150  )
151  )
152  if effect_module.has_custom_effects:
153  platform = entity_platform.async_get_current_platform()
154  platform.async_register_entity_service(
155  SERVICE_RANDOM_EFFECT,
156  RANDOM_EFFECT_DICT,
157  "async_set_random_effect",
158  )
159  platform.async_register_entity_service(
160  SERVICE_SEQUENCE_EFFECT,
161  SEQUENCE_EFFECT_DICT,
162  "async_set_sequence_effect",
163  )
164  elif Module.Light in device.modules:
165  entities.append(
167  device, parent_coordinator, light_module=device.modules[Module.Light]
168  )
169  )
170  entities.extend(
172  child,
173  parent_coordinator,
174  light_module=child.modules[Module.Light],
175  parent=device,
176  )
177  for child in device.children
178  if Module.Light in child.modules
179  )
180  async_add_entities(entities)
181 
182 
184  """Representation of a TPLink Smart Bulb."""
185 
186  _attr_supported_features = LightEntityFeature.TRANSITION
187  _fixed_color_mode: ColorMode | None = None
188 
189  def __init__(
190  self,
191  device: Device,
192  coordinator: TPLinkDataUpdateCoordinator,
193  *,
194  light_module: Light,
195  parent: Device | None = None,
196  ) -> None:
197  """Initialize the light."""
198  self._parent_parent = parent
199  self._light_module_light_module = light_module
200  # If _attr_name is None the entity name will be the device name
201  self._attr_name_attr_name = None if parent is None else device.alias
202  modes: set[ColorMode] = {ColorMode.ONOFF}
203  if light_module.is_variable_color_temp:
204  modes.add(ColorMode.COLOR_TEMP)
205  temp_range = light_module.valid_temperature_range
206  self._attr_min_color_temp_kelvin_attr_min_color_temp_kelvin = temp_range.min
207  self._attr_max_color_temp_kelvin_attr_max_color_temp_kelvin = temp_range.max
208  if light_module.is_color:
209  modes.add(ColorMode.HS)
210  if light_module.is_dimmable:
211  modes.add(ColorMode.BRIGHTNESS)
212  self._attr_supported_color_modes_attr_supported_color_modes = filter_supported_color_modes(modes)
213  if len(self._attr_supported_color_modes_attr_supported_color_modes) == 1:
214  # If the light supports only a single color mode, set it now
215  self._fixed_color_mode_fixed_color_mode = next(iter(self._attr_supported_color_modes_attr_supported_color_modes))
216 
217  super().__init__(device, coordinator, parent=parent)
218 
219  def _get_unique_id(self) -> str:
220  """Return unique ID for the entity."""
221  # For historical reasons the light platform uses the mac address as
222  # the unique id whereas all other platforms use device_id.
223  device = self._device
224 
225  # For backwards compat with pyHS100
226  if device.device_type is DeviceType.Dimmer and isinstance(device, IotDevice):
227  # Dimmers used to use the switch format since
228  # pyHS100 treated them as SmartPlug but the old code
229  # created them as lights
230  # https://github.com/home-assistant/core/blob/2021.9.7/ \
231  # homeassistant/components/tplink/common.py#L86
232  return legacy_device_id(device)
233 
234  # Newer devices can have child lights. While there isn't currently
235  # an example of a device with more than one light we use the device_id
236  # for consistency and future proofing
237  if self._parent_parent or device.children:
238  return legacy_device_id(device)
239 
240  return device.mac.replace(":", "").upper()
241 
242  @callback
244  self, **kwargs: Any
245  ) -> tuple[int | None, int | None]:
246  if (transition := kwargs.get(ATTR_TRANSITION)) is not None:
247  transition = int(transition * 1_000)
248 
249  if (brightness := kwargs.get(ATTR_BRIGHTNESS)) is not None:
250  brightness = round((brightness * 100.0) / 255.0)
251 
252  if self._device.device_type is DeviceType.Dimmer and transition is None:
253  # This is a stopgap solution for inconsistent set_brightness
254  # handling in the upstream library, see #57265.
255  # This should be removed when the upstream has fixed the issue.
256  # The device logic is to change the settings without turning it on
257  # except when transition is defined so we leverage that for now.
258  transition = 1
259 
260  return brightness, transition
261 
262  async def _async_set_hsv(
263  self, hs_color: tuple[int, int], brightness: int | None, transition: int | None
264  ) -> None:
265  # TP-Link requires integers.
266  hue, sat = tuple(int(val) for val in hs_color)
267  await self._light_module_light_module.set_hsv(hue, sat, brightness, transition=transition)
268 
270  self, color_temp: float, brightness: int | None, transition: int | None
271  ) -> None:
272  light_module = self._light_module_light_module
273  valid_temperature_range = light_module.valid_temperature_range
274  requested_color_temp = round(color_temp)
275  # Clamp color temp to valid range
276  # since if the light in a group we will
277  # get requests for color temps for the range
278  # of the group and not the light
279  clamped_color_temp = min(
280  valid_temperature_range.max,
281  max(valid_temperature_range.min, requested_color_temp),
282  )
283  await light_module.set_color_temp(
284  clamped_color_temp,
285  brightness=brightness,
286  transition=transition,
287  )
288 
290  self, brightness: int | None, transition: int | None
291  ) -> None:
292  # Fallback to adjusting brightness or turning the bulb on
293  if brightness is not None:
294  await self._light_module_light_module.set_brightness(brightness, transition=transition)
295  return
296  await self._light_module_light_module.set_state(
297  LightState(light_on=True, transition=transition)
298  )
299 
300  @async_refresh_after
301  async def async_turn_on(self, **kwargs: Any) -> None:
302  """Turn the light on."""
303  brightness, transition = self._async_extract_brightness_transition_async_extract_brightness_transition(**kwargs)
304  if ATTR_COLOR_TEMP_KELVIN in kwargs:
305  await self._async_set_color_temp_async_set_color_temp(
306  kwargs[ATTR_COLOR_TEMP_KELVIN], brightness, transition
307  )
308  if ATTR_HS_COLOR in kwargs:
309  await self._async_set_hsv_async_set_hsv(kwargs[ATTR_HS_COLOR], brightness, transition)
310  else:
311  await self._async_turn_on_with_brightness_async_turn_on_with_brightness(brightness, transition)
312 
313  @async_refresh_after
314  async def async_turn_off(self, **kwargs: Any) -> None:
315  """Turn the light off."""
316  if (transition := kwargs.get(ATTR_TRANSITION)) is not None:
317  transition = int(transition * 1_000)
318  await self._light_module_light_module.set_state(
319  LightState(light_on=False, transition=transition)
320  )
321 
322  def _determine_color_mode(self) -> ColorMode:
323  """Return the active color mode."""
324  if self._fixed_color_mode_fixed_color_mode:
325  # The light supports only a single color mode, return it
326  return self._fixed_color_mode_fixed_color_mode
327 
328  # The light supports both color temp and color, determine which on is active
329  if self._light_module_light_module.is_variable_color_temp and self._light_module_light_module.color_temp:
330  return ColorMode.COLOR_TEMP
331  return ColorMode.HS
332 
333  @callback
334  def _async_update_attrs(self) -> None:
335  """Update the entity's attributes."""
336  light_module = self._light_module_light_module
337  self._attr_is_on_attr_is_on = light_module.state.light_on is True
338  if light_module.is_dimmable:
339  self._attr_brightness_attr_brightness = round((light_module.brightness * 255.0) / 100.0)
340  color_mode = self._determine_color_mode_determine_color_mode()
341  self._attr_color_mode_attr_color_mode = color_mode
342  if color_mode is ColorMode.COLOR_TEMP:
343  self._attr_color_temp_kelvin_attr_color_temp_kelvin = light_module.color_temp
344  elif color_mode is ColorMode.HS:
345  hue, saturation, _ = light_module.hsv
346  self._attr_hs_color_attr_hs_color = hue, saturation
347 
348 
350  """Representation of a TPLink Smart Light Strip."""
351 
352  def __init__(
353  self,
354  device: Device,
355  coordinator: TPLinkDataUpdateCoordinator,
356  *,
357  light_module: Light,
358  effect_module: LightEffect,
359  ) -> None:
360  """Initialize the light strip."""
361  self._effect_module_effect_module = effect_module
362  super().__init__(device, coordinator, light_module=light_module)
363 
364  _attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT
365 
366  @callback
367  def _async_update_attrs(self) -> None:
368  """Update the entity's attributes."""
369  super()._async_update_attrs()
370  effect_module = self._effect_module_effect_module
371  if effect_module.effect != LightEffect.LIGHT_EFFECTS_OFF:
372  self._attr_effect_attr_effect = effect_module.effect
373  self._attr_color_mode_attr_color_mode_attr_color_mode = ColorMode.BRIGHTNESS
374  else:
375  self._attr_effect_attr_effect = EFFECT_OFF
376  if effect_list := effect_module.effect_list:
377  self._attr_effect_list_attr_effect_list = effect_list
378  else:
379  self._attr_effect_list_attr_effect_list = None
380 
381  @async_refresh_after
382  async def async_turn_on(self, **kwargs: Any) -> None:
383  """Turn the light on."""
384  brightness, transition = self._async_extract_brightness_transition_async_extract_brightness_transition(**kwargs)
385  effect_off_called = False
386  if effect := kwargs.get(ATTR_EFFECT):
387  if effect in {LightEffect.LIGHT_EFFECTS_OFF, EFFECT_OFF}:
388  if self._effect_module_effect_module.effect is not LightEffect.LIGHT_EFFECTS_OFF:
389  await self._effect_module_effect_module.set_effect(LightEffect.LIGHT_EFFECTS_OFF)
390  effect_off_called = True
391  if len(kwargs) == 1:
392  return
393  elif effect in self._effect_module_effect_module.effect_list:
394  await self._effect_module_effect_module.set_effect(
395  kwargs[ATTR_EFFECT], brightness=brightness, transition=transition
396  )
397  return
398  else:
399  _LOGGER.error("Invalid effect %s for %s", effect, self._device.host)
400  return
401 
402  if ATTR_COLOR_TEMP_KELVIN in kwargs:
403  if self.effecteffect and self.effecteffect != EFFECT_OFF and not effect_off_called:
404  # If there is an effect in progress
405  # we have to clear the effect
406  # before we can set a color temp
407  await self._effect_module_effect_module.set_effect(LightEffect.LIGHT_EFFECTS_OFF)
408  await self._async_set_color_temp_async_set_color_temp(
409  kwargs[ATTR_COLOR_TEMP_KELVIN], brightness, transition
410  )
411  elif ATTR_HS_COLOR in kwargs:
412  await self._async_set_hsv_async_set_hsv(kwargs[ATTR_HS_COLOR], brightness, transition)
413  else:
414  await self._async_turn_on_with_brightness_async_turn_on_with_brightness(brightness, transition)
415 
417  self,
418  brightness: int,
419  duration: int,
420  transition: int,
421  segments: list[int],
422  fadeoff: int,
423  init_states: tuple[int, int, int],
424  random_seed: int,
425  backgrounds: Sequence[tuple[int, int, int]] | None = None,
426  hue_range: tuple[int, int] | None = None,
427  saturation_range: tuple[int, int] | None = None,
428  brightness_range: tuple[int, int] | None = None,
429  transition_range: tuple[int, int] | None = None,
430  ) -> None:
431  """Set a random effect."""
432  effect: dict[str, Any] = {
433  **_async_build_base_effect(brightness, duration, transition, segments),
434  "type": "random",
435  "init_states": [init_states],
436  "random_seed": random_seed,
437  }
438  if backgrounds:
439  effect["backgrounds"] = backgrounds
440  if fadeoff:
441  effect["fadeoff"] = fadeoff
442  if hue_range:
443  effect["hue_range"] = hue_range
444  if saturation_range:
445  effect["saturation_range"] = saturation_range
446  if brightness_range:
447  effect["brightness_range"] = brightness_range
448  effect["brightness"] = min(
449  brightness_range[1], max(brightness, brightness_range[0])
450  )
451  if transition_range:
452  effect["transition_range"] = transition_range
453  effect["transition"] = 0
454  await self._effect_module_effect_module.set_custom_effect(effect)
455 
457  self,
458  brightness: int,
459  duration: int,
460  transition: int,
461  segments: list[int],
462  sequence: Sequence[tuple[int, int, int]],
463  repeat_times: int,
464  spread: int,
465  direction: int,
466  ) -> None:
467  """Set a sequence effect."""
468  effect: dict[str, Any] = {
469  **_async_build_base_effect(brightness, duration, transition, segments),
470  "type": "sequence",
471  "sequence": sequence,
472  "repeat_times": repeat_times,
473  "spread": spread,
474  "direction": direction,
475  }
476  await self._effect_module_effect_module.set_custom_effect(effect)
set[ColorMode] filter_supported_color_modes(Iterable[ColorMode] color_modes)
Definition: __init__.py:122