diff --git a/CODEOWNERS b/CODEOWNERS index 52f13748303174..6ae40eecf4b9bd 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -322,7 +322,7 @@ homeassistant/components/tado/* @michaelarnauts homeassistant/components/tahoma/* @philklei homeassistant/components/tautulli/* @ludeeus homeassistant/components/tellduslive/* @fredrike -homeassistant/components/template/* @PhracturedBlue +homeassistant/components/template/* @PhracturedBlue @tetienne homeassistant/components/tesla/* @zabuldon @alandtse homeassistant/components/tfiac/* @fredrike @mellado homeassistant/components/thethingsnetwork/* @fabaff diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index e18833aae3943e..d1d10d04b79d93 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -5,8 +5,12 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_HS_COLOR, ENTITY_ID_FORMAT, SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_COLOR_TEMP, Light, ) from homeassistant.const import ( @@ -38,6 +42,10 @@ CONF_OFF_ACTION = "turn_off" CONF_LEVEL_ACTION = "set_level" CONF_LEVEL_TEMPLATE = "level_template" +CONF_TEMPERATURE_TEMPLATE = "temperature_template" +CONF_TEMPERATURE_ACTION = "set_temperature" +CONF_COLOR_TEMPLATE = "color_template" +CONF_COLOR_ACTION = "set_color" LIGHT_SCHEMA = vol.Schema( { @@ -51,6 +59,10 @@ vol.Optional(CONF_LEVEL_TEMPLATE): cv.template, vol.Optional(CONF_FRIENDLY_NAME): cv.string, vol.Optional(CONF_ENTITY_ID): cv.entity_ids, + vol.Optional(CONF_TEMPERATURE_TEMPLATE): cv.template, + vol.Optional(CONF_TEMPERATURE_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_COLOR_TEMPLATE): cv.template, + vol.Optional(CONF_COLOR_ACTION): cv.SCRIPT_SCHEMA, } ) @@ -70,11 +82,15 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= icon_template = device_config.get(CONF_ICON_TEMPLATE) entity_picture_template = device_config.get(CONF_ENTITY_PICTURE_TEMPLATE) availability_template = device_config.get(CONF_AVAILABILITY_TEMPLATE) - level_template = device_config.get(CONF_LEVEL_TEMPLATE) on_action = device_config[CONF_ON_ACTION] off_action = device_config[CONF_OFF_ACTION] level_action = device_config.get(CONF_LEVEL_ACTION) + level_template = device_config.get(CONF_LEVEL_TEMPLATE) + temperature_action = device_config.get(CONF_TEMPERATURE_ACTION) + temperature_template = device_config.get(CONF_TEMPERATURE_TEMPLATE) + color_action = device_config.get(CONF_COLOR_ACTION) + color_template = device_config.get(CONF_COLOR_TEMPLATE) templates = { CONF_VALUE_TEMPLATE: state_template, @@ -82,6 +98,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= CONF_ENTITY_PICTURE_TEMPLATE: entity_picture_template, CONF_AVAILABILITY_TEMPLATE: availability_template, CONF_LEVEL_TEMPLATE: level_template, + CONF_TEMPERATURE_TEMPLATE: temperature_template, + CONF_COLOR_TEMPLATE: color_template, } initialise_templates(hass, templates) @@ -101,6 +119,10 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= level_action, level_template, entity_ids, + temperature_action, + temperature_template, + color_action, + color_template, ) ) @@ -129,6 +151,10 @@ def __init__( level_action, level_template, entity_ids, + temperature_action, + temperature_template, + color_action, + color_template, ): """Initialize the light.""" self.hass = hass @@ -146,11 +172,21 @@ def __init__( if level_action is not None: self._level_script = Script(hass, level_action) self._level_template = level_template + self._temperature_script = None + if temperature_action is not None: + self._temperature_script = Script(hass, temperature_action) + self._temperature_template = temperature_template + self._color_script = None + if color_action is not None: + self._color_script = Script(hass, color_action) + self._color_template = color_template self._state = False self._icon = None self._entity_picture = None self._brightness = None + self._temperature = None + self._color = None self._entities = entity_ids self._available = True @@ -164,12 +200,26 @@ def __init__( self._entity_picture_template.hass = self.hass if self._availability_template is not None: self._availability_template.hass = self.hass + if self._temperature_template is not None: + self._temperature_template.hass = self.hass + if self._color_template is not None: + self._color_template.hass = self.hass @property def brightness(self): """Return the brightness of the light.""" return self._brightness + @property + def color_temp(self): + """Return the CT color value in mireds.""" + return self._temperature + + @property + def hs_color(self): + """Return the hue and saturation color value [float, float].""" + return self._color + @property def name(self): """Return the display name of this light.""" @@ -178,10 +228,14 @@ def name(self): @property def supported_features(self): """Flag supported features.""" + supported_features = 0 if self._level_script is not None: - return SUPPORT_BRIGHTNESS - - return 0 + supported_features |= SUPPORT_BRIGHTNESS + if self._temperature_script is not None: + supported_features |= SUPPORT_COLOR_TEMP + if self._color_script is not None: + supported_features |= SUPPORT_COLOR + return supported_features @property def is_on(self): @@ -222,6 +276,8 @@ def template_light_startup(event): if ( self._template is not None or self._level_template is not None + or self._temperature_template is not None + or self._color_template is not None or self._availability_template is not None ): async_track_state_change( @@ -249,10 +305,33 @@ async def async_turn_on(self, **kwargs): self._brightness = kwargs[ATTR_BRIGHTNESS] optimistic_set = True + if self._temperature_template is None and ATTR_COLOR_TEMP in kwargs: + _LOGGER.info( + "Optimistically setting color temperature to %s", + kwargs[ATTR_COLOR_TEMP], + ) + self._temperature = kwargs[ATTR_COLOR_TEMP] + optimistic_set = True + + if self._color_template is None and ATTR_HS_COLOR in kwargs: + _LOGGER.info("Optimistically setting color to %s", kwargs[ATTR_HS_COLOR]) + self._color = kwargs[ATTR_HS_COLOR] + optimistic_set = True + if ATTR_BRIGHTNESS in kwargs and self._level_script: await self._level_script.async_run( {"brightness": kwargs[ATTR_BRIGHTNESS]}, context=self._context ) + elif ATTR_COLOR_TEMP in kwargs and self._temperature_script: + await self._temperature_script.async_run( + {"color_temp": kwargs[ATTR_COLOR_TEMP]}, context=self._context + ) + elif ATTR_HS_COLOR in kwargs and self._color_script: + hs_value = kwargs[ATTR_HS_COLOR] + await self._color_script.async_run( + {"hs": hs_value, "h": int(hs_value[0]), "s": int(hs_value[1])}, + context=self._context, + ) else: await self._on_script.async_run() @@ -267,38 +346,14 @@ async def async_turn_off(self, **kwargs): self.async_schedule_update_ha_state() async def async_update(self): - """Update the state from the template.""" - if self._template is not None: - try: - state = self._template.async_render().lower() - except TemplateError as ex: - _LOGGER.error(ex) - self._state = None + """Update from templates.""" + self.update_state() - if state in _VALID_STATES: - self._state = state in ("true", STATE_ON) - else: - _LOGGER.error( - "Received invalid light is_on state: %s. Expected: %s", - state, - ", ".join(_VALID_STATES), - ) - self._state = None + self.update_brightness() - if self._level_template is not None: - try: - brightness = self._level_template.async_render() - except TemplateError as ex: - _LOGGER.error(ex) - self._state = None + self.update_temperature() - if 0 <= int(brightness) <= 255: - self._brightness = int(brightness) - else: - _LOGGER.error( - "Received invalid brightness : %s. Expected: 0-255", brightness - ) - self._brightness = None + self.update_color() for property_name, template in ( ("_icon", self._icon_template), @@ -320,7 +375,7 @@ async def async_update(self): ): # Common during HA startup - so just a warning _LOGGER.warning( - "Could not render %s template %s," " the state is unknown.", + "Could not render %s template %s, the state is unknown.", friendly_property_name, self._name, ) @@ -335,3 +390,90 @@ async def async_update(self): self._name, ex, ) + + @callback + def update_temperature(self): + """Update the temperature from the template.""" + if self._temperature_template is not None: + try: + temperature = int(self._temperature_template.async_render()) + except TemplateError: + _LOGGER.error("Cannot evaluate temperature template", exc_info=True) + self._temperature = None + + if self.min_mireds <= temperature <= self.max_mireds: + self._temperature = temperature + else: + _LOGGER.error( + "Received invalid color temperature : %s. Expected: 0-%s", + temperature, + self.max_mireds, + ) + self._temperature = None + + @callback + def update_brightness(self): + """Update the brightness from the template.""" + if self._level_template is not None: + try: + brightness = self._level_template.async_render() + except TemplateError as ex: + _LOGGER.error(ex) + self._state = None + + if 0 <= int(brightness) <= 255: + self._brightness = int(brightness) + else: + _LOGGER.error( + "Received invalid brightness : %s. Expected: 0-255", brightness + ) + self._brightness = None + + @callback + def update_color(self): + """Update the hs_color from the template.""" + if self._color_template is not None: + h_str = None + s_str = None + self._color = None + + try: + render = self._color_template.async_render() + h_str, s_str = map( + int, render.replace("(", "").replace(")", "").split(",", 1) + ) + except TemplateError as ex: + _LOGGER.error(ex) + 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). Expected: (0-360, 0-100)", + h_str, + s_str, + ) + + @callback + def update_state(self): + """Update the state from the template.""" + if self._template is not None: + try: + state = self._template.async_render().lower() + except TemplateError as ex: + _LOGGER.error(ex) + self._state = None + + if state in _VALID_STATES: + self._state = state in ("true", STATE_ON) + else: + _LOGGER.error( + "Received invalid light is_on state: %s. Expected: %s", + state, + ", ".join(_VALID_STATES), + ) + self._state = None diff --git a/homeassistant/components/template/manifest.json b/homeassistant/components/template/manifest.json index 20a35f1afe75a3..e4984528069104 100644 --- a/homeassistant/components/template/manifest.json +++ b/homeassistant/components/template/manifest.json @@ -5,6 +5,6 @@ "requirements": [], "dependencies": [], "codeowners": [ - "@PhracturedBlue" + "@PhracturedBlue", "@tetienne" ] } diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index 62d377a93370a7..48f9ea542e0a30 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -1,8 +1,14 @@ """The tests for the Template light platform.""" import logging +import pytest + from homeassistant import setup -from homeassistant.components.light import ATTR_BRIGHTNESS +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_HS_COLOR, +) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import callback @@ -20,6 +26,7 @@ class TestTemplateLight: hass = None calls = None + # pylint: disable=invalid-name def setup_method(self, method): @@ -594,7 +601,11 @@ def test_level_action_no_template(self): assert state is not None assert state.attributes.get("brightness") == 124 - def test_level_template(self): + @pytest.mark.parametrize( + "expected_level,template", + [(255, "{{255}}"), (None, "{{256}}"), (None, "{{x - 12}}")], + ) + def test_level_template(self, expected_level, template): """Test the template for the level.""" with assert_setup_component(1, "light"): assert setup.setup_component( @@ -621,7 +632,7 @@ def test_level_template(self): "brightness": "{{brightness}}", }, }, - "level_template": "{{42}}", + "level_template": template, } }, } @@ -633,8 +644,215 @@ def test_level_template(self): state = self.hass.states.get("light.test_template_light") assert state is not None + assert state.attributes.get("brightness") == expected_level - assert state.attributes.get("brightness") == 42 + @pytest.mark.parametrize( + "expected_temp,template", + [(500, "{{500}}"), (None, "{{501}}"), (None, "{{x - 12}}")], + ) + def test_temperature_template(self, expected_temp, template): + """Test the template for the temperature.""" + with assert_setup_component(1, "light"): + assert setup.setup_component( + self.hass, + "light", + { + "light": { + "platform": "template", + "lights": { + "test_template_light": { + "value_template": "{{ 1 == 1 }}", + "turn_on": { + "service": "light.turn_on", + "entity_id": "light.test_state", + }, + "turn_off": { + "service": "light.turn_off", + "entity_id": "light.test_state", + }, + "set_temperature": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "color_temp": "{{color_temp}}", + }, + }, + "temperature_template": template, + } + }, + } + }, + ) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get("light.test_template_light") + assert state is not None + assert state.attributes.get("color_temp") == expected_temp + + def test_color_action_no_template(self): + """Test setting color with optimistic template.""" + assert setup.setup_component( + self.hass, + "light", + { + "light": { + "platform": "template", + "lights": { + "test_template_light": { + "value_template": "{{1 == 1}}", + "turn_on": { + "service": "light.turn_on", + "entity_id": "light.test_state", + }, + "turn_off": { + "service": "light.turn_off", + "entity_id": "light.test_state", + }, + "set_color": [ + { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "h": "{{h}}", + "s": "{{s}}", + }, + }, + { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "s": "{{s}}", + "h": "{{h}}", + }, + }, + ], + } + }, + } + }, + ) + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get("light.test_template_light") + assert state.attributes.get("hs_color") is None + + common.turn_on( + self.hass, "light.test_template_light", **{ATTR_HS_COLOR: (40, 50)} + ) + self.hass.block_till_done() + assert len(self.calls) == 2 + assert self.calls[0].data["h"] == "40" + assert self.calls[0].data["s"] == "50" + assert self.calls[1].data["h"] == "40" + assert self.calls[1].data["s"] == "50" + + state = self.hass.states.get("light.test_template_light") + _LOGGER.info(str(state.attributes)) + assert state is not None + assert self.calls[0].data["h"] == "40" + assert self.calls[0].data["s"] == "50" + assert self.calls[1].data["h"] == "40" + assert self.calls[1].data["s"] == "50" + + @pytest.mark.parametrize( + "expected_hs,template", + [ + ((360, 100), "{{(360, 100)}}"), + (None, "{{(361, 100)}}"), + (None, "{{(360, 101)}}"), + (None, "{{x - 12}}"), + ], + ) + def test_color_template(self, expected_hs, template): + """Test the template for the color.""" + with assert_setup_component(1, "light"): + assert setup.setup_component( + self.hass, + "light", + { + "light": { + "platform": "template", + "lights": { + "test_template_light": { + "value_template": "{{ 1 == 1 }}", + "turn_on": { + "service": "light.turn_on", + "entity_id": "light.test_state", + }, + "turn_off": { + "service": "light.turn_off", + "entity_id": "light.test_state", + }, + "set_color": [ + { + "service": "input_number.set_value", + "data_template": { + "entity_id": "input_number.h", + "color_temp": "{{h}}", + }, + } + ], + "color_template": template, + } + }, + } + }, + ) + self.hass.start() + self.hass.block_till_done() + state = self.hass.states.get("light.test_template_light") + assert state is not None + assert state.attributes.get("hs_color") == expected_hs + + def test_temperature_action_no_template(self): + """Test setting temperature with optimistic template.""" + assert setup.setup_component( + self.hass, + "light", + { + "light": { + "platform": "template", + "lights": { + "test_template_light": { + "value_template": "{{1 == 1}}", + "turn_on": { + "service": "light.turn_on", + "entity_id": "light.test_state", + }, + "turn_off": { + "service": "light.turn_off", + "entity_id": "light.test_state", + }, + "set_temperature": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "color_temp": "{{color_temp}}", + }, + }, + } + }, + } + }, + ) + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get("light.test_template_light") + assert state.attributes.get("color_template") is None + + common.turn_on(self.hass, "light.test_template_light", **{ATTR_COLOR_TEMP: 345}) + self.hass.block_till_done() + assert len(self.calls) == 1 + assert self.calls[0].data["color_temp"] == "345" + + state = self.hass.states.get("light.test_template_light") + _LOGGER.info(str(state.attributes)) + assert state is not None + assert state.attributes.get("color_temp") == 345 def test_friendly_name(self): """Test the accessibility of the friendly_name attribute."""