diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 3bcc7d4f2a9..eb7c5a79da9 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -1,5 +1,6 @@ """Support for MQTT cover devices.""" import functools +from json import JSONDecodeError, loads as json_loads import logging import voluptuous as vol @@ -252,7 +253,7 @@ class MqttCover(MqttEntity, CoverEntity): if tilt_status_template is not None: tilt_status_template.hass = self.hass - async def _subscribe_topics(self): + async def _subscribe_topics(self): # noqa: C901 """(Re)Subscribe to topics.""" topics = {} @@ -261,45 +262,36 @@ class MqttCover(MqttEntity, CoverEntity): def tilt_message_received(msg): """Handle tilt updates.""" payload = msg.payload - tilt_status_template = self._config.get(CONF_TILT_STATUS_TEMPLATE) - if tilt_status_template is not None: - payload = tilt_status_template.async_render_with_possible_json_value( - payload + template = self._config.get(CONF_TILT_STATUS_TEMPLATE) + if template is not None: + variables = { + "entity_id": self.entity_id, + "position_open": self._config[CONF_POSITION_OPEN], + "position_closed": self._config[CONF_POSITION_CLOSED], + "tilt_min": self._config[CONF_TILT_MIN], + "tilt_max": self._config[CONF_TILT_MAX], + } + payload = template.async_render_with_possible_json_value( + payload, variables=variables ) if not payload: _LOGGER.debug("Ignoring empty tilt message from '%s'", msg.topic) return - if not payload.isnumeric(): - _LOGGER.warning("Payload '%s' is not numeric", payload) - elif ( - self._config[CONF_TILT_MIN] - <= int(payload) - <= self._config[CONF_TILT_MAX] - or self._config[CONF_TILT_MAX] - <= int(payload) - <= self._config[CONF_TILT_MIN] - ): - level = self.find_percentage_in_range(float(payload)) - self._tilt_value = level - self.async_write_ha_state() - else: - _LOGGER.warning( - "Payload '%s' is out of range, must be between '%s' and '%s' inclusive", - payload, - self._config[CONF_TILT_MIN], - self._config[CONF_TILT_MAX], - ) + self.tilt_payload_received(payload) @callback @log_messages(self.hass, self.entity_id) def state_message_received(msg): """Handle new MQTT state messages.""" payload = msg.payload - value_template = self._config.get(CONF_VALUE_TEMPLATE) - if value_template is not None: - payload = value_template.async_render_with_possible_json_value(payload) + template = self._config.get(CONF_VALUE_TEMPLATE) + if template is not None: + variables = {"entity_id": self.entity_id} + payload = template.async_render_with_possible_json_value( + payload, variables=variables + ) if not payload: _LOGGER.debug("Ignoring empty state message from '%s'", msg.topic) @@ -347,26 +339,57 @@ class MqttCover(MqttEntity, CoverEntity): template = self._config.get(CONF_VALUE_TEMPLATE) if template is not None: - payload = template.async_render_with_possible_json_value(payload) + variables = { + "entity_id": self.entity_id, + "position_open": self._config[CONF_POSITION_OPEN], + "position_closed": self._config[CONF_POSITION_CLOSED], + "tilt_min": self._config[CONF_TILT_MIN], + "tilt_max": self._config[CONF_TILT_MAX], + } + payload = template.async_render_with_possible_json_value( + payload, variables=variables + ) - if not payload: - _LOGGER.debug("Ignoring empty position message from '%s'", msg.topic) - return + if not payload: + _LOGGER.debug( + "Ignoring empty position message from '%s'", msg.topic + ) + return - if payload.isnumeric(): + try: + payload = json_loads(payload) + except JSONDecodeError: + pass + + if isinstance(payload, dict): + if "position" not in payload: + _LOGGER.warning( + "Template (position_template) returned JSON without position attribute" + ) + return + if "tilt_position" in payload: + if not self._config.get(CONF_TILT_STATE_OPTIMISTIC): + # reset forced set tilt optimistic + self._tilt_optimistic = False + self.tilt_payload_received(payload["tilt_position"]) + payload = payload["position"] + + try: percentage_payload = self.find_percentage_in_range( float(payload), COVER_PAYLOAD ) - self._position = percentage_payload - if self._config.get(CONF_STATE_TOPIC) is None: - self._state = ( - STATE_CLOSED - if percentage_payload == DEFAULT_POSITION_CLOSED - else STATE_OPEN - ) - else: + except ValueError: _LOGGER.warning("Payload '%s' is not numeric", payload) return + + self._position = percentage_payload + if self._config.get(CONF_STATE_TOPIC) is None: + self._state = ( + STATE_CLOSED + if percentage_payload == DEFAULT_POSITION_CLOSED + else STATE_OPEN + ) + self.async_write_ha_state() if self._config.get(CONF_GET_POSITION_TOPIC): @@ -391,6 +414,7 @@ class MqttCover(MqttEntity, CoverEntity): self._optimistic = True if self._config.get(CONF_TILT_STATUS_TOPIC) is None: + # Force into optimistic tilt mode. self._tilt_optimistic = True else: self._tilt_value = STATE_UNKNOWN @@ -550,12 +574,21 @@ class MqttCover(MqttEntity, CoverEntity): async def async_set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" - set_tilt_template = self._config.get(CONF_TILT_COMMAND_TEMPLATE) + template = self._config.get(CONF_TILT_COMMAND_TEMPLATE) tilt = kwargs[ATTR_TILT_POSITION] percentage_tilt = tilt tilt = self.find_in_range_from_percent(tilt) - if set_tilt_template is not None: - tilt = set_tilt_template.async_render(parse_result=False, **kwargs) + # Handover the tilt after calculated from percent would make it more consistent with receiving templates + if template is not None: + variables = { + "tilt_position": percentage_tilt, + "entity_id": self.entity_id, + "position_open": self._config[CONF_POSITION_OPEN], + "position_closed": self._config[CONF_POSITION_CLOSED], + "tilt_min": self._config[CONF_TILT_MIN], + "tilt_max": self._config[CONF_TILT_MAX], + } + tilt = template.async_render(parse_result=False, variables=variables) mqtt.async_publish( self.hass, @@ -565,17 +598,26 @@ class MqttCover(MqttEntity, CoverEntity): self._config[CONF_RETAIN], ) if self._tilt_optimistic: + _LOGGER.debug("Set tilt value optimistic") self._tilt_value = percentage_tilt self.async_write_ha_state() async def async_set_cover_position(self, **kwargs): """Move the cover to a specific position.""" - set_position_template = self._config.get(CONF_SET_POSITION_TEMPLATE) + template = self._config.get(CONF_SET_POSITION_TEMPLATE) position = kwargs[ATTR_POSITION] percentage_position = position position = self.find_in_range_from_percent(position, COVER_PAYLOAD) - if set_position_template is not None: - position = set_position_template.async_render(parse_result=False, **kwargs) + if template is not None: + variables = { + "position": percentage_position, + "entity_id": self.entity_id, + "position_open": self._config[CONF_POSITION_OPEN], + "position_closed": self._config[CONF_POSITION_CLOSED], + "tilt_min": self._config[CONF_TILT_MIN], + "tilt_max": self._config[CONF_TILT_MAX], + } + position = template.async_render(parse_result=False, variables=variables) mqtt.async_publish( self.hass, @@ -650,3 +692,29 @@ class MqttCover(MqttEntity, CoverEntity): if range_type == TILT_PAYLOAD and self._config.get(CONF_TILT_INVERT_STATE): position = max_range - position + offset return position + + def tilt_payload_received(self, _payload): + """Set the tilt value.""" + + try: + payload = int(round(float(_payload))) + except ValueError: + _LOGGER.warning("Payload '%s' is not numeric", _payload) + return + + if ( + self._config[CONF_TILT_MIN] <= int(payload) <= self._config[CONF_TILT_MAX] + or self._config[CONF_TILT_MAX] + <= int(payload) + <= self._config[CONF_TILT_MIN] + ): + level = self.find_percentage_in_range(payload) + self._tilt_value = level + self.async_write_ha_state() + else: + _LOGGER.warning( + "Payload '%s' is out of range, must be between '%s' and '%s' inclusive", + payload, + self._config[CONF_TILT_MIN], + self._config[CONF_TILT_MAX], + ) diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 8d729ca9dde..d0665fba318 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -260,6 +260,45 @@ async def test_state_via_template(hass, mqtt_mock): assert state.state == STATE_CLOSED +async def test_state_via_template_and_entity_id(hass, mqtt_mock): + """Test the controlling state via topic.""" + assert await async_setup_component( + hass, + cover.DOMAIN, + { + cover.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "qos": 0, + "value_template": '\ + {% if value == "open" or value == "closed" %}\ + {{ value }}\ + {% else %}\ + {{ states(entity_id) }}\ + {% endif %}', + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("cover.test") + assert state.state == STATE_UNKNOWN + + async_fire_mqtt_message(hass, "state-topic", "open") + async_fire_mqtt_message(hass, "state-topic", "invalid") + + state = hass.states.get("cover.test") + assert state.state == STATE_OPEN + + async_fire_mqtt_message(hass, "state-topic", "closed") + async_fire_mqtt_message(hass, "state-topic", "invalid") + + state = hass.states.get("cover.test") + assert state.state == STATE_CLOSED + + async def test_state_via_template_with_json_value(hass, mqtt_mock, caplog): """Test the controlling state via topic with JSON value.""" assert await async_setup_component( @@ -336,6 +375,47 @@ async def test_position_via_template(hass, mqtt_mock): assert state.state == STATE_CLOSED +async def test_position_via_template_and_entity_id(hass, mqtt_mock): + """Test the controlling state via topic.""" + assert await async_setup_component( + hass, + cover.DOMAIN, + { + cover.DOMAIN: { + "platform": "mqtt", + "name": "test", + "position_topic": "get-position-topic", + "command_topic": "command-topic", + "qos": 0, + "position_template": '\ + {% if state_attr(entity_id, "current_position") == None %}\ + {{ value }}\ + {% else %}\ + {{ state_attr(entity_id, "current_position") + value | int }}\ + {% endif %}', + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("cover.test") + assert state.state == STATE_UNKNOWN + + async_fire_mqtt_message(hass, "get-position-topic", "10") + + current_cover_position = hass.states.get("cover.test").attributes[ + ATTR_CURRENT_POSITION + ] + assert current_cover_position == 10 + + async_fire_mqtt_message(hass, "get-position-topic", "10") + + current_cover_position = hass.states.get("cover.test").attributes[ + ATTR_CURRENT_POSITION + ] + assert current_cover_position == 20 + + async def test_optimistic_state_change(hass, mqtt_mock): """Test changing state optimistically.""" assert await async_setup_component( @@ -712,7 +792,13 @@ async def test_position_update(hass, mqtt_mock): assert current_cover_position == 22 -async def test_set_position_templated(hass, mqtt_mock): +@pytest.mark.parametrize( + "pos_template,pos_call,pos_message", + [("{{position-1}}", 43, "42"), ("{{100-62}}", 100, "38")], +) +async def test_set_position_templated( + hass, mqtt_mock, pos_template, pos_call, pos_message +): """Test setting cover position via template.""" assert await async_setup_component( hass, @@ -726,7 +812,51 @@ async def test_set_position_templated(hass, mqtt_mock): "position_open": 100, "position_closed": 0, "set_position_topic": "set-position-topic", - "set_position_template": "{{100-62}}", + "set_position_template": pos_template, + "payload_open": "OPEN", + "payload_close": "CLOSE", + "payload_stop": "STOP", + } + }, + ) + await hass.async_block_till_done() + + await hass.services.async_call( + cover.DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: "cover.test", ATTR_POSITION: pos_call}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "set-position-topic", pos_message, 0, False + ) + + +async def test_set_position_templated_and_attributes(hass, mqtt_mock): + """Test setting cover position via template and using entities attributes.""" + assert await async_setup_component( + hass, + cover.DOMAIN, + { + cover.DOMAIN: { + "platform": "mqtt", + "name": "test", + "position_topic": "get-position-topic", + "command_topic": "command-topic", + "position_open": 100, + "position_closed": 0, + "set_position_topic": "set-position-topic", + "set_position_template": '\ + {% if position > 99 %}\ + {% if state_attr(entity_id, "current_position") == None %}\ + {{ 5 }}\ + {% else %}\ + {{ 23 }}\ + {% endif %}\ + {% else %}\ + {{ 42 }}\ + {% endif %}', "payload_open": "OPEN", "payload_close": "CLOSE", "payload_stop": "STOP", @@ -742,8 +872,85 @@ async def test_set_position_templated(hass, mqtt_mock): blocking=True, ) + mqtt_mock.async_publish.assert_called_once_with("set-position-topic", "5", 0, False) + + +async def test_set_tilt_templated(hass, mqtt_mock): + """Test setting cover tilt position via template.""" + assert await async_setup_component( + hass, + cover.DOMAIN, + { + cover.DOMAIN: { + "platform": "mqtt", + "name": "test", + "position_topic": "get-position-topic", + "command_topic": "command-topic", + "tilt_command_topic": "tilt-command-topic", + "position_open": 100, + "position_closed": 0, + "set_position_topic": "set-position-topic", + "set_position_template": "{{position-1}}", + "tilt_command_template": "{{tilt_position+1}}", + "payload_open": "OPEN", + "payload_close": "CLOSE", + "payload_stop": "STOP", + } + }, + ) + await hass.async_block_till_done() + + await hass.services.async_call( + cover.DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: "cover.test", ATTR_TILT_POSITION: 41}, + blocking=True, + ) + mqtt_mock.async_publish.assert_called_once_with( - "set-position-topic", "38", 0, False + "tilt-command-topic", "42", 0, False + ) + + +async def test_set_tilt_templated_and_attributes(hass, mqtt_mock): + """Test setting cover tilt position via template and using entities attributes.""" + assert await async_setup_component( + hass, + cover.DOMAIN, + { + cover.DOMAIN: { + "platform": "mqtt", + "name": "test", + "position_topic": "get-position-topic", + "command_topic": "command-topic", + "tilt_command_topic": "tilt-command-topic", + "position_open": 100, + "position_closed": 0, + "set_position_topic": "set-position-topic", + "set_position_template": "{{position-1}}", + "tilt_command_template": '\ + {% if state_attr(entity_id, "friendly_name") != "test" %}\ + {{ 5 }}\ + {% else %}\ + {{ 23 }}\ + {% endif %}', + "payload_open": "OPEN", + "payload_close": "CLOSE", + "payload_stop": "STOP", + } + }, + ) + await hass.async_block_till_done() + + await hass.services.async_call( + cover.DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: "cover.test", ATTR_TILT_POSITION: 99}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "tilt-command-topic", "23", 0, False ) @@ -2508,6 +2715,187 @@ async def test_position_via_position_topic_template_json_value(hass, mqtt_mock, ) in caplog.text +async def test_position_template_with_entity_id(hass, mqtt_mock): + """Test position by updating status via position template.""" + assert await async_setup_component( + hass, + cover.DOMAIN, + { + cover.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "set_position_topic": "set-position-topic", + "position_topic": "get-position-topic", + "position_template": '\ + {% if state_attr(entity_id, "current_position") != None %}\ + {{ value | int + state_attr(entity_id, "current_position") }} \ + {% else %} \ + {{ value }} \ + {% endif %}', + } + }, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "get-position-topic", "10") + + current_cover_position_position = hass.states.get("cover.test").attributes[ + ATTR_CURRENT_POSITION + ] + assert current_cover_position_position == 10 + + async_fire_mqtt_message(hass, "get-position-topic", "10") + + current_cover_position_position = hass.states.get("cover.test").attributes[ + ATTR_CURRENT_POSITION + ] + assert current_cover_position_position == 20 + + +async def test_position_via_position_topic_template_return_json(hass, mqtt_mock): + """Test position by updating status via position template and returning json.""" + assert await async_setup_component( + hass, + cover.DOMAIN, + { + cover.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "set_position_topic": "set-position-topic", + "position_topic": "get-position-topic", + "position_template": '{{ {"position" : value} | tojson }}', + } + }, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "get-position-topic", "55") + + current_cover_position_position = hass.states.get("cover.test").attributes[ + ATTR_CURRENT_POSITION + ] + assert current_cover_position_position == 55 + + +async def test_position_via_position_topic_template_return_json_warning( + hass, caplog, mqtt_mock +): + """Test position by updating status via position template returning json without position attribute.""" + assert await async_setup_component( + hass, + cover.DOMAIN, + { + cover.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "set_position_topic": "set-position-topic", + "position_topic": "get-position-topic", + "position_template": '{{ {"pos" : value} | tojson }}', + } + }, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "get-position-topic", "55") + + assert ( + "Template (position_template) returned JSON without position attribute" + in caplog.text + ) + + +async def test_position_and_tilt_via_position_topic_template_return_json( + hass, mqtt_mock +): + """Test position and tilt by updating the position via position template.""" + assert await async_setup_component( + hass, + cover.DOMAIN, + { + cover.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "set_position_topic": "set-position-topic", + "position_topic": "get-position-topic", + "position_template": '\ + {{ {"position" : value, "tilt_position" : (value | int / 2)| int } | tojson }}', + } + }, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "get-position-topic", "0") + + current_cover_position = hass.states.get("cover.test").attributes[ + ATTR_CURRENT_POSITION + ] + current_tilt_position = hass.states.get("cover.test").attributes[ + ATTR_CURRENT_TILT_POSITION + ] + assert current_cover_position == 0 and current_tilt_position == 0 + + async_fire_mqtt_message(hass, "get-position-topic", "99") + current_cover_position = hass.states.get("cover.test").attributes[ + ATTR_CURRENT_POSITION + ] + current_tilt_position = hass.states.get("cover.test").attributes[ + ATTR_CURRENT_TILT_POSITION + ] + assert current_cover_position == 99 and current_tilt_position == 49 + + +async def test_position_via_position_topic_template_all_variables(hass, mqtt_mock): + """Test position by updating status via position template.""" + assert await async_setup_component( + hass, + cover.DOMAIN, + { + cover.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "set_position_topic": "set-position-topic", + "position_topic": "get-position-topic", + "tilt_command_topic": "tilt-command-topic", + "position_open": 99, + "position_closed": 1, + "tilt_min": 11, + "tilt_max": 22, + "position_template": "\ + {% if value | int < tilt_max %}\ + {{ tilt_min }}\ + {% endif %}\ + {% if value | int > position_closed %}\ + {{ position_open }}\ + {% endif %}", + } + }, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "get-position-topic", "0") + + current_cover_position = hass.states.get("cover.test").attributes[ + ATTR_CURRENT_POSITION + ] + assert current_cover_position == 10 + + async_fire_mqtt_message(hass, "get-position-topic", "55") + current_cover_position = hass.states.get("cover.test").attributes[ + ATTR_CURRENT_POSITION + ] + assert current_cover_position == 100 + + async def test_set_state_via_stopped_state_no_position_topic(hass, mqtt_mock): """Test the controlling state via stopped state when no position topic.""" assert await async_setup_component( @@ -2555,3 +2943,29 @@ async def test_set_state_via_stopped_state_no_position_topic(hass, mqtt_mock): state = hass.states.get("cover.test") assert state.state == STATE_CLOSED + + +async def test_position_via_position_topic_template_return_invalid_json( + hass, caplog, mqtt_mock +): + """Test position by updating status via position template and returning invalid json.""" + assert await async_setup_component( + hass, + cover.DOMAIN, + { + cover.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "set_position_topic": "set-position-topic", + "position_topic": "get-position-topic", + "position_template": '{{ {"position" : invalid_json} }}', + } + }, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "get-position-topic", "55") + + assert ("Payload '{'position': Undefined}' is not numeric") in caplog.text