diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index bf826b652695..21155a3a0a28 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -222,12 +222,21 @@ class StateBinarySensorEntity(TemplateEntity, AbstractTemplateBinarySensor): def _set_state(_): """Set state of template binary sensor.""" self._attr_is_on = state - self.async_write_ha_state() + if self._preview_callback: + self._async_preview_update() + else: + self.async_write_ha_state() delay = (self._delay_on if state else self._delay_off).total_seconds() # state with delay. Cancelled if template result changes. self._delay_cancel = async_call_later(self.hass, delay, _set_state) + @override + def _call_on_remove_callbacks(self): + if self._delay_cancel: + self._delay_cancel() + return super()._call_on_remove_callbacks() + @dataclass class AutoOffExtraStoredData(ExtraStoredData): diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index 84901194565b..a3569dbc179d 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -52,7 +52,11 @@ from .alarm_control_panel import ( TemplateCodeFormat, async_create_preview_alarm_control_panel, ) -from .binary_sensor import async_create_preview_binary_sensor +from .binary_sensor import ( + CONF_DELAY_OFF, + CONF_DELAY_ON, + async_create_preview_binary_sensor, +) from .climate import ( CONF_CURRENT_TEMPERATURE, CONF_HVAC_ACTION, @@ -198,6 +202,14 @@ def generate_schema(domain: str, flow_type: str) -> probatio.Schema: selector.DeviceClassSelectorConfig(domain=Platform.BINARY_SENSOR), ), } + additional_options |= { + probatio.Optional(CONF_DELAY_ON): selector.DurationSelector( + selector.DurationSelectorConfig(allow_negative=False) + ), + probatio.Optional(CONF_DELAY_OFF): selector.DurationSelector( + selector.DurationSelectorConfig(allow_negative=False) + ), + } if domain == Platform.BUTTON: schema |= { diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 99bfe69c5301..dcdb2f50c82c 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -70,10 +70,14 @@ "sections": { "additional_options": { "data": { - "availability": "[%key:component::template::common::availability%]" + "availability": "[%key:component::template::common::availability%]", + "delay_off": "Delay off", + "delay_on": "Delay on" }, "data_description": { - "availability": "[%key:component::template::common::availability_description%]" + "availability": "[%key:component::template::common::availability_description%]", + "delay_off": "The amount of time the template state must not be met before this sensor switches to `off`.", + "delay_on": "The amount of time the template state must be met before this sensor switches to `on`." }, "name": "[%key:component::template::common::additional_options%]" } @@ -712,10 +716,14 @@ "sections": { "additional_options": { "data": { - "availability": "[%key:component::template::common::availability%]" + "availability": "[%key:component::template::common::availability%]", + "delay_off": "[%key:component::template::config::step::binary_sensor::sections::additional_options::data::delay_off%]", + "delay_on": "[%key:component::template::config::step::binary_sensor::sections::additional_options::data::delay_on%]" }, "data_description": { - "availability": "[%key:component::template::common::availability_description%]" + "availability": "[%key:component::template::common::availability_description%]", + "delay_off": "[%key:component::template::config::step::binary_sensor::sections::additional_options::data_description::delay_off%]", + "delay_on": "[%key:component::template::config::step::binary_sensor::sections::additional_options::data_description::delay_on%]" }, "name": "[%key:component::template::common::additional_options%]" } diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 967a310bf32f..bd92181ff516 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -453,6 +453,18 @@ class TemplateEntity(AbstractTemplateEntity): self._preview_callback(None, None, None, str(errors[-1])) return + self._async_preview_update() + + @callback + def _async_preview_update(self) -> None: + """Send an updated state to the preview callback.""" + if not self._preview_callback: + return + + if not self._template_result_info: + self._preview_callback(None, None, None, "Preview not ready") + return + try: calculated_state = self._async_calculate_state() validate_state(calculated_state.state) diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index 2cd53265f05a..a628c53fa258 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -10,7 +10,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant import setup +from homeassistant import config_entries, setup from homeassistant.components import binary_sensor, template from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -21,10 +21,12 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Context, CoreState, HomeAssistant, State +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.helpers.typing import ConfigType +from homeassistant.util import dt as dt_util from .conftest import ( RESTORE_STATE_SAVED_ATTRIBUTES, @@ -598,6 +600,143 @@ async def test_delay_off(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> assert hass.states.get(TEST_BINARY_SENSOR.entity_id).state == STATE_OFF +@pytest.mark.parametrize( + ("advanced_input", "expected_advanced_options", "expected_state"), + [ + ( + {"delay_on": {"seconds": 5}}, + {"delay_on": {"seconds": 5.0}}, + STATE_UNKNOWN, + ), + ( + {"delay_off": {"minutes": 1}}, + {"delay_off": {"minutes": 1.0}}, + STATE_ON, + ), + ( + {"delay_on": {"seconds": 5}, "delay_off": {"minutes": 1}}, + {"delay_on": {"seconds": 5.0}, "delay_off": {"minutes": 1.0}}, + STATE_UNKNOWN, + ), + ], +) +async def test_config_flow_binary_sensor_delay_options( + hass: HomeAssistant, + advanced_input: dict[str, Any], + expected_advanced_options: dict[str, Any], + expected_state: str, +) -> None: + """Test delay options in the binary sensor config flow.""" + result = await hass.config_entries.flow.async_init( + template.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "binary_sensor"}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "binary_sensor" + + with patch( + "homeassistant.components.template.async_setup_entry", + wraps=template.async_setup_entry, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "name": "My template", + "state": "{{ true }}", + "additional_options": advanced_input, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "My template" + assert result["data"] == {} + assert result["options"] == { + "name": "My template", + "template_type": "binary_sensor", + "state": "{{ true }}", + "additional_options": expected_advanced_options, + } + assert len(mock_setup_entry.mock_calls) == 1 + + config_entry = hass.config_entries.async_entries(template.DOMAIN)[0] + assert config_entry.data == {} + assert config_entry.options == { + "name": "My template", + "template_type": "binary_sensor", + "state": "{{ true }}", + "additional_options": expected_advanced_options, + } + state = hass.states.get("binary_sensor.my_template") + assert state.state == expected_state + + +async def test_config_flow_preview_binary_sensor_delay( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview with a delayed binary sensor.""" + client = await hass_ws_client(hass) + + hass.states.async_set("binary_sensor.available", "on") + hass.states.async_set("binary_sensor.one", "off") + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + template.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "binary_sensor"}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "binary_sensor" + assert result["preview"] == "template" + + await client.send_json_auto_id( + { + "type": "template/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": { + "name": "My template", + "state": "{{ is_state('binary_sensor.one', 'on') }}", + "additional_options": { + "availability": "{{ True }}", + "delay_on": {"seconds": 1}, + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + assert msg["event"]["state"] == "off" + + hass.states.async_set("binary_sensor.one", "on") + await hass.async_block_till_done() + + msg = await client.receive_json() + assert msg["event"]["state"] == "off" + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() + + msg = await client.receive_json() + assert msg["event"]["state"] == "on" + + @pytest.mark.parametrize( ("count", "state_template", "extra_config"), [