From 0bda8695531b7916555477b76c51f77c306d2fee Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 8 May 2023 22:06:11 +0200 Subject: [PATCH] Lock entity options (#88139) --- homeassistant/components/lock/__init__.py | 47 ++++++++- tests/components/lock/test_init.py | 98 +++++++++++++++++++ .../custom_components/test/lock.py | 5 + 3 files changed, 146 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index c68d99bfb22..8cbce69dc7c 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -24,7 +24,7 @@ from homeassistant.const import ( STATE_UNLOCKED, STATE_UNLOCKING, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, @@ -39,6 +39,7 @@ from homeassistant.helpers.typing import ConfigType, StateType _LOGGER = logging.getLogger(__name__) ATTR_CHANGED_BY = "changed_by" +CONF_DEFAULT_CODE = "default_code" DOMAIN = "lock" SCAN_INTERVAL = timedelta(seconds=30) @@ -88,7 +89,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def _async_lock(entity: LockEntity, service_call: ServiceCall) -> None: """Lock the lock.""" - code: str = service_call.data.get(ATTR_CODE, "") + code: str = service_call.data.get( + ATTR_CODE, entity._lock_option_default_code # pylint: disable=protected-access + ) if entity.code_format_cmp and not entity.code_format_cmp.match(code): raise ValueError( f"Code '{code}' for locking {entity.entity_id} doesn't match pattern {entity.code_format}" @@ -98,7 +101,9 @@ async def _async_lock(entity: LockEntity, service_call: ServiceCall) -> None: async def _async_unlock(entity: LockEntity, service_call: ServiceCall) -> None: """Unlock the lock.""" - code: str = service_call.data.get(ATTR_CODE, "") + code: str = service_call.data.get( + ATTR_CODE, entity._lock_option_default_code # pylint: disable=protected-access + ) if entity.code_format_cmp and not entity.code_format_cmp.match(code): raise ValueError( f"Code '{code}' for unlocking {entity.entity_id} doesn't match pattern {entity.code_format}" @@ -108,7 +113,9 @@ async def _async_unlock(entity: LockEntity, service_call: ServiceCall) -> None: async def _async_open(entity: LockEntity, service_call: ServiceCall) -> None: """Open the door latch.""" - code: str = service_call.data.get(ATTR_CODE, "") + code: str = service_call.data.get( + ATTR_CODE, entity._lock_option_default_code # pylint: disable=protected-access + ) if entity.code_format_cmp and not entity.code_format_cmp.match(code): raise ValueError( f"Code '{code}' for opening {entity.entity_id} doesn't match pattern {entity.code_format}" @@ -145,6 +152,7 @@ class LockEntity(Entity): _attr_is_jammed: bool | None = None _attr_state: None = None _attr_supported_features: LockEntityFeature = LockEntityFeature(0) + _lock_option_default_code: str = "" __code_format_cmp: re.Pattern[str] | None = None @property @@ -243,3 +251,34 @@ class LockEntity(Entity): def supported_features(self) -> LockEntityFeature: """Return the list of supported features.""" return self._attr_supported_features + + async def async_internal_added_to_hass(self) -> None: + """Call when the sensor entity is added to hass.""" + await super().async_internal_added_to_hass() + if not self.registry_entry: + return + self._async_read_entity_options() + + @callback + def async_registry_entry_updated(self) -> None: + """Run when the entity registry entry has been updated.""" + self._async_read_entity_options() + + @callback + def _async_read_entity_options(self) -> None: + """Read entity options from entity registry. + + Called when the entity registry entry has been updated and before the lock is + added to the state machine. + """ + assert self.registry_entry + if (lock_options := self.registry_entry.options.get(DOMAIN)) and ( + custom_default_lock_code := lock_options.get(CONF_DEFAULT_CODE) + ): + if self.code_format_cmp and self.code_format_cmp.match( + custom_default_lock_code + ): + self._lock_option_default_code = custom_default_lock_code + return + + self._lock_option_default_code = "" diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index 4943d63c6ed..0d33881c46c 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -8,6 +8,7 @@ import pytest from homeassistant.components.lock import ( ATTR_CODE, + CONF_DEFAULT_CODE, DOMAIN, SERVICE_LOCK, SERVICE_OPEN, @@ -24,6 +25,10 @@ from homeassistant.components.lock import ( _async_unlock, ) from homeassistant.core import HomeAssistant, ServiceCall +import homeassistant.helpers.entity_registry as er +from homeassistant.setup import async_setup_component + +from tests.testing_config.custom_components.test.lock import MockLock class MockLockEntity(LockEntity): @@ -32,6 +37,7 @@ class MockLockEntity(LockEntity): def __init__( self, code_format: str | None = None, + lock_option_default_code: str = "", supported_features: LockEntityFeature = LockEntityFeature(0), ) -> None: """Initialize mock lock entity.""" @@ -39,6 +45,7 @@ class MockLockEntity(LockEntity): self.calls_open = MagicMock() if code_format is not None: self._attr_code_format = code_format + self._lock_option_default_code = lock_option_default_code async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" @@ -95,6 +102,80 @@ async def test_lock_states(hass: HomeAssistant) -> None: assert not lock.is_locked +async def test_set_default_code_option( + hass: HomeAssistant, + enable_custom_integrations: None, +) -> None: + """Test default code stored in the registry.""" + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get_or_create("lock", "test", "very_unique") + await hass.async_block_till_done() + + platform = getattr(hass.components, "test.lock") + platform.init(empty=True) + platform.ENTITIES["lock1"] = platform.MockLock( + name="Test", + code_format=r"^\d{4}$", + supported_features=LockEntityFeature.OPEN, + unique_id="very_unique", + ) + + assert await async_setup_component(hass, "lock", {"lock": {"platform": "test"}}) + await hass.async_block_till_done() + + entity0: MockLock = platform.ENTITIES["lock1"] + entity_registry.async_update_entity_options( + entry.entity_id, "lock", {CONF_DEFAULT_CODE: "1234"} + ) + await hass.async_block_till_done() + + assert entity0._lock_option_default_code == "1234" + + +async def test_default_code_option_update( + hass: HomeAssistant, + enable_custom_integrations: None, +) -> None: + """Test default code stored in the registry is updated.""" + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get_or_create("lock", "test", "very_unique") + await hass.async_block_till_done() + + platform = getattr(hass.components, "test.lock") + platform.init(empty=True) + + # Pre-register entities + entry = entity_registry.async_get_or_create("lock", "test", "very_unique") + entity_registry.async_update_entity_options( + entry.entity_id, + "lock", + { + "default_code": "5432", + }, + ) + platform.ENTITIES["lock1"] = platform.MockLock( + name="Test", + code_format=r"^\d{4}$", + supported_features=LockEntityFeature.OPEN, + unique_id="very_unique", + ) + + assert await async_setup_component(hass, "lock", {"lock": {"platform": "test"}}) + await hass.async_block_till_done() + + entity0: MockLock = platform.ENTITIES["lock1"] + assert entity0._lock_option_default_code == "5432" + + entity_registry.async_update_entity_options( + entry.entity_id, "lock", {CONF_DEFAULT_CODE: "1234"} + ) + await hass.async_block_till_done() + + assert entity0._lock_option_default_code == "1234" + + async def test_lock_open_with_code(hass: HomeAssistant) -> None: """Test lock entity with open service.""" lock = MockLockEntity( @@ -150,3 +231,20 @@ async def test_lock_unlock_with_code(hass: HomeAssistant) -> None: ) await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "1234"})) assert not lock.is_locked + + +async def test_lock_with_default_code(hass: HomeAssistant) -> None: + """Test lock entity with default code.""" + lock = MockLockEntity( + code_format=r"^\d{4}$", + supported_features=LockEntityFeature.OPEN, + lock_option_default_code="1234", + ) + lock.hass = hass + + assert lock.state_attributes == {"code_format": r"^\d{4}$"} + assert lock._lock_option_default_code == "1234" + + await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) + await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) diff --git a/tests/testing_config/custom_components/test/lock.py b/tests/testing_config/custom_components/test/lock.py index f98cc3fa671..b48e8b1fad9 100644 --- a/tests/testing_config/custom_components/test/lock.py +++ b/tests/testing_config/custom_components/test/lock.py @@ -43,6 +43,11 @@ async def async_setup_platform( class MockLock(MockEntity, LockEntity): """Mock Lock class.""" + @property + def code_format(self) -> str | None: + """Return code format.""" + return self._handle("code_format") + @property def is_locked(self): """Return true if the lock is locked."""