Add number platform to template integration (#54789)

This commit is contained in:
Raman Gupta 2021-08-25 14:34:20 -04:00 committed by GitHub
parent 8407ad01d4
commit e9625e4b7a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 587 additions and 0 deletions

View file

@ -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]
),

View file

@ -15,6 +15,7 @@ PLATFORMS = [
"fan",
"light",
"lock",
"number",
"select",
"sensor",
"switch",

View file

@ -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
)

View file

@ -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)