From 6306baa3c985e7a6c47304e11a5291c0ecde460a Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Wed, 30 Jul 2025 11:04:39 -0400 Subject: [PATCH] Add config flow to template lock platform (#149449) --- .../components/template/config_flow.py | 21 +++++++ homeassistant/components/template/lock.py | 46 +++++++++++++- .../components/template/strings.json | 46 ++++++++++++++ .../template/snapshots/test_lock.ambr | 15 +++++ tests/components/template/test_config_flow.py | 32 ++++++++++ tests/components/template/test_lock.py | 61 ++++++++++++++++++- 6 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 tests/components/template/snapshots/test_lock.ambr diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index 394af688152..2e581628da2 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -89,6 +89,7 @@ from .light import ( CONF_TEMPERATURE_ACTION, async_create_preview_light, ) +from .lock import CONF_LOCK, CONF_OPEN, CONF_UNLOCK, async_create_preview_lock from .number import ( CONF_MAX, CONF_MIN, @@ -233,6 +234,14 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: vol.Optional(CONF_TEMPERATURE_ACTION): selector.ActionSelector(), } + if domain == Platform.LOCK: + schema |= _SCHEMA_STATE | { + vol.Required(CONF_LOCK): selector.ActionSelector(), + vol.Required(CONF_UNLOCK): selector.ActionSelector(), + vol.Optional(CONF_CODE_FORMAT): selector.TemplateSelector(), + vol.Optional(CONF_OPEN): selector.ActionSelector(), + } + if domain == Platform.NUMBER: schema |= { vol.Required(CONF_STATE): selector.TemplateSelector(), @@ -435,6 +444,7 @@ TEMPLATE_TYPES = [ Platform.FAN, Platform.IMAGE, Platform.LIGHT, + Platform.LOCK, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, @@ -478,6 +488,11 @@ CONFIG_FLOW = { preview="template", validate_user_input=validate_user_input(Platform.LIGHT), ), + Platform.LOCK: SchemaFlowFormStep( + config_schema(Platform.LOCK), + preview="template", + validate_user_input=validate_user_input(Platform.LOCK), + ), Platform.NUMBER: SchemaFlowFormStep( config_schema(Platform.NUMBER), preview="template", @@ -542,6 +557,11 @@ OPTIONS_FLOW = { preview="template", validate_user_input=validate_user_input(Platform.LIGHT), ), + Platform.LOCK: SchemaFlowFormStep( + options_schema(Platform.LOCK), + preview="template", + validate_user_input=validate_user_input(Platform.LOCK), + ), Platform.NUMBER: SchemaFlowFormStep( options_schema(Platform.NUMBER), preview="template", @@ -578,6 +598,7 @@ CREATE_PREVIEW_ENTITY: dict[ Platform.COVER: async_create_preview_cover, Platform.FAN: async_create_preview_fan, Platform.LIGHT: async_create_preview_light, + Platform.LOCK: async_create_preview_lock, Platform.NUMBER: async_create_preview_number, Platform.SELECT: async_create_preview_select, Platform.SENSOR: async_create_preview_sensor, diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index e89f95734d1..04d26521ef1 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -15,6 +15,7 @@ from homeassistant.components.lock import ( LockEntityFeature, LockState, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_CODE, CONF_NAME, @@ -26,15 +27,23 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError, TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity -from .helpers import async_setup_template_platform +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) from .template_entity import ( TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_schema, @@ -82,6 +91,10 @@ PLATFORM_SCHEMA = LOCK_PLATFORM_SCHEMA.extend( } ).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema) +LOCK_CONFIG_ENTRY_SCHEMA = LOCK_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema +) + async def async_setup_platform( hass: HomeAssistant, @@ -102,6 +115,35 @@ async def async_setup_platform( ) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize config entry.""" + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateLockEntity, + LOCK_CONFIG_ENTRY_SCHEMA, + ) + + +@callback +def async_create_preview_lock( + hass: HomeAssistant, name: str, config: dict[str, Any] +) -> StateLockEntity: + """Create a preview.""" + return async_setup_template_preview( + hass, + name, + config, + StateLockEntity, + LOCK_CONFIG_ENTRY_SCHEMA, + ) + + class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): """Representation of a template lock features.""" diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index cb1e26fac78..edf4516e8ab 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -188,6 +188,29 @@ }, "title": "Template light" }, + "lock": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "name": "[%key:common::config_flow::data::name%]", + "state": "[%key:component::template::common::state%]", + "lock": "Actions on lock", + "unlock": "Actions on unlock", + "code_format": "[%key:component::template::common::code_format%]", + "open": "Actions on open" + }, + "data_description": { + "device_id": "[%key:component::template::common::device_id_description%]" + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + } + } + }, + "title": "Template lock" + }, "number": { "data": { "device_id": "[%key:common::config_flow::data::device%]", @@ -265,6 +288,7 @@ "fan": "Template a fan", "image": "Template an image", "light": "Template a light", + "lock": "Template a lock", "number": "Template a number", "select": "Template a select", "sensor": "Template a sensor", @@ -495,6 +519,28 @@ }, "title": "[%key:component::template::config::step::light::title%]" }, + "lock": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "state": "[%key:component::template::common::state%]", + "lock": "[%key:component::template::config::step::lock::data::lock%]", + "unlock": "[%key:component::template::config::step::lock::data::unlock%]", + "code_format": "[%key:component::template::common::code_format%]", + "open": "[%key:component::template::config::step::lock::data::open%]" + }, + "data_description": { + "device_id": "[%key:component::template::common::device_id_description%]" + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + } + } + }, + "title": "[%key:component::template::config::step::lock::title%]" + }, "number": { "data": { "device_id": "[%key:common::config_flow::data::device%]", diff --git a/tests/components/template/snapshots/test_lock.ambr b/tests/components/template/snapshots/test_lock.ambr new file mode 100644 index 00000000000..250fc6ba8d4 --- /dev/null +++ b/tests/components/template/snapshots/test_lock.ambr @@ -0,0 +1,15 @@ +# serializer version: 1 +# name: test_setup_config_entry + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My template', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.my_template', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'locked', + }) +# --- diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 9bfb0d439f7..08104025582 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -179,6 +179,16 @@ BINARY_SENSOR_OPTIONS = { {"turn_on": [], "turn_off": []}, {}, ), + ( + "lock", + {"state": "{{ states('lock.one') }}"}, + "locked", + {"one": "locked", "two": "unlocked"}, + {}, + {"lock": [], "unlock": []}, + {"lock": [], "unlock": []}, + {}, + ), ( "number", {"state": "{{ states('number.one') }}"}, @@ -372,6 +382,12 @@ async def test_config_flow( {"turn_on": [], "turn_off": []}, {"turn_on": [], "turn_off": []}, ), + ( + "lock", + {"state": "{{ states('lock.one') }}"}, + {"lock": [], "unlock": []}, + {"lock": [], "unlock": []}, + ), ( "number", {"state": "{{ states('number.one') }}"}, @@ -603,6 +619,16 @@ async def test_config_flow_device( {"turn_on": [], "turn_off": []}, "state", ), + ( + "lock", + {"state": "{{ states('lock.one') }}"}, + {"state": "{{ states('lock.two') }}"}, + ["locked", "unlocked"], + {"one": "locked", "two": "unlocked"}, + {"lock": [], "unlock": []}, + {"lock": [], "unlock": []}, + "state", + ), ( "number", {"state": "{{ states('number.one') }}"}, @@ -1464,6 +1490,12 @@ async def test_option_flow_sensor_preview_config_entry_removed( {"turn_on": [], "turn_off": []}, {"turn_on": [], "turn_off": []}, ), + ( + "lock", + {"state": "{{ states('lock.one') }}"}, + {"lock": [], "unlock": []}, + {"lock": [], "unlock": []}, + ), ( "number", {"state": "{{ states('number.one') }}"}, diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py index 457c5b7bf5c..823306015bf 100644 --- a/tests/components/template/test_lock.py +++ b/tests/components/template/test_lock.py @@ -3,6 +3,7 @@ from typing import Any import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant import setup from homeassistant.components import lock, template @@ -19,9 +20,10 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import ConfigurationStyle +from .conftest import ConfigurationStyle, async_get_flow_preview_state -from tests.common import assert_setup_component +from tests.common import MockConfigEntry, assert_setup_component +from tests.typing import WebSocketGenerator TEST_OBJECT_ID = "test_template_lock" TEST_ENTITY_ID = f"lock.{TEST_OBJECT_ID}" @@ -1186,3 +1188,58 @@ async def test_optimistic(hass: HomeAssistant) -> None: state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.UNLOCKED + + +async def test_setup_config_entry( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Tests creating a lock from a config entry.""" + + hass.states.async_set( + "sensor.test_state", + LockState.LOCKED, + {}, + ) + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": "My template", + "state": "{{ states('sensor.test_state') }}", + "lock": [], + "unlock": [], + "template_type": lock.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("lock.my_template") + assert state is not None + assert state == snapshot + + +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + lock.DOMAIN, + { + "name": "My template", + "state": "{{ 'locked' }}", + "lock": [], + "unlock": [], + }, + ) + + assert state["state"] == LockState.LOCKED