Adapt template sensors to use the same plural trigger/condition/action definitions as automations (#127875)

* Add plurals to template entities

* Ruff

* Ruffy ruff

* Fix linters

* Fix bug introduced after merging dev

* Fix merge mistake

* Revert adding automation helper

* Revert "Fix bug introduced after merging dev"

This reverts commit 098d478f150a06546fb9ec3668865fa5d763c6b2.

* Fix blueprint validation

* Apply suggestions from code review

---------

Co-authored-by: Erik <erik@montnemery.com>
This commit is contained in:
chammp 2025-04-29 11:52:58 +02:00 committed by GitHub
parent f2838e493b
commit 8ff4d5dcbf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 162 additions and 61 deletions

View File

@ -18,6 +18,7 @@ from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_MODE,
ATTR_NAME,
CONF_ACTIONS,
CONF_ALIAS,
CONF_CONDITIONS,
CONF_DEVICE_ID,
@ -27,6 +28,7 @@ from homeassistant.const import (
CONF_MODE,
CONF_PATH,
CONF_PLATFORM,
CONF_TRIGGERS,
CONF_VARIABLES,
CONF_ZONE,
EVENT_HOMEASSISTANT_STARTED,
@ -86,11 +88,9 @@ from homeassistant.util.hass_dict import HassKey
from .config import AutomationConfig, ValidationStatus
from .const import (
CONF_ACTIONS,
CONF_INITIAL_STATE,
CONF_TRACE,
CONF_TRIGGER_VARIABLES,
CONF_TRIGGERS,
DEFAULT_INITIAL_STATE,
DOMAIN,
LOGGER,

View File

@ -14,11 +14,15 @@ from homeassistant.components import blueprint
from homeassistant.components.trace import TRACE_CONFIG_SCHEMA
from homeassistant.config import config_per_platform, config_without_domain
from homeassistant.const import (
CONF_ACTION,
CONF_ACTIONS,
CONF_ALIAS,
CONF_CONDITION,
CONF_CONDITIONS,
CONF_DESCRIPTION,
CONF_ID,
CONF_TRIGGER,
CONF_TRIGGERS,
CONF_VARIABLES,
)
from homeassistant.core import HomeAssistant
@ -30,14 +34,10 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.util.yaml.input import UndefinedSubstitution
from .const import (
CONF_ACTION,
CONF_ACTIONS,
CONF_HIDE_ENTITY,
CONF_INITIAL_STATE,
CONF_TRACE,
CONF_TRIGGER,
CONF_TRIGGER_VARIABLES,
CONF_TRIGGERS,
DOMAIN,
LOGGER,
)

View File

@ -2,10 +2,6 @@
import logging
CONF_ACTION = "action"
CONF_ACTIONS = "actions"
CONF_TRIGGER = "trigger"
CONF_TRIGGERS = "triggers"
CONF_TRIGGER_VARIABLES = "trigger_variables"
DOMAIN = "automation"

View File

@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_DEVICE_ID,
CONF_NAME,
CONF_TRIGGERS,
CONF_UNIQUE_ID,
SERVICE_RELOAD,
)
@ -27,7 +28,7 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_integration
from homeassistant.util.hass_dict import HassKey
from .const import CONF_MAX, CONF_MIN, CONF_STEP, CONF_TRIGGER, DOMAIN, PLATFORMS
from .const import CONF_MAX, CONF_MIN, CONF_STEP, DOMAIN, PLATFORMS
from .coordinator import TriggerUpdateCoordinator
from .helpers import async_get_blueprints
@ -136,7 +137,7 @@ async def _process_config(hass: HomeAssistant, hass_config: ConfigType) -> None:
coordinator_tasks: list[Coroutine[Any, Any, TriggerUpdateCoordinator]] = []
for conf_section in hass_config[DOMAIN]:
if CONF_TRIGGER in conf_section:
if CONF_TRIGGERS in conf_section:
coordinator_tasks.append(init_coordinator(hass, conf_section))
continue

View File

@ -3,6 +3,7 @@
from collections.abc import Callable
from contextlib import suppress
import logging
from typing import Any
import voluptuous as vol
@ -10,6 +11,7 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAI
from homeassistant.components.blueprint import (
BLUEPRINT_INSTANCE_FIELDS,
is_blueprint_instance_config,
schemas as blueprint_schemas,
)
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN
from homeassistant.components.cover import DOMAIN as COVER_DOMAIN
@ -22,9 +24,15 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN
from homeassistant.config import async_log_schema_error, config_without_domain
from homeassistant.const import (
CONF_ACTION,
CONF_ACTIONS,
CONF_BINARY_SENSORS,
CONF_CONDITION,
CONF_CONDITIONS,
CONF_NAME,
CONF_SENSORS,
CONF_TRIGGER,
CONF_TRIGGERS,
CONF_UNIQUE_ID,
CONF_VARIABLES,
)
@ -47,14 +55,7 @@ from . import (
switch as switch_platform,
weather as weather_platform,
)
from .const import (
CONF_ACTION,
CONF_CONDITION,
CONF_TRIGGER,
DOMAIN,
PLATFORMS,
TemplateConfig,
)
from .const import DOMAIN, PLATFORMS, TemplateConfig
from .helpers import async_get_blueprints
PACKAGE_MERGE_HINT = "list"
@ -67,7 +68,7 @@ def ensure_domains_do_not_have_trigger_or_action(*keys: str) -> Callable[[dict],
def validate(obj: dict):
options = set(obj.keys())
if found_domains := domains.intersection(options):
invalid = {CONF_TRIGGER, CONF_ACTION}
invalid = {CONF_TRIGGERS, CONF_ACTIONS}
if found_invalid := invalid.intersection(set(obj.keys())):
raise vol.Invalid(
f"Unsupported option(s) found for domain {found_domains.pop()}, please remove ({', '.join(found_invalid)}) from your configuration",
@ -78,13 +79,22 @@ def ensure_domains_do_not_have_trigger_or_action(*keys: str) -> Callable[[dict],
return validate
CONFIG_SECTION_SCHEMA = vol.Schema(
vol.All(
def _backward_compat_schema(value: Any | None) -> Any:
"""Backward compatibility for automations."""
value = cv.renamed(CONF_TRIGGER, CONF_TRIGGERS)(value)
value = cv.renamed(CONF_ACTION, CONF_ACTIONS)(value)
return cv.renamed(CONF_CONDITION, CONF_CONDITIONS)(value)
CONFIG_SECTION_SCHEMA = vol.All(
_backward_compat_schema,
vol.Schema(
{
vol.Optional(CONF_UNIQUE_ID): cv.string,
vol.Optional(CONF_TRIGGER): cv.TRIGGER_SCHEMA,
vol.Optional(CONF_CONDITION): cv.CONDITIONS_SCHEMA,
vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_TRIGGERS): cv.TRIGGER_SCHEMA,
vol.Optional(CONF_CONDITIONS): cv.CONDITIONS_SCHEMA,
vol.Optional(CONF_ACTIONS): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA,
vol.Optional(NUMBER_DOMAIN): vol.All(
cv.ensure_list, [number_platform.NUMBER_SCHEMA]
@ -123,10 +133,14 @@ CONFIG_SECTION_SCHEMA = vol.Schema(
cv.ensure_list, [cover_platform.COVER_SCHEMA]
),
},
ensure_domains_do_not_have_trigger_or_action(
BUTTON_DOMAIN, COVER_DOMAIN, LIGHT_DOMAIN
),
)
),
ensure_domains_do_not_have_trigger_or_action(
BUTTON_DOMAIN, COVER_DOMAIN, LIGHT_DOMAIN
),
)
TEMPLATE_BLUEPRINT_SCHEMA = vol.All(
_backward_compat_schema, blueprint_schemas.BLUEPRINT_SCHEMA
)
TEMPLATE_BLUEPRINT_INSTANCE_SCHEMA = vol.Schema(
@ -169,7 +183,7 @@ async def _async_resolve_blueprints(
# house input results for template entities. For Trigger based template entities
# CONF_VARIABLES should not be removed because the variables are always
# executed between the trigger and action.
if CONF_TRIGGER not in config and CONF_VARIABLES in config:
if CONF_TRIGGERS not in config and CONF_VARIABLES in config:
config[platform][CONF_VARIABLES] = config.pop(CONF_VARIABLES)
raw_config = dict(config)
@ -187,14 +201,14 @@ async def async_validate_config_section(
validated_config = await _async_resolve_blueprints(hass, config)
if CONF_TRIGGER in validated_config:
validated_config[CONF_TRIGGER] = await async_validate_trigger_config(
hass, validated_config[CONF_TRIGGER]
if CONF_TRIGGERS in validated_config:
validated_config[CONF_TRIGGERS] = await async_validate_trigger_config(
hass, validated_config[CONF_TRIGGERS]
)
if CONF_CONDITION in validated_config:
validated_config[CONF_CONDITION] = await async_validate_conditions_config(
hass, validated_config[CONF_CONDITION]
if CONF_CONDITIONS in validated_config:
validated_config[CONF_CONDITIONS] = await async_validate_conditions_config(
hass, validated_config[CONF_CONDITIONS]
)
return validated_config

View File

@ -1,22 +1,18 @@
"""Constants for the Template Platform Components."""
from homeassistant.components.blueprint import BLUEPRINT_SCHEMA
from homeassistant.const import Platform
from homeassistant.helpers.typing import ConfigType
CONF_ACTION = "action"
CONF_ATTRIBUTE_TEMPLATES = "attribute_templates"
CONF_ATTRIBUTES = "attributes"
CONF_AVAILABILITY = "availability"
CONF_AVAILABILITY_TEMPLATE = "availability_template"
CONF_CONDITION = "condition"
CONF_MAX = "max"
CONF_MIN = "min"
CONF_OBJECT_ID = "object_id"
CONF_PICTURE = "picture"
CONF_PRESS = "press"
CONF_STEP = "step"
CONF_TRIGGER = "trigger"
CONF_TURN_OFF = "turn_off"
CONF_TURN_ON = "turn_on"
@ -41,8 +37,6 @@ PLATFORMS = [
Platform.WEATHER,
]
TEMPLATE_BLUEPRINT_SCHEMA = BLUEPRINT_SCHEMA
class TemplateConfig(dict):
"""Dummy class to allow adding attributes."""

View File

@ -5,7 +5,14 @@ import logging
from typing import TYPE_CHECKING, Any, cast
from homeassistant.components.blueprint import CONF_USE_BLUEPRINT
from homeassistant.const import CONF_PATH, CONF_VARIABLES, EVENT_HOMEASSISTANT_START
from homeassistant.const import (
CONF_ACTIONS,
CONF_CONDITIONS,
CONF_PATH,
CONF_TRIGGERS,
CONF_VARIABLES,
EVENT_HOMEASSISTANT_START,
)
from homeassistant.core import Context, CoreState, Event, HomeAssistant, callback
from homeassistant.helpers import condition, discovery, trigger as trigger_helper
from homeassistant.helpers.script import Script
@ -14,7 +21,7 @@ from homeassistant.helpers.trace import trace_get
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import CONF_ACTION, CONF_CONDITION, CONF_TRIGGER, DOMAIN, PLATFORMS
from .const import DOMAIN, PLATFORMS
_LOGGER = logging.getLogger(__name__)
@ -84,17 +91,17 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator):
async def _attach_triggers(self, start_event: Event | None = None) -> None:
"""Attach the triggers."""
if CONF_ACTION in self.config:
if CONF_ACTIONS in self.config:
self._script = Script(
self.hass,
self.config[CONF_ACTION],
self.config[CONF_ACTIONS],
self.name,
DOMAIN,
)
if CONF_CONDITION in self.config:
if CONF_CONDITIONS in self.config:
self._cond_func = await condition.async_conditions_from_config(
self.hass, self.config[CONF_CONDITION], _LOGGER, "template entity"
self.hass, self.config[CONF_CONDITIONS], _LOGGER, "template entity"
)
if start_event is not None:
@ -107,7 +114,7 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator):
self._unsub_trigger = await trigger_helper.async_initialize_triggers(
self.hass,
self.config[CONF_TRIGGER],
self.config[CONF_TRIGGERS],
action,
DOMAIN,
self.name,

View File

@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import async_get_platforms
from homeassistant.helpers.singleton import singleton
from .const import DOMAIN, TEMPLATE_BLUEPRINT_SCHEMA
from .const import DOMAIN
from .entity import AbstractTemplateEntity
DATA_BLUEPRINTS = "template_blueprints"
@ -54,6 +54,9 @@ async def _reload_blueprint_templates(hass: HomeAssistant, blueprint_path: str)
@callback
def async_get_blueprints(hass: HomeAssistant) -> blueprint.DomainBlueprints:
"""Get template blueprints."""
# pylint: disable-next=import-outside-toplevel
from .config import TEMPLATE_BLUEPRINT_SCHEMA
return blueprint.DomainBlueprints(
hass,
DOMAIN,

View File

@ -33,6 +33,8 @@ from homeassistant.const import (
CONF_NAME,
CONF_SENSORS,
CONF_STATE,
CONF_TRIGGER,
CONF_TRIGGERS,
CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_VALUE_TEMPLATE,
@ -53,12 +55,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
from . import TriggerUpdateCoordinator
from .const import (
CONF_ATTRIBUTE_TEMPLATES,
CONF_AVAILABILITY_TEMPLATE,
CONF_OBJECT_ID,
CONF_TRIGGER,
)
from .const import CONF_ATTRIBUTE_TEMPLATES, CONF_AVAILABILITY_TEMPLATE, CONF_OBJECT_ID
from .template_entity import (
TEMPLATE_ENTITY_COMMON_SCHEMA,
TemplateEntity,
@ -132,7 +129,7 @@ LEGACY_SENSOR_SCHEMA = vol.All(
def extra_validation_checks(val):
"""Run extra validation checks."""
if CONF_TRIGGER in val:
if CONF_TRIGGERS in val or CONF_TRIGGER in val:
raise vol.Invalid(
"You can only add triggers to template entities if they are defined under"
" `template:`. See the template documentation for more information:"
@ -170,6 +167,7 @@ PLATFORM_SCHEMA = vol.All(
SENSOR_PLATFORM_SCHEMA.extend(
{
vol.Optional(CONF_TRIGGER): cv.match_all, # to raise custom warning
vol.Optional(CONF_TRIGGERS): cv.match_all, # to raise custom warning
vol.Required(CONF_SENSORS): cv.schema_with_slug_keys(LEGACY_SENSOR_SCHEMA),
}
),

View File

@ -115,6 +115,7 @@ SUN_EVENT_SUNRISE: Final = "sunrise"
CONF_ABOVE: Final = "above"
CONF_ACCESS_TOKEN: Final = "access_token"
CONF_ACTION: Final = "action"
CONF_ACTIONS: Final = "actions"
CONF_ADDRESS: Final = "address"
CONF_AFTER: Final = "after"
CONF_ALIAS: Final = "alias"

View File

@ -212,11 +212,16 @@ async def test_reload_template_when_blueprint_changes(hass: HomeAssistant) -> No
assert not_inverted.state == "on"
@pytest.mark.parametrize(
("blueprint"),
["test_event_sensor.yaml", "test_event_sensor_legacy_schema.yaml"],
)
async def test_trigger_event_sensor(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
blueprint: str,
) -> None:
"""Test event sensor blueprint."""
blueprint = "test_event_sensor.yaml"
assert await async_setup_component(
hass,
"template",

View File

@ -2303,6 +2303,61 @@ async def test_trigger_conditional_action(hass: HomeAssistant) -> None:
assert len(events) == 1
@pytest.mark.parametrize("trigger_field", ["trigger", "triggers"])
@pytest.mark.parametrize("condition_field", ["condition", "conditions"])
@pytest.mark.parametrize("action_field", ["action", "actions"])
async def test_legacy_and_new_config_schema(
hass: HomeAssistant, trigger_field: str, condition_field: str, action_field: str
) -> None:
"""Tests that both old and new config schema (singular -> plural) work."""
assert await async_setup_component(
hass,
"template",
{
"template": [
{
"unique_id": "listening-test-event",
f"{trigger_field}": {
"platform": "event",
"event_type": "beer_event",
},
f"{condition_field}": [
{
"condition": "template",
"value_template": "{{ trigger.event.data.beer >= 42 }}",
}
],
f"{action_field}": [
{"event": "test_event_by_action"},
],
"sensor": [
{
"name": "Unimportant",
"state": "Uninteresting",
}
],
},
],
},
)
await hass.async_block_till_done()
event = "test_event_by_action"
events = async_capture_events(hass, event)
hass.bus.async_fire("beer_event", {"beer": 1})
await hass.async_block_till_done()
assert len(events) == 0
hass.bus.async_fire("beer_event", {"beer": 42})
await hass.async_block_till_done()
assert len(events) == 1
async def test_device_id(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,

View File

@ -14,7 +14,7 @@ blueprint:
description: The event_data for the event trigger
selector:
object:
trigger:
triggers:
- trigger: event
event_type: !input event_type
event_data: !input event_data

View File

@ -0,0 +1,27 @@
blueprint:
name: Create Sensor from Event
description: Creates a timestamp sensor from an event
domain: template
source_url: https://github.com/home-assistant/core/blob/dev/homeassistant/components/template/blueprints/event_sensor.yaml
input:
event_type:
name: Name of the event_type
description: The event_type for the event trigger
selector:
text:
event_data:
name: The data for the event
description: The event_data for the event trigger
selector:
object:
trigger:
- trigger: event
event_type: !input event_type
event_data: !input event_data
variables:
event_data: "{{ trigger.event.data }}"
sensor:
state: "{{ now() }}"
device_class: timestamp
attributes:
data: "{{ event_data }}"