diff --git a/homeassistant/components/demo/lock.py b/homeassistant/components/demo/lock.py index 8c10877482f..c17e10edd85 100644 --- a/homeassistant/components/demo/lock.py +++ b/homeassistant/components/demo/lock.py @@ -11,6 +11,8 @@ from homeassistant.const import ( STATE_JAMMED, STATE_LOCKED, STATE_LOCKING, + STATE_OPEN, + STATE_OPENING, STATE_UNLOCKED, STATE_UNLOCKING, ) @@ -76,6 +78,16 @@ class DemoLock(LockEntity): """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 @@ -97,5 +109,8 @@ class DemoLock(LockEntity): 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() diff --git a/homeassistant/components/group/lock.py b/homeassistant/components/group/lock.py index b0cf36bd6b1..4da5829634b 100644 --- a/homeassistant/components/group/lock.py +++ b/homeassistant/components/group/lock.py @@ -25,6 +25,8 @@ from homeassistant.const import ( STATE_JAMMED, STATE_LOCKED, STATE_LOCKING, + STATE_OPEN, + STATE_OPENING, STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_UNLOCKING, @@ -175,12 +177,16 @@ class LockGroup(GroupEntity, LockEntity): # 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) diff --git a/homeassistant/components/kitchen_sink/lock.py b/homeassistant/components/kitchen_sink/lock.py index 228e383e94d..9b8093c2f0b 100644 --- a/homeassistant/components/kitchen_sink/lock.py +++ b/homeassistant/components/kitchen_sink/lock.py @@ -6,7 +6,7 @@ from typing import Any 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 @@ -79,6 +79,11 @@ class DemoLock(LockEntity): """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 @@ -97,5 +102,5 @@ class DemoLock(LockEntity): 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() diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index bdd65868e62..55f48fd8d22 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -22,6 +22,8 @@ from homeassistant.const import ( STATE_JAMMED, STATE_LOCKED, STATE_LOCKING, + STATE_OPEN, + STATE_OPENING, STATE_UNLOCKED, STATE_UNLOCKING, ) @@ -121,6 +123,8 @@ CACHED_PROPERTIES_WITH_ATTR_ = { "is_locked", "is_locking", "is_unlocking", + "is_open", + "is_opening", "is_jammed", "supported_features", } @@ -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 @@ -202,6 +208,16 @@ class LockEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """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).""" @@ -262,8 +278,12 @@ class LockEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """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: diff --git a/homeassistant/components/lock/device_condition.py b/homeassistant/components/lock/device_condition.py index 327bde2c0e3..ec6373c889f 100644 --- a/homeassistant/components/lock/device_condition.py +++ b/homeassistant/components/lock/device_condition.py @@ -14,6 +14,8 @@ from homeassistant.const import ( STATE_JAMMED, STATE_LOCKED, STATE_LOCKING, + STATE_OPEN, + STATE_OPENING, STATE_UNLOCKED, STATE_UNLOCKING, ) @@ -31,11 +33,13 @@ from . import DOMAIN # mypy: disallow-any-generics CONDITION_TYPES = { - "is_locked", - "is_unlocked", - "is_locking", - "is_unlocking", "is_jammed", + "is_locked", + "is_locking", + "is_open", + "is_opening", + "is_unlocked", + "is_unlocking", } CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( @@ -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": diff --git a/homeassistant/components/lock/device_trigger.py b/homeassistant/components/lock/device_trigger.py index 57a83c7dc7a..336fe127ca6 100644 --- a/homeassistant/components/lock/device_trigger.py +++ b/homeassistant/components/lock/device_trigger.py @@ -16,6 +16,8 @@ from homeassistant.const import ( STATE_JAMMED, STATE_LOCKED, STATE_LOCKING, + STATE_OPEN, + STATE_OPENING, STATE_UNLOCKED, STATE_UNLOCKING, ) @@ -26,7 +28,15 @@ from homeassistant.helpers.typing import ConfigType 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( { @@ -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": diff --git a/homeassistant/components/lock/group.py b/homeassistant/components/lock/group.py index 20aaed2b39a..b69d916781f 100644 --- a/homeassistant/components/lock/group.py +++ b/homeassistant/components/lock/group.py @@ -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 @@ -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, + ) diff --git a/homeassistant/components/lock/icons.json b/homeassistant/components/lock/icons.json index 0ce2e70d372..009bd84a372 100644 --- a/homeassistant/components/lock/icons.json +++ b/homeassistant/components/lock/icons.json @@ -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" } diff --git a/homeassistant/components/lock/reproduce_state.py b/homeassistant/components/lock/reproduce_state.py index 36afcf5f310..5fc3345c1f6 100644 --- a/homeassistant/components/lock/reproduce_state.py +++ b/homeassistant/components/lock/reproduce_state.py @@ -10,9 +10,12 @@ from typing import Any 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, ) @@ -22,7 +25,14 @@ from . import DOMAIN _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( @@ -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 diff --git a/homeassistant/components/lock/strings.json b/homeassistant/components/lock/strings.json index 152a06f9e53..3b36171bf94 100644 --- a/homeassistant/components/lock/strings.json +++ b/homeassistant/components/lock/strings.json @@ -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": { @@ -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" }, diff --git a/tests/components/demo/test_lock.py b/tests/components/demo/test_lock.py index 634eee44385..853b9197ab7 100644 --- a/tests/components/demo/test_lock.py +++ b/tests/components/demo/test_lock.py @@ -16,7 +16,13 @@ from homeassistant.components.lock import ( 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 @@ -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.""" @@ -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 diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index 9dbd1fe1f6e..d83f8be6993 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -19,13 +19,17 @@ from homeassistant.const import ( SERVICE_RELOAD, STATE_CLOSED, STATE_HOME, + STATE_JAMMED, STATE_LOCKED, + STATE_LOCKING, STATE_NOT_HOME, STATE_OFF, STATE_ON, STATE_OPEN, + STATE_OPENING, STATE_UNKNOWN, STATE_UNLOCKED, + STATE_UNLOCKING, ) from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import entity_registry as er @@ -769,6 +773,48 @@ async def test_is_on(hass: HomeAssistant) -> None: (STATE_ON, True), (STATE_OFF, False), ), + ( + ("lock", "lock"), + (STATE_OPEN, STATE_LOCKED), + (STATE_LOCKED, STATE_LOCKED), + (STATE_UNLOCKED, True), + (STATE_LOCKED, False), + ), + ( + ("lock", "lock"), + (STATE_OPENING, STATE_LOCKED), + (STATE_LOCKED, STATE_LOCKED), + (STATE_UNLOCKED, True), + (STATE_LOCKED, False), + ), + ( + ("lock", "lock"), + (STATE_UNLOCKING, STATE_LOCKED), + (STATE_LOCKED, STATE_LOCKED), + (STATE_UNLOCKED, True), + (STATE_LOCKED, False), + ), + ( + ("lock", "lock"), + (STATE_LOCKING, STATE_LOCKED), + (STATE_LOCKED, STATE_LOCKED), + (STATE_UNLOCKED, True), + (STATE_LOCKED, False), + ), + ( + ("lock", "lock"), + (STATE_JAMMED, STATE_LOCKED), + (STATE_LOCKED, STATE_LOCKED), + (STATE_LOCKED, False), + (STATE_LOCKED, False), + ), + ( + ("cover", "lock"), + (STATE_OPEN, STATE_OPEN), + (STATE_CLOSED, STATE_LOCKED), + (STATE_ON, True), + (STATE_OFF, False), + ), ], ) async def test_is_on_and_state_mixed_domains( @@ -1247,6 +1293,8 @@ async def test_group_mixed_domains_off(hass: HomeAssistant) -> None: [ (("locked", "locked", "unlocked"), "unlocked"), (("locked", "locked", "locked"), "locked"), + (("locked", "locked", "open"), "unlocked"), + (("locked", "unlocked", "open"), "unlocked"), ], ) async def test_group_locks(hass: HomeAssistant, states, group_state) -> None: diff --git a/tests/components/group/test_lock.py b/tests/components/group/test_lock.py index c8102b79ff9..0c62913ae3e 100644 --- a/tests/components/group/test_lock.py +++ b/tests/components/group/test_lock.py @@ -18,6 +18,7 @@ from homeassistant.const import ( STATE_JAMMED, STATE_LOCKED, STATE_LOCKING, + STATE_OPEN, STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_UNLOCKED, @@ -204,8 +205,8 @@ async def test_service_calls_openable(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: "lock.lock_group"}, blocking=True, ) - assert hass.states.get("lock.openable_lock").state == STATE_UNLOCKED - assert hass.states.get("lock.another_openable_lock").state == STATE_UNLOCKED + assert hass.states.get("lock.openable_lock").state == STATE_OPEN + assert hass.states.get("lock.another_openable_lock").state == STATE_OPEN await hass.services.async_call( LOCK_DOMAIN, diff --git a/tests/components/kitchen_sink/test_lock.py b/tests/components/kitchen_sink/test_lock.py index ad5e9b7515d..e86300a4d35 100644 --- a/tests/components/kitchen_sink/test_lock.py +++ b/tests/components/kitchen_sink/test_lock.py @@ -16,7 +16,12 @@ from homeassistant.components.lock import ( 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, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -103,4 +108,4 @@ async def test_opening(hass: HomeAssistant) -> None: LOCK_DOMAIN, SERVICE_OPEN, {ATTR_ENTITY_ID: OPENABLE_LOCK}, blocking=True ) state = hass.states.get(OPENABLE_LOCK) - assert state.state == STATE_UNLOCKED + assert state.state == STATE_OPEN diff --git a/tests/components/lock/test_device_condition.py b/tests/components/lock/test_device_condition.py index 749e1037662..7c9cb62e143 100644 --- a/tests/components/lock/test_device_condition.py +++ b/tests/components/lock/test_device_condition.py @@ -10,6 +10,8 @@ from homeassistant.const import ( STATE_JAMMED, STATE_LOCKED, STATE_LOCKING, + STATE_OPEN, + STATE_OPENING, STATE_UNLOCKED, STATE_UNLOCKING, EntityCategory, @@ -32,7 +34,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant): """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -67,6 +69,8 @@ async def test_get_conditions( "is_unlocking", "is_locking", "is_jammed", + "is_open", + "is_opening", ] ] conditions = await async_get_device_automations( @@ -121,6 +125,8 @@ async def test_get_conditions_hidden_auxiliary( "is_unlocking", "is_locking", "is_jammed", + "is_open", + "is_opening", ] ] conditions = await async_get_device_automations( @@ -243,6 +249,42 @@ async def test_if_state( }, }, }, + { + "trigger": {"platform": "event", "event_type": "test_event6"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "entity_id": entry.id, + "type": "is_opening", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_opening - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event7"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "entity_id": entry.id, + "type": "is_open", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_open - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, ] }, ) @@ -277,6 +319,18 @@ async def test_if_state( assert len(calls) == 5 assert calls[4].data["some"] == "is_jammed - event - test_event5" + hass.states.async_set(entry.entity_id, STATE_OPENING) + hass.bus.async_fire("test_event6") + await hass.async_block_till_done() + assert len(calls) == 6 + assert calls[5].data["some"] == "is_opening - event - test_event6" + + hass.states.async_set(entry.entity_id, STATE_OPEN) + hass.bus.async_fire("test_event7") + await hass.async_block_till_done() + assert len(calls) == 7 + assert calls[6].data["some"] == "is_open - event - test_event7" + async def test_if_state_legacy( hass: HomeAssistant, diff --git a/tests/components/lock/test_device_trigger.py b/tests/components/lock/test_device_trigger.py index 3ad992d4458..a6d6c0870db 100644 --- a/tests/components/lock/test_device_trigger.py +++ b/tests/components/lock/test_device_trigger.py @@ -7,11 +7,13 @@ from pytest_unordered import unordered from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType -from homeassistant.components.lock import DOMAIN +from homeassistant.components.lock import DOMAIN, LockEntityFeature from homeassistant.const import ( STATE_JAMMED, STATE_LOCKED, STATE_LOCKING, + STATE_OPEN, + STATE_OPENING, STATE_UNLOCKED, STATE_UNLOCKING, EntityCategory, @@ -37,7 +39,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant): """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -55,7 +57,11 @@ async def test_get_triggers( connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) entity_entry = entity_registry.async_get_or_create( - DOMAIN, "test", "5678", device_id=device_entry.id + DOMAIN, + "test", + "5678", + device_id=device_entry.id, + supported_features=LockEntityFeature.OPEN, ) expected_triggers = [ { @@ -66,7 +72,15 @@ async def test_get_triggers( "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for trigger in ["locked", "unlocked", "unlocking", "locking", "jammed"] + for trigger in [ + "locked", + "unlocked", + "unlocking", + "locking", + "jammed", + "open", + "opening", + ] ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -104,6 +118,7 @@ async def test_get_triggers_hidden_auxiliary( device_id=device_entry.id, entity_category=entity_category, hidden_by=hidden_by, + supported_features=LockEntityFeature.OPEN, ) expected_triggers = [ { @@ -114,7 +129,15 @@ async def test_get_triggers_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for trigger in ["locked", "unlocked", "unlocking", "locking", "jammed"] + for trigger in [ + "locked", + "unlocked", + "unlocking", + "locking", + "jammed", + "open", + "opening", + ] ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -141,7 +164,7 @@ async def test_get_trigger_capabilities( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert len(triggers) == 5 + assert len(triggers) == 7 for trigger in triggers: capabilities = await async_get_device_automation_capabilities( hass, DeviceAutomationType.TRIGGER, trigger @@ -172,7 +195,7 @@ async def test_get_trigger_capabilities_legacy( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert len(triggers) == 5 + assert len(triggers) == 7 for trigger in triggers: trigger["entity_id"] = entity_registry.async_get(trigger["entity_id"]).entity_id capabilities = await async_get_device_automation_capabilities( @@ -247,6 +270,25 @@ async def test_if_fires_on_state_change( }, }, }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "entity_id": entry.id, + "type": "open", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "open - {{ trigger.platform}} - " + "{{ trigger.entity_id}} - {{ trigger.from_state.state}} - " + "{{ trigger.to_state.state}} - {{ trigger.for }}" + ) + }, + }, + }, ] }, ) @@ -269,6 +311,15 @@ async def test_if_fires_on_state_change( == f"unlocked - device - {entry.entity_id} - locked - unlocked - None" ) + # Fake that the entity is opens. + hass.states.async_set(entry.entity_id, STATE_OPEN) + await hass.async_block_till_done() + assert len(calls) == 3 + assert ( + calls[2].data["some"] + == f"open - device - {entry.entity_id} - unlocked - open - None" + ) + async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, @@ -439,6 +490,28 @@ async def test_if_fires_on_state_change_with_for( }, }, }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "entity_id": entry.id, + "type": "opening", + "for": {"seconds": 5}, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "turn_on {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" + ) + }, + }, + }, ] }, ) @@ -492,3 +565,15 @@ async def test_if_fires_on_state_change_with_for( calls[3].data["some"] == f"turn_on device - {entry.entity_id} - jammed - locking - 0:00:05" ) + + hass.states.async_set(entry.entity_id, STATE_OPENING) + await hass.async_block_till_done() + assert len(calls) == 4 + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=27)) + await hass.async_block_till_done() + assert len(calls) == 5 + await hass.async_block_till_done() + assert ( + calls[4].data["some"] + == f"turn_on device - {entry.entity_id} - locking - opening - 0:00:05" + ) diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index e98a7bd9eda..f0547fbbeae 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -22,6 +22,7 @@ from homeassistant.components.lock import ( STATE_UNLOCKING, LockEntityFeature, ) +from homeassistant.const import STATE_OPEN, STATE_OPENING from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.entity_registry as er @@ -55,6 +56,8 @@ async def test_lock_default(hass: HomeAssistant, mock_lock_entity: MockLock) -> assert mock_lock_entity.is_locked is None assert mock_lock_entity.is_locking is None assert mock_lock_entity.is_unlocking is None + assert mock_lock_entity.is_opening is None + assert mock_lock_entity.is_open is None async def test_lock_states(hass: HomeAssistant, mock_lock_entity: MockLock) -> None: @@ -85,6 +88,19 @@ async def test_lock_states(hass: HomeAssistant, mock_lock_entity: MockLock) -> N assert mock_lock_entity.state == STATE_JAMMED assert not mock_lock_entity.is_locked + mock_lock_entity._attr_is_jammed = False + mock_lock_entity._attr_is_opening = True + assert mock_lock_entity.is_opening + assert mock_lock_entity.state == STATE_OPENING + assert mock_lock_entity.is_opening + + mock_lock_entity._attr_is_opening = False + mock_lock_entity._attr_is_open = True + assert not mock_lock_entity.is_opening + assert mock_lock_entity.state == STATE_OPEN + assert not mock_lock_entity.is_opening + assert mock_lock_entity.is_open + @pytest.mark.parametrize( ("code_format", "supported_features"), diff --git a/tests/components/lock/test_reproduce_state.py b/tests/components/lock/test_reproduce_state.py index 4fa06d9320b..e501e03ebcd 100644 --- a/tests/components/lock/test_reproduce_state.py +++ b/tests/components/lock/test_reproduce_state.py @@ -14,9 +14,11 @@ async def test_reproducing_states( """Test reproducing Lock states.""" hass.states.async_set("lock.entity_locked", "locked", {}) hass.states.async_set("lock.entity_unlocked", "unlocked", {}) + hass.states.async_set("lock.entity_opened", "open", {}) lock_calls = async_mock_service(hass, "lock", "lock") unlock_calls = async_mock_service(hass, "lock", "unlock") + open_calls = async_mock_service(hass, "lock", "open") # These calls should do nothing as entities already in desired state await async_reproduce_state( @@ -24,11 +26,13 @@ async def test_reproducing_states( [ State("lock.entity_locked", "locked"), State("lock.entity_unlocked", "unlocked", {}), + State("lock.entity_opened", "open", {}), ], ) assert len(lock_calls) == 0 assert len(unlock_calls) == 0 + assert len(open_calls) == 0 # Test invalid state is handled await async_reproduce_state(hass, [State("lock.entity_locked", "not_supported")]) @@ -36,13 +40,15 @@ async def test_reproducing_states( assert "not_supported" in caplog.text assert len(lock_calls) == 0 assert len(unlock_calls) == 0 + assert len(open_calls) == 0 # Make sure correct services are called await async_reproduce_state( hass, [ - State("lock.entity_locked", "unlocked"), + State("lock.entity_locked", "open"), State("lock.entity_unlocked", "locked"), + State("lock.entity_opened", "unlocked"), # Should not raise State("lock.non_existing", "on"), ], @@ -54,4 +60,8 @@ async def test_reproducing_states( assert len(unlock_calls) == 1 assert unlock_calls[0].domain == "lock" - assert unlock_calls[0].data == {"entity_id": "lock.entity_locked"} + assert unlock_calls[0].data == {"entity_id": "lock.entity_opened"} + + assert len(open_calls) == 1 + assert open_calls[0].domain == "lock" + assert open_calls[0].data == {"entity_id": "lock.entity_locked"}