diff --git a/homeassistant/components/schlage/__init__.py b/homeassistant/components/schlage/__init__.py index 509a335aafe8f..da7e64b4404e6 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] = [ @@ -33,8 +37,43 @@ 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) + + 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/const.py b/homeassistant/components/schlage/const.py index 1effd4bb33429..75033520d3f07 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 eec143c574fba..3cf287f92f91a 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 0000000000000..c231233be5167 --- /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 83abf9214e38e..a20133a070d54 100644 --- a/homeassistant/components/schlage/lock.py +++ b/homeassistant/components/schlage/lock.py @@ -4,10 +4,14 @@ from typing import Any +from pyschlage.code import AccessCode + from homeassistant.components.lock import LockEntity -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, ServiceResponse, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import DOMAIN from .coordinator import LockData, SchlageConfigEntry, SchlageDataUpdateCoordinator from .entity import SchlageEntity @@ -64,3 +68,84 @@ 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( + translation_domain=DOMAIN, + translation_key="schlage_name_exists", + ) + + 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() + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="schlage_code_exists", + ) + + async def add_code(self, name: str, code: str) -> None: + """Add a lock code.""" + + # 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 = self._lock.access_codes + self._validate_code_name(codes, name) + self._validate_code_value(codes, code) + + access_code = AccessCode(name=name, code=code) + 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(self, name: str) -> None: + """Delete a lock code.""" + codes = self._lock.access_codes + 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 self._lock.access_codes: + await self.coordinator.hass.async_add_executor_job( + self._lock.access_codes[code_id_to_delete].delete + ) + await self.coordinator.async_request_refresh() + + async def get_codes(self) -> ServiceResponse: + """Get lock codes.""" + + if self._lock.access_codes: + return { + code: { + "name": self._lock.access_codes[code].name, + "code": self._lock.access_codes[code].code, + } + for code in self._lock.access_codes + } + return {} diff --git a/homeassistant/components/schlage/services.yaml b/homeassistant/components/schlage/services.yaml new file mode 100644 index 0000000000000..6bebf0a977384 --- /dev/null +++ b/homeassistant/components/schlage/services.yaml @@ -0,0 +1,37 @@ +get_codes: + target: + entity: + domain: lock + integration: schlage + +add_code: + target: + entity: + domain: lock + integration: schlage + fields: + name: + required: true + example: "Example Person" + selector: + text: + multiline: false + code: + required: true + example: "1111" + selector: + text: + multiline: false + +delete_code: + target: + entity: + domain: lock + integration: schlage + fields: + 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 e37f47895801a..9823751560aa6 100644 --- a/homeassistant/components/schlage/strings.json +++ b/homeassistant/components/schlage/strings.json @@ -58,6 +58,48 @@ "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": { + "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": { + "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" } } } diff --git a/tests/components/schlage/test_lock.py b/tests/components/schlage/test_lock.py index 6a3bb799213d5..ec5536429f6b5 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()