From 7404c255aaf4efd6319799a5908c39e64ac4b90e Mon Sep 17 00:00:00 2001 From: Petro Date: Sun, 27 Oct 2024 12:46:06 +0000 Subject: [PATCH] add tests --- homeassistant/components/template/config.py | 9 + homeassistant/components/template/switch.py | 53 ++- .../components/template/trigger_entity.py | 22 +- tests/components/template/test_switch.py | 348 ++++++++++++++++-- 4 files changed, 391 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index a4e16660ecab3b..0cbe6bd129b6b6 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -22,6 +22,7 @@ CONF_BINARY_SENSORS, CONF_NAME, CONF_SENSORS, + CONF_SWITCHES, CONF_UNIQUE_ID, CONF_VARIABLES, ) @@ -91,6 +92,9 @@ vol.Optional(SWITCH_DOMAIN): vol.All( cv.ensure_list, [switch_platform.SWITCH_SCHEMA] ), + vol.Optional(CONF_SWITCHES): cv.schema_with_slug_keys( + switch_platform.LEGACY_SWITCH_SCHEMA + ), }, ) @@ -189,6 +193,11 @@ async def async_validate_config(hass: HomeAssistant, config: ConfigType) -> Conf BINARY_SENSOR_DOMAIN, binary_sensor_platform.rewrite_legacy_to_modern_conf, ), + ( + CONF_SWITCHES, + SWITCH_DOMAIN, + switch_platform.rewrite_legacy_to_modern_conf, + ), ): if old_key not in template_config: continue diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index 9b503085890679..f771d68628869e 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -38,11 +38,12 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator -from .const import CONF_OBJECT_ID, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN +from .const import CONF_OBJECT_ID, CONF_PICTURE, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, - TEMPLATE_ENTITY_COMMON_SCHEMA, + TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, + TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, rewrite_common_legacy_to_modern_conf, ) @@ -54,15 +55,23 @@ CONF_VALUE_TEMPLATE: CONF_STATE, } +DEFAULT_NAME = "Template Switch" -SWITCH_SCHEMA = vol.All( + +SWITCH_SCHEMA = ( vol.Schema( { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, vol.Optional(CONF_STATE): cv.template, vol.Required(CONF_TURN_ON): cv.SCRIPT_SCHEMA, vol.Required(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_PICTURE): cv.template, } - ).extend(TEMPLATE_ENTITY_COMMON_SCHEMA.schema) + ) + .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) + .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema) + .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema) ) LEGACY_SWITCH_SCHEMA = vol.All( @@ -294,7 +303,6 @@ class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity): """Switch entity based on trigger data.""" domain = SWITCH_DOMAIN - extra_template_keys = (CONF_STATE,) def __init__( self, @@ -304,19 +312,22 @@ def __init__( ) -> None: """Initialize the entity.""" super().__init__(hass, coordinator, config) - friendly_name = self._attr_name - self._on_script = ( - Script(hass, config.get(CONF_TURN_ON), friendly_name, DOMAIN) - if config.get(CONF_TURN_ON) is not None - else None - ) - self._off_script = ( - Script(hass, config.get(CONF_TURN_OFF), friendly_name, DOMAIN) - if config.get(CONF_TURN_OFF) is not None - else None + friendly_name = self._rendered.get(CONF_NAME, DEFAULT_NAME) + self._on_script = Script(hass, config.get(CONF_TURN_ON), friendly_name, DOMAIN) + self._off_script = Script( + hass, config.get(CONF_TURN_OFF), friendly_name, DOMAIN ) - self._state: bool | None = False - self._attr_assumed_state = config.get(CONF_STATE) is None + + self._state: bool | None = None + if (tmpl := config.get(CONF_STATE)) is not None and isinstance( + tmpl, template.Template + ): + self._to_render_simple.append(CONF_STATE) + self._parse_result.add(CONF_STATE) + self._attr_assumed_state = False + else: + self._attr_assumed_state = True + self._attr_device_info = async_device_info_to_link_from_device_id( hass, config.get(CONF_DEVICE_ID), @@ -350,6 +361,10 @@ def _handle_coordinator_update(self) -> None: self.async_set_context(self.coordinator.data["context"]) self.async_write_ha_state() + elif self._attr_assumed_state and len(self._rendered) > 0: + # Incase name, icon, or friendly name have a template but + # states does not. + self.async_write_ha_state() @property def is_on(self) -> bool | None: @@ -359,7 +374,7 @@ def is_on(self) -> bool | None: async def async_turn_on(self, **kwargs: Any) -> None: """Fire the on action.""" if self._on_script: - await self._on_script.async_run({}, context=self._context) + await self.async_run_script(self._on_script, context=self._context) if self._attr_assumed_state: self._state = True self.async_write_ha_state() @@ -367,7 +382,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None: """Fire the off action.""" if self._off_script: - await self._off_script.async_run({}, context=self._context) + await self.async_run_script(self._off_script, context=self._context) if self._attr_assumed_state: self._state = False self.async_write_ha_state() diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index df84ce057c3e12..9a0182f2cee545 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -2,7 +2,9 @@ from __future__ import annotations -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.helpers.script import Script, _VarsType +from homeassistant.helpers.template import TemplateStateFromEntityId from homeassistant.helpers.trigger_template_entity import TriggerBaseEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -56,3 +58,21 @@ def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" self._process_data() self.async_write_ha_state() + + async def async_run_script( + self, + script: Script, + *, + run_variables: _VarsType | None = None, + context: Context | None = None, + ) -> None: + """Run an action script.""" + if run_variables is None: + run_variables = {} + await script.async_run( + run_variables={ + "this": TemplateStateFromEntityId(self.hass, self.entity_id), + **run_variables, + }, + context=context, + ) diff --git a/tests/components/template/test_switch.py b/tests/components/template/test_switch.py index 2d488b720e90cd..77303d53bd03db 100644 --- a/tests/components/template/test_switch.py +++ b/tests/components/template/test_switch.py @@ -13,8 +13,9 @@ STATE_OFF, STATE_ON, STATE_UNAVAILABLE, + STATE_UNKNOWN, ) -from homeassistant.core import CoreState, HomeAssistant, ServiceCall, State +from homeassistant.core import Context, CoreState, HomeAssistant, ServiceCall, State from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component @@ -23,11 +24,12 @@ assert_setup_component, mock_component, mock_restore_cache, + mock_restore_cache_with_extra_data, ) TEST_OBJECT_ID = "test_template_switch" TEST_ENTITY_ID = f"switch.{TEST_OBJECT_ID}" -OPTIMISTIC_SWITCH_ACTIONS = { +SWITCH_ACTIONS = { "turn_on": { "service": "test.automation", "data_template": { @@ -43,8 +45,8 @@ }, }, } -OPTIMISTIC_SWITCH_CONFIG = { - **OPTIMISTIC_SWITCH_ACTIONS, +NAMED_SWITCH_ACTIONS = { + **SWITCH_ACTIONS, "name": TEST_OBJECT_ID, } @@ -57,7 +59,7 @@ { "template": { "switch": { - **OPTIMISTIC_SWITCH_CONFIG, + **NAMED_SWITCH_ACTIONS, "state": "{{ True }}", } }, @@ -70,7 +72,7 @@ "platform": "template", "switches": { TEST_OBJECT_ID: { - **OPTIMISTIC_SWITCH_ACTIONS, + **SWITCH_ACTIONS, "value_template": "{{ True }}", } }, @@ -130,7 +132,7 @@ async def test_template_state_text(hass: HomeAssistant) -> None: { "template": { "switch": { - **OPTIMISTIC_SWITCH_CONFIG, + **NAMED_SWITCH_ACTIONS, "state": "{{ states.switch.test_state.state }}", } } @@ -172,7 +174,7 @@ async def test_template_state_boolean( { "template": { "switch": { - **OPTIMISTIC_SWITCH_CONFIG, + **NAMED_SWITCH_ACTIONS, "state": template, }, } @@ -213,7 +215,7 @@ async def test_icon_and_picture_template( { "template": { "switch": { - **OPTIMISTIC_SWITCH_CONFIG, + **NAMED_SWITCH_ACTIONS, "state": "{{ states.switch.test_state.state }}", field: ( "{% if states.switch.test_state.state %}" @@ -248,7 +250,7 @@ async def test_template_syntax_error(hass: HomeAssistant) -> None: { "template": { "switch": { - **OPTIMISTIC_SWITCH_CONFIG, + **NAMED_SWITCH_ACTIONS, "state": "{% if rubbish %}", } } @@ -273,7 +275,7 @@ async def test_invalid_name_does_not_create(hass: HomeAssistant) -> None: "platform": "template", "switches": { "test INVALID switch": { - **OPTIMISTIC_SWITCH_CONFIG, + **NAMED_SWITCH_ACTIONS, "value_template": "{{ rubbish }", } }, @@ -399,7 +401,7 @@ async def test_on_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: "platform": "template", "switches": { TEST_OBJECT_ID: { - **OPTIMISTIC_SWITCH_ACTIONS, + **SWITCH_ACTIONS, "value_template": "{{ states.switch.test_state.state }}", } }, @@ -441,7 +443,7 @@ async def test_on_action_optimistic( "platform": "template", "switches": { TEST_OBJECT_ID: { - **OPTIMISTIC_SWITCH_ACTIONS, + **SWITCH_ACTIONS, } }, } @@ -482,7 +484,7 @@ async def test_off_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None "platform": "template", "switches": { TEST_OBJECT_ID: { - **OPTIMISTIC_SWITCH_ACTIONS, + **SWITCH_ACTIONS, "value_template": "{{ states.switch.test_state.state }}", } }, @@ -524,7 +526,7 @@ async def test_off_action_optimistic( "platform": "template", "switches": { TEST_OBJECT_ID: { - **OPTIMISTIC_SWITCH_ACTIONS, + **SWITCH_ACTIONS, } }, } @@ -576,10 +578,10 @@ async def test_restore_state(hass: HomeAssistant) -> None: "platform": "template", "switches": { "s1": { - **OPTIMISTIC_SWITCH_ACTIONS, + **SWITCH_ACTIONS, }, "s2": { - **OPTIMISTIC_SWITCH_ACTIONS, + **SWITCH_ACTIONS, }, }, } @@ -606,7 +608,7 @@ async def test_available_template_with_entities(hass: HomeAssistant) -> None: "platform": "template", "switches": { TEST_OBJECT_ID: { - **OPTIMISTIC_SWITCH_ACTIONS, + **SWITCH_ACTIONS, "value_template": "{{ 1 == 1 }}", "availability_template": ( "{{ is_state('availability_state.state', 'on') }}" @@ -644,7 +646,7 @@ async def test_invalid_availability_template_keeps_component_available( "platform": "template", "switches": { TEST_OBJECT_ID: { - **OPTIMISTIC_SWITCH_ACTIONS, + **SWITCH_ACTIONS, "value_template": "{{ true }}", "availability_template": "{{ x - 12 }}", } @@ -671,12 +673,12 @@ async def test_unique_id(hass: HomeAssistant) -> None: "platform": "template", "switches": { "test_template_switch_01": { - **OPTIMISTIC_SWITCH_ACTIONS, + **SWITCH_ACTIONS, "unique_id": "not-so-unique-anymore", "value_template": "{{ true }}", }, "test_template_switch_02": { - **OPTIMISTIC_SWITCH_ACTIONS, + **SWITCH_ACTIONS, "unique_id": "not-so-unique-anymore", "value_template": "{{ false }}", }, @@ -729,3 +731,307 @@ async def test_device_id( template_entity = entity_registry.async_get("switch.my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id + + +@pytest.mark.parametrize(("count", "domain"), [(2, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": [ + {"invalid": "config"}, + # Config after invalid should still be set up + { + "unique_id": "listening-test-event", + "trigger": {"platform": "event", "event_type": "test_event"}, + "switches": { + "hello": { + **SWITCH_ACTIONS, + "friendly_name": "Hello Name", + "unique_id": "hello_name-id", + "value_template": "{{ trigger.event.data.beer == 2 }}", + "entity_picture_template": "{{ '/local/dogs.png' }}", + "icon_template": "{{ 'mdi:pirate' }}", + } + }, + "switch": [ + { + **SWITCH_ACTIONS, + "name": "via list", + "unique_id": "via_list-id", + "state": "{{ trigger.event.data.beer == 2 }}", + "picture": "{{ '/local/dogs2.png' if trigger.event.data.uno_mas is defined else '/local/dogs.png' }}", + "icon": "{{ 'mdi:pirate' }}", + }, + ], + }, + { + "trigger": [], + "switches": { + "bare_minimum": {**SWITCH_ACTIONS}, + }, + }, + ], + }, + ], +) +@pytest.mark.usefixtures("start_ha") +async def test_trigger_entity( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test trigger entity works.""" + await hass.async_block_till_done() + state = hass.states.get("switch.hello_name") + assert state is not None + assert state.state == STATE_UNKNOWN + + state = hass.states.get("switch.via_list") + assert state is not None + assert state.state == STATE_UNKNOWN + + state = hass.states.get("switch.bare_minimum") + assert state is not None + assert state.state == STATE_UNKNOWN + + context = Context() + hass.bus.async_fire("test_event", {"beer": 2}, context=context) + await hass.async_block_till_done() + + state = hass.states.get("switch.hello_name") + assert state.state == STATE_ON + assert state.attributes.get("icon") == "mdi:pirate" + assert state.attributes.get("entity_picture") == "/local/dogs.png" + assert state.context is context + + state = hass.states.get("switch.via_list") + assert state.state == STATE_ON + assert state.attributes.get("icon") == "mdi:pirate" + assert state.attributes.get("entity_picture") == "/local/dogs.png" + assert state.context is context + + assert len(entity_registry.entities) == 2 + assert ( + entity_registry.entities["switch.hello_name"].unique_id + == "listening-test-event-hello_name-id" + ) + assert ( + entity_registry.entities["switch.via_list"].unique_id + == "listening-test-event-via_list-id" + ) + + # Even if state itself didn't change, attributes might have changed + hass.bus.async_fire("test_event", {"beer": 2, "uno_mas": "si"}) + await hass.async_block_till_done() + state = hass.states.get("switch.via_list") + assert state.attributes.get("entity_picture") == "/local/dogs2.png" + assert state.state == STATE_ON + + +@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": [ + { + "unique_id": "listening-test-event", + "trigger": {"platform": "event", "event_type": "test_event"}, + "switch": [ + { + **SWITCH_ACTIONS, + "name": "via list", + "unique_id": "via_list-id", + "state": "{{ trigger.event.data.beer == 2 }}", + }, + { + **SWITCH_ACTIONS, + "name": "optimistic", + "unique_id": "optimistic-id", + "picture": "{{ '/local/a.png' if trigger.event.data.beer == 2 else '/local/b.png' }}", + }, + { + **SWITCH_ACTIONS, + "name": "unavailable", + "unique_id": "unavailable-id", + "state": "{{ trigger.event.data.beer == 2 }}", + "availability": "{{ trigger.event.data.beer == 2 }}", + }, + ], + }, + ], + }, + ], +) +@pytest.mark.usefixtures("start_ha") +async def test_trigger_optimistic_entity( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall] +) -> None: + """Test trigger entity works.""" + await hass.async_block_till_done() + + state = hass.states.get("switch.via_list") + assert state is not None + assert state.state == STATE_UNKNOWN + + state = hass.states.get("switch.optimistic") + assert state is not None + assert state.state == STATE_UNKNOWN + + state = hass.states.get("switch.unavailable") + assert state is not None + assert state.state == STATE_UNKNOWN + + context = Context() + hass.bus.async_fire("test_event", {"beer": 2}, context=context) + await hass.async_block_till_done() + + state = hass.states.get("switch.via_list") + assert state.state == STATE_ON + assert state.context is context + + # Even if an event triggered, an optimistic switch should not change + state = hass.states.get("switch.optimistic") + assert state is not None + # Templated attributes should change + assert state.attributes.get("entity_picture") == "/local/a.png" + assert state.state == STATE_UNKNOWN + + state = hass.states.get("switch.unavailable") + assert state.state == STATE_ON + assert state.context is context + + assert len(entity_registry.entities) == 3 + assert ( + entity_registry.entities["switch.via_list"].unique_id + == "listening-test-event-via_list-id" + ) + assert ( + entity_registry.entities["switch.optimistic"].unique_id + == "listening-test-event-optimistic-id" + ) + assert ( + entity_registry.entities["switch.unavailable"].unique_id + == "listening-test-event-unavailable-id" + ) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.optimistic"}, + blocking=True, + ) + assert len(calls) == 1 + assert calls[-1].data["action"] == "turn_on" + assert calls[-1].data["caller"] == "switch.optimistic" + + state = hass.states.get("switch.optimistic") + assert state is not None + assert state.state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.optimistic"}, + blocking=True, + ) + assert len(calls) == 2 + assert calls[-1].data["action"] == "turn_off" + assert calls[-1].data["caller"] == "switch.optimistic" + + state = hass.states.get("switch.optimistic") + assert state is not None + assert state.state == STATE_OFF + + context = Context() + hass.bus.async_fire("test_event", {"beer": 1}, context=context) + await hass.async_block_till_done() + + state = hass.states.get("switch.optimistic") + assert state is not None + assert state.attributes.get("entity_picture") == "/local/b.png" + assert state.state == STATE_OFF + + state = hass.states.get("switch.unavailable") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "switch": [ + { + **SWITCH_ACTIONS, + "name": "test", + "unique_id": "test", + "state": "{{ trigger.event.data.beer == 2 }}", + "icon": "{{ 'mdi:a' if trigger.event.data.beer == 2 else 'mdi:b' }}", + "picture": "{{ '/local/a.png' if trigger.event.data.beer == 2 else '/local/b.png' }}", + }, + ], + } + }, + ], +) +@pytest.mark.parametrize( + ("restored_state", "initial_state", "initial_attributes"), + [ + (STATE_ON, STATE_ON, ["entity_picture", "icon"]), + (STATE_OFF, STATE_OFF, ["entity_picture", "icon", "plus_one"]), + (STATE_UNAVAILABLE, STATE_UNKNOWN, []), + (STATE_UNKNOWN, STATE_UNKNOWN, []), + ], +) +async def test_trigger_entity_restore_state( + hass: HomeAssistant, + count, + domain, + config, + restored_state, + initial_state, + initial_attributes, +) -> None: + """Test restoring trigger template binary sensor.""" + + restored_attributes = { + "entity_picture": "/local/c.png", + "icon": "mdi:c", + } + + fake_state = State( + "switch.test", + restored_state, + restored_attributes, + ) + mock_restore_cache_with_extra_data(hass, ((fake_state, {}),)) + with assert_setup_component(count, domain): + assert await async_setup_component( + hass, + domain, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("switch.test") + assert state.state == initial_state + for attr, value in restored_attributes.items(): + if attr in initial_attributes: + assert state.attributes[attr] == value + else: + assert attr not in state.attributes + assert "another" not in state.attributes + + hass.bus.async_fire("test_event", {"beer": 2}) + await hass.async_block_till_done() + + state = hass.states.get("switch.test") + assert state.state == STATE_ON + assert state.attributes["icon"] == "mdi:a" + assert state.attributes["entity_picture"] == "/local/a.png"