"""Support for MQTT sensors.""" from __future__ import annotations from collections.abc import Callable from datetime import datetime, timedelta import functools import logging from typing import Any import voluptuous as vol from homeassistant.components import sensor from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASSES_SCHEMA, ENTITY_ID_FORMAT, STATE_CLASSES_SCHEMA, RestoreSensor, SensorDeviceClass, SensorExtraStoredData, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_FORCE_UPDATE, CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from . import subscription from .config import MQTT_RO_SCHEMA from .const import CONF_ENCODING, CONF_QOS, CONF_STATE_TOPIC, PAYLOAD_NONE from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttAvailability, MqttEntity, async_setup_entry_helper, write_state_on_attr_change, ) from .models import ( MqttValueTemplate, PayloadSentinel, ReceiveMessage, ReceivePayloadType, ) _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_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision" MQTT_SENSOR_ATTRIBUTES_BLOCKED = frozenset( { sensor.ATTR_LAST_RESET, sensor.ATTR_STATE_CLASS, } ) DEFAULT_NAME = "MQTT Sensor" DEFAULT_FORCE_UPDATE = False _PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( { vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None), vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_SUGGESTED_DISPLAY_PRECISION): cv.positive_int, vol.Optional(CONF_STATE_CLASS): vol.Any(STATE_CLASSES_SCHEMA, None), vol.Optional(CONF_UNIT_OF_MEASUREMENT): vol.Any(cv.string, None), } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) PLATFORM_SCHEMA_MODERN = vol.All( # Deprecated in HA Core 2021.11.0 https://github.com/home-assistant/core/pull/54840 # Removed in HA Core 2023.6.0 cv.removed(CONF_LAST_RESET_TOPIC), _PLATFORM_SCHEMA_BASE, ) DISCOVERY_SCHEMA = vol.All( # Deprecated in HA Core 2021.11.0 https://github.com/home-assistant/core/pull/54840 # Removed in HA Core 2023.6.0 cv.removed(CONF_LAST_RESET_TOPIC), _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), ) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT sensor through YAML and through MQTT discovery.""" setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry ) await async_setup_entry_helper(hass, sensor.DOMAIN, setup, DISCOVERY_SCHEMA) async def _async_setup_entity( hass: HomeAssistant, async_add_entities: AddEntitiesCallback, config: ConfigType, config_entry: ConfigEntry, discovery_data: DiscoveryInfoType | None = None, ) -> None: """Set up MQTT sensor.""" async_add_entities([MqttSensor(hass, config, config_entry, discovery_data)]) class MqttSensor(MqttEntity, RestoreSensor): """Representation of a sensor that can be updated using MQTT.""" _default_name = DEFAULT_NAME _entity_id_format = ENTITY_ID_FORMAT _attr_last_reset: datetime | None = None _attributes_extra_blocked = MQTT_SENSOR_ATTRIBUTES_BLOCKED _expiration_trigger: CALLBACK_TYPE | None = None _expire_after: int | None _expired: bool | None _template: Callable[[ReceivePayloadType, PayloadSentinel], ReceivePayloadType] _last_reset_template: Callable[[ReceivePayloadType], ReceivePayloadType] async def mqtt_async_added_to_hass(self) -> None: """Restore state for entities with expire_after set.""" last_state: State | None last_sensor_data: SensorExtraStoredData | None if ( (_expire_after := self._expire_after) is not None and _expire_after > 0 and (last_state := await self.async_get_last_state()) is not None and last_state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE] and (last_sensor_data := await self.async_get_last_sensor_data()) is not None # We might have set up a trigger already after subscribing from # MqttEntity.async_added_to_hass(), then we should not restore state and not self._expiration_trigger ): expiration_at = last_state.last_changed + timedelta(seconds=_expire_after) remain_seconds = (expiration_at - dt_util.utcnow()).total_seconds() if remain_seconds <= 0: # Skip reactivating the sensor _LOGGER.debug("Skip state recovery after reload for %s", self.entity_id) return self._expired = False self._attr_native_value = last_sensor_data.native_value self._expiration_trigger = async_call_later( self.hass, remain_seconds, self._value_is_expired ) _LOGGER.debug( ( "State recovered after reload for %s, remaining time before" " expiring %s" ), self.entity_id, remain_seconds, ) async def async_will_remove_from_hass(self) -> None: """Remove exprire triggers.""" # Clean up expire triggers if self._expiration_trigger: _LOGGER.debug("Clean up expire after trigger for %s", self.entity_id) self._expiration_trigger() self._expiration_trigger = None self._expired = False await MqttEntity.async_will_remove_from_hass(self) @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" return DISCOVERY_SCHEMA def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_force_update = config[CONF_FORCE_UPDATE] self._attr_suggested_display_precision = config.get( CONF_SUGGESTED_DISPLAY_PRECISION ) self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) self._attr_state_class = config.get(CONF_STATE_CLASS) self._expire_after = config.get(CONF_EXPIRE_AFTER) if self._expire_after is not None and self._expire_after > 0: self._expired = True else: self._expired = None self._template = MqttValueTemplate( self._config.get(CONF_VALUE_TEMPLATE), entity=self ).async_render_with_possible_json_value self._last_reset_template = MqttValueTemplate( self._config.get(CONF_LAST_RESET_VALUE_TEMPLATE), entity=self ).async_render_with_possible_json_value def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" topics: dict[str, dict[str, Any]] = {} def _update_state(msg: ReceiveMessage) -> None: # auto-expire enabled? if self._expire_after is not None and self._expire_after > 0: # When self._expire_after is set, and we receive a message, assume # device is not expired since it has to be to receive the message self._expired = False # Reset old trigger if self._expiration_trigger: self._expiration_trigger() # Set new trigger self._expiration_trigger = async_call_later( self.hass, self._expire_after, self._value_is_expired ) payload = self._template(msg.payload, PayloadSentinel.DEFAULT) if payload is PayloadSentinel.DEFAULT: return new_value = str(payload) if self._numeric_state_expected: if new_value == "": _LOGGER.debug("Ignore empty state from '%s'", msg.topic) elif new_value == PAYLOAD_NONE: self._attr_native_value = None else: self._attr_native_value = new_value return if self.device_class in {None, SensorDeviceClass.ENUM}: self._attr_native_value = new_value return try: if (payload_datetime := dt_util.parse_datetime(new_value)) is None: raise ValueError except ValueError: _LOGGER.warning( "Invalid state message '%s' from '%s'", msg.payload, msg.topic ) self._attr_native_value = None return if self.device_class == SensorDeviceClass.DATE: self._attr_native_value = payload_datetime.date() return self._attr_native_value = payload_datetime def _update_last_reset(msg: ReceiveMessage) -> None: payload = self._last_reset_template(msg.payload) if not payload: _LOGGER.debug("Ignoring empty last_reset message from '%s'", msg.topic) return try: last_reset = dt_util.parse_datetime(str(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 ) @callback @write_state_on_attr_change(self, {"_attr_native_value", "_attr_last_reset"}) @log_messages(self.hass, self.entity_id) def message_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" _update_state(msg) if CONF_LAST_RESET_VALUE_TEMPLATE in self._config: _update_last_reset(msg) topics["state_topic"] = { "topic": self._config[CONF_STATE_TOPIC], "msg_callback": message_received, "qos": self._config[CONF_QOS], "encoding": self._config[CONF_ENCODING] or None, } self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, self._sub_state, topics ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) @callback def _value_is_expired(self, *_: datetime) -> None: """Triggered when value is expired.""" self._expiration_trigger = None self._expired = True self.async_write_ha_state() @property def available(self) -> bool: """Return true if the device is available and value has not expired.""" # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 return MqttAvailability.available.fget(self) and ( # type: ignore[attr-defined] self._expire_after is None or not self._expired )