"""Support for Template lights.""" from collections.abc import Callable import contextlib import logging from typing import TYPE_CHECKING, Any import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, ATTR_TRANSITION, DEFAULT_MAX_KELVIN, DEFAULT_MIN_KELVIN, DOMAIN as LIGHT_DOMAIN, ENTITY_ID_FORMAT, ColorMode, LightEntity, LightEntityFeature, filter_supported_color_modes, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EFFECT, CONF_HS, CONF_NAME, CONF_RGB, CONF_STATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import color as color_util from . import TriggerUpdateCoordinator, validators as template_validators from .const import DOMAIN from .entity import AbstractTemplateEntity from .helpers import ( async_setup_template_entry, async_setup_template_platform, async_setup_template_preview, ) from .schemas import ( TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, make_template_entity_common_modern_schema, ) from .template_entity import TemplateEntity from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) # Legacy ATTR_COLOR_TEMP = "color_temp" CONF_HS_ACTION = "set_hs" CONF_RGB_ACTION = "set_rgb" CONF_RGBW = "rgbw" CONF_RGBW_ACTION = "set_rgbw" CONF_RGBWW = "rgbww" CONF_RGBWW_ACTION = "set_rgbww" CONF_EFFECT_ACTION = "set_effect" CONF_EFFECT_LIST = "effect_list" CONF_LEVEL = "level" CONF_LEVEL_ACTION = "set_level" CONF_MAX_MIREDS = "max_mireds" CONF_MIN_MIREDS = "min_mireds" CONF_OFF_ACTION = "turn_off" CONF_ON_ACTION = "turn_on" CONF_SUPPORTS_TRANSITION = "supports_transition" CONF_TEMPERATURE_ACTION = "set_temperature" CONF_TEMPERATURE = "temperature" DEFAULT_MIN_MIREDS = 153 DEFAULT_MAX_MIREDS = 500 DEFAULT_NAME = "Template Light" SCRIPT_FIELDS = ( CONF_EFFECT_ACTION, CONF_HS_ACTION, CONF_LEVEL_ACTION, CONF_OFF_ACTION, CONF_ON_ACTION, CONF_RGB_ACTION, CONF_RGBW_ACTION, CONF_RGBWW_ACTION, CONF_TEMPERATURE_ACTION, ) LIGHT_COMMON_SCHEMA = vol.Schema( { vol.Inclusive(CONF_EFFECT_ACTION, "effect"): cv.SCRIPT_SCHEMA, vol.Inclusive(CONF_EFFECT_LIST, "effect"): cv.template, vol.Inclusive(CONF_EFFECT, "effect"): cv.template, vol.Optional(CONF_HS_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_HS): cv.template, vol.Optional(CONF_LEVEL_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_LEVEL): cv.template, vol.Optional(CONF_MAX_MIREDS): cv.template, vol.Optional(CONF_MIN_MIREDS): cv.template, vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_RGB_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_RGB): cv.template, vol.Optional(CONF_RGBW_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_RGBW): cv.template, vol.Optional(CONF_RGBWW_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_RGBWW): cv.template, vol.Optional(CONF_STATE): cv.template, vol.Optional(CONF_SUPPORTS_TRANSITION): cv.template, vol.Optional(CONF_TEMPERATURE_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_TEMPERATURE): cv.template, } ) LIGHT_YAML_SCHEMA = LIGHT_COMMON_SCHEMA.extend( TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA ).extend(make_template_entity_common_modern_schema(LIGHT_DOMAIN, DEFAULT_NAME).schema) LIGHT_CONFIG_ENTRY_SCHEMA = LIGHT_COMMON_SCHEMA.extend( TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema ) async def async_setup_platform( hass: HomeAssistant, config: ConfigType, async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template lights.""" await async_setup_template_platform( hass, LIGHT_DOMAIN, config, StateLightEntity, TriggerLightEntity, async_add_entities, discovery_info, script_options=SCRIPT_FIELDS, ) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" await async_setup_template_entry( hass, config_entry, async_add_entities, StateLightEntity, LIGHT_CONFIG_ENTRY_SCHEMA, True, script_options=SCRIPT_FIELDS, ) @callback def async_create_preview_light( hass: HomeAssistant, name: str, config: dict[str, Any] ) -> StateLightEntity: """Create a preview.""" return async_setup_template_preview( hass, name, config, StateLightEntity, LIGHT_CONFIG_ENTRY_SCHEMA, True, ) def _string_to_list(result: str) -> list[float]: for char in "()[] ": result = result.replace(char, "") return [float(v) for v in result.split(",")] def hs_color_list(entity: AbstractTemplateLight) -> Callable[[Any], list[int] | None]: """Convert the result to a list of numbers that represent hue and saturation.""" def convert(result: Any) -> list[int] | None: if template_validators.check_result_for_none(result): return None if isinstance(result, str): with contextlib.suppress(ValueError): result = _string_to_list(result) if ( isinstance(result, (list, tuple)) and len(result) == 2 and all(isinstance(value, (int, float)) for value in result) ): hue, saturation = result if not (0 <= hue <= 360) or not (0 <= saturation <= 100): template_validators.log_validation_result_error( entity, CONF_HS, result, ( "expected a hue value between 0 and 360 and " "a saturation value between 0 and 100: (0-360, 0-100)" ), ) return None return list(result) template_validators.log_validation_result_error( entity, CONF_HS, result, "expected a list of numbers: (0-360, 0-100)", ) return None return convert def rgb_color_list( entity: AbstractTemplateLight, attribute: str, length: int ) -> Callable[[Any], list[int] | None]: """Convert the result to a list of numbers that represent a color.""" example = "[" + ", ".join(("0-255",) * length) + "]" message = f"expected a list of {length} numbers between 0 and 255: {example}" def convert(result: Any) -> list[int] | None: if template_validators.check_result_for_none(result): return None if isinstance(result, str): with contextlib.suppress(ValueError): result = _string_to_list(result) if ( isinstance(result, (list, tuple)) and len(result) == length and all(isinstance(value, (int, float)) for value in result) ): # Ensure the result are numbers between 0 and 255. if all(0 <= value <= 255 for value in result): return list(result) template_validators.log_validation_result_error( entity, attribute, result, message ) return None return convert class AbstractTemplateLight(AbstractTemplateEntity, LightEntity): """Representation of a template lights features.""" _entity_id_format = ENTITY_ID_FORMAT _optimistic_entity = True _attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN _attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN _state_option = CONF_STATE # The super init is not called because TemplateEntity # and TriggerEntity will call # AbstractTemplateEntity.__init__. This ensures that # the __init__ on AbstractTemplateEntity is not # called twice. def __init__( # pylint: disable=super-init-not-called self, name: str, config: dict[str, Any] ) -> None: """Initialize the features.""" # Setup state and brightness self.setup_state_template( "_attr_is_on", template_validators.boolean(self, CONF_STATE) ) self.setup_template( CONF_LEVEL, "_attr_brightness", template_validators.number(self, CONF_LEVEL, 0, 255, int), ) # Setup Color temperature self.setup_template( CONF_TEMPERATURE, "_attr_color_temp_kelvin", self._validate_temperature, self._update_color("_attr_color_temp_kelvin", ColorMode.COLOR_TEMP), ) # Setup Hue Saturation self.setup_template( CONF_HS, "_attr_hs_color", hs_color_list(self), self._update_color("_attr_hs_color", ColorMode.HS), render_complex=True, ) # Setup RGB Colors for option, attribute, length, colormode in ( (CONF_RGB, "_attr_rgb_color", 3, ColorMode.RGB), (CONF_RGBW, "_attr_rgbw_color", 4, ColorMode.RGBW), (CONF_RGBWW, "_attr_rgbww_color", 5, ColorMode.RGBWW), ): self.setup_template( option, attribute, rgb_color_list(self, option, length), self._update_color(attribute, colormode), render_complex=True, ) # Setup Effect templates self.setup_template( CONF_EFFECT_LIST, "_attr_effect_list", template_validators.list_of_strings( self, CONF_EFFECT_LIST, none_on_empty=True ), render_complex=True, ) self.setup_template( CONF_EFFECT, "_attr_effect", template_validators.item_in_list( self, "_attr_effect", "_attr_effect_list", CONF_EFFECT_LIST ), ) # Min/Max temperature templates self.setup_template( CONF_MAX_MIREDS, "_attr_max_color_temp_kelvin", template_validators.number(self, CONF_MAX_MIREDS), self._update_max_mireds, ) self.setup_template( CONF_MIN_MIREDS, "_attr_min_color_temp_kelvin", template_validators.number(self, CONF_MIN_MIREDS), self._update_min_mireds, ) # Transition self.setup_template( CONF_SUPPORTS_TRANSITION, "_supports_transition_template", template_validators.boolean(self, CONF_SUPPORTS_TRANSITION), self._update_supports_transition, ) # Stored values for template attributes self._supports_transition = False color_modes = {ColorMode.ONOFF} for action_id, color_mode in ( (CONF_ON_ACTION, None), (CONF_OFF_ACTION, None), (CONF_EFFECT_ACTION, None), (CONF_TEMPERATURE_ACTION, ColorMode.COLOR_TEMP), (CONF_LEVEL_ACTION, ColorMode.BRIGHTNESS), (CONF_HS_ACTION, ColorMode.HS), (CONF_RGB_ACTION, ColorMode.RGB), (CONF_RGBW_ACTION, ColorMode.RGBW), (CONF_RGBWW_ACTION, ColorMode.RGBWW), ): if (action_config := config.get(action_id)) is not None: self.add_script(action_id, action_config, name, DOMAIN) if color_mode: color_modes.add(color_mode) self._attr_supported_color_modes = filter_supported_color_modes(color_modes) if len(self._attr_supported_color_modes) > 1: self._attr_color_mode = ColorMode.UNKNOWN if len(self._attr_supported_color_modes) == 1: self._attr_color_mode = next(iter(self._attr_supported_color_modes)) self._attr_supported_features = LightEntityFeature(0) if self._action_scripts.get(CONF_EFFECT_ACTION): self._attr_supported_features |= LightEntityFeature.EFFECT if self._supports_transition is True: self._attr_supported_features |= LightEntityFeature.TRANSITION async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" optimistic_set = self.set_optimistic_attributes(**kwargs) script_id, script_params = self.get_registered_script(**kwargs) await self.async_run_script( self._action_scripts[script_id], run_variables=script_params, context=self._context, ) if optimistic_set: self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" off_script = self._action_scripts[CONF_OFF_ACTION] if ATTR_TRANSITION in kwargs and self._supports_transition is True: await self.async_run_script( off_script, run_variables={"transition": kwargs[ATTR_TRANSITION]}, context=self._context, ) else: await self.async_run_script(off_script, context=self._context) if self._attr_assumed_state: self._attr_is_on = False self.async_write_ha_state() def set_optimistic_attributes(self, **kwargs) -> bool: """Update attributes which should be set optimistically. Returns True if any attribute was updated. """ optimistic_set = False if self._attr_assumed_state: self._attr_is_on = True optimistic_set = True if CONF_LEVEL not in self._templates and ATTR_BRIGHTNESS in kwargs: _LOGGER.debug( "Optimistically setting brightness to %s", kwargs[ATTR_BRIGHTNESS] ) self._attr_brightness = kwargs[ATTR_BRIGHTNESS] optimistic_set = True if CONF_TEMPERATURE not in self._templates and ATTR_COLOR_TEMP_KELVIN in kwargs: self._set_optimistic_color( "color temperature", "_attr_color_temp_kelvin", kwargs[ATTR_COLOR_TEMP_KELVIN], ColorMode.COLOR_TEMP, ) optimistic_set = True if CONF_TEMPERATURE not in self._templates and ATTR_COLOR_TEMP in kwargs: self._set_optimistic_color( "color temperature", "_attr_color_temp_kelvin", color_util.color_temperature_mired_to_kelvin(kwargs[ATTR_COLOR_TEMP]), ColorMode.COLOR_TEMP, ) optimistic_set = True if CONF_HS not in self._templates and ATTR_HS_COLOR in kwargs: self._set_optimistic_color( "hs color", "_attr_hs_color", kwargs[ATTR_HS_COLOR], ColorMode.HS ) optimistic_set = True if CONF_RGB not in self._templates and ATTR_RGB_COLOR in kwargs: self._set_optimistic_color( "rgb color", "_attr_rgb_color", kwargs[ATTR_RGB_COLOR], ColorMode.RGB ) optimistic_set = True if CONF_RGBW not in self._templates and ATTR_RGBW_COLOR in kwargs: self._set_optimistic_color( "rgbw color", "_attr_rgbw_color", kwargs[ATTR_RGBW_COLOR], ColorMode.RGBW, ) optimistic_set = True if CONF_RGBWW not in self._templates and ATTR_RGBWW_COLOR in kwargs: self._set_optimistic_color( "rgbww color", "_attr_rgbww_color", kwargs[ATTR_RGBWW_COLOR], ColorMode.RGBWW, ) optimistic_set = True if optimistic_set and not self._attr_assumed_state: # If we are optmistically setting color or level but the state template # has not rendered, optimisically set the state to 'on'. self._attr_is_on = True return optimistic_set def _set_optimistic_color( self, description: str, attribute: str, value: Any, color_mode: ColorMode ) -> None: _LOGGER.debug( "Optimistically setting %s to %s", description, value, ) self._attr_color_mode = color_mode setattr(self, attribute, value) for option, attr in ( (CONF_TEMPERATURE, "_attr_color_temp_kelvin"), (CONF_HS, "_attr_hs_color"), (CONF_RGB, "_attr_rgb_color"), (CONF_RGBW, "_attr_rgbw_color"), (CONF_RGBWW, "_attr_rgbww_color"), ): if attribute == attr: continue if option not in self._templates: setattr(self, attr, None) def get_registered_script(self, **kwargs) -> tuple[str, dict]: """Get registered script for turn_on.""" common_params = {} if ATTR_BRIGHTNESS in kwargs: common_params["brightness"] = kwargs[ATTR_BRIGHTNESS] if ATTR_TRANSITION in kwargs and self._supports_transition is True: common_params["transition"] = kwargs[ATTR_TRANSITION] if ( ATTR_COLOR_TEMP_KELVIN in kwargs and (script := CONF_TEMPERATURE_ACTION) in self._action_scripts ): kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN] common_params[ATTR_COLOR_TEMP_KELVIN] = kelvin common_params[ATTR_COLOR_TEMP] = ( color_util.color_temperature_kelvin_to_mired(kelvin) ) return (script, common_params) if ( ATTR_EFFECT in kwargs and (script := CONF_EFFECT_ACTION) in self._action_scripts ): assert self._attr_effect_list is not None effect = kwargs[ATTR_EFFECT] if ( self._attr_effect_list is not None and effect not in self._attr_effect_list ): _LOGGER.error( "Received invalid effect: %s for entity %s. Expected one of: %s", effect, self.entity_id, self._attr_effect_list, ) common_params["effect"] = effect return (script, common_params) if ( ATTR_HS_COLOR in kwargs and (script := CONF_HS_ACTION) in self._action_scripts ): hs_value = kwargs[ATTR_HS_COLOR] common_params["hs"] = hs_value common_params["h"] = int(hs_value[0]) common_params["s"] = int(hs_value[1]) return (script, common_params) if ( ATTR_RGBWW_COLOR in kwargs and (script := CONF_RGBWW_ACTION) in self._action_scripts ): rgbww_value = kwargs[ATTR_RGBWW_COLOR] common_params["rgbww"] = rgbww_value common_params["rgb"] = ( int(rgbww_value[0]), int(rgbww_value[1]), int(rgbww_value[2]), ) common_params["r"] = int(rgbww_value[0]) common_params["g"] = int(rgbww_value[1]) common_params["b"] = int(rgbww_value[2]) common_params["cw"] = int(rgbww_value[3]) common_params["ww"] = int(rgbww_value[4]) return (script, common_params) if ( ATTR_RGBW_COLOR in kwargs and (script := CONF_RGBW_ACTION) in self._action_scripts ): rgbw_value = kwargs[ATTR_RGBW_COLOR] common_params["rgbw"] = rgbw_value common_params["rgb"] = ( int(rgbw_value[0]), int(rgbw_value[1]), int(rgbw_value[2]), ) common_params["r"] = int(rgbw_value[0]) common_params["g"] = int(rgbw_value[1]) common_params["b"] = int(rgbw_value[2]) common_params["w"] = int(rgbw_value[3]) return (script, common_params) if ( ATTR_RGB_COLOR in kwargs and (script := CONF_RGB_ACTION) in self._action_scripts ): rgb_value = kwargs[ATTR_RGB_COLOR] common_params["rgb"] = rgb_value common_params["r"] = int(rgb_value[0]) common_params["g"] = int(rgb_value[1]) common_params["b"] = int(rgb_value[2]) return (script, common_params) if ( ATTR_BRIGHTNESS in kwargs and (script := CONF_LEVEL_ACTION) in self._action_scripts ): return (script, common_params) return (CONF_ON_ACTION, common_params) def _update_color( self, attribute: str, color_mode: ColorMode ) -> Callable[[Any], None]: """Update the color.""" def update(render) -> None: if render is None: setattr(self, attribute, None) return setattr(self, attribute, render) self._attr_color_mode = color_mode return update @callback def _validate_temperature(self, result: Any) -> int | None: """Validate the temperature from the template.""" if template_validators.check_result_for_none(result): return None if (min_kelvin := self._attr_min_color_temp_kelvin) is not None: max_mireds = color_util.color_temperature_kelvin_to_mired(min_kelvin) else: max_mireds = DEFAULT_MAX_MIREDS if (max_kelvin := self._attr_max_color_temp_kelvin) is not None: min_mireds = color_util.color_temperature_kelvin_to_mired(max_kelvin) else: min_mireds = DEFAULT_MIN_MIREDS if isinstance(result, (int, float)) and min_mireds <= result <= max_mireds: return color_util.color_temperature_mired_to_kelvin(result) template_validators.log_validation_result_error( self, CONF_TEMPERATURE, result, f"expected a number between {min_mireds} and {max_mireds}", ) return None @callback def _update_max_mireds(self, render): """Update the max mireds from the template.""" if render is None: self._attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN return try: self._attr_min_color_temp_kelvin = ( color_util.color_temperature_mired_to_kelvin(int(render)) ) except ValueError: _LOGGER.exception( "Template must supply an integer temperature within the range for" " this light, or 'None'" ) self._attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN @callback def _update_min_mireds(self, render): """Update the min mireds from the template.""" if render is None: self._attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN return try: self._attr_max_color_temp_kelvin = ( color_util.color_temperature_mired_to_kelvin(int(render)) ) except ValueError: _LOGGER.exception( "Template must supply an integer temperature within the range for" " this light, or 'None'" ) self._attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN @callback def _update_supports_transition(self, render): """Update the supports transition from the template.""" if render is None: self._supports_transition = False return self._attr_supported_features &= ~LightEntityFeature.TRANSITION self._supports_transition = bool(render) if self._supports_transition: self._attr_supported_features |= LightEntityFeature.TRANSITION class StateLightEntity(TemplateEntity, AbstractTemplateLight): """Representation of a templated Light, including dimmable.""" _attr_should_poll = False def __init__( self, hass: HomeAssistant, config: dict[str, Any], unique_id: str | None, ) -> None: """Initialize the light.""" TemplateEntity.__init__(self, hass, config, unique_id) name = self._attr_name if TYPE_CHECKING: assert name is not None AbstractTemplateLight.__init__(self, name, config) class TriggerLightEntity(TriggerEntity, AbstractTemplateLight): """Light entity based on trigger data.""" domain = LIGHT_DOMAIN def __init__( self, hass: HomeAssistant, coordinator: TriggerUpdateCoordinator, config: ConfigType, ) -> None: """Initialize the entity.""" TriggerEntity.__init__(self, hass, coordinator, config) # Render the _attr_name before initializing TemplateLightEntity self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME) AbstractTemplateLight.__init__(self, name, config)