Home Assistant Unofficial Reference 2024.12.1
sensor.py
Go to the documentation of this file.
1 """Support for MQTT sensors."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable
6 from datetime import datetime, timedelta
7 import logging
8 
9 import voluptuous as vol
10 
11 from homeassistant.components import sensor
13  CONF_STATE_CLASS,
14  DEVICE_CLASSES_SCHEMA,
15  ENTITY_ID_FORMAT,
16  STATE_CLASSES_SCHEMA,
17  RestoreSensor,
18  SensorDeviceClass,
19  SensorExtraStoredData,
20  SensorStateClass,
21 )
22 from homeassistant.config_entries import ConfigEntry
23 from homeassistant.const import (
24  CONF_DEVICE_CLASS,
25  CONF_FORCE_UPDATE,
26  CONF_NAME,
27  CONF_UNIT_OF_MEASUREMENT,
28  CONF_VALUE_TEMPLATE,
29  STATE_UNAVAILABLE,
30  STATE_UNKNOWN,
31 )
32 from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback
34 from homeassistant.helpers.entity_platform import AddEntitiesCallback
35 from homeassistant.helpers.event import async_call_later
36 from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
37 from homeassistant.helpers.typing import ConfigType, VolSchemaType
38 from homeassistant.util import dt as dt_util
39 
40 from . import subscription
41 from .config import MQTT_RO_SCHEMA
42 from .const import CONF_OPTIONS, CONF_STATE_TOPIC, PAYLOAD_NONE
43 from .entity import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper
44 from .models import MqttValueTemplate, PayloadSentinel, ReceiveMessage
45 from .schemas import MQTT_ENTITY_COMMON_SCHEMA
46 from .util import check_state_too_long
47 
48 _LOGGER = logging.getLogger(__name__)
49 
50 PARALLEL_UPDATES = 0
51 
52 CONF_EXPIRE_AFTER = "expire_after"
53 CONF_LAST_RESET_VALUE_TEMPLATE = "last_reset_value_template"
54 CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision"
55 
56 MQTT_SENSOR_ATTRIBUTES_BLOCKED = frozenset(
57  {
58  sensor.ATTR_LAST_RESET,
59  sensor.ATTR_STATE_CLASS,
60  }
61 )
62 
63 DEFAULT_NAME = "MQTT Sensor"
64 DEFAULT_FORCE_UPDATE = False
65 
66 _PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend(
67  {
68  vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None),
69  vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int,
70  vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean,
71  vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template,
72  vol.Optional(CONF_NAME): vol.Any(cv.string, None),
73  vol.Optional(CONF_OPTIONS): cv.ensure_list,
74  vol.Optional(CONF_SUGGESTED_DISPLAY_PRECISION): cv.positive_int,
75  vol.Optional(CONF_STATE_CLASS): vol.Any(STATE_CLASSES_SCHEMA, None),
76  vol.Optional(CONF_UNIT_OF_MEASUREMENT): vol.Any(cv.string, None),
77  }
78 ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
79 
80 
81 def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigType:
82  """Validate the sensor options, state and device class config."""
83  if (
84  CONF_LAST_RESET_VALUE_TEMPLATE in config
85  and (state_class := config.get(CONF_STATE_CLASS)) != SensorStateClass.TOTAL
86  ):
87  raise vol.Invalid(
88  f"The option `{CONF_LAST_RESET_VALUE_TEMPLATE}` cannot be used "
89  f"together with state class `{state_class}`"
90  )
91 
92  # Only allow `options` to be set for `enum` sensors
93  # to limit the possible sensor values
94  if (options := config.get(CONF_OPTIONS)) is not None:
95  if not options:
96  raise vol.Invalid("An empty options list is not allowed")
97  if config.get(CONF_STATE_CLASS) or config.get(CONF_UNIT_OF_MEASUREMENT):
98  raise vol.Invalid(
99  f"Specifying `{CONF_OPTIONS}` is not allowed together with "
100  f"the `{CONF_STATE_CLASS}` or `{CONF_UNIT_OF_MEASUREMENT}` option"
101  )
102 
103  if (device_class := config.get(CONF_DEVICE_CLASS)) != SensorDeviceClass.ENUM:
104  raise vol.Invalid(
105  f"The option `{CONF_OPTIONS}` must be used "
106  f"together with device class `{SensorDeviceClass.ENUM}`, "
107  f"got `{CONF_DEVICE_CLASS}` '{device_class}'"
108  )
109 
110  return config
111 
112 
113 PLATFORM_SCHEMA_MODERN = vol.All(
114  _PLATFORM_SCHEMA_BASE,
115  validate_sensor_state_and_device_class_config,
116 )
117 
118 DISCOVERY_SCHEMA = vol.All(
119  _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA),
120  validate_sensor_state_and_device_class_config,
121 )
122 
123 
125  hass: HomeAssistant,
126  config_entry: ConfigEntry,
127  async_add_entities: AddEntitiesCallback,
128 ) -> None:
129  """Set up MQTT sensor through YAML and through MQTT discovery."""
131  hass,
132  config_entry,
133  MqttSensor,
134  sensor.DOMAIN,
135  async_add_entities,
136  DISCOVERY_SCHEMA,
137  PLATFORM_SCHEMA_MODERN,
138  )
139 
140 
142  """Representation of a sensor that can be updated using MQTT."""
143 
144  _default_name = DEFAULT_NAME
145  _entity_id_format = ENTITY_ID_FORMAT
146  _attr_last_reset: datetime | None = None
147  _attributes_extra_blocked = MQTT_SENSOR_ATTRIBUTES_BLOCKED
148  _expiration_trigger: CALLBACK_TYPE | None = None
149  _expire_after: int | None
150  _expired: bool | None
151  _template: (
152  Callable[[ReceivePayloadType, PayloadSentinel], ReceivePayloadType] | None
153  ) = None
154  _last_reset_template: Callable[[ReceivePayloadType], ReceivePayloadType] | None = (
155  None
156  )
157 
158  async def mqtt_async_added_to_hass(self) -> None:
159  """Restore state for entities with expire_after set."""
160  last_state: State | None
161  last_sensor_data: SensorExtraStoredData | None
162  if (
163  (_expire_after := self._expire_after_expire_after) is not None
164  and _expire_after > 0
165  and (last_state := await self.async_get_last_state()) is not None
166  and last_state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE]
167  and (last_sensor_data := await self.async_get_last_sensor_dataasync_get_last_sensor_data())
168  is not None
169  # We might have set up a trigger already after subscribing from
170  # MqttEntity.async_added_to_hass(), then we should not restore state
171  and not self._expiration_trigger_expiration_trigger
172  ):
173  expiration_at = last_state.last_changed + timedelta(seconds=_expire_after)
174  remain_seconds = (expiration_at - dt_util.utcnow()).total_seconds()
175 
176  if remain_seconds <= 0:
177  # Skip reactivating the sensor
178  _LOGGER.debug("Skip state recovery after reload for %s", self.entity_identity_id)
179  return
180  self._expired_expired = False
181  self._attr_native_value_attr_native_value = last_sensor_data.native_value
182 
183  self._expiration_trigger_expiration_trigger = async_call_later(
184  self.hasshasshass, remain_seconds, self._value_is_expired
185  )
186  _LOGGER.debug(
187  (
188  "State recovered after reload for %s, remaining time before"
189  " expiring %s"
190  ),
191  self.entity_identity_id,
192  remain_seconds,
193  )
194 
195  async def async_will_remove_from_hass(self) -> None:
196  """Remove expire triggers."""
197  if self._expiration_trigger_expiration_trigger:
198  _LOGGER.debug("Clean up expire after trigger for %s", self.entity_identity_id)
199  self._expiration_trigger_expiration_trigger()
200  self._expiration_trigger_expiration_trigger = None
201  self._expired_expired = False
202  await MqttEntity.async_will_remove_from_hass(self)
203 
204  @staticmethod
205  def config_schema() -> VolSchemaType:
206  """Return the config schema."""
207  return DISCOVERY_SCHEMA
208 
209  def _setup_from_config(self, config: ConfigType) -> None:
210  """(Re)Setup the entity."""
211  self._attr_device_class_attr_device_class = config.get(CONF_DEVICE_CLASS)
212  self._attr_force_update_attr_force_update_attr_force_update = config[CONF_FORCE_UPDATE]
213  self._attr_suggested_display_precision_attr_suggested_display_precision = config.get(
214  CONF_SUGGESTED_DISPLAY_PRECISION
215  )
216  self._attr_native_unit_of_measurement_attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT)
217  self._attr_options_attr_options = config.get(CONF_OPTIONS)
218  self._attr_state_class_attr_state_class = config.get(CONF_STATE_CLASS)
219 
220  self._expire_after_expire_after = config.get(CONF_EXPIRE_AFTER)
221  if self._expire_after_expire_after is not None and self._expire_after_expire_after > 0:
222  self._expired_expired = True
223  else:
224  self._expired_expired = None
225 
226  if value_template := config.get(CONF_VALUE_TEMPLATE):
227  self._template_template = MqttValueTemplate(
228  value_template, entity=self
229  ).async_render_with_possible_json_value
230  if last_reset_template := config.get(CONF_LAST_RESET_VALUE_TEMPLATE):
231  self._last_reset_template_last_reset_template = MqttValueTemplate(
232  last_reset_template, entity=self
233  ).async_render_with_possible_json_value
234 
235  @callback
236  def _update_state(self, msg: ReceiveMessage) -> None:
237  # auto-expire enabled?
238  if self._expire_after_expire_after is not None and self._expire_after_expire_after > 0:
239  # When self._expire_after is set, and we receive a message, assume
240  # device is not expired since it has to be to receive the message
241  self._expired_expired = False
242 
243  # Reset old trigger
244  if self._expiration_trigger_expiration_trigger:
245  self._expiration_trigger_expiration_trigger()
246 
247  # Set new trigger
248  self._expiration_trigger_expiration_trigger = async_call_later(
249  self.hasshasshass, self._expire_after_expire_after, self._value_is_expired
250  )
251 
252  if template := self._template_template:
253  payload = template(msg.payload, PayloadSentinel.DEFAULT)
254  else:
255  payload = msg.payload
256  if payload is PayloadSentinel.DEFAULT:
257  return
258  if not isinstance(payload, str):
259  _LOGGER.warning(
260  "Invalid undecoded state message '%s' received from '%s'",
261  payload,
262  msg.topic,
263  )
264  return
265 
266  if payload == PAYLOAD_NONE:
267  self._attr_native_value_attr_native_value = None
268  return
269 
270  if self._numeric_state_expected_numeric_state_expected:
271  if payload == "":
272  _LOGGER.debug("Ignore empty state from '%s'", msg.topic)
273  else:
274  self._attr_native_value_attr_native_value = payload
275  return
276 
277  if self.optionsoptions and payload not in self.optionsoptions:
278  _LOGGER.warning(
279  "Ignoring invalid option received on topic '%s', got '%s', allowed: %s",
280  msg.topic,
281  payload,
282  ", ".join(self.optionsoptions),
283  )
284  return
285 
286  if self.device_classdevice_classdevice_classdevice_class in {
287  None,
288  SensorDeviceClass.ENUM,
289  } and not check_state_too_long(_LOGGER, payload, self.entity_identity_id, msg):
290  self._attr_native_value_attr_native_value = payload
291  return
292  try:
293  if (payload_datetime := dt_util.parse_datetime(payload)) is None:
294  raise ValueError # noqa: TRY301
295  except ValueError:
296  _LOGGER.warning("Invalid state message '%s' from '%s'", payload, msg.topic)
297  self._attr_native_value_attr_native_value = None
298  return
299  if self.device_classdevice_classdevice_classdevice_class == SensorDeviceClass.DATE:
300  self._attr_native_value_attr_native_value = payload_datetime.date()
301  return
302  self._attr_native_value_attr_native_value = payload_datetime
303 
304  @callback
305  def _update_last_reset(self, msg: ReceiveMessage) -> None:
306  template = self._last_reset_template_last_reset_template
307  payload = msg.payload if template is None else template(msg.payload)
308  if not payload:
309  _LOGGER.debug("Ignoring empty last_reset message from '%s'", msg.topic)
310  return
311  try:
312  last_reset = dt_util.parse_datetime(str(payload))
313  if last_reset is None:
314  raise ValueError # noqa: TRY301
315  self._attr_last_reset_attr_last_reset = last_reset
316  except ValueError:
317  _LOGGER.warning(
318  "Invalid last_reset message '%s' from '%s'", msg.payload, msg.topic
319  )
320 
321  @callback
322  def _state_message_received(self, msg: ReceiveMessage) -> None:
323  """Handle new MQTT state messages."""
324  self._update_state(msg)
325  if CONF_LAST_RESET_VALUE_TEMPLATE in self._config_config:
326  self._update_last_reset(msg)
327 
328  @callback
329  def _prepare_subscribe_topics(self) -> None:
330  """(Re)Subscribe to topics."""
331  self.add_subscriptionadd_subscription(
332  CONF_STATE_TOPIC,
333  self._state_message_received,
334  {"_attr_native_value", "_attr_last_reset", "_expired"},
335  )
336 
337  async def _subscribe_topics(self) -> None:
338  """(Re)Subscribe to topics."""
339  subscription.async_subscribe_topics_internal(self.hasshasshass, self._sub_state_sub_state)
340 
341  @callback
342  def _value_is_expired(self, *_: datetime) -> None:
343  """Triggered when value is expired."""
344  self._expiration_trigger_expiration_trigger = None
345  self._expired_expired = True
346  self.async_write_ha_stateasync_write_ha_state()
347 
348  @property
349  def available(self) -> bool:
350  """Return true if the device is available and value has not expired."""
351  # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185
352  return MqttAvailabilityMixin.available.fget(self) and ( # type: ignore[attr-defined]
353  self._expire_after_expire_after is None or not self._expired_expired
354  )
None _setup_from_config(self, ConfigType config)
Definition: entity.py:1425
bool add_subscription(self, str state_topic_config_key, Callable[[ReceiveMessage], None] msg_callback, set[str]|None tracked_attributes, bool disable_encoding=False)
Definition: entity.py:1484
SensorExtraStoredData|None async_get_last_sensor_data(self)
Definition: __init__.py:934
SensorDeviceClass|None device_class(self)
Definition: __init__.py:313
None async_setup_entity_entry_helper(HomeAssistant hass, ConfigEntry entry, type[MqttEntity]|None entity_class, str domain, AddEntitiesCallback async_add_entities, VolSchemaType discovery_schema, VolSchemaType platform_schema_modern, dict[str, type[MqttEntity]]|None schema_class_mapping=None)
Definition: entity.py:245
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: sensor.py:128
ConfigType validate_sensor_state_and_device_class_config(ConfigType config)
Definition: sensor.py:81
bool check_state_too_long(logging.Logger logger, str proposed_state, str entity_id, ReceiveMessage msg)
Definition: util.py:372
bool template(HomeAssistant hass, Template value_template, TemplateVarsType variables=None)
Definition: condition.py:759
CALLBACK_TYPE async_call_later(HomeAssistant hass, float|timedelta delay, HassJob[[datetime], Coroutine[Any, Any, None]|None]|Callable[[datetime], Coroutine[Any, Any, None]|None] action)
Definition: event.py:1597