Skip to content

Commit

Permalink
Add open state to LockEntity (#111968)
Browse files Browse the repository at this point in the history
* Add `open` state to LockEntity

* Add tests

* Fixes

* Fix tests

* strings and icons

* Adjust demo open lock

* Fix lock and tests

* fix import

* Fix strings

* mute ruff

* Change sequence

* Sequence2

* Group on states

* Fix ruff

* Fix tests

* Add more test cases

* Sorting
  • Loading branch information
gjohansson-ST committed May 8, 2024
1 parent 189c07d commit 7862596
Show file tree
Hide file tree
Showing 18 changed files with 375 additions and 35 deletions.
17 changes: 16 additions & 1 deletion homeassistant/components/demo/lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
STATE_JAMMED,
STATE_LOCKED,
STATE_LOCKING,
STATE_OPEN,
STATE_OPENING,
STATE_UNLOCKED,
STATE_UNLOCKING,
)
Expand Down Expand Up @@ -76,6 +78,16 @@ def is_locked(self) -> bool:
"""Return true if lock is locked."""
return self._state == STATE_LOCKED

@property
def is_open(self) -> bool:
"""Return true if lock is open."""
return self._state == STATE_OPEN

@property
def is_opening(self) -> bool:
"""Return true if lock is opening."""
return self._state == STATE_OPENING

async def async_lock(self, **kwargs: Any) -> None:
"""Lock the device."""
self._state = STATE_LOCKING
Expand All @@ -97,5 +109,8 @@ async def async_unlock(self, **kwargs: Any) -> None:

async def async_open(self, **kwargs: Any) -> None:
"""Open the door latch."""
self._state = STATE_UNLOCKED
self._state = STATE_OPENING
self.async_write_ha_state()
await asyncio.sleep(LOCK_UNLOCK_DELAY)
self._state = STATE_OPEN
self.async_write_ha_state()
6 changes: 6 additions & 0 deletions homeassistant/components/group/lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
STATE_JAMMED,
STATE_LOCKED,
STATE_LOCKING,
STATE_OPEN,
STATE_OPENING,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
STATE_UNLOCKING,
Expand Down Expand Up @@ -175,12 +177,16 @@ def async_update_group_state(self) -> None:
# Set as unknown if any member is unknown or unavailable
self._attr_is_jammed = None
self._attr_is_locking = None
self._attr_is_opening = None
self._attr_is_open = None
self._attr_is_unlocking = None
self._attr_is_locked = None
else:
# Set attributes based on member states and let the lock entity sort out the correct state
self._attr_is_jammed = STATE_JAMMED in states
self._attr_is_locking = STATE_LOCKING in states
self._attr_is_opening = STATE_OPENING in states
self._attr_is_open = STATE_OPEN in states
self._attr_is_unlocking = STATE_UNLOCKING in states
self._attr_is_locked = all(state == STATE_LOCKED for state in states)

Expand Down
9 changes: 7 additions & 2 deletions homeassistant/components/kitchen_sink/lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from homeassistant.components.lock import LockEntity, LockEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED
from homeassistant.const import STATE_LOCKED, STATE_OPEN, STATE_UNLOCKED
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
Expand Down Expand Up @@ -79,6 +79,11 @@ def is_locked(self) -> bool:
"""Return true if lock is locked."""
return self._state == STATE_LOCKED

@property
def is_open(self) -> bool:
"""Return true if lock is open."""
return self._state == STATE_OPEN

async def async_lock(self, **kwargs: Any) -> None:
"""Lock the device."""
self._attr_is_locking = True
Expand All @@ -97,5 +102,5 @@ async def async_unlock(self, **kwargs: Any) -> None:

async def async_open(self, **kwargs: Any) -> None:
"""Open the door latch."""
self._state = STATE_UNLOCKED
self._state = STATE_OPEN
self.async_write_ha_state()
20 changes: 20 additions & 0 deletions homeassistant/components/lock/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
STATE_JAMMED,
STATE_LOCKED,
STATE_LOCKING,
STATE_OPEN,
STATE_OPENING,
STATE_UNLOCKED,
STATE_UNLOCKING,
)
Expand Down Expand Up @@ -121,6 +123,8 @@ class LockEntityDescription(EntityDescription, frozen_or_thawed=True):
"is_locked",
"is_locking",
"is_unlocking",
"is_open",
"is_opening",
"is_jammed",
"supported_features",
}
Expand All @@ -134,6 +138,8 @@ class LockEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
_attr_code_format: str | None = None
_attr_is_locked: bool | None = None
_attr_is_locking: bool | None = None
_attr_is_open: bool | None = None
_attr_is_opening: bool | None = None
_attr_is_unlocking: bool | None = None
_attr_is_jammed: bool | None = None
_attr_state: None = None
Expand Down Expand Up @@ -202,6 +208,16 @@ def is_unlocking(self) -> bool | None:
"""Return true if the lock is unlocking."""
return self._attr_is_unlocking

@cached_property
def is_open(self) -> bool | None:
"""Return true if the lock is open."""
return self._attr_is_open

@cached_property
def is_opening(self) -> bool | None:
"""Return true if the lock is opening."""
return self._attr_is_opening

@cached_property
def is_jammed(self) -> bool | None:
"""Return true if the lock is jammed (incomplete locking)."""
Expand Down Expand Up @@ -262,8 +278,12 @@ def state(self) -> str | None:
"""Return the state."""
if self.is_jammed:
return STATE_JAMMED
if self.is_opening:
return STATE_OPENING
if self.is_locking:
return STATE_LOCKING
if self.is_open:
return STATE_OPEN
if self.is_unlocking:
return STATE_UNLOCKING
if (locked := self.is_locked) is None:
Expand Down
12 changes: 10 additions & 2 deletions homeassistant/components/lock/device_condition.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
STATE_JAMMED,
STATE_LOCKED,
STATE_LOCKING,
STATE_OPEN,
STATE_OPENING,
STATE_UNLOCKED,
STATE_UNLOCKING,
)
Expand All @@ -31,11 +33,13 @@
# mypy: disallow-any-generics

CONDITION_TYPES = {
"is_jammed",
"is_locked",
"is_unlocked",
"is_locking",
"is_open",
"is_opening",
"is_unlocked",
"is_unlocking",
"is_jammed",
}

CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend(
Expand Down Expand Up @@ -78,8 +82,12 @@ def async_condition_from_config(
"""Create a function to test a device condition."""
if config[CONF_TYPE] == "is_jammed":
state = STATE_JAMMED
elif config[CONF_TYPE] == "is_opening":
state = STATE_OPENING
elif config[CONF_TYPE] == "is_locking":
state = STATE_LOCKING
elif config[CONF_TYPE] == "is_open":
state = STATE_OPEN
elif config[CONF_TYPE] == "is_unlocking":
state = STATE_UNLOCKING
elif config[CONF_TYPE] == "is_locked":
Expand Down
16 changes: 15 additions & 1 deletion homeassistant/components/lock/device_trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
STATE_JAMMED,
STATE_LOCKED,
STATE_LOCKING,
STATE_OPEN,
STATE_OPENING,
STATE_UNLOCKED,
STATE_UNLOCKING,
)
Expand All @@ -26,7 +28,15 @@

from . import DOMAIN

TRIGGER_TYPES = {"locked", "unlocked", "locking", "unlocking", "jammed"}
TRIGGER_TYPES = {
"jammed",
"locked",
"locking",
"open",
"opening",
"unlocked",
"unlocking",
}

TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
{
Expand Down Expand Up @@ -84,8 +94,12 @@ async def async_attach_trigger(
"""Attach a trigger."""
if config[CONF_TYPE] == "jammed":
to_state = STATE_JAMMED
elif config[CONF_TYPE] == "opening":
to_state = STATE_OPENING
elif config[CONF_TYPE] == "locking":
to_state = STATE_LOCKING
elif config[CONF_TYPE] == "open":
to_state = STATE_OPEN
elif config[CONF_TYPE] == "unlocking":
to_state = STATE_UNLOCKING
elif config[CONF_TYPE] == "locked":
Expand Down
22 changes: 20 additions & 2 deletions homeassistant/components/lock/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@

from typing import TYPE_CHECKING

from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED
from homeassistant.const import (
STATE_LOCKED,
STATE_LOCKING,
STATE_OPEN,
STATE_OPENING,
STATE_UNLOCKED,
STATE_UNLOCKING,
)
from homeassistant.core import HomeAssistant, callback

from .const import DOMAIN
Expand All @@ -16,4 +23,15 @@ def async_describe_on_off_states(
hass: HomeAssistant, registry: "GroupIntegrationRegistry"
) -> None:
"""Describe group on off states."""
registry.on_off_states(DOMAIN, {STATE_UNLOCKED}, STATE_UNLOCKED, STATE_LOCKED)
registry.on_off_states(
DOMAIN,
{
STATE_LOCKING,
STATE_OPEN,
STATE_OPENING,
STATE_UNLOCKED,
STATE_UNLOCKING,
},
STATE_UNLOCKED,
STATE_LOCKED,
)
2 changes: 2 additions & 0 deletions homeassistant/components/lock/icons.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
"state": {
"jammed": "mdi:lock-alert",
"locking": "mdi:lock-clock",
"open": "mdi:lock-open-variant",
"opening": "mdi:lock-clock",
"unlocked": "mdi:lock-open-variant",
"unlocking": "mdi:lock-clock"
}
Expand Down
14 changes: 13 additions & 1 deletion homeassistant/components/lock/reproduce_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_LOCK,
SERVICE_OPEN,
SERVICE_UNLOCK,
STATE_LOCKED,
STATE_LOCKING,
STATE_OPEN,
STATE_OPENING,
STATE_UNLOCKED,
STATE_UNLOCKING,
)
Expand All @@ -22,7 +25,14 @@

_LOGGER = logging.getLogger(__name__)

VALID_STATES = {STATE_LOCKED, STATE_UNLOCKED, STATE_LOCKING, STATE_UNLOCKING}
VALID_STATES = {
STATE_LOCKED,
STATE_LOCKING,
STATE_OPEN,
STATE_OPENING,
STATE_UNLOCKED,
STATE_UNLOCKING,
}


async def _async_reproduce_state(
Expand Down Expand Up @@ -53,6 +63,8 @@ async def _async_reproduce_state(
service = SERVICE_LOCK
elif state.state in {STATE_UNLOCKED, STATE_UNLOCKING}:
service = SERVICE_UNLOCK
elif state.state in {STATE_OPEN, STATE_OPENING}:
service = SERVICE_OPEN

await hass.services.async_call(
DOMAIN, service, service_data, context=context, blocking=True
Expand Down
8 changes: 6 additions & 2 deletions homeassistant/components/lock/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@
},
"condition_type": {
"is_locked": "{entity_name} is locked",
"is_unlocked": "{entity_name} is unlocked"
"is_unlocked": "{entity_name} is unlocked",
"is_open": "{entity_name} is open"
},
"trigger_type": {
"locked": "{entity_name} locked",
"unlocked": "{entity_name} unlocked"
"unlocked": "{entity_name} unlocked",
"open": "{entity_name} opened"
}
},
"entity_component": {
Expand All @@ -22,6 +24,8 @@
"jammed": "Jammed",
"locked": "[%key:common::state::locked%]",
"locking": "Locking",
"open": "[%key:common::state::open%]",
"opening": "Opening",
"unlocked": "[%key:common::state::unlocked%]",
"unlocking": "Unlocking"
},
Expand Down
37 changes: 27 additions & 10 deletions tests/components/demo/test_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@
STATE_UNLOCKED,
STATE_UNLOCKING,
)
from homeassistant.const import ATTR_ENTITY_ID, EVENT_STATE_CHANGED, Platform
from homeassistant.const import (
ATTR_ENTITY_ID,
EVENT_STATE_CHANGED,
STATE_OPEN,
STATE_OPENING,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component

Expand Down Expand Up @@ -87,6 +93,26 @@ async def test_unlocking(hass: HomeAssistant) -> None:
assert state_changes[1].data["new_state"].state == STATE_UNLOCKED


@patch.object(demo_lock, "LOCK_UNLOCK_DELAY", 0)
async def test_opening(hass: HomeAssistant) -> None:
"""Test the opening of a lock."""
state = hass.states.get(OPENABLE_LOCK)
assert state.state == STATE_LOCKED
await hass.async_block_till_done()

state_changes = async_capture_events(hass, EVENT_STATE_CHANGED)
await hass.services.async_call(
LOCK_DOMAIN, SERVICE_OPEN, {ATTR_ENTITY_ID: OPENABLE_LOCK}, blocking=False
)
await hass.async_block_till_done()

assert state_changes[0].data["entity_id"] == OPENABLE_LOCK
assert state_changes[0].data["new_state"].state == STATE_OPENING

assert state_changes[1].data["entity_id"] == OPENABLE_LOCK
assert state_changes[1].data["new_state"].state == STATE_OPEN


@patch.object(demo_lock, "LOCK_UNLOCK_DELAY", 0)
async def test_jammed_when_locking(hass: HomeAssistant) -> None:
"""Test the locking of a lock jams."""
Expand Down Expand Up @@ -114,12 +140,3 @@ async def test_opening_mocked(hass: HomeAssistant) -> None:
LOCK_DOMAIN, SERVICE_OPEN, {ATTR_ENTITY_ID: OPENABLE_LOCK}, blocking=True
)
assert len(calls) == 1


async def test_opening(hass: HomeAssistant) -> None:
"""Test the opening of a lock."""
await hass.services.async_call(
LOCK_DOMAIN, SERVICE_OPEN, {ATTR_ENTITY_ID: OPENABLE_LOCK}, blocking=True
)
state = hass.states.get(OPENABLE_LOCK)
assert state.state == STATE_UNLOCKED

0 comments on commit 7862596

Please sign in to comment.