From 8407ad01d463adafe5241336506f0179c4f85abf Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 25 Aug 2021 14:21:24 -0400 Subject: [PATCH] Add select platform to template integration (#54835) --- .../components/template/binary_sensor.py | 2 +- homeassistant/components/template/config.py | 10 +- homeassistant/components/template/const.py | 1 + homeassistant/components/template/select.py | 199 ++++++++++++++ .../components/template/trigger_entity.py | 19 +- tests/components/template/test_select.py | 258 ++++++++++++++++++ 6 files changed, 483 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/template/select.py create mode 100644 tests/components/template/test_select.py diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 4d316388eae..9a4c3f93f63 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -358,7 +358,7 @@ class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity): for key in (CONF_DELAY_ON, CONF_DELAY_OFF, CONF_AUTO_OFF): if isinstance(config.get(key), template.Template): - self._to_render.append(key) + self._to_render_simple.append(key) self._parse_result.add(key) self._delay_cancel = None diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 165420bf404..22b0dc4a5b1 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -4,13 +4,18 @@ import logging import voluptuous as vol from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config import async_log_exception, config_without_domain from homeassistant.const import CONF_BINARY_SENSORS, CONF_SENSORS, CONF_UNIQUE_ID from homeassistant.helpers import config_validation as cv from homeassistant.helpers.trigger import async_validate_trigger_config -from . import binary_sensor as binary_sensor_platform, sensor as sensor_platform +from . import ( + binary_sensor as binary_sensor_platform, + select as select_platform, + sensor as sensor_platform, +) from .const import CONF_TRIGGER, DOMAIN PACKAGE_MERGE_HINT = "list" @@ -31,6 +36,9 @@ CONFIG_SECTION_SCHEMA = vol.Schema( vol.Optional(CONF_BINARY_SENSORS): cv.schema_with_slug_keys( binary_sensor_platform.LEGACY_BINARY_SENSOR_SCHEMA ), + vol.Optional(SELECT_DOMAIN): vol.All( + cv.ensure_list, [select_platform.SELECT_SCHEMA] + ), } ) diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index 31896e930e4..44b55581e9d 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -15,6 +15,7 @@ PLATFORMS = [ "fan", "light", "lock", + "select", "sensor", "switch", "vacuum", diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py new file mode 100644 index 00000000000..944c80cbfa4 --- /dev/null +++ b/homeassistant/components/template/select.py @@ -0,0 +1,199 @@ +"""Support for selects which integrates with other components.""" +from __future__ import annotations + +import contextlib +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components.select import SelectEntity +from homeassistant.components.select.const import ( + ATTR_OPTION, + ATTR_OPTIONS, + DOMAIN as SELECT_DOMAIN, +) +from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_STATE, CONF_UNIQUE_ID +from homeassistant.core import Config, HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.script import Script +from homeassistant.helpers.template import Template, TemplateError + +from . import TriggerUpdateCoordinator +from .const import CONF_AVAILABILITY +from .template_entity import TemplateEntity +from .trigger_entity import TriggerEntity + +_LOGGER = logging.getLogger(__name__) + +CONF_SELECT_OPTION = "select_option" + +DEFAULT_NAME = "Template Select" +DEFAULT_OPTIMISTIC = False + +SELECT_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, + vol.Required(CONF_STATE): cv.template, + vol.Required(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA, + vol.Required(ATTR_OPTIONS): cv.template, + vol.Optional(CONF_AVAILABILITY): cv.template, + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } +) + + +async def _async_create_entities( + hass: HomeAssistant, entities: list[dict[str, Any]], unique_id_prefix: str | None +) -> list[TemplateSelect]: + """Create the Template select.""" + for entity in entities: + unique_id = entity.get(CONF_UNIQUE_ID) + + if unique_id and unique_id_prefix: + unique_id = f"{unique_id_prefix}-{unique_id}" + + return [ + TemplateSelect( + hass, + entity.get(CONF_NAME, DEFAULT_NAME), + entity[CONF_STATE], + entity.get(CONF_AVAILABILITY), + entity[CONF_SELECT_OPTION], + entity[ATTR_OPTIONS], + entity.get(CONF_OPTIMISTIC, DEFAULT_OPTIMISTIC), + unique_id, + ) + ] + + +async def async_setup_platform( + hass: HomeAssistant, + config: Config, + async_add_entities: AddEntitiesCallback, + discovery_info: dict[str, Any] | None = None, +) -> None: + """Set up the template select.""" + if discovery_info is None: + _LOGGER.warning( + "Template number entities can only be configured under template:" + ) + return + + if "coordinator" in discovery_info: + async_add_entities( + TriggerSelectEntity(hass, discovery_info["coordinator"], config) + for config in discovery_info["entities"] + ) + return + + async_add_entities( + await _async_create_entities( + hass, discovery_info["entities"], discovery_info["unique_id"] + ) + ) + + +class TemplateSelect(TemplateEntity, SelectEntity): + """Representation of a template select.""" + + def __init__( + self, + hass: HomeAssistant, + name_template: Template | None, + value_template: Template, + availability_template: Template | None, + command_select_option: dict[str, Any], + options_template: Template, + optimistic: bool, + unique_id: str | None, + ) -> None: + """Initialize the select.""" + super().__init__(availability_template=availability_template) + self._attr_name = DEFAULT_NAME + name_template.hass = hass + with contextlib.suppress(TemplateError): + self._attr_name = name_template.async_render(parse_result=False) + self._name_template = name_template + self._value_template = value_template + domain = __name__.split(".")[-2] + self._command_select_option = Script( + hass, command_select_option, self._attr_name, domain + ) + self._options_template = options_template + self._attr_assumed_state = self._optimistic = optimistic + self._attr_unique_id = unique_id + self._attr_options = None + self._attr_current_option = None + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + self.add_template_attribute( + "_attr_current_option", + self._value_template, + validator=cv.string, + none_on_template_error=True, + ) + self.add_template_attribute( + "_attr_options", + self._options_template, + validator=vol.All(cv.ensure_list, [cv.string]), + none_on_template_error=True, + ) + if self._name_template and not self._name_template.is_static: + self.add_template_attribute("_attr_name", self._name_template, cv.string) + await super().async_added_to_hass() + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + if self._optimistic: + self._attr_current_option = option + self.async_write_ha_state() + await self._command_select_option.async_run( + {ATTR_OPTION: option}, context=self._context + ) + + +class TriggerSelectEntity(TriggerEntity, SelectEntity): + """Select entity based on trigger data.""" + + domain = SELECT_DOMAIN + extra_template_keys = (CONF_STATE,) + extra_template_keys_complex = (ATTR_OPTIONS,) + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: dict, + ) -> None: + """Initialize the entity.""" + super().__init__(hass, coordinator, config) + domain = __name__.split(".")[-2] + self._command_select_option = Script( + hass, + config[CONF_SELECT_OPTION], + self._rendered.get(CONF_NAME, DEFAULT_NAME), + domain, + ) + + @property + def current_option(self) -> str | None: + """Return the currently selected option.""" + return self._rendered.get(CONF_STATE) + + @property + def options(self) -> list[str]: + """Return the list of available options.""" + return self._rendered.get(ATTR_OPTIONS, []) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + if self._config[CONF_OPTIMISTIC]: + self._attr_current_option = option + self.async_write_ha_state() + await self._command_select_option.async_run( + {ATTR_OPTION: option}, context=self._context + ) diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index ee9c60293df..84ad4072b66 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -23,6 +23,7 @@ class TriggerEntity(update_coordinator.CoordinatorEntity): domain = "" extra_template_keys: tuple | None = None + extra_template_keys_complex: tuple | None = None def __init__( self, @@ -43,7 +44,8 @@ class TriggerEntity(update_coordinator.CoordinatorEntity): self._config = config self._static_rendered = {} - self._to_render = [] + self._to_render_simple = [] + self._to_render_complex = [] for itm in ( CONF_NAME, @@ -57,10 +59,13 @@ class TriggerEntity(update_coordinator.CoordinatorEntity): if config[itm].is_static: self._static_rendered[itm] = config[itm].template else: - self._to_render.append(itm) + self._to_render_simple.append(itm) if self.extra_template_keys is not None: - self._to_render.extend(self.extra_template_keys) + self._to_render_simple.extend(self.extra_template_keys) + + if self.extra_template_keys_complex is not None: + self._to_render_complex.extend(self.extra_template_keys_complex) # We make a copy so our initial render is 'unknown' and not 'unavailable' self._rendered = dict(self._static_rendered) @@ -124,12 +129,18 @@ class TriggerEntity(update_coordinator.CoordinatorEntity): try: rendered = dict(self._static_rendered) - for key in self._to_render: + for key in self._to_render_simple: rendered[key] = self._config[key].async_render( self.coordinator.data["run_variables"], parse_result=key in self._parse_result, ) + for key in self._to_render_complex: + rendered[key] = template.render_complex( + self._config[key], + self.coordinator.data["run_variables"], + ) + if CONF_ATTRIBUTES in self._config: rendered[CONF_ATTRIBUTES] = template.render_complex( self._config[CONF_ATTRIBUTES], diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py new file mode 100644 index 00000000000..eb94a9284f4 --- /dev/null +++ b/tests/components/template/test_select.py @@ -0,0 +1,258 @@ +"""The tests for the Template select platform.""" +import pytest + +from homeassistant import setup +from homeassistant.components.input_select import ( + ATTR_OPTION as INPUT_SELECT_ATTR_OPTION, + ATTR_OPTIONS as INPUT_SELECT_ATTR_OPTIONS, + DOMAIN as INPUT_SELECT_DOMAIN, + SERVICE_SELECT_OPTION as INPUT_SELECT_SERVICE_SELECT_OPTION, + SERVICE_SET_OPTIONS, +) +from homeassistant.components.select.const import ( + ATTR_OPTION as SELECT_ATTR_OPTION, + ATTR_OPTIONS as SELECT_ATTR_OPTIONS, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION as SELECT_SERVICE_SELECT_OPTION, +) +from homeassistant.const import CONF_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import Context +from homeassistant.helpers.entity_registry import async_get + +from tests.common import ( + assert_setup_component, + async_capture_events, + async_mock_service, +) + +_TEST_SELECT = "select.template_select" +# Represent for select's current_option +_OPTION_INPUT_SELECT = "input_select.option" + + +@pytest.fixture +def calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +async def test_missing_optional_config(hass, calls): + """Test: missing optional template is ok.""" + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "select": { + "state": "{{ 'a' }}", + "select_option": {"service": "script.select_option"}, + "options": "{{ ['a', 'b'] }}", + } + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify(hass, "a", ["a", "b"]) + + +async def test_missing_required_keys(hass, calls): + """Test: missing required fields will fail.""" + with assert_setup_component(0, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "select": { + "select_option": {"service": "script.select_option"}, + "options": "{{ ['a', 'b'] }}", + } + } + }, + ) + + with assert_setup_component(0, "select"): + assert await setup.async_setup_component( + hass, + "select", + { + "template": { + "select": { + "state": "{{ 'a' }}", + "select_option": {"service": "script.select_option"}, + } + } + }, + ) + + with assert_setup_component(0, "select"): + assert await setup.async_setup_component( + hass, + "select", + { + "template": { + "select": { + "state": "{{ 'a' }}", + "options": "{{ ['a', 'b'] }}", + } + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert hass.states.async_all() == [] + + +async def test_templates_with_entities(hass, calls): + """Test tempalates with values from other entities.""" + with assert_setup_component(1, "input_select"): + assert await setup.async_setup_component( + hass, + "input_select", + { + "input_select": { + "option": { + "options": ["a", "b"], + "initial": "a", + "name": "Option", + }, + } + }, + ) + + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "unique_id": "b", + "select": { + "state": f"{{{{ states('{_OPTION_INPUT_SELECT}') }}}}", + "options": f"{{{{ state_attr('{_OPTION_INPUT_SELECT}', '{INPUT_SELECT_ATTR_OPTIONS}') }}}}", + "select_option": { + "service": "input_select.select_option", + "data_template": { + "entity_id": _OPTION_INPUT_SELECT, + "option": "{{ option }}", + }, + }, + "optimistic": True, + "unique_id": "a", + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + ent_reg = async_get(hass) + entry = ent_reg.async_get(_TEST_SELECT) + assert entry + assert entry.unique_id == "b-a" + + _verify(hass, "a", ["a", "b"]) + + await hass.services.async_call( + INPUT_SELECT_DOMAIN, + INPUT_SELECT_SERVICE_SELECT_OPTION, + {CONF_ENTITY_ID: _OPTION_INPUT_SELECT, INPUT_SELECT_ATTR_OPTION: "b"}, + blocking=True, + ) + await hass.async_block_till_done() + _verify(hass, "b", ["a", "b"]) + + await hass.services.async_call( + INPUT_SELECT_DOMAIN, + SERVICE_SET_OPTIONS, + { + CONF_ENTITY_ID: _OPTION_INPUT_SELECT, + INPUT_SELECT_ATTR_OPTIONS: ["a", "b", "c"], + }, + blocking=True, + ) + await hass.async_block_till_done() + _verify(hass, "a", ["a", "b", "c"]) + + await hass.services.async_call( + SELECT_DOMAIN, + SELECT_SERVICE_SELECT_OPTION, + {CONF_ENTITY_ID: _TEST_SELECT, SELECT_ATTR_OPTION: "c"}, + blocking=True, + ) + _verify(hass, "c", ["a", "b", "c"]) + + +async def test_trigger_select(hass): + """Test trigger based template select.""" + events = async_capture_events(hass, "test_number_event") + assert await setup.async_setup_component( + hass, + "template", + { + "template": [ + {"invalid": "config"}, + # Config after invalid should still be set up + { + "unique_id": "listening-test-event", + "trigger": {"platform": "event", "event_type": "test_event"}, + "select": [ + { + "name": "Hello Name", + "unique_id": "hello_name-id", + "state": "{{ trigger.event.data.beer }}", + "options": "{{ trigger.event.data.beers }}", + "select_option": {"event": "test_number_event"}, + "optimistic": True, + }, + ], + }, + ], + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("select.hello_name") + assert state is not None + assert state.state == STATE_UNKNOWN + + context = Context() + hass.bus.async_fire( + "test_event", {"beer": "duff", "beers": ["duff", "alamo"]}, context=context + ) + await hass.async_block_till_done() + + state = hass.states.get("select.hello_name") + assert state is not None + assert state.state == "duff" + assert state.attributes["options"] == ["duff", "alamo"] + + await hass.services.async_call( + SELECT_DOMAIN, + SELECT_SERVICE_SELECT_OPTION, + {CONF_ENTITY_ID: "select.hello_name", SELECT_ATTR_OPTION: "alamo"}, + blocking=True, + ) + assert len(events) == 1 + assert events[0].event_type == "test_number_event" + + +def _verify(hass, expected_current_option, expected_options): + """Verify select's state.""" + state = hass.states.get(_TEST_SELECT) + attributes = state.attributes + assert state.state == str(expected_current_option) + assert attributes.get(SELECT_ATTR_OPTIONS) == expected_options