"""Support for Template lights.""" from __future__ import annotations import logging from typing import Any import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_TRANSITION, ENTITY_ID_FORMAT, ColorMode, LightEntity, LightEntityFeature, filter_supported_color_modes, ) from homeassistant.const import ( CONF_ENTITY_ID, CONF_FRIENDLY_NAME, CONF_LIGHTS, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, STATE_OFF, STATE_ON, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import PLATFORM_SCHEMA from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN from .template_entity import ( TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, TemplateEntity, rewrite_common_legacy_to_modern_conf, ) _LOGGER = logging.getLogger(__name__) _VALID_STATES = [STATE_ON, STATE_OFF, "true", "false"] CONF_COLOR_ACTION = "set_color" CONF_COLOR_TEMPLATE = "color_template" CONF_EFFECT_ACTION = "set_effect" CONF_EFFECT_LIST_TEMPLATE = "effect_list_template" CONF_EFFECT_TEMPLATE = "effect_template" CONF_LEVEL_ACTION = "set_level" CONF_LEVEL_TEMPLATE = "level_template" CONF_MAX_MIREDS_TEMPLATE = "max_mireds_template" CONF_MIN_MIREDS_TEMPLATE = "min_mireds_template" CONF_OFF_ACTION = "turn_off" CONF_ON_ACTION = "turn_on" CONF_SUPPORTS_TRANSITION = "supports_transition_template" CONF_TEMPERATURE_ACTION = "set_temperature" CONF_TEMPERATURE_TEMPLATE = "temperature_template" CONF_WHITE_VALUE_ACTION = "set_white_value" CONF_WHITE_VALUE_TEMPLATE = "white_value_template" LIGHT_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { vol.Optional(CONF_COLOR_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_COLOR_TEMPLATE): cv.template, vol.Inclusive(CONF_EFFECT_ACTION, "effect"): cv.SCRIPT_SCHEMA, vol.Inclusive(CONF_EFFECT_LIST_TEMPLATE, "effect"): cv.template, vol.Inclusive(CONF_EFFECT_TEMPLATE, "effect"): cv.template, vol.Optional(CONF_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_FRIENDLY_NAME): cv.string, vol.Optional(CONF_LEVEL_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_LEVEL_TEMPLATE): cv.template, vol.Optional(CONF_MAX_MIREDS_TEMPLATE): cv.template, vol.Optional(CONF_MIN_MIREDS_TEMPLATE): cv.template, vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_SUPPORTS_TRANSITION): cv.template, vol.Optional(CONF_TEMPERATURE_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_TEMPERATURE_TEMPLATE): cv.template, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, } ).extend(TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY.schema), ) PLATFORM_SCHEMA = vol.All( # CONF_WHITE_VALUE_* is deprecated, support will be removed in release 2022.9 cv.removed(CONF_WHITE_VALUE_ACTION), cv.removed(CONF_WHITE_VALUE_TEMPLATE), PLATFORM_SCHEMA.extend( {vol.Required(CONF_LIGHTS): cv.schema_with_slug_keys(LIGHT_SCHEMA)} ), ) async def _async_create_entities(hass, config): """Create the Template Lights.""" lights = [] for object_id, entity_config in config[CONF_LIGHTS].items(): entity_config = rewrite_common_legacy_to_modern_conf(entity_config) unique_id = entity_config.get(CONF_UNIQUE_ID) lights.append( LightTemplate( hass, object_id, entity_config, unique_id, ) ) return lights async def async_setup_platform( hass: HomeAssistant, config: ConfigType, async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template lights.""" async_add_entities(await _async_create_entities(hass, config)) class LightTemplate(TemplateEntity, LightEntity): """Representation of a templated Light, including dimmable.""" _attr_should_poll = False def __init__( self, hass, object_id, config, unique_id, ): """Initialize the light.""" super().__init__( hass, config=config, fallback_name=object_id, unique_id=unique_id ) self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, object_id, hass=hass ) friendly_name = self._attr_name self._template = config.get(CONF_VALUE_TEMPLATE) self._on_script = Script(hass, config[CONF_ON_ACTION], friendly_name, DOMAIN) self._off_script = Script(hass, config[CONF_OFF_ACTION], friendly_name, DOMAIN) self._level_script = None if (level_action := config.get(CONF_LEVEL_ACTION)) is not None: self._level_script = Script(hass, level_action, friendly_name, DOMAIN) self._level_template = config.get(CONF_LEVEL_TEMPLATE) self._temperature_script = None if (temperature_action := config.get(CONF_TEMPERATURE_ACTION)) is not None: self._temperature_script = Script( hass, temperature_action, friendly_name, DOMAIN ) self._temperature_template = config.get(CONF_TEMPERATURE_TEMPLATE) self._color_script = None if (color_action := config.get(CONF_COLOR_ACTION)) is not None: self._color_script = Script(hass, color_action, friendly_name, DOMAIN) self._color_template = config.get(CONF_COLOR_TEMPLATE) self._effect_script = None if (effect_action := config.get(CONF_EFFECT_ACTION)) is not None: self._effect_script = Script(hass, effect_action, friendly_name, DOMAIN) self._effect_list_template = config.get(CONF_EFFECT_LIST_TEMPLATE) self._effect_template = config.get(CONF_EFFECT_TEMPLATE) self._max_mireds_template = config.get(CONF_MAX_MIREDS_TEMPLATE) self._min_mireds_template = config.get(CONF_MIN_MIREDS_TEMPLATE) self._supports_transition_template = config.get(CONF_SUPPORTS_TRANSITION) self._state = False self._brightness = None self._temperature = None self._color = None self._effect = None self._effect_list = None self._fixed_color_mode = None self._max_mireds = None self._min_mireds = None self._supports_transition = False color_modes = {ColorMode.ONOFF} if self._level_script is not None: color_modes.add(ColorMode.BRIGHTNESS) if self._temperature_script is not None: color_modes.add(ColorMode.COLOR_TEMP) if self._color_script is not None: color_modes.add(ColorMode.HS) self._supported_color_modes = filter_supported_color_modes(color_modes) if len(self._supported_color_modes) == 1: self._fixed_color_mode = next(iter(self._supported_color_modes)) @property def brightness(self) -> int | None: """Return the brightness of the light.""" return self._brightness @property def color_temp(self) -> int | None: """Return the CT color value in mireds.""" return self._temperature @property def max_mireds(self) -> int: """Return the max mireds value in mireds.""" if self._max_mireds is not None: return self._max_mireds return super().max_mireds @property def min_mireds(self) -> int: """Return the min mireds value in mireds.""" if self._min_mireds is not None: return self._min_mireds return super().min_mireds @property def hs_color(self) -> tuple[float, float] | None: """Return the hue and saturation color value [float, float].""" return self._color @property def effect(self) -> str | None: """Return the effect.""" return self._effect @property def effect_list(self) -> list[str] | None: """Return the effect list.""" return self._effect_list @property def color_mode(self): """Return current color mode.""" if self._fixed_color_mode: return self._fixed_color_mode # Support for ct + hs, prioritize hs if self._color is not None: return ColorMode.HS return ColorMode.COLOR_TEMP @property def supported_color_modes(self): """Flag supported color modes.""" return self._supported_color_modes @property def supported_features(self) -> int: """Flag supported features.""" supported_features = 0 if self._effect_script is not None: supported_features |= LightEntityFeature.EFFECT if self._supports_transition is True: supported_features |= LightEntityFeature.TRANSITION return supported_features @property def is_on(self) -> bool | None: """Return true if device is on.""" return self._state async def async_added_to_hass(self) -> None: """Register callbacks.""" if self._template: self.add_template_attribute( "_state", self._template, None, self._update_state ) if self._level_template: self.add_template_attribute( "_brightness", self._level_template, None, self._update_brightness, none_on_template_error=True, ) if self._max_mireds_template: self.add_template_attribute( "_max_mireds_template", self._max_mireds_template, None, self._update_max_mireds, none_on_template_error=True, ) if self._min_mireds_template: self.add_template_attribute( "_min_mireds_template", self._min_mireds_template, None, self._update_min_mireds, none_on_template_error=True, ) if self._temperature_template: self.add_template_attribute( "_temperature", self._temperature_template, None, self._update_temperature, none_on_template_error=True, ) if self._color_template: self.add_template_attribute( "_color", self._color_template, None, self._update_color, none_on_template_error=True, ) if self._effect_list_template: self.add_template_attribute( "_effect_list", self._effect_list_template, None, self._update_effect_list, none_on_template_error=True, ) if self._effect_template: self.add_template_attribute( "_effect", self._effect_template, None, self._update_effect, none_on_template_error=True, ) if self._supports_transition_template: self.add_template_attribute( "_supports_transition_template", self._supports_transition_template, None, self._update_supports_transition, none_on_template_error=True, ) await super().async_added_to_hass() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" optimistic_set = False # set optimistic states if self._template is None: self._state = True optimistic_set = True if self._level_template is None and ATTR_BRIGHTNESS in kwargs: _LOGGER.debug( "Optimistically setting brightness to %s", kwargs[ATTR_BRIGHTNESS] ) self._brightness = kwargs[ATTR_BRIGHTNESS] optimistic_set = True if self._temperature_template is None and ATTR_COLOR_TEMP in kwargs: _LOGGER.debug( "Optimistically setting color temperature to %s", kwargs[ATTR_COLOR_TEMP], ) self._temperature = kwargs[ATTR_COLOR_TEMP] if self._color_template is None: self._color = None optimistic_set = True if self._color_template is None and ATTR_HS_COLOR in kwargs: _LOGGER.debug( "Optimistically setting color to %s", kwargs[ATTR_HS_COLOR], ) self._color = kwargs[ATTR_HS_COLOR] if self._temperature_template is None: self._temperature = None optimistic_set = True 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 in kwargs and self._temperature_script: common_params["color_temp"] = kwargs[ATTR_COLOR_TEMP] await self.async_run_script( self._temperature_script, run_variables=common_params, context=self._context, ) elif ATTR_EFFECT in kwargs and self._effect_script: effect = kwargs[ATTR_EFFECT] if effect not in self._effect_list: _LOGGER.error( "Received invalid effect: %s for entity %s. Expected one of: %s", effect, self.entity_id, self._effect_list, exc_info=True, ) common_params["effect"] = effect await self.async_run_script( self._effect_script, run_variables=common_params, context=self._context ) elif ATTR_HS_COLOR in kwargs and self._color_script: 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]) await self.async_run_script( self._color_script, run_variables=common_params, context=self._context ) elif ATTR_BRIGHTNESS in kwargs and self._level_script: await self.async_run_script( self._level_script, run_variables=common_params, context=self._context ) else: await self.async_run_script( self._on_script, run_variables=common_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.""" if ATTR_TRANSITION in kwargs and self._supports_transition is True: await self.async_run_script( self._off_script, run_variables={"transition": kwargs[ATTR_TRANSITION]}, context=self._context, ) else: await self.async_run_script(self._off_script, context=self._context) if self._template is None: self._state = False self.async_write_ha_state() @callback def _update_brightness(self, brightness): """Update the brightness from the template.""" try: if brightness in (None, "None", ""): self._brightness = None return if 0 <= int(brightness) <= 255: self._brightness = int(brightness) else: _LOGGER.error( "Received invalid brightness : %s for entity %s. Expected: 0-255", brightness, self.entity_id, ) self._brightness = None except ValueError: _LOGGER.error( "Template must supply an integer brightness from 0-255, or 'None'", exc_info=True, ) self._brightness = None @callback def _update_effect_list(self, effect_list): """Update the effect list from the template.""" if effect_list in (None, "None", ""): self._effect_list = None return if not isinstance(effect_list, list): _LOGGER.error( "Received invalid effect list: %s for entity %s. Expected list of strings", effect_list, self.entity_id, ) self._effect_list = None return if len(effect_list) == 0: self._effect_list = None return self._effect_list = effect_list @callback def _update_effect(self, effect): """Update the effect from the template.""" if effect in (None, "None", ""): self._effect = None return if effect not in self._effect_list: _LOGGER.error( "Received invalid effect: %s for entity %s. Expected one of: %s", effect, self.entity_id, self._effect_list, ) self._effect = None return self._effect = effect @callback def _update_state(self, result): """Update the state from the template.""" if isinstance(result, TemplateError): # This behavior is legacy self._state = False if not self._availability_template: self._attr_available = True return if isinstance(result, bool): self._state = result return state = str(result).lower() if state in _VALID_STATES: self._state = state in ("true", STATE_ON) return _LOGGER.error( "Received invalid light is_on state: %s for entity %s. Expected: %s", state, self.entity_id, ", ".join(_VALID_STATES), ) self._state = None @callback def _update_temperature(self, render): """Update the temperature from the template.""" try: if render in (None, "None", ""): self._temperature = None return temperature = int(render) if self.min_mireds <= temperature <= self.max_mireds: self._temperature = temperature else: _LOGGER.error( "Received invalid color temperature : %s for entity %s. Expected: %s-%s", temperature, self.entity_id, self.min_mireds, self.max_mireds, ) self._temperature = None except ValueError: _LOGGER.error( "Template must supply an integer temperature within the range for this light, or 'None'", exc_info=True, ) self._temperature = None @callback def _update_color(self, render): """Update the hs_color from the template.""" if render is None: self._color = None return h_str = s_str = None if isinstance(render, str): if render in ("None", ""): self._color = None return h_str, s_str = map( float, render.replace("(", "").replace(")", "").split(",", 1) ) elif isinstance(render, (list, tuple)) and len(render) == 2: h_str, s_str = render if ( h_str is not None and s_str is not None and 0 <= h_str <= 360 and 0 <= s_str <= 100 ): self._color = (h_str, s_str) elif h_str is not None and s_str is not None: _LOGGER.error( "Received invalid hs_color : (%s, %s) for entity %s. Expected: (0-360, 0-100)", h_str, s_str, self.entity_id, ) self._color = None else: _LOGGER.error( "Received invalid hs_color : (%s) for entity %s", render, self.entity_id ) self._color = None @callback def _update_max_mireds(self, render): """Update the max mireds from the template.""" try: if render in (None, "None", ""): self._max_mireds = None return self._max_mireds = int(render) except ValueError: _LOGGER.error( "Template must supply an integer temperature within the range for this light, or 'None'", exc_info=True, ) self._max_mireds = None @callback def _update_min_mireds(self, render): """Update the min mireds from the template.""" try: if render in (None, "None", ""): self._min_mireds = None return self._min_mireds = int(render) except ValueError: _LOGGER.error( "Template must supply an integer temperature within the range for this light, or 'None'", exc_info=True, ) self._min_mireds = None @callback def _update_supports_transition(self, render): """Update the supports transition from the template.""" if render in (None, "None", ""): self._supports_transition = False return self._supports_transition = bool(render)