From 52454f521812581d3eac77dc3190f4b9e0ddb9be Mon Sep 17 00:00:00 2001 From: dougiteixeira <31328123+dougiteixeira@users.noreply.github.com> Date: Thu, 11 Jul 2024 05:11:31 -0300 Subject: [PATCH] Add config flow for platform switch in Template (#121639) --- .../components/template/config_flow.py | 23 +++++- homeassistant/components/template/const.py | 2 + .../components/template/strings.json | 29 ++++++- homeassistant/components/template/switch.py | 77 ++++++++++++++---- .../template/snapshots/test_switch.ambr | 14 ++++ tests/components/template/test_config_flow.py | 49 ++++++++++- tests/components/template/test_init.py | 10 +++ tests/components/template/test_switch.py | 81 ++++++++++++++++++- 8 files changed, 264 insertions(+), 21 deletions(-) create mode 100644 tests/components/template/snapshots/test_switch.ambr diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index 0f8a36a8e31..71eb04d8ad9 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -25,6 +25,7 @@ from homeassistant.const import ( CONF_STATE, CONF_UNIT_OF_MEASUREMENT, CONF_URL, + CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, Platform, ) @@ -39,8 +40,9 @@ from homeassistant.helpers.schema_config_entry_flow import ( ) from .binary_sensor import async_create_preview_binary_sensor -from .const import CONF_PRESS, DOMAIN +from .const import CONF_PRESS, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN from .sensor import async_create_preview_sensor +from .switch import async_create_preview_switch from .template_entity import TemplateEntity _SCHEMA_STATE: dict[vol.Marker, Any] = { @@ -132,6 +134,13 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: ), } + if domain == Platform.SWITCH: + schema |= { + vol.Required(CONF_VALUE_TEMPLATE): selector.TemplateSelector(), + vol.Optional(CONF_TURN_ON): selector.ActionSelector(), + vol.Optional(CONF_TURN_OFF): selector.ActionSelector(), + } + schema[vol.Optional(CONF_DEVICE_ID)] = selector.DeviceSelector() return vol.Schema(schema) @@ -224,6 +233,7 @@ TEMPLATE_TYPES = [ "button", "image", "sensor", + "switch", ] CONFIG_FLOW = { @@ -246,6 +256,11 @@ CONFIG_FLOW = { preview="template", validate_user_input=validate_user_input(Platform.SENSOR), ), + Platform.SWITCH: SchemaFlowFormStep( + config_schema(Platform.SWITCH), + preview="template", + validate_user_input=validate_user_input(Platform.SWITCH), + ), } @@ -269,6 +284,11 @@ OPTIONS_FLOW = { preview="template", validate_user_input=validate_user_input(Platform.SENSOR), ), + Platform.SWITCH: SchemaFlowFormStep( + options_schema(Platform.SWITCH), + preview="template", + validate_user_input=validate_user_input(Platform.SWITCH), + ), } CREATE_PREVIEW_ENTITY: dict[ @@ -277,6 +297,7 @@ CREATE_PREVIEW_ENTITY: dict[ ] = { "binary_sensor": async_create_preview_binary_sensor, "sensor": async_create_preview_sensor, + "switch": async_create_preview_switch, } diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index e7681225a49..8b4e46ba383 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -34,3 +34,5 @@ CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" CONF_PICTURE = "picture" CONF_PRESS = "press" CONF_OBJECT_ID = "object_id" +CONF_TURN_OFF = "turn_off" +CONF_TURN_ON = "turn_on" diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 6eb0fbd75ad..649a1aa3898 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -57,9 +57,23 @@ "binary_sensor": "Template a binary sensor", "button": "Template a button", "image": "Template a image", - "sensor": "Template a sensor" + "sensor": "Template a sensor", + "switch": "Template a switch" }, "title": "Template helper" + }, + "switch": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "name": "[%key:common::config_flow::data::name%]", + "turn_off": "Actions on turn off", + "turn_on": "Actions on turn on", + "state": "[%key:component::template::config::step::sensor::data::state%]" + }, + "data_description": { + "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + }, + "title": "Template switch" } } }, @@ -108,6 +122,19 @@ "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" }, "title": "[%key:component::template::config::step::sensor::title%]" + }, + "switch": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "name": "[%key:common::config_flow::data::name%]", + "state": "[%key:component::template::config::step::sensor::data::state%]", + "turn_off": "[%key:component::template::config::step::switch::data::turn_off%]", + "turn_on": "[%key:component::template::config::step::switch::data::turn_on%]" + }, + "data_description": { + "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + }, + "title": "[%key:component::template::config::step::switch::title%]" } } }, diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index 3a7cfcde0f7..f1973ff96d4 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -11,9 +11,12 @@ from homeassistant.components.switch import ( PLATFORM_SCHEMA as SWITCH_PLATFORM_SCHEMA, SwitchEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, + CONF_DEVICE_ID, + CONF_NAME, CONF_SWITCHES, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, @@ -22,14 +25,15 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DOMAIN +from .const import CONF_TURN_OFF, CONF_TURN_ON, DOMAIN from .template_entity import ( TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, TemplateEntity, @@ -38,16 +42,13 @@ from .template_entity import ( _VALID_STATES = [STATE_ON, STATE_OFF, "true", "false"] -ON_ACTION = "turn_on" -OFF_ACTION = "turn_off" - SWITCH_SCHEMA = vol.All( cv.deprecated(ATTR_ENTITY_ID), vol.Schema( { vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Required(ON_ACTION): cv.SCRIPT_SCHEMA, - vol.Required(OFF_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(CONF_TURN_ON): cv.SCRIPT_SCHEMA, + vol.Required(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, vol.Optional(ATTR_FRIENDLY_NAME): cv.string, vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_UNIQUE_ID): cv.string, @@ -59,6 +60,16 @@ PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend( {vol.Required(CONF_SWITCHES): cv.schema_with_slug_keys(SWITCH_SCHEMA)} ) +SWICTH_CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.template, + vol.Required(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_TURN_ON): selector.ActionSelector(), + vol.Optional(CONF_TURN_OFF): selector.ActionSelector(), + vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), + } +) + async def _async_create_entities(hass, config): """Create the Template switches.""" @@ -90,6 +101,29 @@ async def async_setup_platform( async_add_entities(await _async_create_entities(hass, config)) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize config entry.""" + _options = dict(config_entry.options) + _options.pop("template_type") + validated_config = SWICTH_CONFIG_SCHEMA(_options) + async_add_entities( + [SwitchTemplate(hass, None, validated_config, config_entry.entry_id)] + ) + + +@callback +def async_create_preview_switch( + hass: HomeAssistant, name: str, config: dict[str, Any] +) -> SwitchTemplate: + """Create a preview switch.""" + validated_config = SWICTH_CONFIG_SCHEMA(config | {CONF_NAME: name}) + return SwitchTemplate(hass, None, validated_config, None) + + class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): """Representation of a Template switch.""" @@ -106,15 +140,28 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): super().__init__( hass, config=config, fallback_name=object_id, unique_id=unique_id ) - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) + if object_id is not None: + self.entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, object_id, hass=hass + ) friendly_name = self._attr_name self._template = config.get(CONF_VALUE_TEMPLATE) - self._on_script = Script(hass, config[ON_ACTION], friendly_name, DOMAIN) - self._off_script = Script(hass, config[OFF_ACTION], friendly_name, DOMAIN) + 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 + ) self._state: bool | None = False self._attr_assumed_state = self._template is None + self._attr_device_info = async_device_info_to_link_from_device_id( + hass, + config.get(CONF_DEVICE_ID), + ) @callback def _update_state(self, result): @@ -159,14 +206,16 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Fire the on action.""" - await self.async_run_script(self._on_script, context=self._context) + if self._on_script: + await self.async_run_script(self._on_script, context=self._context) if self._template is None: self._state = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Fire the off action.""" - await self.async_run_script(self._off_script, context=self._context) + if self._off_script: + await self.async_run_script(self._off_script, context=self._context) if self._template is None: self._state = False self.async_write_ha_state() diff --git a/tests/components/template/snapshots/test_switch.ambr b/tests/components/template/snapshots/test_switch.ambr new file mode 100644 index 00000000000..c240a9436a0 --- /dev/null +++ b/tests/components/template/snapshots/test_switch.ambr @@ -0,0 +1,14 @@ +# serializer version: 1 +# name: test_setup_config_entry + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My template', + }), + 'context': , + 'entity_id': 'switch.my_template', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 124fc119450..14276bb355c 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -91,6 +91,16 @@ from tests.typing import WebSocketGenerator {"verify_ssl": True}, {}, ), + ( + "switch", + {"value_template": "{{ states('switch.one') }}"}, + "on", + {"one": "on", "two": "off"}, + {}, + {}, + {}, + {}, + ), ], ) @pytest.mark.freeze_time("2024-07-09 00:00:00+00:00") @@ -186,6 +196,12 @@ async def test_config_flow( {}, {}, ), + ( + "switch", + {"value_template": "{{ false }}"}, + {}, + {}, + ), ( "button", {}, @@ -295,6 +311,7 @@ def get_suggested(schema, key): "input_states", "extra_options", "options_options", + "key_template", ), [ ( @@ -309,6 +326,7 @@ def get_suggested(schema, key): {"one": "on", "two": "off"}, {}, {}, + "state", ), ( "sensor", @@ -322,6 +340,7 @@ def get_suggested(schema, key): {"one": "30.0", "two": "20.0"}, {}, {}, + "state", ), ( "button", @@ -348,6 +367,7 @@ def get_suggested(schema, key): } ], }, + "state", ), ( "image", @@ -364,6 +384,17 @@ def get_suggested(schema, key): "url": "{{ states('sensor.two') }}", "verify_ssl": True, }, + "url", + ), + ( + "switch", + {"value_template": "{{ states('switch.one') }}"}, + {"value_template": "{{ states('switch.two') }}"}, + ["on", "off"], + {"one": "on", "two": "off"}, + {}, + {}, + "value_template", ), ], ) @@ -377,6 +408,7 @@ async def test_options( input_states, extra_options, options_options, + key_template, ) -> None: """Test reconfiguring.""" input_entities = ["one", "two"] @@ -411,13 +443,16 @@ async def test_options( assert result["type"] is FlowResultType.FORM assert result["step_id"] == template_type assert get_suggested( - result["data_schema"].schema, "state" - ) == old_state_template.get("state") + result["data_schema"].schema, key_template + ) == old_state_template.get(key_template) assert "name" not in result["data_schema"].schema result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={**new_state_template, **options_options}, + user_input={ + **new_state_template, + **options_options, + }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { @@ -455,7 +490,7 @@ async def test_options( assert result["step_id"] == template_type assert get_suggested(result["data_schema"].schema, "name") is None - assert get_suggested(result["data_schema"].schema, "state") is None + assert get_suggested(result["data_schema"].schema, key_template) is None @pytest.mark.parametrize( @@ -1095,6 +1130,12 @@ async def test_option_flow_sensor_preview_config_entry_removed( {}, {}, ), + ( + "switch", + {"value_template": "{{ false }}"}, + {}, + {}, + ), ], ) async def test_options_flow_change_device( diff --git a/tests/components/template/test_init.py b/tests/components/template/test_init.py index 95a864e1ec9..f1e5fe7f920 100644 --- a/tests/components/template/test_init.py +++ b/tests/components/template/test_init.py @@ -314,6 +314,16 @@ async def async_yaml_patch_helper(hass, filename): }, {}, ), + ( + { + "template_type": "switch", + "name": "My template", + "value_template": "{{ true }}", + }, + { + "value_template": "{{ true }}", + }, + ), ], ) async def test_change_device( diff --git a/tests/components/template/test_switch.py b/tests/components/template/test_switch.py index 68cca990ef1..2fc0f29acaf 100644 --- a/tests/components/template/test_switch.py +++ b/tests/components/template/test_switch.py @@ -1,8 +1,10 @@ """The tests for the Template switch platform.""" import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant import setup +from homeassistant.components import template from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, @@ -13,9 +15,15 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import CoreState, HomeAssistant, ServiceCall, State +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from tests.common import assert_setup_component, mock_component, mock_restore_cache +from tests.common import ( + MockConfigEntry, + assert_setup_component, + mock_component, + mock_restore_cache, +) OPTIMISTIC_SWITCH_CONFIG = { "turn_on": { @@ -35,6 +43,38 @@ OPTIMISTIC_SWITCH_CONFIG = { } +async def test_setup_config_entry( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test the config flow.""" + + hass.states.async_set( + "switch.one", + "on", + {}, + ) + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": "My template", + "value_template": "{{ states('switch.one') }}", + "template_type": SWITCH_DOMAIN, + }, + title="My template", + ) + template_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("switch.my_template") + assert state is not None + assert state == snapshot + + async def test_template_state_text(hass: HomeAssistant) -> None: """Test the state text of a template.""" with assert_setup_component(1, "switch"): @@ -655,3 +695,42 @@ async def test_unique_id(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(hass.states.async_all("switch")) == 1 + + +async def test_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test for device for Template.""" + + device_config_entry = MockConfigEntry() + device_config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=device_config_entry.entry_id, + identifiers={("test", "identifier_test")}, + connections={("mac", "30:31:32:33:34:35")}, + ) + await hass.async_block_till_done() + assert device_entry is not None + assert device_entry.id is not None + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": "My template", + "value_template": "{{ true }}", + "template_type": "switch", + "device_id": device_entry.id, + }, + title="My template", + ) + template_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + template_entity = entity_registry.async_get("switch.my_template") + assert template_entity is not None + assert template_entity.device_id == device_entry.id