From 54052792738e62366b2e07ad6dfe3b1d9049b0c1 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Sun, 8 Sep 2024 11:39:23 -0400 Subject: [PATCH] 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 --- homeassistant/components/schlage/entity.py | 3 +- homeassistant/components/schlage/lock.py | 5 +- homeassistant/components/schlage/sensor.py | 5 +- .../components/schlage/test_binary_sensor.py | 34 +++++++++--- tests/components/schlage/test_lock.py | 54 +++++++++++++++++-- 5 files changed, 84 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/schlage/entity.py b/homeassistant/components/schlage/entity.py index 61bdbcb773085..cc4745e51cc49 100644 --- a/homeassistant/components/schlage/entity.py +++ b/homeassistant/components/schlage/entity.py @@ -42,5 +42,4 @@ def _lock(self) -> Lock: @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 + return super().available and self.device_id in self.coordinator.data.locks diff --git a/homeassistant/components/schlage/lock.py b/homeassistant/components/schlage/lock.py index 7e6f60211b0f0..59ce00e809a96 100644 --- a/homeassistant/components/schlage/lock.py +++ b/homeassistant/components/schlage/lock.py @@ -42,8 +42,9 @@ def __init__( @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - self._update_attrs() - return super()._handle_coordinator_update() + if self.device_id in self.coordinator.data.locks: + self._update_attrs() + super()._handle_coordinator_update() def _update_attrs(self) -> None: """Update our internal state attributes.""" diff --git a/homeassistant/components/schlage/sensor.py b/homeassistant/components/schlage/sensor.py index 2cf1694e111cb..8de09fa4cbbf5 100644 --- a/homeassistant/components/schlage/sensor.py +++ b/homeassistant/components/schlage/sensor.py @@ -64,5 +64,6 @@ def __init__( @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() + if self.device_id in self.coordinator.data.locks: + self._attr_native_value = getattr(self._lock, self.entity_description.key) + super()._handle_coordinator_update() diff --git a/tests/components/schlage/test_binary_sensor.py b/tests/components/schlage/test_binary_sensor.py index 97f11577b864d..dbbc5b07b8731 100644 --- a/tests/components/schlage/test_binary_sensor.py +++ b/tests/components/schlage/test_binary_sensor.py @@ -3,37 +3,56 @@ from datetime import timedelta from unittest.mock import Mock +from freezegun.api import FrozenDateTimeFactory from pyschlage.exceptions import UnknownError from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.util.dt import utcnow from tests.common import async_fire_time_changed 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: """Test the keypad_disabled binary_sensor.""" mock_lock.keypad_disabled.reset_mock() mock_lock.keypad_disabled.return_value = True # 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) keypad = hass.states.get("binary_sensor.vault_door_keypad_disabled") assert keypad is not None - assert keypad.state == "on" + assert keypad.state == STATE_ON assert keypad.attributes["device_class"] == BinarySensorDeviceClass.PROBLEM 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( - 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: """Test the keypad_disabled binary_sensor.""" 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") # 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) keypad = hass.states.get("binary_sensor.vault_door_keypad_disabled") assert keypad is not None - assert keypad.state == "on" + assert keypad.state == STATE_ON assert keypad.attributes["device_class"] == BinarySensorDeviceClass.PROBLEM mock_lock.keypad_disabled.assert_called_once_with([]) diff --git a/tests/components/schlage/test_lock.py b/tests/components/schlage/test_lock.py index 6c06f124693a9..ab0f4f5d8636d 100644 --- a/tests/components/schlage/test_lock.py +++ b/tests/components/schlage/test_lock.py @@ -3,12 +3,20 @@ from datetime import timedelta from unittest.mock import Mock +from freezegun.api import FrozenDateTimeFactory + from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN 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.helpers import device_registry as dr -from homeassistant.util.dt import utcnow from tests.common import async_fire_time_changed @@ -26,6 +34,40 @@ async def test_lock_device_registry( 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( hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry ) -> None: @@ -52,14 +94,18 @@ async def test_lock_services( 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: """Test population of the changed_by attribute.""" mock_lock.last_changed_by.reset_mock() mock_lock.last_changed_by.return_value = "access code - foo" # 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) mock_lock.last_changed_by.assert_called_once_with()