diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 22b0dc4a5b1..4bcda6b6752 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -4,6 +4,7 @@ import logging import voluptuous as vol from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.number import DOMAIN as NUMBER_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 @@ -13,6 +14,7 @@ from homeassistant.helpers.trigger import async_validate_trigger_config from . import ( binary_sensor as binary_sensor_platform, + number as number_platform, select as select_platform, sensor as sensor_platform, ) @@ -24,6 +26,9 @@ CONFIG_SECTION_SCHEMA = vol.Schema( { vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_TRIGGER): cv.TRIGGER_SCHEMA, + vol.Optional(NUMBER_DOMAIN): vol.All( + cv.ensure_list, [number_platform.NUMBER_SCHEMA] + ), vol.Optional(SENSOR_DOMAIN): vol.All( cv.ensure_list, [sensor_platform.SENSOR_SCHEMA] ), diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index 44b55581e9d..0309321afbc 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -15,6 +15,7 @@ PLATFORMS = [ "fan", "light", "lock", + "number", "select", "sensor", "switch", diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py new file mode 100644 index 00000000000..a7737c31246 --- /dev/null +++ b/homeassistant/components/template/number.py @@ -0,0 +1,245 @@ +"""Support for numbers which integrates with other components.""" +from __future__ import annotations + +import contextlib +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components.number import NumberEntity +from homeassistant.components.number.const import ( + ATTR_MAX, + ATTR_MIN, + ATTR_STEP, + ATTR_VALUE, + DEFAULT_MAX_VALUE, + DEFAULT_MIN_VALUE, + DOMAIN as NUMBER_DOMAIN, +) +from homeassistant.components.template import TriggerUpdateCoordinator +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 .const import CONF_AVAILABILITY +from .template_entity import TemplateEntity +from .trigger_entity import TriggerEntity + +_LOGGER = logging.getLogger(__name__) + +CONF_SET_VALUE = "set_value" + +DEFAULT_NAME = "Template Number" +DEFAULT_OPTIMISTIC = False + +NUMBER_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, + vol.Required(CONF_STATE): cv.template, + vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA, + vol.Required(ATTR_STEP): cv.template, + vol.Optional(ATTR_MIN, default=DEFAULT_MIN_VALUE): cv.template, + vol.Optional(ATTR_MAX, default=DEFAULT_MAX_VALUE): 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, definitions: list[dict[str, Any]], unique_id_prefix: str | None +) -> list[TemplateNumber]: + """Create the Template number.""" + entities = [] + for definition in definitions: + unique_id = definition.get(CONF_UNIQUE_ID) + if unique_id and unique_id_prefix: + unique_id = f"{unique_id_prefix}-{unique_id}" + entities.append( + TemplateNumber( + hass, + definition[CONF_NAME], + definition[CONF_STATE], + definition.get(CONF_AVAILABILITY), + definition[CONF_SET_VALUE], + definition[ATTR_STEP], + definition[ATTR_MIN], + definition[ATTR_MAX], + definition[CONF_OPTIMISTIC], + unique_id, + ) + ) + return entities + + +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 number.""" + 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( + TriggerNumberEntity(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 TemplateNumber(TemplateEntity, NumberEntity): + """Representation of a template number.""" + + def __init__( + self, + hass: HomeAssistant, + name_template: Template, + value_template: Template, + availability_template: Template | None, + command_set_value: dict[str, Any], + step_template: Template, + minimum_template: Template | None, + maximum_template: Template | None, + optimistic: bool, + unique_id: str | None, + ) -> None: + """Initialize the number.""" + super().__init__(availability_template=availability_template) + self._attr_name = DEFAULT_NAME + self._name_template = name_template + name_template.hass = hass + with contextlib.suppress(TemplateError): + self._attr_name = name_template.async_render(parse_result=False) + self._value_template = value_template + domain = __name__.split(".")[-2] + self._command_set_value = Script( + hass, command_set_value, self._attr_name, domain + ) + self._step_template = step_template + self._min_value_template = minimum_template + self._max_value_template = maximum_template + self._attr_assumed_state = self._optimistic = optimistic + self._attr_unique_id = unique_id + self._attr_value = None + self._attr_step = None + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + if self._name_template and not self._name_template.is_static: + self.add_template_attribute("_attr_name", self._name_template, cv.string) + self.add_template_attribute( + "_attr_value", + self._value_template, + validator=vol.Coerce(float), + none_on_template_error=True, + ) + self.add_template_attribute( + "_attr_step", + self._step_template, + validator=vol.Coerce(float), + none_on_template_error=True, + ) + if self._min_value_template is not None: + self.add_template_attribute( + "_attr_min_value", + self._min_value_template, + validator=vol.Coerce(float), + none_on_template_error=True, + ) + if self._max_value_template is not None: + self.add_template_attribute( + "_attr_max_value", + self._max_value_template, + validator=vol.Coerce(float), + none_on_template_error=True, + ) + await super().async_added_to_hass() + + async def async_set_value(self, value: float) -> None: + """Set value of the number.""" + if self._optimistic: + self._attr_value = value + self.async_write_ha_state() + await self._command_set_value.async_run( + {ATTR_VALUE: value}, context=self._context + ) + + +class TriggerNumberEntity(TriggerEntity, NumberEntity): + """Number entity based on trigger data.""" + + domain = NUMBER_DOMAIN + extra_template_keys = ( + CONF_STATE, + ATTR_STEP, + ATTR_MIN, + ATTR_MAX, + ) + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: dict, + ) -> None: + """Initialize the entity.""" + super().__init__(hass, coordinator, config) + domain = __name__.split(".")[-2] + self._command_set_value = Script( + hass, + config[CONF_SET_VALUE], + self._rendered.get(CONF_NAME, DEFAULT_NAME), + domain, + ) + + @property + def value(self) -> float | None: + """Return the currently selected option.""" + return vol.Any(vol.Coerce(float), None)(self._rendered.get(CONF_STATE)) + + @property + def min_value(self) -> int: + """Return the minimum value.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(ATTR_MIN, super().min_value) + ) + + @property + def max_value(self) -> int: + """Return the maximum value.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(ATTR_MAX, super().max_value) + ) + + @property + def step(self) -> int: + """Return the increment/decrement step.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(ATTR_STEP, super().step) + ) + + async def async_set_value(self, value: float) -> None: + """Set value of the number.""" + if self._config[CONF_OPTIMISTIC]: + self._attr_value = value + self.async_write_ha_state() + await self._command_set_value.async_run( + {ATTR_VALUE: value}, context=self._context + ) diff --git a/tests/components/template/test_number.py b/tests/components/template/test_number.py new file mode 100644 index 00000000000..f297307fd0e --- /dev/null +++ b/tests/components/template/test_number.py @@ -0,0 +1,336 @@ +"""The tests for the Template number platform.""" +import pytest + +from homeassistant import setup +from homeassistant.components.input_number import ( + ATTR_VALUE as INPUT_NUMBER_ATTR_VALUE, + DOMAIN as INPUT_NUMBER_DOMAIN, + SERVICE_SET_VALUE as INPUT_NUMBER_SERVICE_SET_VALUE, +) +from homeassistant.components.number.const import ( + ATTR_MAX, + ATTR_MIN, + ATTR_STEP, + ATTR_VALUE as NUMBER_ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE as NUMBER_SERVICE_SET_VALUE, +) +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_NUMBER = "number.template_number" +# Represent for number's value +_VALUE_INPUT_NUMBER = "input_number.value" +# Represent for number's minimum +_MINIMUM_INPUT_NUMBER = "input_number.minimum" +# Represent for number's maximum +_MAXIMUM_INPUT_NUMBER = "input_number.maximum" +# Represent for number's step +_STEP_INPUT_NUMBER = "input_number.step" + + +@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": { + "number": { + "state": "{{ 4 }}", + "set_value": {"service": "script.set_value"}, + "step": "{{ 1 }}", + } + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify(hass, 4, 1, 0.0, 100.0) + + +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": { + "number": { + "set_value": {"service": "script.set_value"}, + } + } + }, + ) + + with assert_setup_component(0, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "number": { + "state": "{{ 4 }}", + } + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert hass.states.async_all() == [] + + +async def test_all_optional_config(hass, calls): + """Test: including all optional templates is ok.""" + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "number": { + "state": "{{ 4 }}", + "set_value": {"service": "script.set_value"}, + "min": "{{ 3 }}", + "max": "{{ 5 }}", + "step": "{{ 1 }}", + } + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify(hass, 4, 1, 3, 5) + + +async def test_templates_with_entities(hass, calls): + """Test tempalates with values from other entities.""" + with assert_setup_component(4, "input_number"): + assert await setup.async_setup_component( + hass, + "input_number", + { + "input_number": { + "value": { + "min": 0.0, + "max": 100.0, + "name": "Value", + "step": 1.0, + "mode": "slider", + }, + "step": { + "min": 0.0, + "max": 100.0, + "name": "Step", + "step": 1.0, + "mode": "slider", + }, + "minimum": { + "min": 0.0, + "max": 100.0, + "name": "Minimum", + "step": 1.0, + "mode": "slider", + }, + "maximum": { + "min": 0.0, + "max": 100.0, + "name": "Maximum", + "step": 1.0, + "mode": "slider", + }, + } + }, + ) + + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "unique_id": "b", + "number": { + "state": f"{{{{ states('{_VALUE_INPUT_NUMBER}') }}}}", + "step": f"{{{{ states('{_STEP_INPUT_NUMBER}') }}}}", + "min": f"{{{{ states('{_MINIMUM_INPUT_NUMBER}') }}}}", + "max": f"{{{{ states('{_MAXIMUM_INPUT_NUMBER}') }}}}", + "set_value": { + "service": "input_number.set_value", + "data_template": { + "entity_id": _VALUE_INPUT_NUMBER, + "value": "{{ value }}", + }, + }, + "optimistic": True, + "unique_id": "a", + }, + } + }, + ) + + hass.states.async_set(_VALUE_INPUT_NUMBER, 4) + hass.states.async_set(_STEP_INPUT_NUMBER, 1) + hass.states.async_set(_MINIMUM_INPUT_NUMBER, 3) + hass.states.async_set(_MAXIMUM_INPUT_NUMBER, 5) + + 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_NUMBER) + assert entry + assert entry.unique_id == "b-a" + + _verify(hass, 4, 1, 3, 5) + + await hass.services.async_call( + INPUT_NUMBER_DOMAIN, + INPUT_NUMBER_SERVICE_SET_VALUE, + {CONF_ENTITY_ID: _VALUE_INPUT_NUMBER, INPUT_NUMBER_ATTR_VALUE: 5}, + blocking=True, + ) + await hass.async_block_till_done() + _verify(hass, 5, 1, 3, 5) + + await hass.services.async_call( + INPUT_NUMBER_DOMAIN, + INPUT_NUMBER_SERVICE_SET_VALUE, + {CONF_ENTITY_ID: _STEP_INPUT_NUMBER, INPUT_NUMBER_ATTR_VALUE: 2}, + blocking=True, + ) + await hass.async_block_till_done() + _verify(hass, 5, 2, 3, 5) + + await hass.services.async_call( + INPUT_NUMBER_DOMAIN, + INPUT_NUMBER_SERVICE_SET_VALUE, + {CONF_ENTITY_ID: _MINIMUM_INPUT_NUMBER, INPUT_NUMBER_ATTR_VALUE: 2}, + blocking=True, + ) + await hass.async_block_till_done() + _verify(hass, 5, 2, 2, 5) + + await hass.services.async_call( + INPUT_NUMBER_DOMAIN, + INPUT_NUMBER_SERVICE_SET_VALUE, + {CONF_ENTITY_ID: _MAXIMUM_INPUT_NUMBER, INPUT_NUMBER_ATTR_VALUE: 6}, + blocking=True, + ) + await hass.async_block_till_done() + _verify(hass, 5, 2, 2, 6) + + await hass.services.async_call( + NUMBER_DOMAIN, + NUMBER_SERVICE_SET_VALUE, + {CONF_ENTITY_ID: _TEST_NUMBER, NUMBER_ATTR_VALUE: 2}, + blocking=True, + ) + _verify(hass, 2, 2, 2, 6) + + +async def test_trigger_number(hass): + """Test trigger based template number.""" + 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"}, + "number": [ + { + "name": "Hello Name", + "unique_id": "hello_name-id", + "state": "{{ trigger.event.data.beers_drank }}", + "min": "{{ trigger.event.data.min_beers }}", + "max": "{{ trigger.event.data.max_beers }}", + "step": "{{ trigger.event.data.step }}", + "set_value": {"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("number.hello_name") + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.attributes["min"] == 0.0 + assert state.attributes["max"] == 100.0 + assert state.attributes["step"] == 1.0 + + context = Context() + hass.bus.async_fire( + "test_event", + {"beers_drank": 3, "min_beers": 1.0, "max_beers": 5.0, "step": 0.5}, + context=context, + ) + await hass.async_block_till_done() + + state = hass.states.get("number.hello_name") + assert state is not None + assert state.state == "3.0" + assert state.attributes["min"] == 1.0 + assert state.attributes["max"] == 5.0 + assert state.attributes["step"] == 0.5 + + await hass.services.async_call( + NUMBER_DOMAIN, + NUMBER_SERVICE_SET_VALUE, + {CONF_ENTITY_ID: "number.hello_name", NUMBER_ATTR_VALUE: 2}, + blocking=True, + ) + assert len(events) == 1 + assert events[0].event_type == "test_number_event" + + +def _verify( + hass, + expected_value, + expected_step, + expected_minimum, + expected_maximum, +): + """Verify number's state.""" + state = hass.states.get(_TEST_NUMBER) + attributes = state.attributes + assert state.state == str(float(expected_value)) + assert attributes.get(ATTR_STEP) == float(expected_step) + assert attributes.get(ATTR_MAX) == float(expected_maximum) + assert attributes.get(ATTR_MIN) == float(expected_minimum)