diff --git a/homeassistant/components/demo/lock.py b/homeassistant/components/demo/lock.py index cafc0e3f748..7eabf9bea2d 100644 --- a/homeassistant/components/demo/lock.py +++ b/homeassistant/components/demo/lock.py @@ -1,6 +1,14 @@ """Demo lock platform that has two fake locks.""" +import asyncio + from homeassistant.components.lock import SUPPORT_OPEN, LockEntity -from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED +from homeassistant.const import ( + STATE_JAMMED, + STATE_LOCKED, + STATE_LOCKING, + STATE_UNLOCKED, + STATE_UNLOCKING, +) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -9,6 +17,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= [ DemoLock("Front Door", STATE_LOCKED), DemoLock("Kitchen Door", STATE_UNLOCKED), + DemoLock("Poorly Installed Door", STATE_UNLOCKED, False, True), DemoLock("Openable Lock", STATE_LOCKED, True), ] ) @@ -24,24 +33,67 @@ class DemoLock(LockEntity): _attr_should_poll = False - def __init__(self, name: str, state: str, openable: bool = False) -> None: + def __init__( + self, + name: str, + state: str, + openable: bool = False, + jam_on_operation: bool = False, + ) -> None: """Initialize the lock.""" self._attr_name = name - self._attr_is_locked = state == STATE_LOCKED if openable: self._attr_supported_features = SUPPORT_OPEN + self._state = state + self._openable = openable + self._jam_on_operation = jam_on_operation - def lock(self, **kwargs): + @property + def is_locking(self): + """Return true if lock is locking.""" + return self._state == STATE_LOCKING + + @property + def is_unlocking(self): + """Return true if lock is unlocking.""" + return self._state == STATE_UNLOCKING + + @property + def is_jammed(self): + """Return true if lock is jammed.""" + return self._state == STATE_JAMMED + + @property + def is_locked(self): + """Return true if lock is locked.""" + return self._state == STATE_LOCKED + + async def async_lock(self, **kwargs): """Lock the device.""" - self._attr_is_locked = True - self.schedule_update_ha_state() + self._state = STATE_LOCKING + self.async_write_ha_state() + await asyncio.sleep(2) + if self._jam_on_operation: + self._state = STATE_JAMMED + else: + self._state = STATE_LOCKED + self.async_write_ha_state() - def unlock(self, **kwargs): + async def async_unlock(self, **kwargs): """Unlock the device.""" - self._attr_is_locked = False - self.schedule_update_ha_state() + self._state = STATE_UNLOCKING + self.async_write_ha_state() + await asyncio.sleep(2) + self._state = STATE_UNLOCKED + self.async_write_ha_state() - def open(self, **kwargs): + async def async_open(self, **kwargs): """Open the door latch.""" - self._attr_is_locked = False - self.schedule_update_ha_state() + self._state = STATE_UNLOCKED + self.async_write_ha_state() + + @property + def supported_features(self): + """Flag supported features.""" + if self._openable: + return SUPPORT_OPEN diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index 9e8bf3a740c..e98202a1ee5 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -15,8 +15,11 @@ from homeassistant.const import ( SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK, + STATE_JAMMED, STATE_LOCKED, + STATE_LOCKING, STATE_UNLOCKED, + STATE_UNLOCKING, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv @@ -87,6 +90,9 @@ class LockEntity(Entity): _attr_changed_by: str | None = None _attr_code_format: str | None = None _attr_is_locked: bool | None = None + _attr_is_locking: bool | None = None + _attr_is_unlocking: bool | None = None + _attr_is_jammed: bool | None = None _attr_state: None = None @property @@ -104,6 +110,21 @@ class LockEntity(Entity): """Return true if the lock is locked.""" return self._attr_is_locked + @property + def is_locking(self) -> bool | None: + """Return true if the lock is locking.""" + return self._attr_is_locking + + @property + def is_unlocking(self) -> bool | None: + """Return true if the lock is unlocking.""" + return self._attr_is_unlocking + + @property + def is_jammed(self) -> bool | None: + """Return true if the lock is jammed (incomplete locking).""" + return self._attr_is_jammed + def lock(self, **kwargs: Any) -> None: """Lock the lock.""" raise NotImplementedError() @@ -143,6 +164,12 @@ class LockEntity(Entity): @property def state(self) -> str | None: """Return the state.""" + if self.is_jammed: + return STATE_JAMMED + if self.is_locking: + return STATE_LOCKING + if self.is_unlocking: + return STATE_UNLOCKING locked = self.is_locked if locked is None: return None diff --git a/homeassistant/components/lock/device_condition.py b/homeassistant/components/lock/device_condition.py index 3e77a23ffdb..d0829eb742b 100644 --- a/homeassistant/components/lock/device_condition.py +++ b/homeassistant/components/lock/device_condition.py @@ -10,8 +10,11 @@ from homeassistant.const import ( CONF_DOMAIN, CONF_ENTITY_ID, CONF_TYPE, + STATE_JAMMED, STATE_LOCKED, + STATE_LOCKING, STATE_UNLOCKED, + STATE_UNLOCKING, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import condition, config_validation as cv, entity_registry @@ -20,7 +23,13 @@ from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN -CONDITION_TYPES = {"is_locked", "is_unlocked"} +CONDITION_TYPES = { + "is_locked", + "is_unlocked", + "is_locking", + "is_unlocking", + "is_jammed", +} CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( { @@ -60,7 +69,13 @@ def async_condition_from_config( """Create a function to test a device condition.""" if config_validation: config = CONDITION_SCHEMA(config) - if config[CONF_TYPE] == "is_locked": + if config[CONF_TYPE] == "is_jammed": + state = STATE_JAMMED + elif config[CONF_TYPE] == "is_locking": + state = STATE_LOCKING + elif config[CONF_TYPE] == "is_unlocking": + state = STATE_UNLOCKING + elif config[CONF_TYPE] == "is_locked": state = STATE_LOCKED else: state = STATE_UNLOCKED diff --git a/homeassistant/components/lock/device_trigger.py b/homeassistant/components/lock/device_trigger.py index 2e96b470893..641030e9f23 100644 --- a/homeassistant/components/lock/device_trigger.py +++ b/homeassistant/components/lock/device_trigger.py @@ -13,8 +13,11 @@ from homeassistant.const import ( CONF_FOR, CONF_PLATFORM, CONF_TYPE, + STATE_JAMMED, STATE_LOCKED, + STATE_LOCKING, STATE_UNLOCKED, + STATE_UNLOCKING, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers import config_validation as cv, entity_registry @@ -22,7 +25,7 @@ from homeassistant.helpers.typing import ConfigType from . import DOMAIN -TRIGGER_TYPES = {"locked", "unlocked"} +TRIGGER_TYPES = {"locked", "unlocked", "locking", "unlocking", "jammed"} TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { @@ -74,7 +77,13 @@ async def async_attach_trigger( automation_info: dict, ) -> CALLBACK_TYPE: """Attach a trigger.""" - if config[CONF_TYPE] == "locked": + if config[CONF_TYPE] == "jammed": + to_state = STATE_JAMMED + elif config[CONF_TYPE] == "locking": + to_state = STATE_LOCKING + elif config[CONF_TYPE] == "unlocking": + to_state = STATE_UNLOCKING + elif config[CONF_TYPE] == "locked": to_state = STATE_LOCKED else: to_state = STATE_UNLOCKED diff --git a/homeassistant/components/lock/reproduce_state.py b/homeassistant/components/lock/reproduce_state.py index ea5cf370af6..cdd538c88be 100644 --- a/homeassistant/components/lock/reproduce_state.py +++ b/homeassistant/components/lock/reproduce_state.py @@ -11,7 +11,9 @@ from homeassistant.const import ( SERVICE_LOCK, SERVICE_UNLOCK, STATE_LOCKED, + STATE_LOCKING, STATE_UNLOCKED, + STATE_UNLOCKING, ) from homeassistant.core import Context, HomeAssistant, State @@ -19,7 +21,7 @@ from . import DOMAIN _LOGGER = logging.getLogger(__name__) -VALID_STATES = {STATE_LOCKED, STATE_UNLOCKED} +VALID_STATES = {STATE_LOCKED, STATE_UNLOCKED, STATE_LOCKING, STATE_UNLOCKING} async def _async_reproduce_state( @@ -48,9 +50,9 @@ async def _async_reproduce_state( service_data = {ATTR_ENTITY_ID: state.entity_id} - if state.state == STATE_LOCKED: + if state.state in {STATE_LOCKED, STATE_LOCKING}: service = SERVICE_LOCK - elif state.state == STATE_UNLOCKED: + elif state.state in {STATE_UNLOCKED, STATE_UNLOCKING}: service = SERVICE_UNLOCK await hass.services.async_call( diff --git a/homeassistant/const.py b/homeassistant/const.py index fb47ef7cb7c..1d754b78b7c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -274,6 +274,9 @@ STATE_ALARM_DISARMING: Final = "disarming" STATE_ALARM_TRIGGERED: Final = "triggered" STATE_LOCKED: Final = "locked" STATE_UNLOCKED: Final = "unlocked" +STATE_LOCKING: Final = "locking" +STATE_UNLOCKING: Final = "unlocking" +STATE_JAMMED: Final = "jammed" STATE_UNAVAILABLE: Final = "unavailable" STATE_OK: Final = "ok" STATE_PROBLEM: Final = "problem" diff --git a/tests/components/demo/test_lock.py b/tests/components/demo/test_lock.py index bf8c0ddb63d..15e4e14524d 100644 --- a/tests/components/demo/test_lock.py +++ b/tests/components/demo/test_lock.py @@ -1,4 +1,6 @@ """The tests for the Demo lock platform.""" +import asyncio + import pytest from homeassistant.components.demo import DOMAIN @@ -7,8 +9,11 @@ from homeassistant.components.lock import ( SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK, + STATE_JAMMED, STATE_LOCKED, + STATE_LOCKING, STATE_UNLOCKED, + STATE_UNLOCKING, ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.setup import async_setup_component @@ -17,6 +22,7 @@ from tests.common import async_mock_service FRONT = "lock.front_door" KITCHEN = "lock.kitchen_door" +POORLY_INSTALLED = "lock.poorly_installed_door" OPENABLE_LOCK = "lock.openable_lock" @@ -35,9 +41,13 @@ async def test_locking(hass): assert state.state == STATE_UNLOCKED await hass.services.async_call( - LOCK_DOMAIN, SERVICE_LOCK, {ATTR_ENTITY_ID: KITCHEN}, blocking=True + LOCK_DOMAIN, SERVICE_LOCK, {ATTR_ENTITY_ID: KITCHEN}, blocking=False ) + await asyncio.sleep(1) + state = hass.states.get(KITCHEN) + assert state.state == STATE_LOCKING + await asyncio.sleep(2) state = hass.states.get(KITCHEN) assert state.state == STATE_LOCKED @@ -48,17 +58,46 @@ async def test_unlocking(hass): assert state.state == STATE_LOCKED await hass.services.async_call( - LOCK_DOMAIN, SERVICE_UNLOCK, {ATTR_ENTITY_ID: FRONT}, blocking=True + LOCK_DOMAIN, SERVICE_UNLOCK, {ATTR_ENTITY_ID: FRONT}, blocking=False ) - + await asyncio.sleep(1) + state = hass.states.get(FRONT) + assert state.state == STATE_UNLOCKING + await asyncio.sleep(2) state = hass.states.get(FRONT) assert state.state == STATE_UNLOCKED -async def test_opening(hass): +async def test_jammed_when_locking(hass): + """Test the locking of a lock jams.""" + state = hass.states.get(POORLY_INSTALLED) + assert state.state == STATE_UNLOCKED + + await hass.services.async_call( + LOCK_DOMAIN, SERVICE_LOCK, {ATTR_ENTITY_ID: POORLY_INSTALLED}, blocking=False + ) + + await asyncio.sleep(1) + state = hass.states.get(POORLY_INSTALLED) + assert state.state == STATE_LOCKING + await asyncio.sleep(2) + state = hass.states.get(POORLY_INSTALLED) + assert state.state == STATE_JAMMED + + +async def test_opening_mocked(hass): """Test the opening of a lock.""" calls = async_mock_service(hass, LOCK_DOMAIN, SERVICE_OPEN) await hass.services.async_call( LOCK_DOMAIN, SERVICE_OPEN, {ATTR_ENTITY_ID: OPENABLE_LOCK}, blocking=True ) assert len(calls) == 1 + + +async def test_opening(hass): + """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/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index a8b44511fb2..f7537db18de 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -382,6 +382,13 @@ DEMO_DEVICES = [ "type": "action.devices.types.LOCK", "willReportState": False, }, + { + "id": "lock.poorly_installed_door", + "name": {"name": "Poorly Installed Door"}, + "traits": ["action.devices.traits.LockUnlock"], + "type": "action.devices.types.LOCK", + "willReportState": False, + }, { "id": "alarm_control_panel.alarm", "name": {"name": "Alarm"}, diff --git a/tests/components/lock/test_device_condition.py b/tests/components/lock/test_device_condition.py index b021ef23391..aeb304cb1c8 100644 --- a/tests/components/lock/test_device_condition.py +++ b/tests/components/lock/test_device_condition.py @@ -3,7 +3,13 @@ import pytest import homeassistant.components.automation as automation from homeassistant.components.lock import DOMAIN -from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED +from homeassistant.const import ( + STATE_JAMMED, + STATE_LOCKED, + STATE_LOCKING, + STATE_UNLOCKED, + STATE_UNLOCKING, +) from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component @@ -60,6 +66,27 @@ async def test_get_conditions(hass, device_reg, entity_reg): "device_id": device_entry.id, "entity_id": f"{DOMAIN}.test_5678", }, + { + "condition": "device", + "domain": DOMAIN, + "type": "is_unlocking", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "condition": "device", + "domain": DOMAIN, + "type": "is_locking", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "condition": "device", + "domain": DOMAIN, + "type": "is_jammed", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, ] conditions = await async_get_device_automations(hass, "condition", device_entry.id) assert_lists_same(conditions, expected_conditions) @@ -110,6 +137,60 @@ async def test_if_state(hass, calls): }, }, }, + { + "trigger": {"platform": "event", "event_type": "test_event3"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "lock.entity", + "type": "is_unlocking", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_unlocking - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event4"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "lock.entity", + "type": "is_locking", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_locking - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event5"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "lock.entity", + "type": "is_jammed", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_jammed - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, ] }, ) @@ -125,3 +206,21 @@ async def test_if_state(hass, calls): await hass.async_block_till_done() assert len(calls) == 2 assert calls[1].data["some"] == "is_unlocked - event - test_event2" + + hass.states.async_set("lock.entity", STATE_UNLOCKING) + hass.bus.async_fire("test_event3") + await hass.async_block_till_done() + assert len(calls) == 3 + assert calls[2].data["some"] == "is_unlocking - event - test_event3" + + hass.states.async_set("lock.entity", STATE_LOCKING) + hass.bus.async_fire("test_event4") + await hass.async_block_till_done() + assert len(calls) == 4 + assert calls[3].data["some"] == "is_locking - event - test_event4" + + hass.states.async_set("lock.entity", STATE_JAMMED) + hass.bus.async_fire("test_event5") + await hass.async_block_till_done() + assert len(calls) == 5 + assert calls[4].data["some"] == "is_jammed - event - test_event5" diff --git a/tests/components/lock/test_device_trigger.py b/tests/components/lock/test_device_trigger.py index d4d96927b56..c3539288f94 100644 --- a/tests/components/lock/test_device_trigger.py +++ b/tests/components/lock/test_device_trigger.py @@ -5,7 +5,13 @@ import pytest import homeassistant.components.automation as automation from homeassistant.components.lock import DOMAIN -from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED +from homeassistant.const import ( + STATE_JAMMED, + STATE_LOCKED, + STATE_LOCKING, + STATE_UNLOCKED, + STATE_UNLOCKING, +) from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -65,6 +71,27 @@ async def test_get_triggers(hass, device_reg, entity_reg): "device_id": device_entry.id, "entity_id": f"{DOMAIN}.test_5678", }, + { + "platform": "device", + "domain": DOMAIN, + "type": "unlocking", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "locking", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "jammed", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, ] triggers = await async_get_device_automations(hass, "trigger", device_entry.id) assert_lists_same(triggers, expected_triggers) @@ -81,7 +108,7 @@ async def test_get_trigger_capabilities(hass, device_reg, entity_reg): entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id) triggers = await async_get_device_automations(hass, "trigger", device_entry.id) - assert len(triggers) == 2 + assert len(triggers) == 5 for trigger in triggers: capabilities = await async_get_device_automation_capabilities( hass, "trigger", trigger @@ -195,7 +222,82 @@ async def test_if_fires_on_state_change_with_for(hass, calls): ) }, }, - } + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entity_id, + "type": "unlocking", + "for": {"seconds": 5}, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_on {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entity_id, + "type": "jammed", + "for": {"seconds": 5}, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_off {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entity_id, + "type": "locking", + "for": {"seconds": 5}, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_on {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, ] }, ) @@ -214,3 +316,39 @@ async def test_if_fires_on_state_change_with_for(hass, calls): calls[0].data["some"] == f"turn_off device - {entity_id} - unlocked - locked - 0:00:05" ) + + hass.states.async_set(entity_id, STATE_UNLOCKING) + await hass.async_block_till_done() + assert len(calls) == 1 + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=16)) + await hass.async_block_till_done() + assert len(calls) == 2 + await hass.async_block_till_done() + assert ( + calls[1].data["some"] + == f"turn_on device - {entity_id} - locked - unlocking - 0:00:05" + ) + + hass.states.async_set(entity_id, STATE_JAMMED) + await hass.async_block_till_done() + assert len(calls) == 2 + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=21)) + await hass.async_block_till_done() + assert len(calls) == 3 + await hass.async_block_till_done() + assert ( + calls[2].data["some"] + == f"turn_off device - {entity_id} - unlocking - jammed - 0:00:05" + ) + + hass.states.async_set(entity_id, STATE_LOCKING) + await hass.async_block_till_done() + assert len(calls) == 3 + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=27)) + await hass.async_block_till_done() + assert len(calls) == 4 + await hass.async_block_till_done() + assert ( + calls[3].data["some"] + == f"turn_on device - {entity_id} - jammed - locking - 0:00:05" + )