"""Coordinator for lifx.""" from __future__ import annotations import asyncio from collections.abc import Callable from datetime import timedelta from enum import IntEnum from functools import cached_property, partial from math import floor, log10 from typing import Any, cast from aiolifx.aiolifx import ( Light, Message, MultiZoneDirection, MultiZoneEffectType, TileEffectSkyType, TileEffectType, ) from aiolifx.connection import LIFXConnection from aiolifx_themes.themes import ThemeLibrary, ThemePainter from awesomeversion import AwesomeVersion from homeassistant.const import ( SIGNAL_STRENGTH_DECIBELS, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, Platform, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( _LOGGER, ATTR_REMAINING, DEFAULT_ATTEMPTS, DOMAIN, IDENTIFY_WAVEFORM, MAX_ATTEMPTS_PER_UPDATE_REQUEST_MESSAGE, MAX_UPDATE_TIME, MESSAGE_RETRIES, MESSAGE_TIMEOUT, OVERALL_TIMEOUT, TARGET_ANY, UNAVAILABLE_GRACE, ) from .util import ( async_execute_lifx, async_multi_execute_lifx_with_retries, get_real_mac_addr, infrared_brightness_option_to_value, infrared_brightness_value_to_option, lifx_features, ) LIGHT_UPDATE_INTERVAL = 10 REQUEST_REFRESH_DELAY = 0.35 LIFX_IDENTIFY_DELAY = 3.0 ZONES_PER_COLOR_UPDATE_REQUEST = 8 RSSI_DBM_FW = AwesomeVersion("2.77") class FirmwareEffect(IntEnum): """Enumeration of LIFX firmware effects.""" OFF = 0 MOVE = 1 MORPH = 2 FLAME = 3 SKY = 5 class SkyType(IntEnum): """Enumeration of sky types for SKY firmware effect.""" SUNRISE = 0 SUNSET = 1 CLOUDS = 2 class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): # noqa: PLR0904 """DataUpdateCoordinator to gather data for a specific lifx device.""" def __init__( self, hass: HomeAssistant, connection: LIFXConnection, title: str, ) -> None: """Initialize DataUpdateCoordinator.""" assert connection.device is not None self.connection = connection self.device: Light = connection.device self.lock = asyncio.Lock() self.active_effect = FirmwareEffect.OFF self._update_rssi: bool = False self._rssi: int = 0 self.last_used_theme: str = "" super().__init__( hass, _LOGGER, name=f"{title} ({self.device.ip_addr})", update_interval=timedelta(seconds=LIGHT_UPDATE_INTERVAL), # We don't want an immediate refresh since the device # takes a moment to reflect the state change request_refresh_debouncer=Debouncer( hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False ), ) @callback def async_setup(self) -> None: """Change timeouts.""" self.device.timeout = MESSAGE_TIMEOUT self.device.retry_count = MESSAGE_RETRIES self.device.unregister_timeout = UNAVAILABLE_GRACE @property def rssi(self) -> int: """Return stored RSSI value.""" return self._rssi @property def rssi_uom(self) -> str: """Return the RSSI unit of measurement.""" if AwesomeVersion(self.device.host_firmware_version) <= RSSI_DBM_FW: return SIGNAL_STRENGTH_DECIBELS return SIGNAL_STRENGTH_DECIBELS_MILLIWATT @property def current_infrared_brightness(self) -> str | None: """Return the current infrared brightness as a string.""" return infrared_brightness_value_to_option(self.device.infrared_brightness) @cached_property def serial_number(self) -> str: """Return the internal mac address.""" return cast( str, self.device.mac_addr ) # device.mac_addr is not the mac_address, its the serial number @cached_property def mac_address(self) -> str: """Return the physical mac address.""" return get_real_mac_addr( # device.mac_addr is not the mac_address, its the serial number self.device.mac_addr, self.device.host_firmware_version, ) @property def label(self) -> str: """Return the label of the bulb.""" return cast(str, self.device.label) @cached_property def is_extended_multizone(self) -> bool: """Return true if this is a multizone device.""" return bool(lifx_features(self.device)["extended_multizone"]) @cached_property def is_legacy_multizone(self) -> bool: """Return true if this is a legacy multizone device.""" return bool( lifx_features(self.device)["multizone"] and not self.is_extended_multizone ) @cached_property def is_matrix(self) -> bool: """Return true if this is a matrix device.""" return bool(lifx_features(self.device)["matrix"]) async def diagnostics(self) -> dict[str, Any]: """Return diagnostic information about the device.""" features = lifx_features(self.device) device_data = { "firmware": self.device.host_firmware_version, "vendor": self.device.vendor, "product_id": self.device.product, "features": features, "hue": self.device.color[0], "saturation": self.device.color[1], "brightness": self.device.color[2], "kelvin": self.device.color[3], "power": self.device.power_level, } if features["multizone"] is True: zones = {"count": self.device.zones_count, "state": {}} for index, zone_color in enumerate(self.device.color_zones): zones["state"][index] = { "hue": zone_color[0], "saturation": zone_color[1], "brightness": zone_color[2], "kelvin": zone_color[3], } device_data["zones"] = zones if features["hev"] is True: device_data["hev"] = { "hev_cycle": self.device.hev_cycle, "hev_config": self.device.hev_cycle_configuration, "last_result": self.device.last_hev_cycle_result, } if features["infrared"] is True: device_data["infrared"] = {"brightness": self.device.infrared_brightness} return device_data def async_get_entity_id(self, platform: Platform, key: str) -> str | None: """Return the entity_id from the platform and key provided.""" ent_reg = er.async_get(self.hass) return ent_reg.async_get_entity_id( platform, DOMAIN, f"{self.serial_number}_{key}" ) async def _async_populate_device_info(self) -> None: """Populate device info.""" methods: list[Callable] = [] device = self.device if self.device.host_firmware_version is None: methods.append(device.get_hostfirmware) if self.device.product is None: methods.append(device.get_version) if self.device.group is None: methods.append(device.get_group) assert methods, "Device info already populated" await async_multi_execute_lifx_with_retries( methods, DEFAULT_ATTEMPTS, OVERALL_TIMEOUT ) def get_number_of_zones(self) -> int: """Return the number of zones. If the number of zones is not yet populated, return 1 since the device will have a least one zone. """ return len(self.device.color_zones) if self.device.color_zones else 1 @callback def _async_build_color_zones_update_requests(self) -> list[Callable]: """Build a color zones update request.""" device = self.device calls: list[Callable] = [] for zone in range( 0, self.get_number_of_zones(), ZONES_PER_COLOR_UPDATE_REQUEST ): def _wrap_get_color_zones( callb: Callable[[Message, dict[str, Any] | None], None], get_color_zones_args: dict[str, Any], ) -> None: """Capture the callback and make sure resp_set_multizonemultizone is called before.""" def _wrapped_callback( bulb: Light, response: Message, **kwargs: Any, ) -> None: # We need to call resp_set_multizonemultizone to populate # the color_zones attribute before calling the callback device.resp_set_multizonemultizone(response) # Now call the original callback callb(bulb, response, **kwargs) device.get_color_zones(**get_color_zones_args, callb=_wrapped_callback) calls.append( partial( _wrap_get_color_zones, get_color_zones_args={ "start_index": zone, "end_index": zone + ZONES_PER_COLOR_UPDATE_REQUEST - 1, }, ) ) return calls async def _async_update_data(self) -> None: """Fetch all device data from the api.""" device = self.device if ( device.host_firmware_version is None or device.product is None or device.group is None ): await self._async_populate_device_info() num_zones = self.get_number_of_zones() features = lifx_features(self.device) update_rssi = self._update_rssi methods: list[Callable] = [self.device.get_color] if update_rssi: methods.append(self.device.get_wifiinfo) if self.is_matrix: methods.extend( [ self.device.get_tile_effect, self.device.get_device_chain, self.device.get64, ] ) if self.is_extended_multizone: methods.append(self.device.get_extended_color_zones) elif self.is_legacy_multizone: methods.extend(self._async_build_color_zones_update_requests()) if self.is_extended_multizone or self.is_legacy_multizone: methods.append(self.device.get_multizone_effect) if features["hev"]: methods.append(self.device.get_hev_cycle) if features["infrared"]: methods.append(self.device.get_infrared) responses = await async_multi_execute_lifx_with_retries( methods, MAX_ATTEMPTS_PER_UPDATE_REQUEST_MESSAGE, MAX_UPDATE_TIME ) # device.mac_addr is not the mac_address, its the serial number if device.mac_addr == TARGET_ANY: device.mac_addr = responses[0].target_addr if update_rssi: # We always send the rssi request second self._rssi = int(floor(10 * log10(responses[1].signal) + 0.5)) if self.is_matrix or self.is_extended_multizone or self.is_legacy_multizone: self.active_effect = FirmwareEffect[self.device.effect.get("effect", "OFF")] if self.is_legacy_multizone and num_zones != self.get_number_of_zones(): # The number of zones has changed so we need # to update the zones again. This happens rarely. await self.async_get_color_zones() async def async_get_color_zones(self) -> None: """Get updated color information for each zone.""" await async_multi_execute_lifx_with_retries( self._async_build_color_zones_update_requests(), DEFAULT_ATTEMPTS, OVERALL_TIMEOUT, ) async def async_get_extended_color_zones(self) -> None: """Get updated color information for all zones.""" try: await async_execute_lifx(self.device.get_extended_color_zones) except TimeoutError as ex: raise HomeAssistantError( f"Timeout getting color zones from {self.name}" ) from ex async def async_set_waveform_optional( self, value: dict[str, Any], rapid: bool = False ) -> None: """Send a set_waveform_optional message to the device.""" await async_execute_lifx( partial(self.device.set_waveform_optional, value=value, rapid=rapid) ) async def async_get_color(self) -> None: """Send a get color message to the device.""" await async_execute_lifx(self.device.get_color) async def async_set_power(self, state: bool, duration: int | None) -> None: """Send a set power message to the device.""" await async_execute_lifx( partial(self.device.set_power, state, duration=duration) ) async def async_set_color( self, hsbk: list[float | int | None], duration: int | None ) -> None: """Send a set color message to the device.""" await async_execute_lifx( partial(self.device.set_color, hsbk, duration=duration) ) async def async_set_color_zones( self, start_index: int, end_index: int, hsbk: list[float | int | None], duration: int | None, apply: int, ) -> None: """Send a set color zones message to the device.""" await async_execute_lifx( partial( self.device.set_color_zones, start_index=start_index, end_index=end_index, color=hsbk, duration=duration, apply=apply, ) ) async def async_set_extended_color_zones( self, colors: list[tuple[int | float, int | float, int | float, int | float]], colors_count: int | None = None, duration: int = 0, apply: int = 1, ) -> None: """Send a single set extended color zones message to the device.""" if colors_count is None: colors_count = len(colors) # pad the color list with blanks if necessary if len(colors) < 82: colors.extend([(0, 0, 0, 0) for _ in range(82 - len(colors))]) await async_execute_lifx( partial( self.device.set_extended_color_zones, colors=colors, colors_count=colors_count, duration=duration, apply=apply, ) ) async def async_set_multizone_effect( self, effect: str, speed: float = 3.0, direction: str = "RIGHT", theme_name: str | None = None, power_on: bool = True, ) -> None: """Control the firmware-based Move effect on a multizone device.""" if self.is_extended_multizone or self.is_legacy_multizone: if power_on and self.device.power_level == 0: await self.async_set_power(True, 0) if theme_name is not None: theme = ThemeLibrary().get_theme(theme_name) await ThemePainter(self.hass.loop).paint( theme, [self.device], round(speed) ) await async_execute_lifx( partial( self.device.set_multizone_effect, effect=MultiZoneEffectType[effect.upper()].value, speed=speed, direction=MultiZoneDirection[direction.upper()].value, ) ) self.active_effect = FirmwareEffect[effect.upper()] async def async_set_matrix_effect( # noqa: PLR0917 self, effect: str, palette: list[tuple[int, int, int, int]] | None = None, speed: float | None = None, power_on: bool = True, sky_type: str | None = None, cloud_saturation_min: int | None = None, cloud_saturation_max: int | None = None, ) -> None: """Control the firmware-based effects on a matrix device.""" if self.is_matrix: if power_on and self.device.power_level == 0: await self.async_set_power(True, 0) if palette is None: palette = [] if sky_type is not None: sky_type = TileEffectSkyType[sky_type.upper()].value await async_execute_lifx( partial( self.device.set_tile_effect, effect=TileEffectType[effect.upper()].value, speed=speed, palette=palette, sky_type=sky_type, cloud_saturation_min=cloud_saturation_min, cloud_saturation_max=cloud_saturation_max, ) ) self.active_effect = FirmwareEffect[effect.upper()] def async_get_active_effect(self) -> int: """Return the enum value of the currently active firmware effect.""" return self.active_effect.value async def async_set_infrared_brightness(self, option: str) -> None: """Set infrared brightness.""" infrared_brightness = infrared_brightness_option_to_value(option) await async_execute_lifx(partial(self.device.set_infrared, infrared_brightness)) async def async_identify_bulb(self) -> None: """Identify the device by flashing it three times.""" bulb: Light = self.device if bulb.power_level: # just flash the bulb for three seconds await self.async_set_waveform_optional(value=IDENTIFY_WAVEFORM) return # Turn the bulb on first, flash for 3 seconds, then turn off await self.async_set_power(state=True, duration=1) await self.async_set_waveform_optional(value=IDENTIFY_WAVEFORM) await asyncio.sleep(LIFX_IDENTIFY_DELAY) await self.async_set_power(state=False, duration=1) def async_enable_rssi_updates(self) -> Callable[[], None]: """Enable RSSI signal strength updates.""" @callback def _async_disable_rssi_updates() -> None: """Disable RSSI updates when sensor removed.""" self._update_rssi = False self._update_rssi = True return _async_disable_rssi_updates def async_get_hev_cycle_state(self) -> bool | None: """Return the current HEV cycle state.""" if self.device.hev_cycle is None: return None return bool(self.device.hev_cycle.get(ATTR_REMAINING, 0) > 0) async def async_set_hev_cycle_state(self, enable: bool, duration: int = 0) -> None: """Start or stop an HEV cycle on a LIFX Clean bulb.""" if lifx_features(self.device)["hev"]: await async_execute_lifx( partial(self.device.set_hev_cycle, enable=enable, duration=duration) ) async def async_apply_theme(self, theme_name: str) -> None: """Apply the selected theme to the device.""" self.last_used_theme = theme_name theme = ThemeLibrary().get_theme(theme_name) await ThemePainter(self.hass.loop).paint(theme, [self.device])