Report every SwitchBot Cloud lock state (#180448)

This commit is contained in:
Franck Nijhof
2026-08-28 16:35:13 -04:00
committed by GitHub
parent 907943ed68
commit fac772eb80
2 changed files with 131 additions and 9 deletions
@@ -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)
@@ -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