From fac772eb803aea48043a998b640d82102958dfa2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 28 Aug 2026 22:35:13 +0200 Subject: [PATCH] Report every SwitchBot Cloud lock state (#180448) --- .../components/switchbot_cloud/lock.py | 54 ++++++++++-- tests/components/switchbot_cloud/test_lock.py | 86 +++++++++++++++++++ 2 files changed, 131 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/lock.py b/homeassistant/components/switchbot_cloud/lock.py index c17ac1128b45..1b8770980aec 100644 --- a/homeassistant/components/switchbot_cloud/lock.py +++ b/homeassistant/components/switchbot_cloud/lock.py @@ -2,15 +2,36 @@ from typing import Any, override -from switchbot_api import Device, LockCommands, LockV2Commands, Remote, SwitchBotAPI +from switchbot_api import ( + Device, + LockCommands, + LockV2Commands, + Remote, + SwitchBotAPI, + SwitchbotCloudDeviceLockState, +) from homeassistant.components.lock import LockEntity, LockEntityFeature -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SwitchbotCloudConfigEntry, SwitchBotCoordinator from .entity import SwitchBotCloudEntity +# The cloud camel cases these when polled and upper cases them over the +# webhook, so look them up by their lower cased value +LOCK_STATES_BY_VALUE = { + state.value.lower(): state for state in SwitchbotCloudDeviceLockState +} + +# A latch bolt or half locked door is secured, both are resting positions the +# lock can be commanded into +LOCKED_STATES = { + SwitchbotCloudDeviceLockState.LOCKED, + SwitchbotCloudDeviceLockState.LATCH_BOLT_LOCKED, + SwitchbotCloudDeviceLockState.HALF_LOCKED, +} + async def async_setup_entry( hass: HomeAssistant, @@ -44,7 +65,11 @@ class SwitchBotCloudLock(SwitchBotCloudEntity, LockEntity): def _set_attributes(self) -> None: """Set attributes from coordinator data.""" if coord_data := self.coordinator.data: - self._attr_is_locked = coord_data["lockState"].lower() == "locked" + state = LOCK_STATES_BY_VALUE.get(coord_data["lockState"].lower()) + self._attr_is_locked = state in LOCKED_STATES if state else None + self._attr_is_locking = state is SwitchbotCloudDeviceLockState.LOCKING + self._attr_is_unlocking = state is SwitchbotCloudDeviceLockState.UNLOCKING + self._attr_is_jammed = state is SwitchbotCloudDeviceLockState.JAMMED if self.__model not in [ "Smart Lock Lite", "Smart Lock Vision", @@ -54,23 +79,34 @@ class SwitchBotCloudLock(SwitchBotCloudEntity, LockEntity): ]: self._attr_supported_features = LockEntityFeature.OPEN + @callback + def _write_optimistic_state(self, *, is_locked: bool) -> None: + """Write the state the command asked for, until the cloud reports back. + + The transient states have to go with it: they outrank `is_locked` in + `LockEntity.state`, so a lock commanded out of a jam would keep + reading jammed until the next poll. + """ + self._attr_is_locked = is_locked + self._attr_is_locking = False + self._attr_is_unlocking = False + self._attr_is_jammed = False + self.async_write_ha_state() + @override async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" await self.send_api_command(LockCommands.LOCK) - self._attr_is_locked = True - self.async_write_ha_state() + self._write_optimistic_state(is_locked=True) @override async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" await self.send_api_command(LockCommands.UNLOCK) - self._attr_is_locked = False - self.async_write_ha_state() + self._write_optimistic_state(is_locked=False) @override async def async_open(self, **kwargs: Any) -> None: """Latch open the lock.""" await self.send_api_command(LockV2Commands.DEADBOLT) - self._attr_is_locked = False - self.async_write_ha_state() + self._write_optimistic_state(is_locked=False) diff --git a/tests/components/switchbot_cloud/test_lock.py b/tests/components/switchbot_cloud/test_lock.py index c4d7bf6d090d..c43013555a23 100644 --- a/tests/components/switchbot_cloud/test_lock.py +++ b/tests/components/switchbot_cloud/test_lock.py @@ -13,6 +13,7 @@ from homeassistant.const import ( SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK, + STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant @@ -104,3 +105,88 @@ async def test_lock_open( LOCK_DOMAIN, SERVICE_OPEN, {ATTR_ENTITY_ID: lock_id}, blocking=True ) assert hass.states.get(lock_id).state == LockState.UNLOCKED + + +@pytest.mark.parametrize( + ("lock_state", "expected_state"), + [ + ("locked", LockState.LOCKED), + ("unlocked", LockState.UNLOCKED), + ("locking", LockState.LOCKING), + ("unlocking", LockState.UNLOCKING), + ("jammed", LockState.JAMMED), + ("latchBoltLocked", LockState.LOCKED), + ("halfLocked", LockState.LOCKED), + # The webhook reports the very same states upper cased + ("LOCKED", LockState.LOCKED), + ("JAMMED", LockState.JAMMED), + # Anything the cloud adds later is unknown rather than unlocked + ("somethingNew", STATE_UNKNOWN), + ], +) +async def test_lock_states( + hass: HomeAssistant, + mock_list_devices, + mock_get_status, + lock_state: str, + expected_state: str, +) -> None: + """Test every lock state the cloud reports.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="lock-id-1", + deviceName="lock-1", + deviceType="Smart Lock Ultra", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = {"lockState": lock_state} + + entry = await configure_integration(hass) + + assert entry.state is ConfigEntryState.LOADED + assert hass.states.get("lock.lock_1").state == expected_state + + +@pytest.mark.parametrize( + ("service", "expected_state"), + [ + (SERVICE_LOCK, LockState.LOCKED), + (SERVICE_UNLOCK, LockState.UNLOCKED), + (SERVICE_OPEN, LockState.UNLOCKED), + ], +) +async def test_command_replaces_a_jam( + hass: HomeAssistant, + mock_list_devices, + mock_get_status, + service: str, + expected_state: str, +) -> None: + """Test a command clears the jam instead of leaving it standing.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="lock-id-1", + deviceName="lock-1", + deviceType="Smart Lock Ultra", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = {"lockState": "jammed"} + + entry = await configure_integration(hass) + + assert entry.state is ConfigEntryState.LOADED + + lock_id = "lock.lock_1" + assert hass.states.get(lock_id).state == LockState.JAMMED + + with patch.object(SwitchBotAPI, "send_command"): + await hass.services.async_call( + LOCK_DOMAIN, service, {ATTR_ENTITY_ID: lock_id}, blocking=True + ) + assert hass.states.get(lock_id).state == expected_state