"""BleBox light entities implementation.""" import logging import math from typing import Any import blebox_uniapi.light from blebox_uniapi.light import BleboxColorMode from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, ColorMode, LightEntity, LightEntityFeature, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BleBoxConfigEntry from .const import DOMAIN, LIGHT_MAX_KELVINS, LIGHT_MIN_KELVINS from .coordinator import BleBoxCoordinator from .entity import BleBoxEntity from .util import blebox_command _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, config_entry: BleBoxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a BleBox entry.""" coordinator = config_entry.runtime_data entities = [ BleBoxLightEntity(coordinator, feature) for feature in coordinator.box.features.get("lights", []) ] async_add_entities(entities) COLOR_MODE_MAP = { BleboxColorMode.RGBW: ColorMode.RGBW, BleboxColorMode.RGB: ColorMode.RGB, BleboxColorMode.MONO: ColorMode.BRIGHTNESS, BleboxColorMode.RGBorW: ColorMode.RGBW, # white hex is prioritised over RGB channel BleboxColorMode.CT: ColorMode.COLOR_TEMP, BleboxColorMode.CTx2: ColorMode.COLOR_TEMP, # two instances BleboxColorMode.RGBWW: ColorMode.RGBWW, } class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity): """Representation of BleBox lights.""" _attr_min_color_temp_kelvin = LIGHT_MIN_KELVINS _attr_max_color_temp_kelvin = LIGHT_MAX_KELVINS def __init__( self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.light.Light ) -> None: """Initialize a BleBox light.""" super().__init__(coordinator, feature) if feature.effect_list: self._attr_supported_features = LightEntityFeature.EFFECT if feature.index is not None: self._attr_translation_key = "channel" self._attr_translation_placeholders = {"index": str(feature.index + 1)} else: self._attr_name = None @property def is_on(self) -> bool: """Return if light is on.""" return self._feature.is_on @property def brightness(self) -> int | None: """Return the name.""" return self._feature.brightness def _color_temp_to_native_scale(self, x: int) -> int: """Convert color temperature from Kelvin to native BleBox scale (0-255). BleBox native scale is inverted: 0=warm (2700K), 255=cold (6500K). """ scaled = ( (self._attr_max_color_temp_kelvin - x) / (self._attr_max_color_temp_kelvin - self._attr_min_color_temp_kelvin) ) * 255 # note: within the operating temperature range here the Kelvin # scale has less "integer steps" than the native scale used # by blebox devices. Thus we need to use rounding method that is opposite # to the one used in _color_temp_from_native_scale in order to avoid # temperature value jumping by one step when the temperature value is read # back from the device bounded = max(min(math.floor(scaled), 255), 0) return int(bounded) def _color_temp_from_native_scale(self, x: int) -> int: """Convert color temperature from native BleBox scale (0-255) to Kelvin. BleBox native scale is inverted: 0=warm (2700K), 255=cold (6500K). """ scaled = self._attr_max_color_temp_kelvin - (x / 255) * ( self._attr_max_color_temp_kelvin - self._attr_min_color_temp_kelvin ) # note: see _color_temp_to_native_scale for explanation of rounding method bounded = max( min(math.ceil(scaled), self._attr_max_color_temp_kelvin), self._attr_min_color_temp_kelvin, ) return int(bounded) @property def color_temp_kelvin(self) -> int: """Return the color temperature value in Kelvin.""" return self._color_temp_from_native_scale(self._feature.color_temp) @property def color_mode(self) -> ColorMode: """Return the color mode. Set values to _attr_ibutes if needed. """ return COLOR_MODE_MAP.get(self._feature.color_mode, ColorMode.ONOFF) @property def supported_color_modes(self) -> set[ColorMode]: """Return supported color modes.""" return {self.color_mode} @property def effect_list(self) -> list[str]: """Return the list of supported effects.""" return self._feature.effect_list @property def effect(self) -> str | None: """Return the current effect.""" return self._feature.effect @property def rgb_color(self) -> tuple[int, int, int] | None: """Return value for rgb.""" if (rgb_hex := self._feature.rgb_hex) is None: return None return tuple( blebox_uniapi.light.Light.normalise_elements_of_rgb( blebox_uniapi.light.Light.rgb_hex_to_rgb_list(rgb_hex)[0:3] ) ) @property def rgbw_color(self) -> tuple[int, int, int, int] | None: """Return the hue and saturation.""" if (rgbw_hex := self._feature.rgbw_hex) is None: return None return tuple(blebox_uniapi.light.Light.rgb_hex_to_rgb_list(rgbw_hex)[0:4]) @property def rgbww_color(self) -> tuple[int, int, int, int, int] | None: """Return value for rgbww.""" if (rgbww_hex := self._feature.rgbww_hex) is None: return None return tuple(blebox_uniapi.light.Light.rgb_hex_to_rgb_list(rgbww_hex)) @blebox_command async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" rgbw = kwargs.get(ATTR_RGBW_COLOR) brightness = kwargs.get(ATTR_BRIGHTNESS) effect = kwargs.get(ATTR_EFFECT) color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN) rgbww = kwargs.get(ATTR_RGBWW_COLOR) rgb = kwargs.get(ATTR_RGB_COLOR) feature = self._feature value = feature.sensible_on_value if rgbw is not None: value = list(rgbw) if color_temp_kelvin is not None: value = feature.return_color_temp_with_brightness( self._color_temp_to_native_scale(color_temp_kelvin), self.brightness, ) if rgbww is not None: value = list(rgbww) if rgb is not None: if self.color_mode == ColorMode.RGB and brightness is None: brightness = self.brightness value = list(rgb) if brightness is not None: if self.color_mode == ColorMode.COLOR_TEMP: value = feature.return_color_temp_with_brightness( self._color_temp_to_native_scale(self.color_temp_kelvin), brightness, ) else: value = feature.apply_brightness(value, brightness) if isinstance(value, (list, tuple)) and not any(value): await self._feature.async_off() return try: await self._feature.async_on(value) except ValueError as exc: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="bad_value", translation_placeholders={"error": str(exc)}, ) from exc if effect is not None: try: effect_value = self.effect_list.index(effect) await self._feature.async_api_command("effect", effect_value) except ValueError as exc: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="effect_not_found", translation_placeholders={"error": str(exc)}, ) from exc @blebox_command async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" await self._feature.async_off()