From 92742ae4237625f0bedb2e9b833f9b62a2a84fc9 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 20 Jan 2023 12:19:26 +0100 Subject: [PATCH] Add jammed state support for MQTT lock (#86010) * Add jammed state support for MQTT lock * Correct payload jammed key * Add tests - rename solved to ok * Rename jammed state and template topics to motor * Use state topic for handling motor state * Follow up comments * Change default behaviour `state_unjammed` * Skip `state_unjammed` --- .../components/mqtt/abbreviations.py | 1 + homeassistant/components/mqtt/lock.py | 35 +++--- tests/components/mqtt/test_lock.py | 108 ++++++++++++++++++ 3 files changed, 131 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index bada22a6544..467f2c02ace 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -198,6 +198,7 @@ ABBREVIATIONS = { "stat_cla": "state_class", "stat_clsd": "state_closed", "stat_closing": "state_closing", + "stat_jam": "state_jammed", "stat_off": "state_off", "stat_on": "state_on", "stat_open": "state_open", diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index a8d8a3df668..b6ab987b640 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -43,6 +43,7 @@ CONF_STATE_LOCKED = "state_locked" CONF_STATE_LOCKING = "state_locking" CONF_STATE_UNLOCKED = "state_unlocked" CONF_STATE_UNLOCKING = "state_unlocking" +CONF_STATE_JAMMED = "state_jammed" DEFAULT_NAME = "MQTT Lock" DEFAULT_PAYLOAD_LOCK = "LOCK" @@ -52,6 +53,7 @@ DEFAULT_STATE_LOCKED = "LOCKED" DEFAULT_STATE_LOCKING = "LOCKING" DEFAULT_STATE_UNLOCKED = "UNLOCKED" DEFAULT_STATE_UNLOCKING = "UNLOCKING" +DEFAULT_STATE_JAMMED = "JAMMED" MQTT_LOCK_ATTRIBUTES_BLOCKED = frozenset( { @@ -66,6 +68,7 @@ PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( 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_JAMMED, default=DEFAULT_STATE_JAMMED): cv.string, vol.Optional(CONF_STATE_LOCKED, default=DEFAULT_STATE_LOCKED): cv.string, vol.Optional(CONF_STATE_LOCKING, default=DEFAULT_STATE_LOCKING): cv.string, vol.Optional(CONF_STATE_UNLOCKED, default=DEFAULT_STATE_UNLOCKED): cv.string, @@ -83,6 +86,7 @@ PLATFORM_SCHEMA = vol.All( DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) STATE_CONFIG_KEYS = [ + CONF_STATE_JAMMED, CONF_STATE_LOCKED, CONF_STATE_LOCKING, CONF_STATE_UNLOCKED, @@ -157,15 +161,20 @@ class MqttLock(MqttEntity, LockEntity): def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" + topics: dict[str, dict[str, Any]] = {} + qos: int = self._config[CONF_QOS] + encoding: str | None = self._config[CONF_ENCODING] or None + @callback @log_messages(self.hass, self.entity_id) def message_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages.""" + """Handle new lock state messages.""" payload = self._value_template(msg.payload) if payload in self._valid_states: self._attr_is_locked = payload == self._config[CONF_STATE_LOCKED] self._attr_is_locking = payload == self._config[CONF_STATE_LOCKING] self._attr_is_unlocking = payload == self._config[CONF_STATE_UNLOCKING] + self._attr_is_jammed = payload == self._config[CONF_STATE_JAMMED] get_mqtt_data(self.hass).state_write_requests.write_state_request(self) @@ -173,18 +182,18 @@ class MqttLock(MqttEntity, LockEntity): # Force into optimistic mode. self._optimistic = True else: - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - "state_topic": { - "topic": self._config.get(CONF_STATE_TOPIC), - "msg_callback": message_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - }, - ) + topics[CONF_STATE_TOPIC] = { + "topic": self._config.get(CONF_STATE_TOPIC), + "msg_callback": message_received, + CONF_QOS: qos, + CONF_ENCODING: encoding, + } + + self._sub_state = subscription.async_prepare_subscribe_topics( + self.hass, + self._sub_state, + topics, + ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index 20079e3c1f7..31fc3ec74b7 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -8,6 +8,7 @@ from homeassistant.components.lock import ( SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK, + STATE_JAMMED, STATE_LOCKED, STATE_LOCKING, STATE_UNLOCKED, @@ -21,6 +22,7 @@ from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, Platform, ) +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from .test_common import ( @@ -468,6 +470,112 @@ async def test_sending_mqtt_commands_support_open_and_explicit_optimistic( assert state.attributes.get(ATTR_ASSUMED_STATE) +async def test_sending_mqtt_commands_pessimistic( + hass: HomeAssistant, mqtt_mock_entry_with_yaml_config +) -> None: + """Test function of the lock with state topics.""" + assert await async_setup_component( + hass, + mqtt.DOMAIN, + { + mqtt.DOMAIN: { + lock.DOMAIN: { + "name": "test", + "command_topic": "command-topic", + "state_topic": "state-topic", + "payload_lock": "LOCK", + "payload_unlock": "UNLOCK", + "payload_open": "OPEN", + "state_locked": "LOCKED", + "state_locking": "LOCKING", + "state_unlocked": "UNLOCKED", + "state_unlocking": "UNLOCKING", + "state_jammed": "JAMMED", + } + } + }, + ) + await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() + + state = hass.states.get("lock.test") + assert state.state is STATE_UNLOCKED + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == LockEntityFeature.OPEN + + # send lock command to lock + 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() + + # receive state from lock + async_fire_mqtt_message(hass, "state-topic", "LOCKED") + await hass.async_block_till_done() + + state = hass.states.get("lock.test") + assert state.state is STATE_LOCKED + + 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() + + # receive state from lock + async_fire_mqtt_message(hass, "state-topic", "UNLOCKED") + await hass.async_block_till_done() + + state = hass.states.get("lock.test") + assert state.state is STATE_UNLOCKED + + 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() + + # receive state from lock + async_fire_mqtt_message(hass, "state-topic", "UNLOCKED") + await hass.async_block_till_done() + + state = hass.states.get("lock.test") + assert state.state is STATE_UNLOCKED + + # send lock command to lock + await hass.services.async_call( + lock.DOMAIN, SERVICE_LOCK, {ATTR_ENTITY_ID: "lock.test"}, blocking=True + ) + + # Go to locking state + mqtt_mock.async_publish.assert_called_once_with("command-topic", "LOCK", 0, False) + mqtt_mock.async_publish.reset_mock() + + # receive locking state from lock + async_fire_mqtt_message(hass, "state-topic", "LOCKING") + await hass.async_block_till_done() + + state = hass.states.get("lock.test") + assert state.state is STATE_LOCKING + + # receive jammed state from lock + async_fire_mqtt_message(hass, "state-topic", "JAMMED") + await hass.async_block_till_done() + + state = hass.states.get("lock.test") + assert state.state is STATE_JAMMED + + # receive solved state from lock + async_fire_mqtt_message(hass, "state-topic", "LOCKED") + await hass.async_block_till_done() + + state = hass.states.get("lock.test") + assert state.state is STATE_LOCKED + + async def test_availability_when_connection_lost( hass, mqtt_mock_entry_with_yaml_config ):