"""Light for Shelly.""" from __future__ import annotations from dataclasses import dataclass from typing import Any, Final, cast from aioshelly.block_device import Block from aioshelly.const import MODEL_BULB, RPC_GENERATIONS from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_TRANSITION, DOMAIN as LIGHT_DOMAIN, ColorMode, LightEntity, LightEntityDescription, LightEntityFeature, brightness_supported, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( BLOCK_MAX_TRANSITION_TIME_MS, DUAL_MODE_LIGHT_MODELS, KELVIN_MAX_VALUE, KELVIN_MIN_VALUE_COLOR, KELVIN_MIN_VALUE_WHITE, LOGGER, MODELS_SUPPORTING_LIGHT_TRANSITION, RGBW_MODELS, RPC_MIN_TRANSITION_TIME_SEC, SHBLB_1_RGB_EFFECTS, STANDARD_RGB_EFFECTS, ) from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, RpcEntityDescription, ShellyBlockAttributeEntity, ShellyRpcAttributeEntity, async_setup_entry_attribute_entities, async_setup_entry_rpc, ) from .utils import ( async_remove_orphaned_entities, brightness_to_percentage, get_device_entry_gen, is_block_channel_type_light, is_rpc_channel_type_light, percentage_to_brightness, ) PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) class BlockLightDescription(BlockEntityDescription, LightEntityDescription): """Description for a Shelly BLOCK light entity.""" BLOCK_LIGHTS = { ("light", "output"): BlockLightDescription( key="light|output", ), ("relay", "output"): BlockLightDescription( key="relay|output", removal_condition=lambda settings, block: not is_block_channel_type_light( settings, block ), ), } async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up light entities.""" if get_device_entry_gen(config_entry) in RPC_GENERATIONS: return _async_setup_rpc_entry(hass, config_entry, async_add_entities) return _async_setup_block_entry(hass, config_entry, async_add_entities) @callback def _async_setup_block_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entities for BLOCK device.""" coordinator = config_entry.runtime_data.block assert coordinator async_setup_entry_attribute_entities( hass, config_entry, async_add_entities, BLOCK_LIGHTS, BlockShellyLight ) class BlockShellyLight(ShellyBlockAttributeEntity, LightEntity): """Entity that controls a light on block based Shelly devices.""" entity_description: BlockLightDescription _attr_supported_color_modes: set[str] def __init__( self, coordinator: ShellyBlockCoordinator, block: Block, attribute: str, description: BlockLightDescription, ) -> None: """Initialize block light.""" super().__init__(coordinator, block, attribute, description) self.control_result: dict[str, Any] | None = None self._attr_unique_id: str = f"{coordinator.mac}-{block.description}" self._attr_supported_color_modes = set() self._attr_min_color_temp_kelvin = KELVIN_MIN_VALUE_WHITE self._attr_max_color_temp_kelvin = KELVIN_MAX_VALUE if hasattr(block, "red") and hasattr(block, "green") and hasattr(block, "blue"): self._attr_min_color_temp_kelvin = KELVIN_MIN_VALUE_COLOR if coordinator.model in RGBW_MODELS: self._attr_supported_color_modes.add(ColorMode.RGBW) else: self._attr_supported_color_modes.add(ColorMode.RGB) if hasattr(block, "colorTemp"): self._attr_supported_color_modes.add(ColorMode.COLOR_TEMP) if not self._attr_supported_color_modes: if hasattr(block, "brightness") or hasattr(block, "gain"): self._attr_supported_color_modes.add(ColorMode.BRIGHTNESS) else: self._attr_supported_color_modes.add(ColorMode.ONOFF) if hasattr(block, "effect"): self._attr_supported_features |= LightEntityFeature.EFFECT if coordinator.model in MODELS_SUPPORTING_LIGHT_TRANSITION: self._attr_supported_features |= LightEntityFeature.TRANSITION @property def is_on(self) -> bool: """If light is on.""" if self.control_result: return cast(bool, self.control_result["ison"]) return bool(self.block.output) @property def mode(self) -> str: """Return the color mode of the light.""" if self.control_result and self.control_result.get("mode"): return cast(str, self.control_result["mode"]) if hasattr(self.block, "mode"): return cast(str, self.block.mode) if ( hasattr(self.block, "red") and hasattr(self.block, "green") and hasattr(self.block, "blue") ): return "color" return "white" @property def brightness(self) -> int: """Return the brightness of this light between 0..255.""" if self.mode == "color": if self.control_result: return percentage_to_brightness(self.control_result["gain"]) return percentage_to_brightness(cast(int, self.block.gain)) # white mode if self.control_result: return percentage_to_brightness(self.control_result["brightness"]) return percentage_to_brightness(cast(int, self.block.brightness)) @property def color_mode(self) -> ColorMode: """Return the color mode of the light.""" if self.mode == "color": if self.coordinator.model in RGBW_MODELS: return ColorMode.RGBW return ColorMode.RGB if hasattr(self.block, "colorTemp"): return ColorMode.COLOR_TEMP if hasattr(self.block, "brightness") or hasattr(self.block, "gain"): return ColorMode.BRIGHTNESS return ColorMode.ONOFF @property def rgb_color(self) -> tuple[int, int, int]: """Return the rgb color value [int, int, int].""" if self.control_result: red = self.control_result["red"] green = self.control_result["green"] blue = self.control_result["blue"] else: red = self.block.red green = self.block.green blue = self.block.blue return (cast(int, red), cast(int, green), cast(int, blue)) @property def rgbw_color(self) -> tuple[int, int, int, int]: """Return the rgbw color value [int, int, int, int].""" if self.control_result: white = self.control_result["white"] else: white = self.block.white return (*self.rgb_color, cast(int, white)) @property def color_temp_kelvin(self) -> int: """Return the CT color value in kelvin.""" color_temp = cast(int, self.block.colorTemp) if self.control_result: color_temp = self.control_result["temp"] return min( self.max_color_temp_kelvin, max(self.min_color_temp_kelvin, color_temp), ) @property def effect_list(self) -> list[str] | None: """Return the list of supported effects.""" if self.coordinator.model == MODEL_BULB: return list(SHBLB_1_RGB_EFFECTS.values()) return list(STANDARD_RGB_EFFECTS.values()) @property def effect(self) -> str | None: """Return the current effect.""" if self.control_result: effect_index = self.control_result["effect"] else: effect_index = self.block.effect if self.coordinator.model == MODEL_BULB: return SHBLB_1_RGB_EFFECTS[cast(int, effect_index)] return STANDARD_RGB_EFFECTS[cast(int, effect_index)] async def async_turn_on(self, **kwargs: Any) -> None: """Turn on light.""" if self.block.type == "relay": self.control_result = await self.set_state(turn="on") self.async_write_ha_state() return set_mode = None supported_color_modes = self._attr_supported_color_modes params: dict[str, Any] = {"turn": "on"} if ATTR_TRANSITION in kwargs: params["transition"] = min( int(kwargs[ATTR_TRANSITION] * 1000), BLOCK_MAX_TRANSITION_TIME_MS ) if ATTR_BRIGHTNESS in kwargs and brightness_supported(supported_color_modes): if hasattr(self.block, "gain"): params["gain"] = brightness_to_percentage(kwargs[ATTR_BRIGHTNESS]) if hasattr(self.block, "brightness"): params["brightness"] = brightness_to_percentage(kwargs[ATTR_BRIGHTNESS]) if ( ATTR_COLOR_TEMP_KELVIN in kwargs and ColorMode.COLOR_TEMP in supported_color_modes ): # Color temperature change - used only in white mode, # switch device mode to white color_temp = kwargs[ATTR_COLOR_TEMP_KELVIN] set_mode = "white" params["temp"] = int( min( self.max_color_temp_kelvin, max(self.min_color_temp_kelvin, color_temp), ) ) if ATTR_RGB_COLOR in kwargs and ColorMode.RGB in supported_color_modes: # Color channels change - used only in color mode, # switch device mode to color set_mode = "color" (params["red"], params["green"], params["blue"]) = kwargs[ATTR_RGB_COLOR] if ATTR_RGBW_COLOR in kwargs and ColorMode.RGBW in supported_color_modes: # Color channels change - used only in color mode, # switch device mode to color set_mode = "color" (params["red"], params["green"], params["blue"], params["white"]) = kwargs[ ATTR_RGBW_COLOR ] if ATTR_EFFECT in kwargs and ATTR_COLOR_TEMP_KELVIN not in kwargs: # Color effect change - used only in color mode, switch device mode to color set_mode = "color" if self.coordinator.model == MODEL_BULB: effect_dict = SHBLB_1_RGB_EFFECTS else: effect_dict = STANDARD_RGB_EFFECTS if kwargs[ATTR_EFFECT] in effect_dict.values(): params["effect"] = [ k for k, v in effect_dict.items() if v == kwargs[ATTR_EFFECT] ][0] else: LOGGER.error( "Effect '%s' not supported by device %s", kwargs[ATTR_EFFECT], self.coordinator.model, ) if ( set_mode and set_mode != self.mode and self.coordinator.model in DUAL_MODE_LIGHT_MODELS ): params["mode"] = set_mode self.control_result = await self.set_state(**params) self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn off light.""" params: dict[str, Any] = {"turn": "off"} if ATTR_TRANSITION in kwargs: params["transition"] = min( int(kwargs[ATTR_TRANSITION] * 1000), BLOCK_MAX_TRANSITION_TIME_MS ) self.control_result = await self.set_state(**params) self.async_write_ha_state() @callback def _update_callback(self) -> None: """When device updates, clear control & mode result that overrides state.""" self.control_result = None super()._update_callback() @dataclass(frozen=True, kw_only=True) class RpcLightDescription(RpcEntityDescription, LightEntityDescription): """Description for a Shelly RPC light entity.""" class RpcShellyLightBase(ShellyRpcAttributeEntity, LightEntity): """Base Entity for RPC based Shelly devices.""" entity_description: RpcLightDescription _component: str = "Light" def __init__( self, coordinator: ShellyRpcCoordinator, key: str, attribute: str, description: RpcEntityDescription, ) -> None: """Initialize light.""" super().__init__(coordinator, key, attribute, description) self._attr_unique_id = f"{coordinator.mac}-{key}" @property def is_on(self) -> bool: """If light is on.""" return bool(self.status["output"]) @property def brightness(self) -> int: """Return the brightness of this light between 0..255.""" return percentage_to_brightness(self.status["brightness"]) @property def rgb_color(self) -> tuple[int, int, int]: """Return the rgb color value [int, int, int].""" return cast(tuple, self.status["rgb"]) @property def rgbw_color(self) -> tuple[int, int, int, int]: """Return the rgbw color value [int, int, int, int].""" return (*self.status["rgb"], self.status["white"]) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on light.""" params: dict[str, Any] = {"id": self._id, "on": True} if ATTR_BRIGHTNESS in kwargs: params["brightness"] = brightness_to_percentage(kwargs[ATTR_BRIGHTNESS]) if ATTR_COLOR_TEMP_KELVIN in kwargs: params["ct"] = kwargs[ATTR_COLOR_TEMP_KELVIN] if ATTR_TRANSITION in kwargs: params["transition_duration"] = max( kwargs[ATTR_TRANSITION], RPC_MIN_TRANSITION_TIME_SEC ) if ATTR_RGB_COLOR in kwargs: params["rgb"] = list(kwargs[ATTR_RGB_COLOR]) if ATTR_RGBW_COLOR in kwargs: params["rgb"] = list(kwargs[ATTR_RGBW_COLOR][:-1]) params["white"] = kwargs[ATTR_RGBW_COLOR][-1] await self.call_rpc(f"{self._component}.Set", params) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off light.""" params: dict[str, Any] = {"id": self._id, "on": False} if ATTR_TRANSITION in kwargs: params["transition_duration"] = max( kwargs[ATTR_TRANSITION], RPC_MIN_TRANSITION_TIME_SEC ) await self.call_rpc(f"{self._component}.Set", params) class RpcShellySwitchAsLight(RpcShellyLightBase): """Entity that controls a relay as light on RPC based Shelly devices.""" _component = "Switch" _attr_color_mode = ColorMode.ONOFF _attr_supported_color_modes = {ColorMode.ONOFF} class RpcShellyLight(RpcShellyLightBase): """Entity that controls a light on RPC based Shelly devices.""" _component = "Light" _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} _attr_supported_features = LightEntityFeature.TRANSITION class RpcShellyCctLight(RpcShellyLightBase): """Entity that controls a CCT light on RPC based Shelly devices.""" _component = "CCT" _attr_color_mode = ColorMode.COLOR_TEMP _attr_supported_color_modes = {ColorMode.COLOR_TEMP} _attr_supported_features = LightEntityFeature.TRANSITION def __init__( self, coordinator: ShellyRpcCoordinator, key: str, attribute: str, description: RpcEntityDescription, ) -> None: """Initialize light.""" super().__init__(coordinator, key, attribute, description) if color_temp_range := coordinator.device.config[key].get("ct_range"): self._attr_min_color_temp_kelvin = color_temp_range[0] self._attr_max_color_temp_kelvin = color_temp_range[1] else: self._attr_min_color_temp_kelvin = KELVIN_MIN_VALUE_WHITE self._attr_max_color_temp_kelvin = KELVIN_MAX_VALUE @property def color_temp_kelvin(self) -> int: """Return the CT color value in Kelvin.""" return cast(int, self.status["ct"]) class RpcShellyRgbLight(RpcShellyLightBase): """Entity that controls a RGB light on RPC based Shelly devices.""" _component = "RGB" _attr_color_mode = ColorMode.RGB _attr_supported_color_modes = {ColorMode.RGB} _attr_supported_features = LightEntityFeature.TRANSITION class RpcShellyRgbwLight(RpcShellyLightBase): """Entity that controls a RGBW light on RPC based Shelly devices.""" _component = "RGBW" _attr_color_mode = ColorMode.RGBW _attr_supported_color_modes = {ColorMode.RGBW} _attr_supported_features = LightEntityFeature.TRANSITION LIGHTS: Final = { "switch": RpcEntityDescription( key="switch", sub_key="output", removal_condition=lambda config, _status, key: not is_rpc_channel_type_light( config, int(key.split(":")[-1]) ), entity_class=RpcShellySwitchAsLight, ), "light": RpcEntityDescription( key="light", sub_key="output", entity_class=RpcShellyLight, ), "cct": RpcEntityDescription( key="cct", sub_key="output", entity_class=RpcShellyCctLight, ), "rgb": RpcEntityDescription( key="rgb", sub_key="output", entity_class=RpcShellyRgbLight, ), "rgbw": RpcEntityDescription( key="rgbw", sub_key="output", entity_class=RpcShellyRgbwLight, ), } @callback def _async_setup_rpc_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entities for RPC device.""" coordinator = config_entry.runtime_data.rpc assert coordinator async_setup_entry_rpc( hass, config_entry, async_add_entities, LIGHTS, RpcShellyLight ) async_remove_orphaned_entities( hass, config_entry.entry_id, coordinator.mac, LIGHT_DOMAIN, coordinator.device.status, )