Add Schlage integration (#93777)

Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com>
pull/97315/head
David Knowles 2023-07-27 00:15:01 -04:00 committed by GitHub
parent 7d8462b11c
commit b31cfe0b24
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 550 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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()})

View File

@ -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()

View File

@ -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"]
}

View File

@ -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%]"
}
}
}

View File

@ -395,6 +395,7 @@ FLOWS = {
"rympro",
"sabnzbd",
"samsungtv",
"schlage",
"scrape",
"screenlogic",
"season",

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the Schlage integration."""

View File

@ -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

View File

@ -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"}

View File

@ -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

View File

@ -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="<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 == "<model-name>"
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)