diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index a16d721ba7c..6c572c093a3 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -77,6 +77,8 @@ ABBREVIATIONS = { "json_attr": "json_attributes", "json_attr_t": "json_attributes_topic", "json_attr_tpl": "json_attributes_template", + "lrst_t": "last_reset_topic", + "lrst_val_tpl": "last_reset_value_template", "max": "max", "min": "min", "max_mirs": "max_mireds", diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 145af55daa8..51caeb5f6da 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import timedelta import functools +import logging import voluptuous as vol @@ -36,7 +37,11 @@ from .mixins import ( async_setup_entry_helper, ) +_LOGGER = logging.getLogger(__name__) + CONF_EXPIRE_AFTER = "expire_after" +CONF_LAST_RESET_TOPIC = "last_reset_topic" +CONF_LAST_RESET_VALUE_TEMPLATE = "last_reset_value_template" CONF_STATE_CLASS = "state_class" DEFAULT_NAME = "MQTT Sensor" @@ -46,6 +51,8 @@ PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend( vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, + vol.Optional(CONF_LAST_RESET_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, @@ -79,6 +86,8 @@ async def _async_setup_entity( class MqttSensor(MqttEntity, SensorEntity): """Representation of a sensor that can be updated using MQTT.""" + _attr_last_reset = None + def __init__(self, hass, config, config_entry, discovery_data): """Initialize the sensor.""" self._state = None @@ -102,9 +111,13 @@ class MqttSensor(MqttEntity, SensorEntity): template = self._config.get(CONF_VALUE_TEMPLATE) if template is not None: template.hass = self.hass + last_reset_template = self._config.get(CONF_LAST_RESET_VALUE_TEMPLATE) + if last_reset_template is not None: + last_reset_template.hass = self.hass async def _subscribe_topics(self): """(Re)Subscribe to topics.""" + topics = {} @callback @log_messages(self.hass, self.entity_id) @@ -140,16 +153,49 @@ class MqttSensor(MqttEntity, SensorEntity): self._state = payload self.async_write_ha_state() + topics["state_topic"] = { + "topic": self._config[CONF_STATE_TOPIC], + "msg_callback": message_received, + "qos": self._config[CONF_QOS], + } + + @callback + @log_messages(self.hass, self.entity_id) + def last_reset_message_received(msg): + """Handle new last_reset messages.""" + payload = msg.payload + + template = self._config.get(CONF_LAST_RESET_VALUE_TEMPLATE) + if template is not None: + variables = {"entity_id": self.entity_id} + payload = template.async_render_with_possible_json_value( + payload, + self._state, + variables=variables, + ) + if not payload: + _LOGGER.debug("Ignoring empty last_reset message from '%s'", msg.topic) + return + try: + last_reset = dt_util.parse_datetime(payload) + if last_reset is None: + raise ValueError + self._attr_last_reset = last_reset + except ValueError: + _LOGGER.warning( + "Invalid last_reset message '%s' from '%s'", msg.payload, msg.topic + ) + self.async_write_ha_state() + + if CONF_LAST_RESET_TOPIC in self._config: + topics["state_topic"] = { + "topic": self._config[CONF_LAST_RESET_TOPIC], + "msg_callback": last_reset_message_received, + "qos": self._config[CONF_QOS], + } + self._sub_state = await subscription.async_subscribe_topics( - self.hass, - self._sub_state, - { - "state_topic": { - "topic": self._config[CONF_STATE_TOPIC], - "msg_callback": message_received, - "qos": self._config[CONF_QOS], - } - }, + self.hass, self._sub_state, topics ) @callback diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index fe97bdfbfde..7d732849906 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -206,6 +206,104 @@ async def test_setting_sensor_value_via_mqtt_json_message(hass, mqtt_mock): assert state.state == "100" +async def test_setting_sensor_last_reset_via_mqtt_message(hass, mqtt_mock): + """Test the setting of the last_reset property via MQTT.""" + assert await async_setup_component( + hass, + sensor.DOMAIN, + { + sensor.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + "unit_of_measurement": "fav unit", + "last_reset_topic": "last-reset-topic", + } + }, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "last-reset-topic", "2020-01-02 08:11:00") + state = hass.states.get("sensor.test") + assert state.attributes.get("last_reset") == "2020-01-02T08:11:00" + + +@pytest.mark.parametrize("datestring", ["2020-21-02 08:11:00", "Hello there!"]) +async def test_setting_sensor_bad_last_reset_via_mqtt_message( + hass, caplog, datestring, mqtt_mock +): + """Test the setting of the last_reset property via MQTT.""" + assert await async_setup_component( + hass, + sensor.DOMAIN, + { + sensor.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + "unit_of_measurement": "fav unit", + "last_reset_topic": "last-reset-topic", + } + }, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "last-reset-topic", datestring) + state = hass.states.get("sensor.test") + assert state.attributes.get("last_reset") is None + assert "Invalid last_reset message" in caplog.text + + +async def test_setting_sensor_empty_last_reset_via_mqtt_message( + hass, caplog, mqtt_mock +): + """Test the setting of the last_reset property via MQTT.""" + assert await async_setup_component( + hass, + sensor.DOMAIN, + { + sensor.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + "unit_of_measurement": "fav unit", + "last_reset_topic": "last-reset-topic", + } + }, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "last-reset-topic", "") + state = hass.states.get("sensor.test") + assert state.attributes.get("last_reset") is None + assert "Ignoring empty last_reset message" in caplog.text + + +async def test_setting_sensor_last_reset_via_mqtt_json_message(hass, mqtt_mock): + """Test the setting of the value via MQTT with JSON payload.""" + assert await async_setup_component( + hass, + sensor.DOMAIN, + { + sensor.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + "unit_of_measurement": "fav unit", + "last_reset_topic": "last-reset-topic", + "last_reset_value_template": "{{ value_json.last_reset }}", + } + }, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message( + hass, "last-reset-topic", '{ "last_reset": "2020-01-02 08:11:00" }' + ) + state = hass.states.get("sensor.test") + assert state.attributes.get("last_reset") == "2020-01-02T08:11:00" + + async def test_force_update_disabled(hass, mqtt_mock): """Test force update option.""" assert await async_setup_component(