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

View File

@@ -1,7 +1,8 @@
"""Template entity base class.""" """Template entity base class."""
from abc import abstractmethod from abc import abstractmethod
from collections.abc import Sequence from collections.abc import Callable, Sequence
from dataclasses import dataclass
import logging import logging
from typing import Any from typing import Any
@@ -18,6 +19,17 @@ from .const import CONF_DEFAULT_ENTITY_ID
_LOGGER = logging.getLogger(__name__) _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): class AbstractTemplateEntity(Entity):
"""Actions linked to a template entity.""" """Actions linked to a template entity."""
@@ -34,6 +46,8 @@ class AbstractTemplateEntity(Entity):
"""Initialize the entity.""" """Initialize the entity."""
self.hass = hass self.hass = hass
self._config = config
self._templates: dict[str, EntityTemplate] = {}
self._action_scripts: dict[str, Script] = {} self._action_scripts: dict[str, Script] = {}
if self._optimistic_entity: if self._optimistic_entity:
@@ -72,6 +86,35 @@ class AbstractTemplateEntity(Entity):
def _render_script_variables(self) -> dict: def _render_script_variables(self) -> dict:
"""Render configured variables.""" """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( def add_script(
self, self,
script_id: str, script_id: str,

View File

@@ -181,9 +181,6 @@ class TemplateEntity(AbstractTemplateEntity):
self._run_variables: ScriptVariables | dict self._run_variables: ScriptVariables | dict
self._attribute_templates = config.get(CONF_ATTRIBUTES) self._attribute_templates = config.get(CONF_ATTRIBUTES)
self._availability_template = config.get(CONF_AVAILABILITY) 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._run_variables = config.get(CONF_VARIABLES, {})
self._blueprint_inputs = config.get("raw_blueprint_inputs") self._blueprint_inputs = config.get("raw_blueprint_inputs")
@@ -208,26 +205,27 @@ class TemplateEntity(AbstractTemplateEntity):
) )
variables = {"this": DummyState(), **variables} 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 self._attr_name = None
if self._friendly_name_template: 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): with contextlib.suppress(TemplateError):
self._attr_name = self._friendly_name_template.async_render( setattr(
variables=variables, parse_result=False self,
) attribute,
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
) )
@callback @callback
@@ -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( def add_template_attribute(
self, self,
attribute: str, attribute: str,
@@ -417,30 +442,20 @@ class TemplateEntity(AbstractTemplateEntity):
@callback @callback
def _async_setup_templates(self) -> None: def _async_setup_templates(self) -> None:
"""Set up templates.""" """Set up templates."""
if self._availability_template is not None:
self.add_template_attribute( # Handle attributes as a dictionary.
"_attr_available",
self._availability_template,
None,
self._update_available,
)
if self._attribute_templates is not None: if self._attribute_templates is not None:
for key, value in self._attribute_templates.items(): for key, value in self._attribute_templates.items():
self._add_attribute_template(key, value) 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( self.add_template_attribute(
"_attr_icon", self._icon_template, vol.Or(cv.whitespace, cv.icon) entity_template.attribute,
) entity_template.template,
if self._entity_picture_template is not None: entity_template.validator,
self.add_template_attribute( entity_template.on_update,
"_attr_entity_picture", self._entity_picture_template, cv.string entity_template.none_on_template_error,
)
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
) )
@callback @callback

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from typing import Any from typing import Any
from homeassistant.const import CONF_STATE, CONF_VARIABLES from homeassistant.const import CONF_STATE, CONF_VARIABLES
@@ -50,6 +51,19 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
else: else:
self._unique_id = unique_id 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 @property
def referenced_blueprint(self) -> str | None: def referenced_blueprint(self) -> str | None:
"""Return referenced blueprint or None.""" """Return referenced blueprint or None."""
@@ -89,6 +103,23 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
self._render_attributes(rendered, variables) self._render_attributes(rendered, variables)
self._rendered = rendered 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 @callback
def _process_data(self) -> None: def _process_data(self) -> None:
"""Process new data.""" """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.""" """template conftest."""
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum, StrEnum
import pytest import pytest
@@ -25,6 +25,15 @@ class ConfigurationStyle(Enum):
TRIGGER = "Trigger" TRIGGER = "Trigger"
class Brewery(StrEnum):
"""Test enum."""
MMMM = "mmmm"
BEER = "beer"
IS = "is"
GOOD = "good"
def make_test_trigger(*entities: str) -> dict: def make_test_trigger(*entities: str) -> dict:
"""Make a test state trigger.""" """Make a test state trigger."""
return { return {

View File

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