diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index 5410af5d38c..b43a00166ae 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -4,7 +4,7 @@ import functools import voluptuous as vol from homeassistant.components import lock -from homeassistant.components.lock import LockEntity +from homeassistant.components.lock import SUPPORT_OPEN, LockEntity from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv @@ -19,6 +19,7 @@ from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_hel CONF_PAYLOAD_LOCK = "payload_lock" CONF_PAYLOAD_UNLOCK = "payload_unlock" +CONF_PAYLOAD_OPEN = "payload_open" CONF_STATE_LOCKED = "state_locked" CONF_STATE_UNLOCKED = "state_unlocked" @@ -27,6 +28,7 @@ DEFAULT_NAME = "MQTT Lock" DEFAULT_OPTIMISTIC = False DEFAULT_PAYLOAD_LOCK = "LOCK" DEFAULT_PAYLOAD_UNLOCK = "UNLOCK" +DEFAULT_PAYLOAD_OPEN = "OPEN" DEFAULT_STATE_LOCKED = "LOCKED" DEFAULT_STATE_UNLOCKED = "UNLOCKED" @@ -43,6 +45,7 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend( vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_PAYLOAD_LOCK, default=DEFAULT_PAYLOAD_LOCK): cv.string, vol.Optional(CONF_PAYLOAD_UNLOCK, default=DEFAULT_PAYLOAD_UNLOCK): cv.string, + vol.Optional(CONF_PAYLOAD_OPEN): cv.string, vol.Optional(CONF_STATE_LOCKED, default=DEFAULT_STATE_LOCKED): cv.string, vol.Optional(CONF_STATE_UNLOCKED, default=DEFAULT_STATE_UNLOCKED): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, @@ -145,6 +148,11 @@ class MqttLock(MqttEntity, LockEntity): """Return true if we do optimistic updates.""" return self._optimistic + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_OPEN if CONF_PAYLOAD_OPEN in self._config else 0 + async def async_lock(self, **kwargs): """Lock the device. @@ -178,3 +186,20 @@ class MqttLock(MqttEntity, LockEntity): # Optimistically assume that the lock has changed state. self._state = False self.async_write_ha_state() + + async def async_open(self, **kwargs): + """Open the door latch. + + This method is a coroutine. + """ + await mqtt.async_publish( + self.hass, + self._config[CONF_COMMAND_TOPIC], + self._config[CONF_PAYLOAD_OPEN], + self._config[CONF_QOS], + self._config[CONF_RETAIN], + ) + if self._optimistic: + # Optimistically assume that the lock unlocks when opened. + self._state = False + self.async_write_ha_state() diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index 97f524d5d82..8d76e46f32b 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -6,12 +6,18 @@ import pytest from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, SERVICE_LOCK, + SERVICE_OPEN, SERVICE_UNLOCK, STATE_LOCKED, STATE_UNLOCKED, + SUPPORT_OPEN, ) from homeassistant.components.mqtt.lock import MQTT_LOCK_ATTRIBUTES_BLOCKED -from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, +) from homeassistant.setup import async_setup_component from .test_common import ( @@ -69,6 +75,7 @@ async def test_controlling_state_via_topic(hass, mqtt_mock): state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED assert not state.attributes.get(ATTR_ASSUMED_STATE) + assert not state.attributes.get(ATTR_SUPPORTED_FEATURES) async_fire_mqtt_message(hass, "state-topic", "LOCKED") @@ -278,6 +285,122 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock): assert state.attributes.get(ATTR_ASSUMED_STATE) +async def test_sending_mqtt_commands_support_open_and_optimistic(hass, mqtt_mock): + """Test open function of the lock without state topic.""" + assert await async_setup_component( + hass, + LOCK_DOMAIN, + { + LOCK_DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "command-topic", + "payload_lock": "LOCK", + "payload_unlock": "UNLOCK", + "payload_open": "OPEN", + "state_locked": "LOCKED", + "state_unlocked": "UNLOCKED", + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("lock.test") + assert state.state is STATE_UNLOCKED + assert state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == SUPPORT_OPEN + + await hass.services.async_call( + LOCK_DOMAIN, SERVICE_LOCK, {ATTR_ENTITY_ID: "lock.test"}, blocking=True + ) + + mqtt_mock.async_publish.assert_called_once_with("command-topic", "LOCK", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("lock.test") + assert state.state is STATE_LOCKED + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await hass.services.async_call( + LOCK_DOMAIN, SERVICE_UNLOCK, {ATTR_ENTITY_ID: "lock.test"}, blocking=True + ) + + mqtt_mock.async_publish.assert_called_once_with("command-topic", "UNLOCK", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("lock.test") + assert state.state is STATE_UNLOCKED + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await hass.services.async_call( + LOCK_DOMAIN, SERVICE_OPEN, {ATTR_ENTITY_ID: "lock.test"}, blocking=True + ) + + mqtt_mock.async_publish.assert_called_once_with("command-topic", "OPEN", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("lock.test") + assert state.state is STATE_UNLOCKED + assert state.attributes.get(ATTR_ASSUMED_STATE) + + +async def test_sending_mqtt_commands_support_open_and_explicit_optimistic( + hass, mqtt_mock +): + """Test open function of the lock without state topic.""" + assert await async_setup_component( + hass, + LOCK_DOMAIN, + { + LOCK_DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_lock": "LOCK", + "payload_unlock": "UNLOCK", + "payload_open": "OPEN", + "state_locked": "LOCKED", + "state_unlocked": "UNLOCKED", + "optimistic": True, + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("lock.test") + assert state.state is STATE_UNLOCKED + assert state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == SUPPORT_OPEN + + await hass.services.async_call( + LOCK_DOMAIN, SERVICE_LOCK, {ATTR_ENTITY_ID: "lock.test"}, blocking=True + ) + + mqtt_mock.async_publish.assert_called_once_with("command-topic", "LOCK", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("lock.test") + assert state.state is STATE_LOCKED + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await hass.services.async_call( + LOCK_DOMAIN, SERVICE_UNLOCK, {ATTR_ENTITY_ID: "lock.test"}, blocking=True + ) + + mqtt_mock.async_publish.assert_called_once_with("command-topic", "UNLOCK", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("lock.test") + assert state.state is STATE_UNLOCKED + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await hass.services.async_call( + LOCK_DOMAIN, SERVICE_OPEN, {ATTR_ENTITY_ID: "lock.test"}, blocking=True + ) + + mqtt_mock.async_publish.assert_called_once_with("command-topic", "OPEN", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("lock.test") + assert state.state is STATE_UNLOCKED + assert state.attributes.get(ATTR_ASSUMED_STATE) + + async def test_availability_when_connection_lost(hass, mqtt_mock): """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost(