1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-20 02:48:57 +00:00

Add new template entity framework to template alarm control panel (#156614)

This commit is contained in:
Petro31
2025-12-19 08:41:45 -05:00
committed by GitHub
parent 6553337b79
commit ddb1ae371d
8 changed files with 1406 additions and 150 deletions

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
from collections.abc import Generator, Sequence
from enum import Enum
import logging
from typing import TYPE_CHECKING, Any
@@ -29,8 +28,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
@@ -39,6 +37,7 @@ from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.script import Script
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import validators as tcv
from .const import DOMAIN
from .coordinator import TriggerUpdateCoordinator
from .entity import AbstractTemplateEntity
@@ -56,19 +55,6 @@ from .template_entity import TemplateEntity
from .trigger_entity import TriggerEntity
_LOGGER = logging.getLogger(__name__)
_VALID_STATES = [
AlarmControlPanelState.ARMED_AWAY,
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
AlarmControlPanelState.ARMED_HOME,
AlarmControlPanelState.ARMED_NIGHT,
AlarmControlPanelState.ARMED_VACATION,
AlarmControlPanelState.ARMING,
AlarmControlPanelState.DISARMED,
AlarmControlPanelState.DISARMING,
AlarmControlPanelState.PENDING,
AlarmControlPanelState.TRIGGERED,
STATE_UNAVAILABLE,
]
CONF_ALARM_CONTROL_PANELS = "panels"
CONF_ARM_AWAY_ACTION = "arm_away"
@@ -212,22 +198,22 @@ class AbstractTemplateAlarmControlPanel(
_entity_id_format = ENTITY_ID_FORMAT
_optimistic_entity = True
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called
"""Initialize the features."""
self._attr_code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED]
self._attr_code_format = config[CONF_CODE_FORMAT].value
# The super init is not called because TemplateEntity calls AbstractTemplateEntity.__init__.
def __init__(self, name: str) -> None: # pylint: disable=super-init-not-called
"""Setup the templates and scripts."""
self._attr_code_arm_required: bool = self._config[CONF_CODE_ARM_REQUIRED]
self._attr_code_format = self._config[CONF_CODE_FORMAT].value
self.setup_state_template(
CONF_STATE,
"_attr_alarm_state",
validator=tcv.strenum(self, CONF_STATE, AlarmControlPanelState),
)
self._attr_supported_features: AlarmControlPanelEntityFeature = (
AlarmControlPanelEntityFeature(0)
)
def _iterate_scripts(
self, config: dict[str, Any]
) -> Generator[
tuple[str, Sequence[dict[str, Any]], AlarmControlPanelEntityFeature | int]
]:
for action_id, supported_feature in (
(CONF_DISARM_ACTION, 0),
(CONF_ARM_AWAY_ACTION, AlarmControlPanelEntityFeature.ARM_AWAY),
@@ -240,35 +226,21 @@ class AbstractTemplateAlarmControlPanel(
),
(CONF_TRIGGER_ACTION, AlarmControlPanelEntityFeature.TRIGGER),
):
if (action_config := config.get(action_id)) is not None:
yield (action_id, action_config, supported_feature)
if (action_config := self._config.get(action_id)) is not None:
self.add_script(action_id, action_config, name, DOMAIN)
self._attr_supported_features |= supported_feature
async def _async_handle_restored_state(self) -> None:
if (
(last_state := await self.async_get_last_state()) is not None
and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
and last_state.state in _VALID_STATES
and last_state.state in AlarmControlPanelState
# The trigger might have fired already while we waited for stored data,
# then we should not restore state
and self._attr_alarm_state is None
):
self._attr_alarm_state = AlarmControlPanelState(last_state.state)
def _handle_state(self, result: Any) -> None:
# Validate state
if result in _VALID_STATES:
self._attr_alarm_state = result
_LOGGER.debug("Valid state - %s", result)
return
_LOGGER.error(
"Received invalid alarm panel state: %s for entity %s. Expected: %s",
result,
self.entity_id,
", ".join(_VALID_STATES),
)
self._attr_alarm_state = None
async def _async_alarm_arm(self, state: Any, script: Script | None, code: Any):
"""Arm the panel to specified state with supplied script."""
@@ -351,39 +323,17 @@ class StateAlarmControlPanelEntity(TemplateEntity, AbstractTemplateAlarmControlP
) -> None:
"""Initialize the panel."""
TemplateEntity.__init__(self, hass, config, unique_id)
AbstractTemplateAlarmControlPanel.__init__(self, config)
name = self._attr_name
if TYPE_CHECKING:
assert name is not None
for action_id, action_config, supported_feature in self._iterate_scripts(
config
):
self.add_script(action_id, action_config, name, DOMAIN)
self._attr_supported_features |= supported_feature
AbstractTemplateAlarmControlPanel.__init__(self, name)
async def async_added_to_hass(self) -> None:
"""Restore last state."""
await super().async_added_to_hass()
await self._async_handle_restored_state()
@callback
def _update_state(self, result):
if isinstance(result, TemplateError):
self._attr_alarm_state = None
return
self._handle_state(result)
@callback
def _async_setup_templates(self) -> None:
"""Set up templates."""
if self._template:
self.add_template_attribute(
"_attr_alarm_state", self._template, None, self._update_state
)
super()._async_setup_templates()
class TriggerAlarmControlPanelEntity(TriggerEntity, AbstractTemplateAlarmControlPanel):
"""Alarm Control Panel entity based on trigger data."""
@@ -398,19 +348,8 @@ class TriggerAlarmControlPanelEntity(TriggerEntity, AbstractTemplateAlarmControl
) -> None:
"""Initialize the entity."""
TriggerEntity.__init__(self, hass, coordinator, config)
AbstractTemplateAlarmControlPanel.__init__(self, config)
self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME)
if isinstance(config.get(CONF_STATE), template.Template):
self._to_render_simple.append(CONF_STATE)
self._parse_result.add(CONF_STATE)
for action_id, action_config, supported_feature in self._iterate_scripts(
config
):
self.add_script(action_id, action_config, name, DOMAIN)
self._attr_supported_features |= supported_feature
AbstractTemplateAlarmControlPanel.__init__(self, name)
async def async_added_to_hass(self) -> None:
"""Restore last state."""
@@ -426,7 +365,6 @@ class TriggerAlarmControlPanelEntity(TriggerEntity, AbstractTemplateAlarmControl
self.async_write_ha_state()
return
if (rendered := self._rendered.get(CONF_STATE)) is not None:
self._handle_state(rendered)
if self.handle_rendered_result(CONF_STATE):
self.async_set_context(self.coordinator.data["context"])
self.async_write_ha_state()

View File

@@ -1,7 +1,8 @@
"""Template entity base class."""
from abc import abstractmethod
from collections.abc import Sequence
from collections.abc import Callable, Sequence
from dataclasses import dataclass
import logging
from typing import Any
@@ -18,6 +19,17 @@ from .const import CONF_DEFAULT_ENTITY_ID
_LOGGER = logging.getLogger(__name__)
@dataclass
class EntityTemplate:
"""Information class for properly handling template results."""
attribute: str
template: Template
validator: Callable[[Any], Any] | None
on_update: Callable[[Any], None] | None
none_on_template_error: bool
class AbstractTemplateEntity(Entity):
"""Actions linked to a template entity."""
@@ -34,6 +46,8 @@ class AbstractTemplateEntity(Entity):
"""Initialize the entity."""
self.hass = hass
self._config = config
self._templates: dict[str, EntityTemplate] = {}
self._action_scripts: dict[str, Script] = {}
if self._optimistic_entity:
@@ -72,6 +86,35 @@ class AbstractTemplateEntity(Entity):
def _render_script_variables(self) -> dict:
"""Render configured variables."""
@abstractmethod
def setup_state_template(
self,
option: str,
attribute: str,
validator: Callable[[Any], Any] | None = None,
on_update: Callable[[Any], None] | None = None,
) -> None:
"""Set up a template that manages the main state of the entity."""
def add_template(
self,
option: str,
attribute: str,
validator: Callable[[Any], Any] | None = None,
on_update: Callable[[Any], None] | None = None,
none_on_template_error: bool = False,
add_if_static: bool = True,
) -> Template | None:
"""Add a template."""
if (template := self._config.get(option)) and isinstance(template, Template):
if add_if_static or (not template.is_static):
self._templates[option] = EntityTemplate(
attribute, template, validator, on_update, none_on_template_error
)
return template
return None
def add_script(
self,
script_id: str,

View File

@@ -181,9 +181,6 @@ class TemplateEntity(AbstractTemplateEntity):
self._run_variables: ScriptVariables | dict
self._attribute_templates = config.get(CONF_ATTRIBUTES)
self._availability_template = config.get(CONF_AVAILABILITY)
self._icon_template = config.get(CONF_ICON)
self._entity_picture_template = config.get(CONF_PICTURE)
self._friendly_name_template = config.get(CONF_NAME)
self._run_variables = config.get(CONF_VARIABLES, {})
self._blueprint_inputs = config.get("raw_blueprint_inputs")
@@ -208,27 +205,28 @@ class TemplateEntity(AbstractTemplateEntity):
)
variables = {"this": DummyState(), **variables}
# Try to render the name as it can influence the entity ID
self.add_template(
CONF_AVAILABILITY, "_attr_available", on_update=self._update_available
)
# Render name, icon, and picture early. name is rendered early because it influences
# the entity_id. icon and picture are rendered early to ensure they are populated even
# if the entity renders unavailable.
self._attr_name = None
if self._friendly_name_template:
with contextlib.suppress(TemplateError):
self._attr_name = self._friendly_name_template.async_render(
variables=variables, parse_result=False
)
# Templates will not render while the entity is unavailable, try to render the
# icon and picture templates.
if self._entity_picture_template:
with contextlib.suppress(TemplateError):
self._attr_entity_picture = self._entity_picture_template.async_render(
variables=variables, parse_result=False
)
if self._icon_template:
with contextlib.suppress(TemplateError):
self._attr_icon = self._icon_template.async_render(
variables=variables, parse_result=False
)
for option, attribute, validator in (
(CONF_ICON, "_attr_icon", vol.Or(cv.whitespace, cv.icon)),
(CONF_PICTURE, "_attr_entity_picture", cv.string),
(CONF_NAME, "_attr_name", cv.string),
):
if template := self.add_template(
option, attribute, validator, add_if_static=option != CONF_NAME
):
with contextlib.suppress(TemplateError):
setattr(
self,
attribute,
template.async_render(variables=variables, parse_result=False),
)
@callback
def _update_available(self, result: str | TemplateError) -> None:
@@ -278,6 +276,33 @@ class TemplateEntity(AbstractTemplateEntity):
},
)
def setup_state_template(
self,
option: str,
attribute: str,
validator: Callable[[Any], Any] | None = None,
on_update: Callable[[Any], None] | None = None,
) -> None:
"""Set up a template that manages the main state of the entity."""
@callback
def _update_state(result: Any) -> None:
if isinstance(result, TemplateError):
setattr(self, attribute, None)
if self._availability_template:
return
self._attr_available = False
return
state = validator(result) if validator else result
if on_update:
on_update(state)
else:
setattr(self, attribute, state)
self.add_template(option, attribute, on_update=_update_state)
def add_template_attribute(
self,
attribute: str,
@@ -417,30 +442,20 @@ class TemplateEntity(AbstractTemplateEntity):
@callback
def _async_setup_templates(self) -> None:
"""Set up templates."""
if self._availability_template is not None:
self.add_template_attribute(
"_attr_available",
self._availability_template,
None,
self._update_available,
)
# Handle attributes as a dictionary.
if self._attribute_templates is not None:
for key, value in self._attribute_templates.items():
self._add_attribute_template(key, value)
if self._icon_template is not None:
# Iterate all dynamic templates and add listeners.
for entity_template in self._templates.values():
self.add_template_attribute(
"_attr_icon", self._icon_template, vol.Or(cv.whitespace, cv.icon)
)
if self._entity_picture_template is not None:
self.add_template_attribute(
"_attr_entity_picture", self._entity_picture_template, cv.string
)
if (
self._friendly_name_template is not None
and not self._friendly_name_template.is_static
):
self.add_template_attribute(
"_attr_name", self._friendly_name_template, cv.string
entity_template.attribute,
entity_template.template,
entity_template.validator,
entity_template.on_update,
entity_template.none_on_template_error,
)
@callback

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from homeassistant.const import CONF_STATE, CONF_VARIABLES
@@ -50,6 +51,19 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
else:
self._unique_id = unique_id
def setup_state_template(
self,
option: str,
attribute: str,
validator: Callable[[Any], Any] | None = None,
on_update: Callable[[Any], None] | None = None,
) -> None:
"""Set up a template that manages the main state of the entity."""
if self._config.get(option):
self._to_render_simple.append(CONF_STATE)
self._parse_result.add(CONF_STATE)
self.add_template(option, attribute, validator, on_update)
@property
def referenced_blueprint(self) -> str | None:
"""Return referenced blueprint or None."""
@@ -89,6 +103,23 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
self._render_attributes(rendered, variables)
self._rendered = rendered
def handle_rendered_result(self, key: str) -> bool:
"""Get a rendered result and return the value."""
if (rendered := self._rendered.get(key)) is not None:
if (entity_template := self._templates.get(key)) is not None:
value = rendered
if entity_template.validator:
value = entity_template.validator(rendered)
if entity_template.on_update:
entity_template.on_update(value)
else:
setattr(self, entity_template.attribute, value)
return True
return False
@callback
def _process_data(self) -> None:
"""Process new data."""

View File

@@ -0,0 +1,305 @@
"""Template config validation methods."""
from collections.abc import Callable
from enum import StrEnum
import logging
from typing import Any
import voluptuous as vol
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import Entity
_LOGGER = logging.getLogger(__name__)
# Valid on/off values for booleans. These tuples are pulled
# from cv.boolean and are used to produce logger errors for the user.
RESULT_ON = ("1", "true", "yes", "on", "enable")
RESULT_OFF = ("0", "false", "no", "off", "disable")
def _log_validation_result_error(
entity: Entity,
attribute: str,
value: Any,
expected: tuple[str, ...] | str,
) -> None:
"""Log a template result error."""
# in some cases, like `preview` entities, the entity_id does not exist.
if entity.entity_id is None:
message = f"Received invalid {attribute}: {value} for entity {entity.name}, %s"
else:
message = (
f"Received invalid {entity.entity_id.split('.')[0]} {attribute}"
f": {value} for entity {entity.entity_id}, %s"
)
_LOGGER.error(
message,
expected
if isinstance(expected, str)
else "expected one of " + ", ".join(expected),
)
def _check_result_for_none(result: Any, **kwargs: Any) -> bool:
"""Checks the result for none, unknown, unavailable."""
if result is None:
return True
if kwargs.get("none_on_unknown_unavailable") and isinstance(result, str):
return result.lower() in (STATE_UNAVAILABLE, STATE_UNKNOWN)
return False
def strenum[T: StrEnum](
entity: Entity,
attribute: str,
state_enum: type[T],
state_on: T | None = None,
state_off: T | None = None,
**kwargs: Any,
) -> Callable[[Any], T | None]:
"""Converts the template result to an StrEnum.
All strings will attempt to convert to the StrEnum
If state_on or state_off are provided, boolean values will return the
enum that represents each boolean value.
Anything that cannot convert will result in None.
none_on_unknown_unavailable
"""
def convert(result: Any) -> T | None:
if _check_result_for_none(result, **kwargs):
return None
if isinstance(result, str):
value = result.lower().strip()
try:
return state_enum(value)
except ValueError:
pass
if state_on or state_off:
try:
bool_value = cv.boolean(result)
if state_on and bool_value:
return state_on
if state_off and not bool_value:
return state_off
except vol.Invalid:
pass
expected = tuple(s.value for s in state_enum)
if state_on:
expected += RESULT_ON
if state_off:
expected += RESULT_OFF
_log_validation_result_error(
entity,
attribute,
result,
expected,
)
return None
return convert
def boolean(
entity: Entity,
attribute: str,
as_true: tuple[str, ...] | None = None,
as_false: tuple[str, ...] | None = None,
**kwargs: Any,
) -> Callable[[Any], bool | None]:
"""Convert the result to a boolean.
True/not 0/'1'/'true'/'yes'/'on'/'enable' are considered truthy
False/0/'0'/'false'/'no'/'off'/'disable' are considered falsy
Additional values provided by as_true are considered truthy
Additional values provided by as_false are considered truthy
All other values are None
"""
def convert(result: Any) -> bool | None:
if _check_result_for_none(result, **kwargs):
return None
if isinstance(result, bool):
return result
if isinstance(result, str) and (as_true or as_false):
value = result.lower().strip()
if as_true and value in as_true:
return True
if as_false and value in as_false:
return False
try:
return cv.boolean(result)
except vol.Invalid:
pass
items: tuple[str, ...] = RESULT_ON + RESULT_OFF
if as_true:
items += as_true
if as_false:
items += as_false
_log_validation_result_error(entity, attribute, result, items)
return None
return convert
def number(
entity: Entity,
attribute: str,
minimum: float | None = None,
maximum: float | None = None,
return_type: type[float] | type[int] = float,
**kwargs: Any,
) -> Callable[[Any], float | int | None]:
"""Convert the result to a number (float or int).
Any value in the range is converted to a float or int
All other values are None
"""
message = "expected a number"
if minimum is not None and maximum is not None:
message = f"{message} between {minimum:0.1f} and {maximum:0.1f}"
elif minimum is not None and maximum is None:
message = f"{message} greater than or equal to {minimum:0.1f}"
elif minimum is None and maximum is not None:
message = f"{message} less than or equal to {maximum:0.1f}"
def convert(result: Any) -> float | int | None:
if _check_result_for_none(result, **kwargs):
return None
if (result_type := type(result)) is bool:
_log_validation_result_error(entity, attribute, result, message)
return None
if isinstance(result, (float, int)):
value = result
if return_type is int and result_type is float:
value = int(value)
elif return_type is float and result_type is int:
value = float(value)
else:
try:
value = vol.Coerce(float)(result)
if return_type is int:
value = int(value)
except vol.Invalid:
_log_validation_result_error(entity, attribute, result, message)
return None
if minimum is None and maximum is None:
return value
if (
(
minimum is not None
and maximum is not None
and minimum <= value <= maximum
)
or (minimum is not None and maximum is None and value >= minimum)
or (minimum is None and maximum is not None and value <= maximum)
):
return value
_log_validation_result_error(entity, attribute, result, message)
return None
return convert
def list_of_strings(
entity: Entity,
attribute: str,
none_on_empty: bool = False,
**kwargs: Any,
) -> Callable[[Any], list[str] | None]:
"""Convert the result to a list of strings.
This ensures the result is a list of strings.
All other values that are not lists will result in None.
none_on_empty will cause the converter to return None when the list is empty.
"""
def convert(result: Any) -> list[str] | None:
if _check_result_for_none(result, **kwargs):
return None
if not isinstance(result, list):
_log_validation_result_error(
entity,
attribute,
result,
"expected a list of strings",
)
return None
if none_on_empty and len(result) == 0:
return None
# Ensure the result are strings.
return [str(v) for v in result]
return convert
def item_in_list[T](
entity: Entity,
attribute: str,
items: list[Any] | None,
items_attribute: str | None = None,
**kwargs: Any,
) -> Callable[[Any], Any | None]:
"""Assert the result of the template is an item inside a list.
Returns the result if the result is inside the list.
All results that are not inside the list will return None.
"""
def convert(result: Any) -> Any | None:
if _check_result_for_none(result, **kwargs):
return None
# items may be mutable based on another template field. Always
# perform this check when the items come from an configured
# attribute.
if items is None or (len(items) == 0):
if items_attribute:
_log_validation_result_error(
entity,
attribute,
result,
f"{items_attribute} is empty",
)
return None
if result not in items:
_log_validation_result_error(
entity,
attribute,
result,
tuple(str(v) for v in items),
)
return None
return result
return convert

View File

@@ -1,7 +1,7 @@
"""template conftest."""
from dataclasses import dataclass
from enum import Enum
from enum import Enum, StrEnum
import pytest
@@ -25,6 +25,15 @@ class ConfigurationStyle(Enum):
TRIGGER = "Trigger"
class Brewery(StrEnum):
"""Test enum."""
MMMM = "mmmm"
BEER = "beer"
IS = "is"
GOOD = "good"
def make_test_trigger(*entities: str) -> dict:
"""Make a test state trigger."""
return {

View File

@@ -364,23 +364,19 @@ async def test_template_state_text(hass: HomeAssistant) -> None:
@pytest.mark.parametrize("count", [1])
@pytest.mark.parametrize(
("state_template", "expected", "trigger_expected"),
("state_template", "expected"),
[
("{{ 'disarmed' }}", AlarmControlPanelState.DISARMED, None),
("{{ 'armed_home' }}", AlarmControlPanelState.ARMED_HOME, None),
("{{ 'armed_away' }}", AlarmControlPanelState.ARMED_AWAY, None),
("{{ 'armed_night' }}", AlarmControlPanelState.ARMED_NIGHT, None),
("{{ 'armed_vacation' }}", AlarmControlPanelState.ARMED_VACATION, None),
(
"{{ 'armed_custom_bypass' }}",
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
None,
),
("{{ 'pending' }}", AlarmControlPanelState.PENDING, None),
("{{ 'arming' }}", AlarmControlPanelState.ARMING, None),
("{{ 'disarming' }}", AlarmControlPanelState.DISARMING, None),
("{{ 'triggered' }}", AlarmControlPanelState.TRIGGERED, None),
("{{ x - 1 }}", STATE_UNKNOWN, STATE_UNAVAILABLE),
("{{ 'disarmed' }}", AlarmControlPanelState.DISARMED),
("{{ 'armed_home' }}", AlarmControlPanelState.ARMED_HOME),
("{{ 'armed_away' }}", AlarmControlPanelState.ARMED_AWAY),
("{{ 'armed_night' }}", AlarmControlPanelState.ARMED_NIGHT),
("{{ 'armed_vacation' }}", AlarmControlPanelState.ARMED_VACATION),
("{{ 'armed_custom_bypass' }}", AlarmControlPanelState.ARMED_CUSTOM_BYPASS),
("{{ 'pending' }}", AlarmControlPanelState.PENDING),
("{{ 'arming' }}", AlarmControlPanelState.ARMING),
("{{ 'disarming' }}", AlarmControlPanelState.DISARMING),
("{{ 'triggered' }}", AlarmControlPanelState.TRIGGERED),
("{{ x - 1 }}", STATE_UNAVAILABLE),
],
)
@pytest.mark.parametrize(
@@ -388,9 +384,7 @@ async def test_template_state_text(hass: HomeAssistant) -> None:
[ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.usefixtures("setup_state_panel")
async def test_state_template_states(
hass: HomeAssistant, expected: str, trigger_expected: str, style: ConfigurationStyle
) -> None:
async def test_state_template_states(hass: HomeAssistant, expected: str) -> None:
"""Test the state template."""
# Force a trigger
@@ -399,9 +393,6 @@ async def test_state_template_states(
state = hass.states.get(TEST_ENTITY_ID)
if trigger_expected and style == ConfigurationStyle.TRIGGER:
expected = trigger_expected
assert state.state == expected

View File

@@ -0,0 +1,924 @@
"""Test template validators."""
from typing import Any
import pytest
from homeassistant.components.template import validators as cv
from homeassistant.components.template.template_entity import TemplateEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.template import Template
from .conftest import Brewery
def expect_boolean_on(true_value: Any = True) -> list[tuple[Any, Any]]:
"""Tuple of commonn boolean on value expected pairs."""
return [
("on", true_value),
("On", true_value),
("oN", true_value),
("true", true_value),
("yes", true_value),
("enable", true_value),
("1", true_value),
(True, true_value),
(1, true_value),
(8.23432, true_value),
(0.23432, true_value),
]
def expect_boolean_off(false_value: Any = False) -> list[tuple[Any, Any]]:
"""Tuple of commonn boolean off value expected pairs."""
return [
("off", false_value),
("false", false_value),
("no", false_value),
("disable", false_value),
("0", false_value),
(False, false_value),
(0, false_value),
]
def expect_none(*args: Any) -> list[tuple[Any, None]]:
"""Tuple of results that should return None."""
return [(v, None) for v in args]
def check_for_error(value: Any, expected: Any, caplog_text: str, error: str) -> None:
"""Test the validator error."""
if expected is None and value is not None:
assert error in caplog_text
else:
assert error not in caplog_text
def create_test_entity(hass: HomeAssistant, config: dict) -> TemplateEntity:
"""Create a template result handler."""
class Test(TemplateEntity):
_entity_id_format = "test.{}"
config = {
"name": Template("Test", hass),
**config,
}
return Test(hass, config, "something_unique")
@pytest.mark.parametrize(
("config", "error"),
[
(
{"default_entity_id": "test.test"},
"Received invalid test state: {} for entity test.test, expected one of mmmm, beer, is, good",
),
(
{},
"Received invalid state: {} for entity Test, expected one of mmmm, beer, is, good",
),
],
)
@pytest.mark.parametrize(
("value", "expected"),
[
("mmmm", Brewery.MMMM),
("MmMM", Brewery.MMMM),
("mmMM", Brewery.MMMM),
("beer", Brewery.BEER),
("is", Brewery.IS),
("good", Brewery.GOOD),
*expect_none(
None,
"mm",
"beeal;dfj",
"unknown",
"unavailable",
"tru", # codespell:ignore tru
"7",
"-1",
True,
False,
1,
1.0,
{},
{"junk": "stuff"},
{"junk"},
[],
["stuff"],
),
],
)
async def test_enum(
hass: HomeAssistant,
config: dict,
error: str,
value: Any,
expected: bool | None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test enum validator."""
entity = create_test_entity(hass, config)
assert cv.strenum(entity, "state", Brewery)(value) == expected
check_for_error(value, expected, caplog.text, error.format(value))
@pytest.mark.parametrize(
"value",
[
"unknown",
"unavailable",
"UknoWn", # codespell:ignore
"UnavailablE", # codespell:ignore UnavailablE
],
)
async def test_none_on_unknown_and_unavailable(
hass: HomeAssistant,
value: Any,
) -> None:
"""Test enum validator."""
entity = create_test_entity(hass, {})
assert (
cv.strenum(entity, "state", Brewery, none_on_unknown_unavailable=True)(value)
is None
)
@pytest.mark.parametrize(
("config", "error"),
[
(
{"default_entity_id": "test.test"},
"Received invalid test state: {} for entity test.test, expected one of mmmm, beer, is, good, 1, true, yes, on, enable, 0, false, no, off, disable",
),
(
{},
"Received invalid state: {} for entity Test, expected one of mmmm, beer, is, good, 1, true, yes, on, enable, 0, false, no, off, disable",
),
],
)
@pytest.mark.parametrize(
("value", "expected"),
[
("mmmm", Brewery.MMMM),
("MmMM", Brewery.MMMM),
("mmMM", Brewery.MMMM),
("beer", Brewery.BEER),
("is", Brewery.IS),
("good", Brewery.GOOD),
*expect_boolean_on(Brewery.MMMM),
*expect_boolean_off(Brewery.BEER),
*expect_none(
None,
"mm",
"beeal;dfj",
"unknown",
"unavailable",
"tru", # codespell:ignore tru
"7",
"-1",
{},
{"junk": "stuff"},
{"junk"},
[],
["stuff"],
),
],
)
async def test_enum_with_on_off(
hass: HomeAssistant,
config: dict,
error: str,
value: Any,
expected: bool | None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test enum validator."""
entity = create_test_entity(hass, config)
assert (
cv.strenum(entity, "state", Brewery, Brewery.MMMM, Brewery.BEER)(value)
== expected
)
check_for_error(value, expected, caplog.text, error.format(value))
@pytest.mark.parametrize(
("config", "error"),
[
(
{"default_entity_id": "test.test"},
"Received invalid test state: {} for entity test.test, expected one of mmmm, beer, is, good, 1, true, yes, on, enable",
),
(
{},
"Received invalid state: {} for entity Test, expected one of mmmm, beer, is, good, 1, true, yes, on, enable",
),
],
)
@pytest.mark.parametrize(
("value", "expected"),
[
("mmmm", Brewery.MMMM),
("MmMM", Brewery.MMMM),
("mmMM", Brewery.MMMM),
("beer", Brewery.BEER),
("is", Brewery.IS),
("good", Brewery.GOOD),
*expect_boolean_on(Brewery.MMMM),
*expect_boolean_off(None),
*expect_none(
None,
"mm",
"beeal;dfj",
"unknown",
"unavailable",
"tru", # codespell:ignore tru
"7",
"-1",
{},
{"junk": "stuff"},
{"junk"},
[],
["stuff"],
),
],
)
async def test_enum_with_on(
hass: HomeAssistant,
config: dict,
error: str,
value: Any,
expected: bool | None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test enum with state_on validator."""
entity = create_test_entity(hass, config)
assert (
cv.strenum(entity, "state", Brewery, state_on=Brewery.MMMM)(value) == expected
)
check_for_error(value, expected, caplog.text, error.format(value))
@pytest.mark.parametrize(
("config", "error"),
[
(
{"default_entity_id": "test.test"},
"Received invalid test state: {} for entity test.test, expected one of mmmm, beer, is, good, 0, false, no, off, disable",
),
(
{},
"Received invalid state: {} for entity Test, expected one of mmmm, beer, is, good, 0, false, no, off, disable",
),
],
)
@pytest.mark.parametrize(
("value", "expected"),
[
("mmmm", Brewery.MMMM),
("MmMM", Brewery.MMMM),
("mmMM", Brewery.MMMM),
("beer", Brewery.BEER),
("is", Brewery.IS),
("good", Brewery.GOOD),
*expect_boolean_on(None),
*expect_boolean_off(Brewery.BEER),
*expect_none(
None,
"mm",
"beeal;dfj",
"unknown",
"unavailable",
"tru", # codespell:ignore tru
"7",
"-1",
{},
{"junk": "stuff"},
{"junk"},
[],
["stuff"],
),
],
)
async def test_enum_with_off(
hass: HomeAssistant,
config: dict,
error: str,
value: Any,
expected: bool | None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test enum with state_off validator."""
entity = create_test_entity(hass, config)
assert (
cv.strenum(entity, "state", Brewery, state_off=Brewery.BEER)(value) == expected
)
check_for_error(value, expected, caplog.text, error.format(value))
@pytest.mark.parametrize(
("config", "error"),
[
(
{"default_entity_id": "test.test"},
"Received invalid test state: {} for entity test.test, expected one of 1, true, yes, on, enable, 0, false, no, off, disable",
),
(
{},
"Received invalid state: {} for entity Test, expected one of 1, true, yes, on, enable, 0, false, no, off, disable",
),
],
)
@pytest.mark.parametrize(
("value", "expected"),
[
*expect_boolean_on(),
*expect_boolean_off(),
*expect_none(
None,
"al;dfj",
"unknown",
"unavailable",
"tru", # codespell:ignore tru
"7",
"-1",
{},
{"junk": "stuff"},
{"junk"},
[],
["stuff"],
),
],
)
async def test_boolean(
hass: HomeAssistant,
config: dict,
error: str,
value: Any,
expected: bool | None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test boolean validator."""
entity = create_test_entity(hass, config)
assert cv.boolean(entity, "state")(value) == expected
check_for_error(value, expected, caplog.text, error.format(value))
@pytest.mark.parametrize(
("config", "error"),
[
(
{"default_entity_id": "test.test"},
"Received invalid test state: {} for entity test.test, expected one of 1, true, yes, on, enable, 0, false, no, off, disable",
),
(
{},
"Received invalid state: {} for entity Test, expected one of 1, true, yes, on, enable, 0, false, no, off, disable",
),
],
)
@pytest.mark.parametrize(
("value", "expected"),
[
("something_unique", True),
*expect_boolean_on(),
*expect_boolean_off(),
*expect_none(
None,
"al;dfj",
"unknown",
"unavailable",
"tru", # codespell:ignore tru
"7",
"-1",
{},
{"junk": "stuff"},
{"junk"},
[],
["stuff"],
),
],
)
async def test_boolean_as_true(
hass: HomeAssistant,
config: dict,
error: str,
value: Any,
expected: bool | None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test boolean validator."""
entity = create_test_entity(hass, config)
assert cv.boolean(entity, "state", as_true=("something_unique",))(value) == expected
check_for_error(value, expected, caplog.text, error.format(value))
@pytest.mark.parametrize(
("config", "error"),
[
(
{"default_entity_id": "test.test"},
"Received invalid test state: {} for entity test.test, expected one of 1, true, yes, on, enable, 0, false, no, off, disable, something_unique",
),
(
{},
"Received invalid state: {} for entity Test, expected one of 1, true, yes, on, enable, 0, false, no, off, disable, something_unique",
),
],
)
@pytest.mark.parametrize(
("value", "expected"),
[
("something_unique", False),
*expect_boolean_on(),
*expect_boolean_off(),
*expect_none(
None,
"al;dfj",
"unknown",
"unavailable",
"tru", # codespell:ignore tru
"7",
"-1",
{},
{"junk": "stuff"},
{"junk"},
[],
["stuff"],
),
],
)
async def test_boolean_as_false(
hass: HomeAssistant,
config: dict,
error: str,
value: Any,
expected: bool | None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test boolean validator."""
entity = create_test_entity(hass, config)
assert (
cv.boolean(entity, "state", as_false=("something_unique",))(value) == expected
)
check_for_error(value, expected, caplog.text, error.format(value))
@pytest.mark.parametrize(
("config", "error"),
[
(
{"default_entity_id": "test.test"},
"Received invalid test state: {} for entity test.test, expected a number",
),
(
{},
"Received invalid state: {} for entity Test, expected a number",
),
],
)
@pytest.mark.parametrize(
("value", "expected"),
[
("7.5", 7.5),
("0.0", 0.0),
("-324.4564", -324.4564),
("5e-4", 0.0005),
("5e4", 50000.0),
(7.5, 7.5),
(0.0, 0.0),
(-324.4564, -324.4564),
(1, 1.0),
*expect_none(
None,
"al;dfj",
"unknown",
"unavailable",
"tru", # codespell:ignore tru
True,
False,
{},
{"junk": "stuff"},
{"junk"},
[],
["stuff"],
),
],
)
async def test_number_as_float(
hass: HomeAssistant,
config: dict,
error: str,
value: Any,
expected: bool | None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test number validator."""
entity = create_test_entity(hass, config)
value = cv.number(entity, "state")(value)
assert value == expected
check_for_error(value, expected, caplog.text, error.format(value))
@pytest.mark.parametrize(
("config", "error"),
[
(
{"default_entity_id": "test.test"},
"Received invalid test state: {} for entity test.test, expected a number",
),
(
{},
"Received invalid state: {} for entity Test, expected a number",
),
],
)
@pytest.mark.parametrize(
("value", "expected"),
[
("7.5", 7),
("0.0", 0),
("-324.4564", -324),
("5e-4", 0),
("5e4", 50000),
(7.5, 7),
(0.0, 0),
(-324.4564, -324),
(1, 1),
*expect_none(
None,
"al;dfj",
"unknown",
"unavailable",
"tru", # codespell:ignore tru
True,
False,
{},
{"junk": "stuff"},
{"junk"},
[],
["stuff"],
),
],
)
async def test_number_as_int(
hass: HomeAssistant,
config: dict,
error: str,
value: Any,
expected: bool | None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test number with return_type int validator."""
entity = create_test_entity(hass, config)
value = cv.number(entity, "state", return_type=int)(value)
assert value == expected
check_for_error(value, expected, caplog.text, error.format(value))
@pytest.mark.parametrize(
("config", "error"),
[
(
{"default_entity_id": "test.test"},
"Received invalid test state: {} for entity test.test, expected a number greater than or equal to 0.0",
),
(
{},
"Received invalid state: {} for entity Test, expected a number greater than or equal to 0.0",
),
],
)
@pytest.mark.parametrize(
("value", "expected"),
[
("7.5", 7.5),
("0.0", 0),
("5e-4", 0.0005),
("5e4", 50000.0),
(7.5, 7.5),
(0.0, 0),
(1, 1.0),
*expect_none(
None,
"al;dfj",
"unknown",
"unavailable",
"tru", # codespell:ignore tru
"-324.4564",
-324.4564,
"-0.00001",
-0.00001,
True,
False,
{},
{"junk": "stuff"},
{"junk"},
[],
["stuff"],
),
],
)
async def test_number_with_minimum(
hass: HomeAssistant,
config: dict,
error: str,
value: Any,
expected: bool | None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test number with minimum validator."""
entity = create_test_entity(hass, config)
value = cv.number(entity, "state", minimum=0.0)(value)
assert value == expected
check_for_error(value, expected, caplog.text, error.format(value))
@pytest.mark.parametrize(
("config", "error"),
[
(
{"default_entity_id": "test.test"},
"Received invalid test state: {} for entity test.test, expected a number less than or equal to 0.0",
),
(
{},
"Received invalid state: {} for entity Test, expected a number less than or equal to 0.0",
),
],
)
@pytest.mark.parametrize(
("value", "expected"),
[
("-7.5", -7.5),
("0.0", 0),
("-5e-4", -0.0005),
("-5e4", -50000),
(-7.5, -7.5),
(0.0, 0.0),
(-1, -1.0),
*expect_none(
None,
"al;dfj",
"unknown",
"unavailable",
"tru", # codespell:ignore tru
"324.4564",
"0.00001",
True,
False,
{},
{"junk": "stuff"},
{"junk"},
),
],
)
async def test_number_with_maximum(
hass: HomeAssistant,
config: dict,
error: str,
value: Any,
expected: bool | None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test number with maximum validator."""
entity = create_test_entity(hass, config)
value = cv.number(entity, "state", maximum=0.0)(value)
assert value == expected
check_for_error(value, expected, caplog.text, error.format(value))
@pytest.mark.parametrize(
("config", "error"),
[
(
{"default_entity_id": "test.test"},
"Received invalid test state: {} for entity test.test, expected a number between 0.0 and 100.0",
),
(
{},
"Received invalid state: {} for entity Test, expected a number between 0.0 and 100.0",
),
],
)
@pytest.mark.parametrize(
("value", "expected"),
[
("7.5", 7.5),
("0.0", 0),
("0.0012", 0.0012),
("99.0", 99.0),
("100", 100),
*expect_none(
None,
"al;dfj",
"unknown",
"unavailable",
"tru", # codespell:ignore tru
"324.4564",
"-5e4101",
True,
False,
{},
{"junk": "stuff"},
{"junk"},
),
],
)
async def test_number_in_range(
hass: HomeAssistant,
config: dict,
error: str,
value: Any,
expected: bool | None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test number within a range validator."""
entity = create_test_entity(hass, config)
value = cv.number(entity, "state", minimum=0.0, maximum=100.0)(value)
assert value == expected
check_for_error(value, expected, caplog.text, error.format(value))
@pytest.mark.parametrize(
("config", "error"),
[
(
{"default_entity_id": "test.test"},
"Received invalid test state: {} for entity test.test, expected a list of strings",
),
(
{},
"Received invalid state: {} for entity Test, expected a list of strings",
),
],
)
@pytest.mark.parametrize(
("value", "expected"),
[
(["beer", "is", "good"], ["beer", "is", "good"]),
(["beer", None, True], ["beer", "None", "True"]),
([], []),
(["99.0", 99.0, 99], ["99.0", "99.0", "99"]),
*expect_none(
None,
"al;dfj",
"unknown",
"unavailable",
"tru", # codespell:ignore tru
83242.2342,
True,
False,
{},
{"junk": "stuff"},
{"junk"},
),
],
)
async def test_list_of_strings(
hass: HomeAssistant,
config: dict,
error: str,
value: Any,
expected: bool | None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test result as a list of strings."""
entity = create_test_entity(hass, config)
value = cv.list_of_strings(entity, "state")(value)
assert value == expected
check_for_error(value, expected, caplog.text, error.format(value))
async def test_list_of_strings_none_on_empty(
hass: HomeAssistant,
) -> None:
"""Test result as a list of strings with an empty list returning None."""
entity = create_test_entity(hass, {"default_entity_id": "test.test"})
value = cv.list_of_strings(entity, "state", none_on_empty=True)([])
assert value is None
@pytest.mark.parametrize(
("config", "error"),
[
(
{"default_entity_id": "test.test"},
"Received invalid test state: {} for entity test.test, expected beer, is, GOOD",
),
(
{},
"Received invalid state: {} for entity Test, expected beer, is, GOOD",
),
],
)
@pytest.mark.parametrize(
("value", "expected"),
[
("beer", "beer"),
("is", "is"),
("GOOD", "GOOD"),
*expect_none(
None,
"BEER",
"IS",
"good",
"al;dfj",
"unknown",
"unavailable",
"tru", # codespell:ignore tru
83242.2342,
True,
False,
{},
{"junk": "stuff"},
{"junk"},
),
],
)
async def test_item_in_list(
hass: HomeAssistant,
config: dict,
error: str,
value: Any,
expected: bool | None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test result is in a list."""
entity = create_test_entity(hass, config)
value = cv.item_in_list(entity, "state", ["beer", "is", "GOOD"])(value)
assert value == expected
check_for_error(value, expected, caplog.text, error.format(value))
@pytest.mark.parametrize(
("config", "error"),
[
(
{"default_entity_id": "test.test"},
"Received invalid test state: {} for entity test.test, expected one of beer, is, GOOD",
),
(
{},
"Received invalid state: {} for entity Test, expected one of beer, is, GOOD",
),
],
)
async def test_item_in_list_changes(
hass: HomeAssistant,
config: dict,
error: str,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test test an item is in a list after the list changes."""
entity = create_test_entity(hass, config)
items = ["beer", "is", "GOOD"]
value = cv.item_in_list(entity, "state", items)("mmmm")
assert value is None
assert error.format("mmmm") in caplog.text
items.append("mmmm")
value = cv.item_in_list(entity, "state", items)("mmmm")
assert value == "mmmm"
assert error.format(value) + ", mmmm" not in caplog.text
@pytest.mark.parametrize(
("config", "error"),
[
(
{"default_entity_id": "test.test"},
"Received invalid test state: {} for entity test.test, bar is empty",
),
(
{},
"Received invalid state: {} for entity Test, bar is empty",
),
],
)
@pytest.mark.parametrize(
("value", "expected"),
[("anything", None)],
)
@pytest.mark.parametrize(
("the_list"),
[None, []],
)
async def test_empty_items_in_list(
hass: HomeAssistant,
config: dict,
error: str,
value: Any,
expected: bool | None,
the_list: list | None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test result is in a list."""
entity = create_test_entity(hass, config)
value = cv.item_in_list(entity, "state", the_list, "bar")(value)
assert value == expected
check_for_error(value, expected, caplog.text, error.format(value))