Add a battery sensor to Schlage (#97369)

This commit is contained in:
David Knowles 2023-07-29 00:09:25 -04:00 committed by GitHub
parent 78003886a5
commit 0e8bbbd3d9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 182 additions and 75 deletions

View file

@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant
from .const import DOMAIN, LOGGER from .const import DOMAIN, LOGGER
from .coordinator import SchlageDataUpdateCoordinator from .coordinator import SchlageDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.LOCK] PLATFORMS: list[Platform] = [Platform.LOCK, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

View file

@ -0,0 +1,41 @@
"""Base entity class for Schlage."""
from pyschlage.lock import Lock
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import SchlageDataUpdateCoordinator
class SchlageEntity(CoordinatorEntity[SchlageDataUpdateCoordinator]):
"""Base Schlage entity."""
_attr_has_entity_name = True
def __init__(
self, coordinator: SchlageDataUpdateCoordinator, device_id: str
) -> None:
"""Initialize a Schlage entity."""
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,
)
@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

View file

@ -3,17 +3,14 @@ from __future__ import annotations
from typing import Any from typing import Any
from pyschlage.lock import Lock
from homeassistant.components.lock import LockEntity from homeassistant.components.lock import LockEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER from .const import DOMAIN
from .coordinator import SchlageDataUpdateCoordinator from .coordinator import SchlageDataUpdateCoordinator
from .entity import SchlageEntity
async def async_setup_entry( async def async_setup_entry(
@ -29,39 +26,18 @@ async def async_setup_entry(
) )
class SchlageLockEntity(CoordinatorEntity[SchlageDataUpdateCoordinator], LockEntity): class SchlageLockEntity(SchlageEntity, LockEntity):
"""Schlage lock entity.""" """Schlage lock entity."""
_attr_has_entity_name = True
_attr_name = None _attr_name = None
def __init__( def __init__(
self, coordinator: SchlageDataUpdateCoordinator, device_id: str self, coordinator: SchlageDataUpdateCoordinator, device_id: str
) -> None: ) -> None:
"""Initialize a Schlage Lock.""" """Initialize a Schlage Lock."""
super().__init__(coordinator=coordinator) super().__init__(coordinator=coordinator, device_id=device_id)
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() 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 @callback
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator.""" """Handle updated data from the coordinator."""

View file

@ -0,0 +1,66 @@
"""Platform for Schlage sensor integration."""
from __future__ import annotations
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import SchlageDataUpdateCoordinator
from .entity import SchlageEntity
_SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [
SensorEntityDescription(
key="battery_level",
device_class=SensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=PERCENTAGE,
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up sensors based on a config entry."""
coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities(
SchlageBatterySensor(
coordinator=coordinator,
description=description,
device_id=device_id,
)
for description in _SENSOR_DESCRIPTIONS
for device_id in coordinator.data.locks
)
class SchlageBatterySensor(SchlageEntity, SensorEntity):
"""Schlage battery sensor entity."""
def __init__(
self,
coordinator: SchlageDataUpdateCoordinator,
description: SensorEntityDescription,
device_id: str,
) -> None:
"""Initialize a Schlage battery sensor."""
super().__init__(coordinator=coordinator, device_id=device_id)
self.entity_description = description
self._attr_unique_id = f"{device_id}_{description.key}"
self._attr_native_value = getattr(self._lock, self.entity_description.key)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._attr_native_value = getattr(self._lock, self.entity_description.key)
return super()._handle_coordinator_update()

View file

@ -1,11 +1,13 @@
"""Common fixtures for the Schlage tests.""" """Common fixtures for the Schlage tests."""
from collections.abc import Generator from collections.abc import Generator
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, Mock, create_autospec, patch
from pyschlage.lock import Lock
import pytest import pytest
from homeassistant.components.schlage.const import DOMAIN from homeassistant.components.schlage.const import DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -24,6 +26,23 @@ def mock_config_entry() -> MockConfigEntry:
) )
@pytest.fixture
async def mock_added_config_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_pyschlage_auth: Mock,
mock_schlage: Mock,
mock_lock: Mock,
) -> MockConfigEntry:
"""Mock ConfigEntry that's been added to HA."""
mock_schlage.locks.return_value = [mock_lock]
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 DOMAIN in hass.config_entries.async_domains()
return mock_config_entry
@pytest.fixture @pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock, None, None]: def mock_setup_entry() -> Generator[AsyncMock, None, None]:
"""Override async_setup_entry.""" """Override async_setup_entry."""
@ -46,3 +65,19 @@ def mock_pyschlage_auth():
with patch("pyschlage.Auth", autospec=True) as mock_auth: with patch("pyschlage.Auth", autospec=True) as mock_auth:
mock_auth.return_value.user_id = "abc123" mock_auth.return_value.user_id = "abc123"
yield mock_auth.return_value yield mock_auth.return_value
@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=20,
firmware_version="1.0",
)
return mock_lock

View file

@ -1,56 +1,15 @@
"""Test schlage lock.""" """Test schlage lock."""
from unittest.mock import Mock, create_autospec from unittest.mock import Mock
from pyschlage.lock import Lock
import pytest
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
from homeassistant.components.schlage.const import DOMAIN
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID, SERVICE_LOCK, SERVICE_UNLOCK from homeassistant.const import ATTR_ENTITY_ID, SERVICE_LOCK, SERVICE_UNLOCK
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr 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( async def test_lock_device_registry(
hass: HomeAssistant, mock_entry: ConfigEntry hass: HomeAssistant, mock_added_config_entry: ConfigEntry
) -> None: ) -> None:
"""Test lock is added to device registry.""" """Test lock is added to device registry."""
device_registry = dr.async_get(hass) device_registry = dr.async_get(hass)
@ -62,7 +21,7 @@ async def test_lock_device_registry(
async def test_lock_services( async def test_lock_services(
hass: HomeAssistant, mock_lock: Mock, mock_entry: ConfigEntry hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry
) -> None: ) -> None:
"""Test lock services.""" """Test lock services."""
await hass.services.async_call( await hass.services.async_call(
@ -83,4 +42,4 @@ async def test_lock_services(
await hass.async_block_till_done() await hass.async_block_till_done()
mock_lock.unlock.assert_called_once_with() mock_lock.unlock.assert_called_once_with()
await hass.config_entries.async_unload(mock_entry.entry_id) await hass.config_entries.async_unload(mock_added_config_entry.entry_id)

View file

@ -0,0 +1,30 @@
"""Test schlage sensor."""
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
async def test_sensor_device_registry(
hass: HomeAssistant, mock_added_config_entry: ConfigEntry
) -> None:
"""Test sensor 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_battery_sensor(
hass: HomeAssistant, mock_added_config_entry: ConfigEntry
) -> None:
"""Test the battery sensor."""
battery_sensor = hass.states.get("sensor.vault_door_battery")
assert battery_sensor is not None
assert battery_sensor.state == "20"
assert battery_sensor.attributes["unit_of_measurement"] == PERCENTAGE
assert battery_sensor.attributes["device_class"] == SensorDeviceClass.BATTERY