From b31cfe0b24dc9b20302d38bfb60dac7d61142d30 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Thu, 27 Jul 2023 00:15:01 -0400 Subject: [PATCH] Add Schlage integration (#93777) Co-authored-by: J. Nick Koston Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- CODEOWNERS | 2 + homeassistant/components/schlage/__init__.py | 39 +++++++++ .../components/schlage/config_flow.py | 58 +++++++++++++ homeassistant/components/schlage/const.py | 9 ++ .../components/schlage/coordinator.py | 41 +++++++++ homeassistant/components/schlage/lock.py | 84 ++++++++++++++++++ .../components/schlage/manifest.json | 9 ++ homeassistant/components/schlage/strings.json | 19 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 ++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/schlage/__init__.py | 1 + tests/components/schlage/conftest.py | 48 +++++++++++ tests/components/schlage/test_config_flow.py | 80 +++++++++++++++++ tests/components/schlage/test_init.py | 61 +++++++++++++ tests/components/schlage/test_lock.py | 86 +++++++++++++++++++ 17 files changed, 550 insertions(+) create mode 100644 homeassistant/components/schlage/__init__.py create mode 100644 homeassistant/components/schlage/config_flow.py create mode 100644 homeassistant/components/schlage/const.py create mode 100644 homeassistant/components/schlage/coordinator.py create mode 100644 homeassistant/components/schlage/lock.py create mode 100644 homeassistant/components/schlage/manifest.json create mode 100644 homeassistant/components/schlage/strings.json create mode 100644 tests/components/schlage/__init__.py create mode 100644 tests/components/schlage/conftest.py create mode 100644 tests/components/schlage/test_config_flow.py create mode 100644 tests/components/schlage/test_init.py create mode 100644 tests/components/schlage/test_lock.py diff --git a/CODEOWNERS b/CODEOWNERS index 10acd5dd65a6d..f85b796b145c5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1077,6 +1077,8 @@ build.json @home-assistant/supervisor /tests/components/scene/ @home-assistant/core /homeassistant/components/schedule/ @home-assistant/core /tests/components/schedule/ @home-assistant/core +/homeassistant/components/schlage/ @dknowles2 +/tests/components/schlage/ @dknowles2 /homeassistant/components/schluter/ @prairieapps /homeassistant/components/scrape/ @fabaff @gjohansson-ST @epenet /tests/components/scrape/ @fabaff @gjohansson-ST @epenet diff --git a/homeassistant/components/schlage/__init__.py b/homeassistant/components/schlage/__init__.py new file mode 100644 index 0000000000000..7991645e20dfd --- /dev/null +++ b/homeassistant/components/schlage/__init__.py @@ -0,0 +1,39 @@ +"""The Schlage integration.""" +from __future__ import annotations + +from pycognito.exceptions import WarrantException +import pyschlage + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN, LOGGER +from .coordinator import SchlageDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.LOCK] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Schlage from a config entry.""" + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + try: + auth = await hass.async_add_executor_job(pyschlage.Auth, username, password) + except WarrantException as ex: + LOGGER.error("Schlage authentication failed: %s", ex) + return False + + coordinator = SchlageDataUpdateCoordinator(hass, username, pyschlage.Schlage(auth)) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await coordinator.async_config_entry_first_refresh() + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/schlage/config_flow.py b/homeassistant/components/schlage/config_flow.py new file mode 100644 index 0000000000000..7e09546608793 --- /dev/null +++ b/homeassistant/components/schlage/config_flow.py @@ -0,0 +1,58 @@ +"""Config flow for Schlage integration.""" +from __future__ import annotations + +from typing import Any + +import pyschlage +from pyschlage.exceptions import NotAuthorizedError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN, LOGGER + +STEP_USER_DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Schlage.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + try: + user_id = await self.hass.async_add_executor_job( + _authenticate, username, password + ) + except NotAuthorizedError: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unknown error") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(user_id) + return self.async_create_entry(title=username, data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +def _authenticate(username: str, password: str) -> str: + """Authenticate with the Schlage API.""" + auth = pyschlage.Auth(username, password) + auth.authenticate() + # The user_id property will make a blocking call if it's not already + # cached. To avoid blocking the event loop, we read it here. + return auth.user_id diff --git a/homeassistant/components/schlage/const.py b/homeassistant/components/schlage/const.py new file mode 100644 index 0000000000000..1effd4bb33429 --- /dev/null +++ b/homeassistant/components/schlage/const.py @@ -0,0 +1,9 @@ +"""Constants for the Schlage integration.""" + +from datetime import timedelta +import logging + +DOMAIN = "schlage" +LOGGER = logging.getLogger(__package__) +MANUFACTURER = "Schlage" +UPDATE_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/schlage/coordinator.py b/homeassistant/components/schlage/coordinator.py new file mode 100644 index 0000000000000..8b9cde21f9059 --- /dev/null +++ b/homeassistant/components/schlage/coordinator.py @@ -0,0 +1,41 @@ +"""DataUpdateCoordinator for the Schlage integration.""" +from __future__ import annotations + +from dataclasses import dataclass + +from pyschlage import Lock, Schlage +from pyschlage.exceptions import Error + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER, UPDATE_INTERVAL + + +@dataclass +class SchlageData: + """Container for cached data from the Schlage API.""" + + locks: dict[str, Lock] + + +class SchlageDataUpdateCoordinator(DataUpdateCoordinator[SchlageData]): + """The Schlage data update coordinator.""" + + def __init__(self, hass: HomeAssistant, username: str, api: Schlage) -> None: + """Initialize the class.""" + super().__init__( + hass, LOGGER, name=f"{DOMAIN} ({username})", update_interval=UPDATE_INTERVAL + ) + self.api = api + + async def _async_update_data(self) -> SchlageData: + """Fetch the latest data from the Schlage API.""" + try: + return await self.hass.async_add_executor_job(self._update_data) + except Error as ex: + raise UpdateFailed("Failed to refresh Schlage data") from ex + + def _update_data(self) -> SchlageData: + """Fetch the latest data from the Schlage API.""" + return SchlageData(locks={lock.device_id: lock for lock in self.api.locks()}) diff --git a/homeassistant/components/schlage/lock.py b/homeassistant/components/schlage/lock.py new file mode 100644 index 0000000000000..ad7ff863d4078 --- /dev/null +++ b/homeassistant/components/schlage/lock.py @@ -0,0 +1,84 @@ +"""Platform for Schlage lock integration.""" +from __future__ import annotations + +from typing import Any + +from pyschlage.lock import Lock + +from homeassistant.components.lock import LockEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import SchlageDataUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Schlage WiFi locks based on a config entry.""" + coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + SchlageLockEntity(coordinator=coordinator, device_id=device_id) + for device_id in coordinator.data.locks + ) + + +class SchlageLockEntity(CoordinatorEntity[SchlageDataUpdateCoordinator], LockEntity): + """Schlage lock entity.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, coordinator: SchlageDataUpdateCoordinator, device_id: str + ) -> None: + """Initialize a Schlage Lock.""" + super().__init__(coordinator=coordinator) + self.device_id = device_id + self._attr_unique_id = device_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + name=self._lock.name, + manufacturer=MANUFACTURER, + model=self._lock.model_name, + sw_version=self._lock.firmware_version, + ) + self._update_attrs() + + @property + def _lock(self) -> Lock: + """Fetch the Schlage lock from our coordinator.""" + return self.coordinator.data.locks[self.device_id] + + @property + def available(self) -> bool: + """Return if entity is available.""" + # When is_locked is None the lock is unavailable. + return super().available and self._lock.is_locked is not None + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attrs() + return 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 + + 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/manifest.json b/homeassistant/components/schlage/manifest.json new file mode 100644 index 0000000000000..cbc173b8c34a4 --- /dev/null +++ b/homeassistant/components/schlage/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "schlage", + "name": "Schlage", + "codeowners": ["@dknowles2"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/schlage", + "iot_class": "cloud_polling", + "requirements": ["pyschlage==2023.5.0"] +} diff --git a/homeassistant/components/schlage/strings.json b/homeassistant/components/schlage/strings.json new file mode 100644 index 0000000000000..4f32ad094c039 --- /dev/null +++ b/homeassistant/components/schlage/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 10221d1d58989..7de32dc5071c8 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -395,6 +395,7 @@ "rympro", "sabnzbd", "samsungtv", + "schlage", "scrape", "screenlogic", "season", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a3a8c334c11b9..aa3ad84f19254 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4849,6 +4849,12 @@ "config_flow": false, "iot_class": "local_push" }, + "schlage": { + "name": "Schlage", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "schluter": { "name": "Schluter", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index edd1edd7c2ff5..0d85e334e6370 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1981,6 +1981,9 @@ pysabnzbd==1.1.1 # homeassistant.components.saj pysaj==0.0.16 +# homeassistant.components.schlage +pyschlage==2023.5.0 + # homeassistant.components.sensibo pysensibo==1.0.31 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a07488a189866..45f110e4c5df3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1473,6 +1473,9 @@ pyrympro==0.0.7 # homeassistant.components.sabnzbd pysabnzbd==1.1.1 +# homeassistant.components.schlage +pyschlage==2023.5.0 + # homeassistant.components.sensibo pysensibo==1.0.31 diff --git a/tests/components/schlage/__init__.py b/tests/components/schlage/__init__.py new file mode 100644 index 0000000000000..c6cd3fec0bc84 --- /dev/null +++ b/tests/components/schlage/__init__.py @@ -0,0 +1 @@ +"""Tests for the Schlage integration.""" diff --git a/tests/components/schlage/conftest.py b/tests/components/schlage/conftest.py new file mode 100644 index 0000000000000..681024358c658 --- /dev/null +++ b/tests/components/schlage/conftest.py @@ -0,0 +1,48 @@ +"""Common fixtures for the Schlage tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.schlage.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock ConfigEntry.""" + return MockConfigEntry( + title="asdf@asdf.com", + domain=DOMAIN, + data={ + CONF_USERNAME: "asdf@asdf.com", + CONF_PASSWORD: "hunter2", + }, + unique_id="abc123", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.schlage.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_schlage(): + """Mock pyschlage.Schlage.""" + with patch("pyschlage.Schlage", autospec=True) as mock_schlage: + yield mock_schlage.return_value + + +@pytest.fixture +def mock_pyschlage_auth(): + """Mock pyschlage.Auth.""" + with patch("pyschlage.Auth", autospec=True) as mock_auth: + mock_auth.return_value.user_id = "abc123" + yield mock_auth.return_value diff --git a/tests/components/schlage/test_config_flow.py b/tests/components/schlage/test_config_flow.py new file mode 100644 index 0000000000000..b256e8950ed18 --- /dev/null +++ b/tests/components/schlage/test_config_flow.py @@ -0,0 +1,80 @@ +"""Test the Schlage config flow.""" +from unittest.mock import AsyncMock, Mock + +from pyschlage.exceptions import Error as PyschlageError, NotAuthorizedError +import pytest + +from homeassistant import config_entries +from homeassistant.components.schlage.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_form( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_pyschlage_auth: Mock +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + mock_pyschlage_auth.authenticate.assert_called_once_with() + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "test-username" + assert result2["data"] == { + "username": "test-username", + "password": "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth( + hass: HomeAssistant, mock_pyschlage_auth: Mock +) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_pyschlage_auth.authenticate.side_effect = NotAuthorizedError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_unknown(hass: HomeAssistant, mock_pyschlage_auth: Mock) -> None: + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_pyschlage_auth.authenticate.side_effect = PyschlageError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} diff --git a/tests/components/schlage/test_init.py b/tests/components/schlage/test_init.py new file mode 100644 index 0000000000000..0811d87ec8011 --- /dev/null +++ b/tests/components/schlage/test_init.py @@ -0,0 +1,61 @@ +"""Tests for the Schlage integration.""" + +from unittest.mock import Mock, patch + +from pycognito.exceptions import WarrantException +from pyschlage.exceptions import Error + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@patch( + "pyschlage.Auth", + side_effect=WarrantException, +) +async def test_auth_failed( + mock_auth: Mock, hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test failed auth on setup.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_auth.call_count == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_update_data_fails( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pyschlage_auth: Mock, + mock_schlage: Mock, +) -> None: + """Test that we properly handle API errors.""" + mock_schlage.locks.side_effect = Error + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_schlage.locks.call_count == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pyschlage_auth: Mock, + mock_schlage: Mock, +) -> None: + """Test the Schlage configuration entry loading/unloading.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/schlage/test_lock.py b/tests/components/schlage/test_lock.py new file mode 100644 index 0000000000000..8819d8558fdc8 --- /dev/null +++ b/tests/components/schlage/test_lock.py @@ -0,0 +1,86 @@ +"""Test schlage lock.""" +from unittest.mock import Mock, create_autospec + +from pyschlage.lock import Lock +import pytest + +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.components.schlage.const import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_LOCK, SERVICE_UNLOCK +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_lock(): + """Mock Lock fixture.""" + mock_lock = create_autospec(Lock) + mock_lock.configure_mock( + device_id="test", + name="Vault Door", + model_name="", + is_locked=False, + is_jammed=False, + battery_level=0, + firmware_version="1.0", + ) + return mock_lock + + +@pytest.fixture +async def mock_entry( + hass: HomeAssistant, mock_pyschlage_auth: Mock, mock_schlage: Mock, mock_lock: Mock +) -> ConfigEntry: + """Create and add a mock ConfigEntry.""" + mock_schlage.locks.return_value = [mock_lock] + entry = MockConfigEntry( + domain=DOMAIN, + data={"username": "test-username", "password": "test-password"}, + entry_id="test-username", + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert DOMAIN in hass.config_entries.async_domains() + return entry + + +async def test_lock_device_registry( + hass: HomeAssistant, mock_entry: ConfigEntry +) -> None: + """Test lock is added to device registry.""" + device_registry = dr.async_get(hass) + device = device_registry.async_get_device(identifiers={("schlage", "test")}) + assert device.model == "" + assert device.sw_version == "1.0" + assert device.name == "Vault Door" + assert device.manufacturer == "Schlage" + + +async def test_lock_services( + hass: HomeAssistant, mock_lock: Mock, mock_entry: ConfigEntry +) -> None: + """Test lock services.""" + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_LOCK, + service_data={ATTR_ENTITY_ID: "lock.vault_door"}, + blocking=True, + ) + await hass.async_block_till_done() + mock_lock.lock.assert_called_once_with() + + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_UNLOCK, + service_data={ATTR_ENTITY_ID: "lock.vault_door"}, + blocking=True, + ) + await hass.async_block_till_done() + mock_lock.unlock.assert_called_once_with() + + await hass.config_entries.async_unload(mock_entry.entry_id)