diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index c58dfcf50b4..52435d88971 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -8,15 +8,22 @@ from typing import Any import voluptuous as vol from homeassistant.components.button import DEVICE_CLASSES_SCHEMA, ButtonEntity -from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_UNIQUE_ID +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_DEVICE_ID, + CONF_NAME, + CONF_UNIQUE_ID, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -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_platform import AddEntitiesCallback from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DOMAIN +from .const import CONF_PRESS, DOMAIN from .template_entity import ( TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_ICON_SCHEMA, @@ -25,8 +32,6 @@ from .template_entity import ( _LOGGER = logging.getLogger(__name__) -CONF_PRESS = "press" - DEFAULT_NAME = "Template Button" DEFAULT_OPTIMISTIC = False @@ -43,6 +48,15 @@ BUTTON_SCHEMA = ( .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema) ) +CONFIG_BUTTON_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME): cv.template, + vol.Optional(CONF_PRESS): selector.ActionSelector(), + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), + } +) + async def _async_create_entities( hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None @@ -76,6 +90,20 @@ async def async_setup_platform( ) +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 = CONFIG_BUTTON_SCHEMA(_options) + async_add_entities( + [TemplateButtonEntity(hass, validated_config, config_entry.entry_id)] + ) + + class TemplateButtonEntity(TemplateEntity, ButtonEntity): """Representation of a template button.""" @@ -90,10 +118,19 @@ class TemplateButtonEntity(TemplateEntity, ButtonEntity): """Initialize the button.""" super().__init__(hass, config=config, unique_id=unique_id) assert self._attr_name is not None - self._command_press = Script(hass, config[CONF_PRESS], self._attr_name, DOMAIN) + self._command_press = ( + Script(hass, config.get(CONF_PRESS), self._attr_name, DOMAIN) + if config.get(CONF_PRESS, None) is not None + else None + ) self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_state = None + self._attr_device_info = async_device_info_to_link_from_device_id( + hass, + config.get(CONF_DEVICE_ID), + ) async def async_press(self) -> None: """Press the button.""" - await self.async_run_script(self._command_press, context=self._context) + if self._command_press: + await self.async_run_script(self._command_press, context=self._context) diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index f648d5ca8d5..5a5527cc7c5 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.button import ButtonDeviceClass from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASS_STATE_CLASSES, @@ -36,7 +37,7 @@ from homeassistant.helpers.schema_config_entry_flow import ( ) from .binary_sensor import async_create_preview_binary_sensor -from .const import DOMAIN +from .const import CONF_PRESS, DOMAIN from .sensor import async_create_preview_sensor from .template_entity import TemplateEntity @@ -66,6 +67,22 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: ), } + if domain == Platform.BUTTON: + schema |= { + vol.Optional(CONF_PRESS): selector.ActionSelector(), + } + if flow_type == "config": + schema |= { + vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[cls.value for cls in ButtonDeviceClass], + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="button_device_class", + sort=True, + ), + ) + } + if domain == Platform.SENSOR: schema |= _SCHEMA_STATE | { vol.Optional(CONF_UNIT_OF_MEASUREMENT): selector.SelectSelector( @@ -195,6 +212,7 @@ def validate_user_input( TEMPLATE_TYPES = [ "binary_sensor", + "button", "sensor", ] @@ -205,6 +223,10 @@ CONFIG_FLOW = { preview="template", validate_user_input=validate_user_input(Platform.BINARY_SENSOR), ), + Platform.BUTTON: SchemaFlowFormStep( + config_schema(Platform.BUTTON), + validate_user_input=validate_user_input(Platform.BUTTON), + ), Platform.SENSOR: SchemaFlowFormStep( config_schema(Platform.SENSOR), preview="template", @@ -220,6 +242,10 @@ OPTIONS_FLOW = { preview="template", validate_user_input=validate_user_input(Platform.BINARY_SENSOR), ), + Platform.BUTTON: SchemaFlowFormStep( + options_schema(Platform.BUTTON), + validate_user_input=validate_user_input(Platform.BUTTON), + ), Platform.SENSOR: SchemaFlowFormStep( options_schema(Platform.SENSOR), preview="template", diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index 6805c0ad812..e7681225a49 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -32,4 +32,5 @@ CONF_AVAILABILITY = "availability" CONF_ATTRIBUTES = "attributes" CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" CONF_PICTURE = "picture" +CONF_PRESS = "press" CONF_OBJECT_ID = "object_id" diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index dc481b76ff8..df281b77daa 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -13,6 +13,18 @@ }, "title": "Template binary sensor" }, + "button": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "device_class": "[%key:component::template::config::step::sensor::data::device_class%]", + "name": "[%key:common::config_flow::data::name%]", + "press": "Actions on press" + }, + "data_description": { + "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + }, + "title": "Template button" + }, "sensor": { "data": { "device_id": "[%key:common::config_flow::data::device%]", @@ -31,6 +43,7 @@ "description": "This helper allows you to create helper entities that define their state using a template.", "menu_options": { "binary_sensor": "Template a binary sensor", + "button": "Template a button", "sensor": "Template a sensor" }, "title": "Template helper" @@ -49,6 +62,16 @@ }, "title": "[%key:component::template::config::step::binary_sensor::title%]" }, + "button": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "press": "[%key:component::template::config::step::button::data::press%]" + }, + "data_description": { + "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + }, + "title": "[%key:component::template::config::step::button::title%]" + }, "sensor": { "data": { "device_id": "[%key:common::config_flow::data::device%]", @@ -97,6 +120,13 @@ "window": "[%key:component::binary_sensor::entity_component::window::name%]" } }, + "button_device_class": { + "options": { + "identify": "[%key:component::button::entity_component::identify::name%]", + "restart": "[%key:common::action::restart%]", + "update": "[%key:component::button::entity_component::update::name%]" + } + }, "sensor_device_class": { "options": { "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", diff --git a/tests/components/template/snapshots/test_button.ambr b/tests/components/template/snapshots/test_button.ambr new file mode 100644 index 00000000000..3d96ad66050 --- /dev/null +++ b/tests/components/template/snapshots/test_button.ambr @@ -0,0 +1,28 @@ +# serializer version: 1 +# name: test_setup_config_entry[config_entry_extra_options0] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My template', + }), + 'context': , + 'entity_id': 'button.my_template', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_config_entry[config_entry_extra_options1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'update', + 'friendly_name': 'My template', + }), + 'context': , + 'entity_id': 'button.my_template', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/template/test_button.py b/tests/components/template/test_button.py index c861c7874d4..72c3d2351f5 100644 --- a/tests/components/template/test_button.py +++ b/tests/components/template/test_button.py @@ -3,9 +3,12 @@ import datetime as dt from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant import setup from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.template import DOMAIN from homeassistant.components.template.button import DEFAULT_NAME from homeassistant.const import ( CONF_DEVICE_CLASS, @@ -15,14 +18,58 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import assert_setup_component +from tests.common import MockConfigEntry, assert_setup_component _TEST_BUTTON = "button.template_button" _TEST_OPTIONS_BUTTON = "button.test" +@pytest.mark.parametrize( + "config_entry_extra_options", + [ + {}, + { + "device_class": "update", + }, + ], +) +async def test_setup_config_entry( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry_extra_options: dict[str, str], +) -> None: + """Test the config flow.""" + + template_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My template", + "template_type": "button", + "press": [ + { + "service": "input_boolean.toggle", + "metadata": {}, + "data": {}, + "target": {"entity_id": "input_boolean.test"}, + } + ], + } + | config_entry_extra_options, + 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("button.my_template") + assert state is not None + assert state == snapshot + + async def test_missing_optional_config(hass: HomeAssistant) -> None: """Test: missing optional template is ok.""" with assert_setup_component(1, "template"): @@ -197,3 +244,49 @@ def _verify( state = hass.states.get(entity_id) assert state.state == expected_value assert state.attributes == attributes + + +async def test_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test for device for button 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=DOMAIN, + options={ + "name": "My template", + "template_type": "button", + "device_id": device_entry.id, + "press": [ + { + "service": "input_boolean.toggle", + "metadata": {}, + "data": {}, + "target": {"entity_id": "input_boolean.test"}, + } + ], + }, + 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("button.my_template") + assert template_entity is not None + assert template_entity.device_id == device_entry.id diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index f277b918661..10f7d45637f 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -31,7 +31,9 @@ from tests.typing import WebSocketGenerator [ ( "binary_sensor", - "{{ states('binary_sensor.one') == 'on' or states('binary_sensor.two') == 'on' }}", + { + "state": "{{ states('binary_sensor.one') == 'on' or states('binary_sensor.two') == 'on' }}" + }, "on", {"one": "on", "two": "off"}, {}, @@ -41,7 +43,9 @@ from tests.typing import WebSocketGenerator ), ( "sensor", - "{{ float(states('sensor.one')) + float(states('sensor.two')) }}", + { + "state": "{{ float(states('sensor.one')) + float(states('sensor.two')) }}" + }, "50.0", {"one": "30.0", "two": "20.0"}, {}, @@ -49,6 +53,34 @@ from tests.typing import WebSocketGenerator {}, {}, ), + ( + "button", + {}, + "unknown", + {"one": "30.0", "two": "20.0"}, + {}, + { + "device_class": "restart", + "press": [ + { + "service": "input_boolean.toggle", + "target": {"entity_id": "input_boolean.test"}, + "data": {}, + } + ], + }, + { + "device_class": "restart", + "press": [ + { + "service": "input_boolean.toggle", + "target": {"entity_id": "input_boolean.test"}, + "data": {}, + } + ], + }, + {}, + ), ], ) async def test_config_flow( @@ -91,7 +123,7 @@ async def test_config_flow( result["flow_id"], { "name": "My template", - "state": state_template, + **state_template, **extra_input, }, ) @@ -102,8 +134,8 @@ async def test_config_flow( assert result["data"] == {} assert result["options"] == { "name": "My template", - "state": state_template, "template_type": template_type, + **state_template, **extra_options, } assert len(mock_setup_entry.mock_calls) == 1 @@ -112,8 +144,8 @@ async def test_config_flow( assert config_entry.data == {} assert config_entry.options == { "name": "My template", - "state": state_template, "template_type": template_type, + **state_template, **extra_options, } @@ -127,22 +159,36 @@ async def test_config_flow( ( "template_type", "state_template", + "extra_input", + "extra_options", ), [ ( "sensor", - "{{ 15 }}", + {"state": "{{ 15 }}"}, + {}, + {}, ), ( "binary_sensor", - "{{ false }}", + {"state": "{{ false }}"}, + {}, + {}, + ), + ( + "button", + {}, + {}, + {}, ), ], ) async def test_config_flow_device( hass: HomeAssistant, template_type: str, - state_template: str, + state_template: dict[str, Any], + extra_input: dict[str, Any], + extra_options: dict[str, Any], device_registry: dr.DeviceRegistry, ) -> None: """Test remove the device registry configuration entry when the device changes.""" @@ -180,8 +226,9 @@ async def test_config_flow_device( result["flow_id"], { "name": "My template", - "state": state_template, "device_id": device_id, + **state_template, + **extra_input, }, ) await hass.async_block_till_done() @@ -191,9 +238,10 @@ async def test_config_flow_device( assert result["data"] == {} assert result["options"] == { "name": "My template", - "state": state_template, "template_type": template_type, "device_id": device_id, + **state_template, + **extra_options, } assert len(mock_setup_entry.mock_calls) == 1 @@ -201,9 +249,10 @@ async def test_config_flow_device( assert config_entry.data == {} assert config_entry.options == { "name": "My template", - "state": state_template, "template_type": template_type, "device_id": device_id, + **state_template, + **extra_options, } @@ -214,8 +263,8 @@ def get_suggested(schema, key): if k.description is None or "suggested_value" not in k.description: return None return k.description["suggested_value"] - # Wanted key absent from schema - raise KeyError("Wanted key absent from schema") + # If the desired key is missing from the schema, return None + return None @pytest.mark.parametrize( @@ -231,8 +280,12 @@ def get_suggested(schema, key): [ ( "binary_sensor", - "{{ states('binary_sensor.one') == 'on' or states('binary_sensor.two') == 'on' }}", - "{{ states('binary_sensor.one') == 'on' and states('binary_sensor.two') == 'on' }}", + { + "state": "{{ states('binary_sensor.one') == 'on' or states('binary_sensor.two') == 'on' }}" + }, + { + "state": "{{ states('binary_sensor.one') == 'on' and states('binary_sensor.two') == 'on' }}" + }, ["on", "off"], {"one": "on", "two": "off"}, {}, @@ -240,13 +293,43 @@ def get_suggested(schema, key): ), ( "sensor", - "{{ float(states('sensor.one')) + float(states('sensor.two')) }}", - "{{ float(states('sensor.one')) - float(states('sensor.two')) }}", + { + "state": "{{ float(states('sensor.one')) + float(states('sensor.two')) }}" + }, + { + "state": "{{ float(states('sensor.one')) - float(states('sensor.two')) }}" + }, ["50.0", "10.0"], {"one": "30.0", "two": "20.0"}, {}, {}, ), + ( + "button", + {}, + {}, + ["unknown", "unknown"], + {"one": "30.0", "two": "20.0"}, + { + "device_class": "restart", + "press": [ + { + "service": "input_boolean.toggle", + "target": {"entity_id": "input_boolean.test"}, + "data": {}, + } + ], + }, + { + "press": [ + { + "service": "input_boolean.toggle", + "target": {"entity_id": "input_boolean.test"}, + "data": {}, + } + ], + }, + ), ], ) async def test_options( @@ -272,8 +355,8 @@ async def test_options( domain=DOMAIN, options={ "name": "My template", - "state": old_state_template, "template_type": template_type, + **old_state_template, **extra_options, }, title="My template", @@ -291,25 +374,27 @@ async def test_options( result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == template_type - assert get_suggested(result["data_schema"].schema, "state") == old_state_template + assert get_suggested( + result["data_schema"].schema, "state" + ) == old_state_template.get("state") assert "name" not in result["data_schema"].schema result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"state": new_state_template, **options_options}, + user_input={**new_state_template, **options_options}, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { "name": "My template", - "state": new_state_template, "template_type": template_type, + **new_state_template, **extra_options, } assert config_entry.data == {} assert config_entry.options == { "name": "My template", - "state": new_state_template, "template_type": template_type, + **new_state_template, **extra_options, } assert config_entry.title == "My template" @@ -943,22 +1028,36 @@ async def test_option_flow_sensor_preview_config_entry_removed( ( "template_type", "state_template", + "extra_input", + "extra_options", ), [ ( "sensor", - "{{ 15 }}", + {"state": "{{ 15 }}"}, + {}, + {}, ), ( "binary_sensor", - "{{ false }}", + {"state": "{{ false }}"}, + {}, + {}, + ), + ( + "button", + {}, + {}, + {}, ), ], ) async def test_options_flow_change_device( hass: HomeAssistant, template_type: str, - state_template: str, + state_template: dict[str, Any], + extra_input: dict[str, Any], + extra_options: dict[str, Any], device_registry: dr.DeviceRegistry, ) -> None: """Test remove the device registry configuration entry when the device changes.""" @@ -992,11 +1091,12 @@ async def test_options_flow_change_device( domain=DOMAIN, options={ "template_type": template_type, - "name": "Test", - "state": state_template, + "name": "My template", "device_id": device_id1, + **state_template, + **extra_options, }, - title="Sensor template", + title="Template", ) template_config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(template_config_entry.entry_id) @@ -1011,23 +1111,26 @@ async def test_options_flow_change_device( result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "state": state_template, "device_id": device_id2, + **state_template, + **extra_input, }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { "template_type": template_type, - "name": "Test", - "state": state_template, + "name": "My template", "device_id": device_id2, + **state_template, + **extra_input, } assert template_config_entry.data == {} assert template_config_entry.options == { "template_type": template_type, - "name": "Test", - "state": state_template, + "name": "My template", "device_id": device_id2, + **state_template, + **extra_options, } # Remove link with device @@ -1039,20 +1142,23 @@ async def test_options_flow_change_device( result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "state": state_template, + **state_template, + **extra_input, }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { "template_type": template_type, - "name": "Test", - "state": state_template, + "name": "My template", + **state_template, + **extra_input, } assert template_config_entry.data == {} assert template_config_entry.options == { "template_type": template_type, - "name": "Test", - "state": state_template, + "name": "My template", + **state_template, + **extra_options, } # Change to link to device 1 @@ -1064,21 +1170,24 @@ async def test_options_flow_change_device( result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "state": state_template, "device_id": device_id1, + **state_template, + **extra_input, }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { "template_type": template_type, - "name": "Test", - "state": state_template, + "name": "My template", "device_id": device_id1, + **state_template, + **extra_input, } assert template_config_entry.data == {} assert template_config_entry.options == { "template_type": template_type, - "name": "Test", - "state": state_template, + "name": "My template", "device_id": device_id1, + **state_template, + **extra_options, } diff --git a/tests/components/template/test_init.py b/tests/components/template/test_init.py index a948611f389..58f75560878 100644 --- a/tests/components/template/test_init.py +++ b/tests/components/template/test_init.py @@ -297,6 +297,13 @@ async def async_yaml_patch_helper(hass, filename): "state": "{{1 == 2}}", }, ), + ( + { + "template_type": "button", + "name": "My template", + }, + {}, + ), ], ) async def test_change_device(