From 6e3fc05f92e32a6bcbec87f1603811ee9d191d24 Mon Sep 17 00:00:00 2001 From: Andrew Grimberg Date: Thu, 21 Aug 2025 15:12:11 -0700 Subject: [PATCH 1/2] Feat: Add services for managing Schlage door codes This commit introduces three new services for the Schlage integration: - `add_code`: Allows users to add a new access code to a Schlage lock. - `delete_code`: Enables users to delete an existing access code from a Schlage lock. - `get_codes`: Retrieves all access codes associated with a Schlage lock. Signed-off-by: Andrew Grimberg --- homeassistant/components/schlage/__init__.py | 1 + homeassistant/components/schlage/const.py | 4 + .../components/schlage/coordinator.py | 2 +- homeassistant/components/schlage/icons.json | 13 + homeassistant/components/schlage/lock.py | 136 +++++++- .../components/schlage/services.yaml | 50 +++ homeassistant/components/schlage/strings.json | 56 ++++ tests/components/schlage/test_lock.py | 291 ++++++++++++++++++ 8 files changed, 551 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/schlage/icons.json create mode 100644 homeassistant/components/schlage/services.yaml diff --git a/homeassistant/components/schlage/__init__.py b/homeassistant/components/schlage/__init__.py index 509a335aafe8fb..342f5f9ffcee52 100644 --- a/homeassistant/components/schlage/__init__.py +++ b/homeassistant/components/schlage/__init__.py @@ -33,6 +33,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SchlageConfigEntry) -> b hass, entry, username, pyschlage.Schlage(auth) ) entry.runtime_data = coordinator + await coordinator.async_config_entry_first_refresh() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/schlage/const.py b/homeassistant/components/schlage/const.py index 1effd4bb33429f..75033520d3f07e 100644 --- a/homeassistant/components/schlage/const.py +++ b/homeassistant/components/schlage/const.py @@ -7,3 +7,7 @@ LOGGER = logging.getLogger(__package__) MANUFACTURER = "Schlage" UPDATE_INTERVAL = timedelta(seconds=30) + +SERVICE_ADD_CODE = "add_code" +SERVICE_DELETE_CODE = "delete_code" +SERVICE_GET_CODES = "get_codes" diff --git a/homeassistant/components/schlage/coordinator.py b/homeassistant/components/schlage/coordinator.py index eec143c574fbad..3cf287f92f91a4 100644 --- a/homeassistant/components/schlage/coordinator.py +++ b/homeassistant/components/schlage/coordinator.py @@ -65,7 +65,7 @@ def __init__( async def _async_update_data(self) -> SchlageData: """Fetch the latest data from the Schlage API.""" try: - locks = await self.hass.async_add_executor_job(self.api.locks) + locks = await self.hass.async_add_executor_job(self.api.locks, True) except NotAuthorizedError as ex: raise ConfigEntryAuthFailed from ex except SchlageError as ex: diff --git a/homeassistant/components/schlage/icons.json b/homeassistant/components/schlage/icons.json new file mode 100644 index 00000000000000..c231233be5167f --- /dev/null +++ b/homeassistant/components/schlage/icons.json @@ -0,0 +1,13 @@ +{ + "services": { + "add_code": { + "service": "mdi:key-plus" + }, + "delete_code": { + "service": "mdi:key-minus" + }, + "get_codes": { + "service": "mdi:table-key" + } + } +} diff --git a/homeassistant/components/schlage/lock.py b/homeassistant/components/schlage/lock.py index 83abf9214e38e8..b3317e11b44e66 100644 --- a/homeassistant/components/schlage/lock.py +++ b/homeassistant/components/schlage/lock.py @@ -4,10 +4,22 @@ from typing import Any +from pyschlage.code import AccessCode +import voluptuous as vol + from homeassistant.components.lock import LockEntity -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import DOMAIN, SERVICE_ADD_CODE, SERVICE_DELETE_CODE, SERVICE_GET_CODES from .coordinator import LockData, SchlageConfigEntry, SchlageDataUpdateCoordinator from .entity import SchlageEntity @@ -29,6 +41,128 @@ def _add_new_locks(locks: dict[str, LockData]) -> None: _add_new_locks(coordinator.data.locks) coordinator.new_locks_callbacks.append(_add_new_locks) + # Custom services + def _validate_code_name(codes: dict[str, AccessCode] | None, name: str) -> None: + """Validate that the code name doesn't already exist.""" + if codes and any(code.name.lower() == name.lower() for code in codes.values()): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="schlage_name_exists", + ) + + def _validate_code_value(codes: dict[str, AccessCode] | None, code: str) -> None: + """Validate that the code value doesn't already exist.""" + if codes and any( + existing_code.code == code for existing_code in codes.values() + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="schlage_code_exists", + ) + + async def _add_code(entity: SchlageLockEntity, calls: ServiceCall) -> None: + """Add a lock code.""" + name = calls.data.get("name") + code = calls.data.get("code") + + # name is required by voluptuous, the following is just to satisfy type + # checker, it should never be None + if name is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="schlage_name_required", + ) # pragma: no cover + + # code is required by voluptuous, the following is just to satisfy type + # checker, it should never be None + if code is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="schlage_code_required", + ) # pragma: no cover + + codes = entity._lock.access_codes # noqa: SLF001 + _validate_code_name(codes, name) + _validate_code_value(codes, code) + + access_code = AccessCode(name=name, code=code) + await hass.async_add_executor_job(entity._lock.add_access_code, access_code) # noqa: SLF001 + await coordinator.async_request_refresh() + + async def _delete_code(entity: SchlageLockEntity, calls: ServiceCall) -> None: + """Delete a lock code.""" + name = calls.data.get("name") + + # name is required by voluptuous, the following is just to satisfy type + # checker, it should never be None + if name is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="schlage_name_required", + ) # pragma: no cover + + codes = entity._lock.access_codes # noqa: SLF001 + if not codes: + return + + code_id_to_delete = next( + ( + code_id + for code_id, code_data in codes.items() + if code_data.name.lower() == name.lower() + ), + None, + ) + + if not code_id_to_delete: + return + + if entity._lock.access_codes: # noqa: SLF001 + await hass.async_add_executor_job( + entity._lock.access_codes[code_id_to_delete].delete # noqa: SLF001 + ) + await coordinator.async_request_refresh() + + async def _get_codes( + entity: SchlageLockEntity, calls: ServiceCall + ) -> ServiceResponse: + """Get lock codes.""" + + if entity._lock.access_codes: # noqa: SLF001 + return { + code: { + "name": entity._lock.access_codes[code].name, # noqa: SLF001 + "code": entity._lock.access_codes[code].code, # noqa: SLF001 + } + for code in entity._lock.access_codes # noqa: SLF001 + } + return {} + + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + name=SERVICE_ADD_CODE, + schema={ + vol.Required("name"): cv.string, + vol.Required("code"): cv.matches_regex(r"^\d{4,8}$"), + }, + func=_add_code, + ) + + platform.async_register_entity_service( + name=SERVICE_DELETE_CODE, + schema={ + vol.Required("name"): cv.string, + }, + func=_delete_code, + ) + + platform.async_register_entity_service( + name=SERVICE_GET_CODES, + schema=None, + func=_get_codes, + supports_response=SupportsResponse.ONLY, + ) + class SchlageLockEntity(SchlageEntity, LockEntity): """Schlage lock entity.""" diff --git a/homeassistant/components/schlage/services.yaml b/homeassistant/components/schlage/services.yaml new file mode 100644 index 00000000000000..9f4d5ab4122457 --- /dev/null +++ b/homeassistant/components/schlage/services.yaml @@ -0,0 +1,50 @@ +get_codes: + fields: + entity_id: + required: true + selector: + entity: + filter: + domain: lock + integration: schlage + multiple: true + +add_code: + fields: + entity_id: + required: true + selector: + entity: + filter: + domain: lock + integration: schlage + multiple: true + name: + required: true + example: "Example Person" + selector: + text: + multiline: false + code: + required: true + example: "1111" + selector: + text: + multiline: false + +delete_code: + fields: + entity_id: + required: true + selector: + entity: + filter: + domain: lock + integration: schlage + multiple: true + name: + required: true + example: "Example Person" + selector: + text: + multiline: false diff --git a/homeassistant/components/schlage/strings.json b/homeassistant/components/schlage/strings.json index e37f47895801a6..d73cd72a79e632 100644 --- a/homeassistant/components/schlage/strings.json +++ b/homeassistant/components/schlage/strings.json @@ -58,6 +58,62 @@ "exceptions": { "schlage_refresh_failed": { "message": "Failed to refresh Schlage data" + }, + "schlage_code_exists": { + "message": "A PIN code with this value already exists on the lock" + }, + "schlage_code_required": { + "message": "PIN code is required" + }, + "schlage_name_exists": { + "message": "A PIN code with this name already exists on the lock" + }, + "schlage_name_required": { + "message": "PIN name is required" + } + }, + "services": { + "add_code": { + "name": "Add PIN code", + "description": "Add a PIN code to a lock.", + "fields": { + "entity_id": { + "name": "Lock", + "description": "The lock to add the PIN code to." + }, + "name": { + "name": "PIN Name", + "description": "Name for PIN code. Must be unique to lock." + }, + "code": { + "name": "PIN code", + "description": "The PIN code to add. Must be unique to lock and be between 4 and 8 digits long." + } + } + }, + "delete_code": { + "name": "Delete PIN code", + "description": "Delete a PIN code from a lock.", + "fields": { + "entity_id": { + "name": "Lock", + "description": "The lock to delete the PIN code from." + }, + "name": { + "name": "PIN Name", + "description": "Name of PIN code to delete." + } + } + }, + "get_codes": { + "name": "Get PIN codes", + "description": "Retrieve all PIN codes from the lock", + "fields": { + "entity_id": { + "name": "Lock", + "description": "The lock to retrieve PIN codes from." + } + } } } } diff --git a/tests/components/schlage/test_lock.py b/tests/components/schlage/test_lock.py index 6a3bb799213d5d..ec5536429f6b5c 100644 --- a/tests/components/schlage/test_lock.py +++ b/tests/components/schlage/test_lock.py @@ -4,10 +4,19 @@ from unittest.mock import Mock from freezegun.api import FrozenDateTimeFactory +from pyschlage.code import AccessCode +import pytest from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState +from homeassistant.components.schlage.const import ( + DOMAIN, + SERVICE_ADD_CODE, + SERVICE_DELETE_CODE, + SERVICE_GET_CODES, +) from homeassistant.const import ATTR_ENTITY_ID, SERVICE_LOCK, SERVICE_UNLOCK from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from . import MockSchlageConfigEntry @@ -84,3 +93,285 @@ async def test_changed_by( lock_device = hass.states.get("lock.vault_door") assert lock_device is not None assert lock_device.attributes.get("changed_by") == "access code - foo" + + +async def test_add_code_service( + hass: HomeAssistant, + mock_lock: Mock, + mock_added_config_entry: MockSchlageConfigEntry, +) -> None: + """Test add_code service.""" + # Mock access_codes as empty initially + mock_lock.access_codes = {} + mock_lock.add_access_code = Mock() + + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_CODE, + service_data={ + "entity_id": "lock.vault_door", + "name": "test_user", + "code": "1234", + }, + blocking=True, + ) + await hass.async_block_till_done() + + # Verify add_access_code was called with correct AccessCode + mock_lock.add_access_code.assert_called_once() + call_args = mock_lock.add_access_code.call_args[0][0] + assert isinstance(call_args, AccessCode) + assert call_args.name == "test_user" + assert call_args.code == "1234" + + +async def test_add_code_service_duplicate_name( + hass: HomeAssistant, + mock_lock: Mock, + mock_added_config_entry: MockSchlageConfigEntry, +) -> None: + """Test add_code service with duplicate name.""" + # Mock existing access code + existing_code = Mock() + existing_code.name = "test_user" + existing_code.code = "5678" + mock_lock.access_codes = {"1": existing_code} + + with pytest.raises( + ServiceValidationError, match="Code name 'test_user' already exists" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_CODE, + service_data={ + "entity_id": "lock.vault_door", + "name": "test_user", + "code": "1234", + }, + blocking=True, + ) + + +async def test_add_code_service_duplicate_code( + hass: HomeAssistant, + mock_lock: Mock, + mock_added_config_entry: MockSchlageConfigEntry, +) -> None: + """Test add_code service with duplicate code.""" + # Mock existing access code + existing_code = Mock() + existing_code.name = "existing_user" + existing_code.code = "1234" + mock_lock.access_codes = {"1": existing_code} + + with pytest.raises(ServiceValidationError, match="Code already exists"): + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_CODE, + service_data={ + "entity_id": "lock.vault_door", + "name": "test_user", + "code": "1234", + }, + blocking=True, + ) + + +async def test_delete_code_service( + hass: HomeAssistant, + mock_lock: Mock, + mock_added_config_entry: MockSchlageConfigEntry, +) -> None: + """Test delete_code service.""" + # Mock existing access code + existing_code = Mock() + existing_code.name = "test_user" + existing_code.delete = Mock() + mock_lock.access_codes = {"1": existing_code} + + await hass.services.async_call( + DOMAIN, + SERVICE_DELETE_CODE, + service_data={ + "entity_id": "lock.vault_door", + "name": "test_user", + }, + blocking=True, + ) + await hass.async_block_till_done() + + existing_code.delete.assert_called_once() + + +async def test_delete_code_service_case_insensitive( + hass: HomeAssistant, + mock_lock: Mock, + mock_added_config_entry: MockSchlageConfigEntry, +) -> None: + """Test delete_code service is case insensitive.""" + # Mock existing access code + existing_code = Mock() + existing_code.name = "Test_User" + existing_code.delete = Mock() + mock_lock.access_codes = {"1": existing_code} + + await hass.services.async_call( + DOMAIN, + SERVICE_DELETE_CODE, + service_data={ + "entity_id": "lock.vault_door", + "name": "test_user", + }, + blocking=True, + ) + await hass.async_block_till_done() + + existing_code.delete.assert_called_once() + + +async def test_delete_code_service_nonexistent_code( + hass: HomeAssistant, + mock_lock: Mock, + mock_added_config_entry: MockSchlageConfigEntry, +) -> None: + """Test delete_code service with nonexistent code.""" + mock_lock.access_codes = {} + + # Should not raise an error, just return silently + await hass.services.async_call( + DOMAIN, + SERVICE_DELETE_CODE, + service_data={ + "entity_id": "lock.vault_door", + "name": "nonexistent", + }, + blocking=True, + ) + await hass.async_block_till_done() + + +async def test_delete_code_service_no_access_codes( + hass: HomeAssistant, + mock_lock: Mock, + mock_added_config_entry: MockSchlageConfigEntry, +) -> None: + """Test delete_code service when access_codes is None.""" + mock_lock.access_codes = None + + # Should not raise an error, just return silently + await hass.services.async_call( + DOMAIN, + SERVICE_DELETE_CODE, + service_data={ + "entity_id": "lock.vault_door", + "name": "test_user", + }, + blocking=True, + ) + await hass.async_block_till_done() + + +async def test_get_codes_service( + hass: HomeAssistant, + mock_lock: Mock, + mock_added_config_entry: MockSchlageConfigEntry, +) -> None: + """Test get_codes service.""" + # Mock existing access codes + code1 = Mock() + code1.name = "user1" + code1.code = "1234" + code2 = Mock() + code2.name = "user2" + code2.code = "5678" + mock_lock.access_codes = {"1": code1, "2": code2} + + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_CODES, + service_data={ + "entity_id": "lock.vault_door", + }, + blocking=True, + return_response=True, + ) + await hass.async_block_till_done() + + assert response == { + "lock.vault_door": { + "1": {"name": "user1", "code": "1234"}, + "2": {"name": "user2", "code": "5678"}, + } + } + + +async def test_get_codes_service_no_codes( + hass: HomeAssistant, + mock_lock: Mock, + mock_added_config_entry: MockSchlageConfigEntry, +) -> None: + """Test get_codes service with no codes.""" + mock_lock.access_codes = None + + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_CODES, + service_data={ + "entity_id": "lock.vault_door", + }, + blocking=True, + return_response=True, + ) + await hass.async_block_till_done() + + assert response == {"lock.vault_door": {}} + + +async def test_get_codes_service_empty_codes( + hass: HomeAssistant, + mock_lock: Mock, + mock_added_config_entry: MockSchlageConfigEntry, +) -> None: + """Test get_codes service with empty codes dict.""" + mock_lock.access_codes = {} + + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_CODES, + service_data={ + "entity_id": "lock.vault_door", + }, + blocking=True, + return_response=True, + ) + await hass.async_block_till_done() + + assert response == {"lock.vault_door": {}} + + +async def test_delete_code_service_nonexistent_code_with_existing_codes( + hass: HomeAssistant, + mock_lock: Mock, + mock_added_config_entry: MockSchlageConfigEntry, +) -> None: + """Test delete_code service with nonexistent code when other codes exist.""" + # Mock existing access code with a different name + existing_code = Mock() + existing_code.name = "existing_user" + existing_code.delete = Mock() + mock_lock.access_codes = {"1": existing_code} + + # Try to delete a code that doesn't exist + await hass.services.async_call( + DOMAIN, + SERVICE_DELETE_CODE, + service_data={ + "entity_id": "lock.vault_door", + "name": "nonexistent_user", + }, + blocking=True, + ) + await hass.async_block_till_done() + + # Verify that delete was not called on the existing code + existing_code.delete.assert_not_called() From 0309971352cb065698365b2cf7ec051b65202415 Mon Sep 17 00:00:00 2001 From: Andrew Grimberg Date: Thu, 16 Oct 2025 14:03:46 -0700 Subject: [PATCH 2/2] Refactor: Use new registration method * Move service registration to __init__.py and convert lock services to instance methods. * Update services.yaml and strings.json to use target instead of entity_id. Signed-off-by: Andrew Grimberg --- homeassistant/components/schlage/__init__.py | 40 +++- homeassistant/components/schlage/lock.py | 177 +++++++----------- .../components/schlage/services.yaml | 37 ++-- homeassistant/components/schlage/strings.json | 16 +- 4 files changed, 116 insertions(+), 154 deletions(-) diff --git a/homeassistant/components/schlage/__init__.py b/homeassistant/components/schlage/__init__.py index 342f5f9ffcee52..da7e64b4404e6a 100644 --- a/homeassistant/components/schlage/__init__.py +++ b/homeassistant/components/schlage/__init__.py @@ -4,11 +4,15 @@ from pycognito.exceptions import WarrantException import pyschlage +import voluptuous as vol +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, SupportsResponse from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import config_validation as cv, service +from .const import DOMAIN, SERVICE_ADD_CODE, SERVICE_DELETE_CODE, SERVICE_GET_CODES from .coordinator import SchlageConfigEntry, SchlageDataUpdateCoordinator PLATFORMS: list[Platform] = [ @@ -36,6 +40,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: SchlageConfigEntry) -> b await coordinator.async_config_entry_first_refresh() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_ADD_CODE, + entity_domain=LOCK_DOMAIN, + schema={ + vol.Required("name"): cv.string, + vol.Required("code"): cv.matches_regex(r"^\d{4,8}$"), + }, + func=SERVICE_ADD_CODE, + ) + + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_DELETE_CODE, + entity_domain=LOCK_DOMAIN, + schema={ + vol.Required("name"): cv.string, + }, + func=SERVICE_DELETE_CODE, + ) + + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_GET_CODES, + entity_domain=LOCK_DOMAIN, + schema=None, + func=SERVICE_GET_CODES, + supports_response=SupportsResponse.ONLY, + ) + return True diff --git a/homeassistant/components/schlage/lock.py b/homeassistant/components/schlage/lock.py index b3317e11b44e66..a20133a070d542 100644 --- a/homeassistant/components/schlage/lock.py +++ b/homeassistant/components/schlage/lock.py @@ -5,21 +5,13 @@ from typing import Any from pyschlage.code import AccessCode -import voluptuous as vol from homeassistant.components.lock import LockEntity -from homeassistant.core import ( - HomeAssistant, - ServiceCall, - ServiceResponse, - SupportsResponse, - callback, -) +from homeassistant.core import HomeAssistant, ServiceResponse, callback from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, SERVICE_ADD_CODE, SERVICE_DELETE_CODE, SERVICE_GET_CODES +from .const import DOMAIN from .coordinator import LockData, SchlageConfigEntry, SchlageDataUpdateCoordinator from .entity import SchlageEntity @@ -41,8 +33,46 @@ def _add_new_locks(locks: dict[str, LockData]) -> None: _add_new_locks(coordinator.data.locks) coordinator.new_locks_callbacks.append(_add_new_locks) - # Custom services - def _validate_code_name(codes: dict[str, AccessCode] | None, name: str) -> None: + +class SchlageLockEntity(SchlageEntity, LockEntity): + """Schlage lock entity.""" + + _attr_name = None + + def __init__( + self, coordinator: SchlageDataUpdateCoordinator, device_id: str + ) -> None: + """Initialize a Schlage Lock.""" + super().__init__(coordinator=coordinator, device_id=device_id) + self._update_attrs() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self.device_id in self.coordinator.data.locks: + self._update_attrs() + super()._handle_coordinator_update() + + def _update_attrs(self) -> None: + """Update our internal state attributes.""" + self._attr_is_locked = self._lock.is_locked + self._attr_is_jammed = self._lock.is_jammed + self._attr_changed_by = self._lock.last_changed_by() + + async def async_lock(self, **kwargs: Any) -> None: + """Lock the device.""" + await self.hass.async_add_executor_job(self._lock.lock) + await self.coordinator.async_request_refresh() + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock the device.""" + await self.hass.async_add_executor_job(self._lock.unlock) + await self.coordinator.async_request_refresh() + + # Door code services + def _validate_code_name( + self, codes: dict[str, AccessCode] | None, name: str + ) -> None: """Validate that the code name doesn't already exist.""" if codes and any(code.name.lower() == name.lower() for code in codes.values()): raise ServiceValidationError( @@ -50,7 +80,9 @@ def _validate_code_name(codes: dict[str, AccessCode] | None, name: str) -> None: translation_key="schlage_name_exists", ) - def _validate_code_value(codes: dict[str, AccessCode] | None, code: str) -> None: + def _validate_code_value( + self, codes: dict[str, AccessCode] | None, code: str + ) -> None: """Validate that the code value doesn't already exist.""" if codes and any( existing_code.code == code for existing_code in codes.values() @@ -60,18 +92,8 @@ def _validate_code_value(codes: dict[str, AccessCode] | None, code: str) -> None translation_key="schlage_code_exists", ) - async def _add_code(entity: SchlageLockEntity, calls: ServiceCall) -> None: + async def add_code(self, name: str, code: str) -> None: """Add a lock code.""" - name = calls.data.get("name") - code = calls.data.get("code") - - # name is required by voluptuous, the following is just to satisfy type - # checker, it should never be None - if name is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="schlage_name_required", - ) # pragma: no cover # code is required by voluptuous, the following is just to satisfy type # checker, it should never be None @@ -81,27 +103,19 @@ async def _add_code(entity: SchlageLockEntity, calls: ServiceCall) -> None: translation_key="schlage_code_required", ) # pragma: no cover - codes = entity._lock.access_codes # noqa: SLF001 - _validate_code_name(codes, name) - _validate_code_value(codes, code) + codes = self._lock.access_codes + self._validate_code_name(codes, name) + self._validate_code_value(codes, code) access_code = AccessCode(name=name, code=code) - await hass.async_add_executor_job(entity._lock.add_access_code, access_code) # noqa: SLF001 - await coordinator.async_request_refresh() + await self.coordinator.hass.async_add_executor_job( + self._lock.add_access_code, access_code + ) + await self.coordinator.async_request_refresh() - async def _delete_code(entity: SchlageLockEntity, calls: ServiceCall) -> None: + async def delete_code(self, name: str) -> None: """Delete a lock code.""" - name = calls.data.get("name") - - # name is required by voluptuous, the following is just to satisfy type - # checker, it should never be None - if name is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="schlage_name_required", - ) # pragma: no cover - - codes = entity._lock.access_codes # noqa: SLF001 + codes = self._lock.access_codes if not codes: return @@ -117,84 +131,21 @@ async def _delete_code(entity: SchlageLockEntity, calls: ServiceCall) -> None: if not code_id_to_delete: return - if entity._lock.access_codes: # noqa: SLF001 - await hass.async_add_executor_job( - entity._lock.access_codes[code_id_to_delete].delete # noqa: SLF001 + if self._lock.access_codes: + await self.coordinator.hass.async_add_executor_job( + self._lock.access_codes[code_id_to_delete].delete ) - await coordinator.async_request_refresh() + await self.coordinator.async_request_refresh() - async def _get_codes( - entity: SchlageLockEntity, calls: ServiceCall - ) -> ServiceResponse: + async def get_codes(self) -> ServiceResponse: """Get lock codes.""" - if entity._lock.access_codes: # noqa: SLF001 + if self._lock.access_codes: return { code: { - "name": entity._lock.access_codes[code].name, # noqa: SLF001 - "code": entity._lock.access_codes[code].code, # noqa: SLF001 + "name": self._lock.access_codes[code].name, + "code": self._lock.access_codes[code].code, } - for code in entity._lock.access_codes # noqa: SLF001 + for code in self._lock.access_codes } return {} - - platform = entity_platform.async_get_current_platform() - platform.async_register_entity_service( - name=SERVICE_ADD_CODE, - schema={ - vol.Required("name"): cv.string, - vol.Required("code"): cv.matches_regex(r"^\d{4,8}$"), - }, - func=_add_code, - ) - - platform.async_register_entity_service( - name=SERVICE_DELETE_CODE, - schema={ - vol.Required("name"): cv.string, - }, - func=_delete_code, - ) - - platform.async_register_entity_service( - name=SERVICE_GET_CODES, - schema=None, - func=_get_codes, - supports_response=SupportsResponse.ONLY, - ) - - -class SchlageLockEntity(SchlageEntity, LockEntity): - """Schlage lock entity.""" - - _attr_name = None - - def __init__( - self, coordinator: SchlageDataUpdateCoordinator, device_id: str - ) -> None: - """Initialize a Schlage Lock.""" - super().__init__(coordinator=coordinator, device_id=device_id) - self._update_attrs() - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - if self.device_id in self.coordinator.data.locks: - self._update_attrs() - super()._handle_coordinator_update() - - def _update_attrs(self) -> None: - """Update our internal state attributes.""" - self._attr_is_locked = self._lock.is_locked - self._attr_is_jammed = self._lock.is_jammed - self._attr_changed_by = self._lock.last_changed_by() - - async def async_lock(self, **kwargs: Any) -> None: - """Lock the device.""" - await self.hass.async_add_executor_job(self._lock.lock) - await self.coordinator.async_request_refresh() - - async def async_unlock(self, **kwargs: Any) -> None: - """Unlock the device.""" - await self.hass.async_add_executor_job(self._lock.unlock) - await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/schlage/services.yaml b/homeassistant/components/schlage/services.yaml index 9f4d5ab4122457..6bebf0a977384b 100644 --- a/homeassistant/components/schlage/services.yaml +++ b/homeassistant/components/schlage/services.yaml @@ -1,24 +1,15 @@ get_codes: - fields: - entity_id: - required: true - selector: - entity: - filter: - domain: lock - integration: schlage - multiple: true + target: + entity: + domain: lock + integration: schlage add_code: + target: + entity: + domain: lock + integration: schlage fields: - entity_id: - required: true - selector: - entity: - filter: - domain: lock - integration: schlage - multiple: true name: required: true example: "Example Person" @@ -33,15 +24,11 @@ add_code: multiline: false delete_code: + target: + entity: + domain: lock + integration: schlage fields: - entity_id: - required: true - selector: - entity: - filter: - domain: lock - integration: schlage - multiple: true name: required: true example: "Example Person" diff --git a/homeassistant/components/schlage/strings.json b/homeassistant/components/schlage/strings.json index d73cd72a79e632..9823751560aa6e 100644 --- a/homeassistant/components/schlage/strings.json +++ b/homeassistant/components/schlage/strings.json @@ -77,10 +77,6 @@ "name": "Add PIN code", "description": "Add a PIN code to a lock.", "fields": { - "entity_id": { - "name": "Lock", - "description": "The lock to add the PIN code to." - }, "name": { "name": "PIN Name", "description": "Name for PIN code. Must be unique to lock." @@ -95,10 +91,6 @@ "name": "Delete PIN code", "description": "Delete a PIN code from a lock.", "fields": { - "entity_id": { - "name": "Lock", - "description": "The lock to delete the PIN code from." - }, "name": { "name": "PIN Name", "description": "Name of PIN code to delete." @@ -107,13 +99,7 @@ }, "get_codes": { "name": "Get PIN codes", - "description": "Retrieve all PIN codes from the lock", - "fields": { - "entity_id": { - "name": "Lock", - "description": "The lock to retrieve PIN codes from." - } - } + "description": "Retrieve all PIN codes from the lock" } } }