Fix Schlage removed locks (#123627)

* Fix bugs when a lock is no longer returned by the API

* Changes requested during review

* Only mark unavailable if lock is not present

* Remove stale comment

* Remove over-judicious nullability checks

* Remove another unnecessary null check
This commit is contained in:
David Knowles 2024-09-08 11:39:23 -04:00 committed by GitHub
parent 634582eab7
commit 5405279273
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 84 additions and 17 deletions

View File

@ -42,5 +42,4 @@ class SchlageEntity(CoordinatorEntity[SchlageDataUpdateCoordinator]):
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return if entity is available.""" """Return if entity is available."""
# When is_locked is None the lock is unavailable. return super().available and self.device_id in self.coordinator.data.locks
return super().available and self._lock.is_locked is not None

View File

@ -42,8 +42,9 @@ class SchlageLockEntity(SchlageEntity, LockEntity):
@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."""
self._update_attrs() if self.device_id in self.coordinator.data.locks:
return super()._handle_coordinator_update() self._update_attrs()
super()._handle_coordinator_update()
def _update_attrs(self) -> None: def _update_attrs(self) -> None:
"""Update our internal state attributes.""" """Update our internal state attributes."""

View File

@ -64,5 +64,6 @@ class SchlageBatterySensor(SchlageEntity, SensorEntity):
@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."""
self._attr_native_value = getattr(self._lock, self.entity_description.key) if self.device_id in self.coordinator.data.locks:
return super()._handle_coordinator_update() self._attr_native_value = getattr(self._lock, self.entity_description.key)
super()._handle_coordinator_update()

View File

@ -3,37 +3,56 @@
from datetime import timedelta from datetime import timedelta
from unittest.mock import Mock from unittest.mock import Mock
from freezegun.api import FrozenDateTimeFactory
from pyschlage.exceptions import UnknownError from pyschlage.exceptions import UnknownError
from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_ON, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.util.dt import utcnow
from tests.common import async_fire_time_changed from tests.common import async_fire_time_changed
async def test_keypad_disabled_binary_sensor( async def test_keypad_disabled_binary_sensor(
hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry hass: HomeAssistant,
mock_schlage: Mock,
mock_lock: Mock,
mock_added_config_entry: ConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None: ) -> None:
"""Test the keypad_disabled binary_sensor.""" """Test the keypad_disabled binary_sensor."""
mock_lock.keypad_disabled.reset_mock() mock_lock.keypad_disabled.reset_mock()
mock_lock.keypad_disabled.return_value = True mock_lock.keypad_disabled.return_value = True
# Make the coordinator refresh data. # Make the coordinator refresh data.
async_fire_time_changed(hass, utcnow() + timedelta(seconds=31)) freezer.tick(timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True) await hass.async_block_till_done(wait_background_tasks=True)
keypad = hass.states.get("binary_sensor.vault_door_keypad_disabled") keypad = hass.states.get("binary_sensor.vault_door_keypad_disabled")
assert keypad is not None assert keypad is not None
assert keypad.state == "on" assert keypad.state == STATE_ON
assert keypad.attributes["device_class"] == BinarySensorDeviceClass.PROBLEM assert keypad.attributes["device_class"] == BinarySensorDeviceClass.PROBLEM
mock_lock.keypad_disabled.assert_called_once_with([]) mock_lock.keypad_disabled.assert_called_once_with([])
mock_schlage.locks.return_value = []
# Make the coordinator refresh data.
freezer.tick(timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
keypad = hass.states.get("binary_sensor.vault_door_keypad_disabled")
assert keypad is not None
assert keypad.state == STATE_UNAVAILABLE
async def test_keypad_disabled_binary_sensor_use_previous_logs_on_failure( async def test_keypad_disabled_binary_sensor_use_previous_logs_on_failure(
hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry hass: HomeAssistant,
mock_schlage: Mock,
mock_lock: Mock,
mock_added_config_entry: ConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None: ) -> None:
"""Test the keypad_disabled binary_sensor.""" """Test the keypad_disabled binary_sensor."""
mock_lock.keypad_disabled.reset_mock() mock_lock.keypad_disabled.reset_mock()
@ -42,12 +61,13 @@ async def test_keypad_disabled_binary_sensor_use_previous_logs_on_failure(
mock_lock.logs.side_effect = UnknownError("Cannot load logs") mock_lock.logs.side_effect = UnknownError("Cannot load logs")
# Make the coordinator refresh data. # Make the coordinator refresh data.
async_fire_time_changed(hass, utcnow() + timedelta(seconds=31)) freezer.tick(timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True) await hass.async_block_till_done(wait_background_tasks=True)
keypad = hass.states.get("binary_sensor.vault_door_keypad_disabled") keypad = hass.states.get("binary_sensor.vault_door_keypad_disabled")
assert keypad is not None assert keypad is not None
assert keypad.state == "on" assert keypad.state == STATE_ON
assert keypad.attributes["device_class"] == BinarySensorDeviceClass.PROBLEM assert keypad.attributes["device_class"] == BinarySensorDeviceClass.PROBLEM
mock_lock.keypad_disabled.assert_called_once_with([]) mock_lock.keypad_disabled.assert_called_once_with([])

View File

@ -3,12 +3,20 @@
from datetime import timedelta from datetime import timedelta
from unittest.mock import Mock from unittest.mock import Mock
from freezegun.api import FrozenDateTimeFactory
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.lock import DOMAIN as LOCK_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,
STATE_JAMMED,
STATE_UNAVAILABLE,
STATE_UNLOCKED,
)
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 homeassistant.util.dt import utcnow
from tests.common import async_fire_time_changed from tests.common import async_fire_time_changed
@ -26,6 +34,40 @@ async def test_lock_device_registry(
assert device.manufacturer == "Schlage" assert device.manufacturer == "Schlage"
async def test_lock_attributes(
hass: HomeAssistant,
mock_added_config_entry: ConfigEntry,
mock_schlage: Mock,
mock_lock: Mock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test lock attributes."""
lock = hass.states.get("lock.vault_door")
assert lock is not None
assert lock.state == STATE_UNLOCKED
assert lock.attributes["changed_by"] == "thumbturn"
mock_lock.is_locked = False
mock_lock.is_jammed = True
# Make the coordinator refresh data.
freezer.tick(timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
lock = hass.states.get("lock.vault_door")
assert lock is not None
assert lock.state == STATE_JAMMED
mock_schlage.locks.return_value = []
# Make the coordinator refresh data.
freezer.tick(timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
lock = hass.states.get("lock.vault_door")
assert lock is not None
assert lock.state == STATE_UNAVAILABLE
assert "changed_by" not in lock.attributes
async def test_lock_services( async def test_lock_services(
hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry
) -> None: ) -> None:
@ -52,14 +94,18 @@ async def test_lock_services(
async def test_changed_by( async def test_changed_by(
hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry hass: HomeAssistant,
mock_lock: Mock,
mock_added_config_entry: ConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None: ) -> None:
"""Test population of the changed_by attribute.""" """Test population of the changed_by attribute."""
mock_lock.last_changed_by.reset_mock() mock_lock.last_changed_by.reset_mock()
mock_lock.last_changed_by.return_value = "access code - foo" mock_lock.last_changed_by.return_value = "access code - foo"
# Make the coordinator refresh data. # Make the coordinator refresh data.
async_fire_time_changed(hass, utcnow() + timedelta(seconds=31)) freezer.tick(timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True) await hass.async_block_till_done(wait_background_tasks=True)
mock_lock.last_changed_by.assert_called_once_with() mock_lock.last_changed_by.assert_called_once_with()