Home Assistant Unofficial Reference 2024.12.1
coordinator.py
Go to the documentation of this file.
1 """Coordinator for lifx."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import Callable
7 from datetime import timedelta
8 from enum import IntEnum
9 from functools import partial
10 from math import floor, log10
11 from typing import Any, cast
12 
13 from aiolifx.aiolifx import (
14  Light,
15  Message,
16  MultiZoneDirection,
17  MultiZoneEffectType,
18  TileEffectSkyType,
19  TileEffectType,
20 )
21 from aiolifx.connection import LIFXConnection
22 from aiolifx_themes.themes import ThemeLibrary, ThemePainter
23 from awesomeversion import AwesomeVersion
24 from propcache import cached_property
25 
26 from homeassistant.const import (
27  SIGNAL_STRENGTH_DECIBELS,
28  SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
29  Platform,
30 )
31 from homeassistant.core import HomeAssistant, callback
32 from homeassistant.exceptions import HomeAssistantError
33 from homeassistant.helpers import entity_registry as er
34 from homeassistant.helpers.debounce import Debouncer
35 from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
36 
37 from .const import (
38  _LOGGER,
39  ATTR_REMAINING,
40  DEFAULT_ATTEMPTS,
41  DOMAIN,
42  IDENTIFY_WAVEFORM,
43  MAX_ATTEMPTS_PER_UPDATE_REQUEST_MESSAGE,
44  MAX_UPDATE_TIME,
45  MESSAGE_RETRIES,
46  MESSAGE_TIMEOUT,
47  OVERALL_TIMEOUT,
48  TARGET_ANY,
49  UNAVAILABLE_GRACE,
50 )
51 from .util import (
52  async_execute_lifx,
53  async_multi_execute_lifx_with_retries,
54  get_real_mac_addr,
55  infrared_brightness_option_to_value,
56  infrared_brightness_value_to_option,
57  lifx_features,
58 )
59 
60 LIGHT_UPDATE_INTERVAL = 10
61 REQUEST_REFRESH_DELAY = 0.35
62 LIFX_IDENTIFY_DELAY = 3.0
63 ZONES_PER_COLOR_UPDATE_REQUEST = 8
64 
65 RSSI_DBM_FW = AwesomeVersion("2.77")
66 
67 
68 class FirmwareEffect(IntEnum):
69  """Enumeration of LIFX firmware effects."""
70 
71  OFF = 0
72  MOVE = 1
73  MORPH = 2
74  FLAME = 3
75  SKY = 5
76 
77 
78 class SkyType(IntEnum):
79  """Enumeration of sky types for SKY firmware effect."""
80 
81  SUNRISE = 0
82  SUNSET = 1
83  CLOUDS = 2
84 
85 
86 class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): # noqa: PLR0904
87  """DataUpdateCoordinator to gather data for a specific lifx device."""
88 
89  def __init__(
90  self,
91  hass: HomeAssistant,
92  connection: LIFXConnection,
93  title: str,
94  ) -> None:
95  """Initialize DataUpdateCoordinator."""
96  assert connection.device is not None
97  self.connectionconnection = connection
98  self.device: Light = connection.device
99  self.locklock = asyncio.Lock()
100  self.active_effectactive_effect = FirmwareEffect.OFF
101  self._update_rssi_update_rssi: bool = False
102  self._rssi_rssi: int = 0
103  self.last_used_themelast_used_theme: str = ""
104 
105  super().__init__(
106  hass,
107  _LOGGER,
108  name=f"{title} ({self.device.ip_addr})",
109  update_interval=timedelta(seconds=LIGHT_UPDATE_INTERVAL),
110  # We don't want an immediate refresh since the device
111  # takes a moment to reflect the state change
112  request_refresh_debouncer=Debouncer(
113  hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False
114  ),
115  )
116 
117  @callback
118  def async_setup(self) -> None:
119  """Change timeouts."""
120  self.device.timeout = MESSAGE_TIMEOUT
121  self.device.retry_count = MESSAGE_RETRIES
122  self.device.unregister_timeout = UNAVAILABLE_GRACE
123 
124  @property
125  def rssi(self) -> int:
126  """Return stored RSSI value."""
127  return self._rssi_rssi
128 
129  @property
130  def rssi_uom(self) -> str:
131  """Return the RSSI unit of measurement."""
132  if AwesomeVersion(self.device.host_firmware_version) <= RSSI_DBM_FW:
133  return SIGNAL_STRENGTH_DECIBELS
134 
135  return SIGNAL_STRENGTH_DECIBELS_MILLIWATT
136 
137  @property
138  def current_infrared_brightness(self) -> str | None:
139  """Return the current infrared brightness as a string."""
140  return infrared_brightness_value_to_option(self.device.infrared_brightness)
141 
142  @cached_property
143  def serial_number(self) -> str:
144  """Return the internal mac address."""
145  return cast(
146  str, self.device.mac_addr
147  ) # device.mac_addr is not the mac_address, its the serial number
148 
149  @cached_property
150  def mac_address(self) -> str:
151  """Return the physical mac address."""
152  return get_real_mac_addr(
153  # device.mac_addr is not the mac_address, its the serial number
154  self.device.mac_addr,
155  self.device.host_firmware_version,
156  )
157 
158  @property
159  def label(self) -> str:
160  """Return the label of the bulb."""
161  return cast(str, self.device.label)
162 
163  @cached_property
164  def is_extended_multizone(self) -> bool:
165  """Return true if this is a multizone device."""
166  return bool(lifx_features(self.device)["extended_multizone"])
167 
168  @cached_property
169  def is_legacy_multizone(self) -> bool:
170  """Return true if this is a legacy multizone device."""
171  return bool(
172  lifx_features(self.device)["multizone"] and not self.is_extended_multizoneis_extended_multizone
173  )
174 
175  @cached_property
176  def is_matrix(self) -> bool:
177  """Return true if this is a matrix device."""
178  return bool(lifx_features(self.device)["matrix"])
179 
180  async def diagnostics(self) -> dict[str, Any]:
181  """Return diagnostic information about the device."""
182  features = lifx_features(self.device)
183  device_data = {
184  "firmware": self.device.host_firmware_version,
185  "vendor": self.device.vendor,
186  "product_id": self.device.product,
187  "features": features,
188  "hue": self.device.color[0],
189  "saturation": self.device.color[1],
190  "brightness": self.device.color[2],
191  "kelvin": self.device.color[3],
192  "power": self.device.power_level,
193  }
194 
195  if features["multizone"] is True:
196  zones = {"count": self.device.zones_count, "state": {}}
197  for index, zone_color in enumerate(self.device.color_zones):
198  zones["state"][index] = {
199  "hue": zone_color[0],
200  "saturation": zone_color[1],
201  "brightness": zone_color[2],
202  "kelvin": zone_color[3],
203  }
204  device_data["zones"] = zones
205 
206  if features["hev"] is True:
207  device_data["hev"] = {
208  "hev_cycle": self.device.hev_cycle,
209  "hev_config": self.device.hev_cycle_configuration,
210  "last_result": self.device.last_hev_cycle_result,
211  }
212 
213  if features["infrared"] is True:
214  device_data["infrared"] = {"brightness": self.device.infrared_brightness}
215 
216  return device_data
217 
218  def async_get_entity_id(self, platform: Platform, key: str) -> str | None:
219  """Return the entity_id from the platform and key provided."""
220  ent_reg = er.async_get(self.hasshass)
221  return ent_reg.async_get_entity_id(
222  platform, DOMAIN, f"{self.serial_number}_{key}"
223  )
224 
225  async def _async_populate_device_info(self) -> None:
226  """Populate device info."""
227  methods: list[Callable] = []
228  device = self.device
229  if self.device.host_firmware_version is None:
230  methods.append(device.get_hostfirmware)
231  if self.device.product is None:
232  methods.append(device.get_version)
233  if self.device.group is None:
234  methods.append(device.get_group)
235  assert methods, "Device info already populated"
237  methods, DEFAULT_ATTEMPTS, OVERALL_TIMEOUT
238  )
239 
240  def get_number_of_zones(self) -> int:
241  """Return the number of zones.
242 
243  If the number of zones is not yet populated, return 1 since
244  the device will have a least one zone.
245  """
246  return len(self.device.color_zones) if self.device.color_zones else 1
247 
248  @callback
249  def _async_build_color_zones_update_requests(self) -> list[Callable]:
250  """Build a color zones update request."""
251  device = self.device
252  calls: list[Callable] = []
253  for zone in range(
254  0, self.get_number_of_zonesget_number_of_zones(), ZONES_PER_COLOR_UPDATE_REQUEST
255  ):
256 
257  def _wrap_get_color_zones(
258  callb: Callable[[Message, dict[str, Any] | None], None],
259  get_color_zones_args: dict[str, Any],
260  ) -> None:
261  """Capture the callback and make sure resp_set_multizonemultizone is called before."""
262 
263  def _wrapped_callback(
264  bulb: Light,
265  response: Message,
266  **kwargs: Any,
267  ) -> None:
268  # We need to call resp_set_multizonemultizone to populate
269  # the color_zones attribute before calling the callback
270  device.resp_set_multizonemultizone(response)
271  # Now call the original callback
272  callb(bulb, response, **kwargs)
273 
274  device.get_color_zones(**get_color_zones_args, callb=_wrapped_callback)
275 
276  calls.append(
277  partial(
278  _wrap_get_color_zones,
279  get_color_zones_args={
280  "start_index": zone,
281  "end_index": zone + ZONES_PER_COLOR_UPDATE_REQUEST - 1,
282  },
283  )
284  )
285 
286  return calls
287 
288  async def _async_update_data(self) -> None:
289  """Fetch all device data from the api."""
290  device = self.device
291  if (
292  device.host_firmware_version is None
293  or device.product is None
294  or device.group is None
295  ):
296  await self._async_populate_device_info_async_populate_device_info()
297 
298  num_zones = self.get_number_of_zonesget_number_of_zones()
299  features = lifx_features(self.device)
300  update_rssi = self._update_rssi_update_rssi
301  methods: list[Callable] = [self.device.get_color]
302  if update_rssi:
303  methods.append(self.device.get_wifiinfo)
304  if self.is_matrixis_matrix:
305  methods.extend(
306  [
307  self.device.get_tile_effect,
308  self.device.get_device_chain,
309  self.device.get64,
310  ]
311  )
312  if self.is_extended_multizoneis_extended_multizone:
313  methods.append(self.device.get_extended_color_zones)
314  elif self.is_legacy_multizoneis_legacy_multizone:
315  methods.extend(self._async_build_color_zones_update_requests_async_build_color_zones_update_requests())
316  if self.is_extended_multizoneis_extended_multizone or self.is_legacy_multizoneis_legacy_multizone:
317  methods.append(self.device.get_multizone_effect)
318  if features["hev"]:
319  methods.append(self.device.get_hev_cycle)
320  if features["infrared"]:
321  methods.append(self.device.get_infrared)
322 
323  responses = await async_multi_execute_lifx_with_retries(
324  methods, MAX_ATTEMPTS_PER_UPDATE_REQUEST_MESSAGE, MAX_UPDATE_TIME
325  )
326  # device.mac_addr is not the mac_address, its the serial number
327  if device.mac_addr == TARGET_ANY:
328  device.mac_addr = responses[0].target_addr
329 
330  if update_rssi:
331  # We always send the rssi request second
332  self._rssi_rssi = int(floor(10 * log10(responses[1].signal) + 0.5))
333 
334  if self.is_matrixis_matrix or self.is_extended_multizoneis_extended_multizone or self.is_legacy_multizoneis_legacy_multizone:
335  self.active_effectactive_effect = FirmwareEffect[self.device.effect.get("effect", "OFF")]
336  if self.is_legacy_multizoneis_legacy_multizone and num_zones != self.get_number_of_zonesget_number_of_zones():
337  # The number of zones has changed so we need
338  # to update the zones again. This happens rarely.
339  await self.async_get_color_zonesasync_get_color_zones()
340 
341  async def async_get_color_zones(self) -> None:
342  """Get updated color information for each zone."""
344  self._async_build_color_zones_update_requests_async_build_color_zones_update_requests(),
345  DEFAULT_ATTEMPTS,
346  OVERALL_TIMEOUT,
347  )
348 
349  async def async_get_extended_color_zones(self) -> None:
350  """Get updated color information for all zones."""
351  try:
352  await async_execute_lifx(self.device.get_extended_color_zones)
353  except TimeoutError as ex:
354  raise HomeAssistantError(
355  f"Timeout getting color zones from {self.name}"
356  ) from ex
357 
359  self, value: dict[str, Any], rapid: bool = False
360  ) -> None:
361  """Send a set_waveform_optional message to the device."""
362  await async_execute_lifx(
363  partial(self.device.set_waveform_optional, value=value, rapid=rapid)
364  )
365 
366  async def async_get_color(self) -> None:
367  """Send a get color message to the device."""
368  await async_execute_lifx(self.device.get_color)
369 
370  async def async_set_power(self, state: bool, duration: int | None) -> None:
371  """Send a set power message to the device."""
372  await async_execute_lifx(
373  partial(self.device.set_power, state, duration=duration)
374  )
375 
376  async def async_set_color(
377  self, hsbk: list[float | int | None], duration: int | None
378  ) -> None:
379  """Send a set color message to the device."""
380  await async_execute_lifx(
381  partial(self.device.set_color, hsbk, duration=duration)
382  )
383 
385  self,
386  start_index: int,
387  end_index: int,
388  hsbk: list[float | int | None],
389  duration: int | None,
390  apply: int,
391  ) -> None:
392  """Send a set color zones message to the device."""
393  await async_execute_lifx(
394  partial(
395  self.device.set_color_zones,
396  start_index=start_index,
397  end_index=end_index,
398  color=hsbk,
399  duration=duration,
400  apply=apply,
401  )
402  )
403 
405  self,
406  colors: list[tuple[int | float, int | float, int | float, int | float]],
407  colors_count: int | None = None,
408  duration: int = 0,
409  apply: int = 1,
410  ) -> None:
411  """Send a single set extended color zones message to the device."""
412 
413  if colors_count is None:
414  colors_count = len(colors)
415 
416  # pad the color list with blanks if necessary
417  if len(colors) < 82:
418  colors.extend([(0, 0, 0, 0) for _ in range(82 - len(colors))])
419 
420  await async_execute_lifx(
421  partial(
422  self.device.set_extended_color_zones,
423  colors=colors,
424  colors_count=colors_count,
425  duration=duration,
426  apply=apply,
427  )
428  )
429 
431  self,
432  effect: str,
433  speed: float = 3.0,
434  direction: str = "RIGHT",
435  theme_name: str | None = None,
436  power_on: bool = True,
437  ) -> None:
438  """Control the firmware-based Move effect on a multizone device."""
439  if self.is_extended_multizoneis_extended_multizone or self.is_legacy_multizoneis_legacy_multizone:
440  if power_on and self.device.power_level == 0:
441  await self.async_set_powerasync_set_power(True, 0)
442 
443  if theme_name is not None:
444  theme = ThemeLibrary().get_theme(theme_name)
445  await ThemePainter(self.hasshass.loop).paint(
446  theme, [self.device], round(speed)
447  )
448 
449  await async_execute_lifx(
450  partial(
451  self.device.set_multizone_effect,
452  effect=MultiZoneEffectType[effect.upper()].value,
453  speed=speed,
454  direction=MultiZoneDirection[direction.upper()].value,
455  )
456  )
457  self.active_effectactive_effect = FirmwareEffect[effect.upper()]
458 
459  async def async_set_matrix_effect( # noqa: PLR0917
460  self,
461  effect: str,
462  palette: list[tuple[int, int, int, int]] | None = None,
463  speed: float | None = None,
464  power_on: bool = True,
465  sky_type: str | None = None,
466  cloud_saturation_min: int | None = None,
467  cloud_saturation_max: int | None = None,
468  ) -> None:
469  """Control the firmware-based effects on a matrix device."""
470  if self.is_matrixis_matrix:
471  if power_on and self.device.power_level == 0:
472  await self.async_set_powerasync_set_power(True, 0)
473 
474  if palette is None:
475  palette = []
476 
477  if sky_type is not None:
478  sky_type = TileEffectSkyType[sky_type.upper()].value
479 
480  await async_execute_lifx(
481  partial(
482  self.device.set_tile_effect,
483  effect=TileEffectType[effect.upper()].value,
484  speed=speed,
485  palette=palette,
486  sky_type=sky_type,
487  cloud_saturation_min=cloud_saturation_min,
488  cloud_saturation_max=cloud_saturation_max,
489  )
490  )
491  self.active_effectactive_effect = FirmwareEffect[effect.upper()]
492 
493  def async_get_active_effect(self) -> int:
494  """Return the enum value of the currently active firmware effect."""
495  return self.active_effectactive_effect.value
496 
497  async def async_set_infrared_brightness(self, option: str) -> None:
498  """Set infrared brightness."""
499  infrared_brightness = infrared_brightness_option_to_value(option)
500  await async_execute_lifx(partial(self.device.set_infrared, infrared_brightness))
501 
502  async def async_identify_bulb(self) -> None:
503  """Identify the device by flashing it three times."""
504  bulb: Light = self.device
505  if bulb.power_level:
506  # just flash the bulb for three seconds
507  await self.async_set_waveform_optionalasync_set_waveform_optional(value=IDENTIFY_WAVEFORM)
508  return
509  # Turn the bulb on first, flash for 3 seconds, then turn off
510  await self.async_set_powerasync_set_power(state=True, duration=1)
511  await self.async_set_waveform_optionalasync_set_waveform_optional(value=IDENTIFY_WAVEFORM)
512  await asyncio.sleep(LIFX_IDENTIFY_DELAY)
513  await self.async_set_powerasync_set_power(state=False, duration=1)
514 
515  def async_enable_rssi_updates(self) -> Callable[[], None]:
516  """Enable RSSI signal strength updates."""
517 
518  @callback
519  def _async_disable_rssi_updates() -> None:
520  """Disable RSSI updates when sensor removed."""
521  self._update_rssi_update_rssi = False
522 
523  self._update_rssi_update_rssi = True
524  return _async_disable_rssi_updates
525 
526  def async_get_hev_cycle_state(self) -> bool | None:
527  """Return the current HEV cycle state."""
528  if self.device.hev_cycle is None:
529  return None
530  return bool(self.device.hev_cycle.get(ATTR_REMAINING, 0) > 0)
531 
532  async def async_set_hev_cycle_state(self, enable: bool, duration: int = 0) -> None:
533  """Start or stop an HEV cycle on a LIFX Clean bulb."""
534  if lifx_features(self.device)["hev"]:
535  await async_execute_lifx(
536  partial(self.device.set_hev_cycle, enable=enable, duration=duration)
537  )
538 
539  async def async_apply_theme(self, theme_name: str) -> None:
540  """Apply the selected theme to the device."""
541  self.last_used_themelast_used_theme = theme_name
542  theme = ThemeLibrary().get_theme(theme_name)
543  await ThemePainter(self.hasshass.loop).paint(theme, [self.device])
None async_set_multizone_effect(self, str effect, float speed=3.0, str direction="RIGHT", str|None theme_name=None, bool power_on=True)
Definition: coordinator.py:437
str|None async_get_entity_id(self, Platform platform, str key)
Definition: coordinator.py:218
None async_set_color_zones(self, int start_index, int end_index, list[float|int|None] hsbk, int|None duration, int apply)
Definition: coordinator.py:391
None async_set_extended_color_zones(self, list[tuple[int|float, int|float, int|float, int|float]] colors, int|None colors_count=None, int duration=0, int apply=1)
Definition: coordinator.py:410
None __init__(self, HomeAssistant hass, LIFXConnection connection, str title)
Definition: coordinator.py:94
None async_set_color(self, list[float|int|None] hsbk, int|None duration)
Definition: coordinator.py:378
None async_set_hev_cycle_state(self, bool enable, int duration=0)
Definition: coordinator.py:532
None async_set_power(self, bool state, int|None duration)
Definition: coordinator.py:370
None async_set_waveform_optional(self, dict[str, Any] value, bool rapid=False)
Definition: coordinator.py:360
None async_set_matrix_effect(self, str effect, list[tuple[int, int, int, int]]|None palette=None, float|None speed=None, bool power_on=True, str|None sky_type=None, int|None cloud_saturation_min=None, int|None cloud_saturation_max=None)
Definition: coordinator.py:468
list[Message] async_multi_execute_lifx_with_retries(list[Callable] methods, int attempts, int overall_timeout)
Definition: util.py:196
Message async_execute_lifx(Callable method)
Definition: util.py:185
str get_real_mac_addr(str mac_addr, str firmware)
Definition: util.py:166
str|None infrared_brightness_value_to_option(int value)
Definition: util.py:57
int|None infrared_brightness_option_to_value(str option)
Definition: util.py:62
dict[str, Any] lifx_features(Light bulb)
Definition: util.py:78