Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Component to interface with various sensors that can be monitored."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import Mapping
7 from contextlib import suppress
8 from dataclasses import dataclass
9 from datetime import UTC, date, datetime, timedelta
10 from decimal import Decimal, InvalidOperation as DecimalInvalidOperation
11 from functools import partial
12 import logging
13 from math import ceil, floor, isfinite, log10
14 from typing import Any, Final, Self, cast, final, override
15 
16 from propcache import cached_property
17 
18 from homeassistant.config_entries import ConfigEntry
19 from homeassistant.const import ( # noqa: F401
20  _DEPRECATED_DEVICE_CLASS_AQI,
21  _DEPRECATED_DEVICE_CLASS_BATTERY,
22  _DEPRECATED_DEVICE_CLASS_CO,
23  _DEPRECATED_DEVICE_CLASS_CO2,
24  _DEPRECATED_DEVICE_CLASS_CURRENT,
25  _DEPRECATED_DEVICE_CLASS_DATE,
26  _DEPRECATED_DEVICE_CLASS_ENERGY,
27  _DEPRECATED_DEVICE_CLASS_FREQUENCY,
28  _DEPRECATED_DEVICE_CLASS_GAS,
29  _DEPRECATED_DEVICE_CLASS_HUMIDITY,
30  _DEPRECATED_DEVICE_CLASS_ILLUMINANCE,
31  _DEPRECATED_DEVICE_CLASS_MONETARY,
32  _DEPRECATED_DEVICE_CLASS_NITROGEN_DIOXIDE,
33  _DEPRECATED_DEVICE_CLASS_NITROGEN_MONOXIDE,
34  _DEPRECATED_DEVICE_CLASS_NITROUS_OXIDE,
35  _DEPRECATED_DEVICE_CLASS_OZONE,
36  _DEPRECATED_DEVICE_CLASS_PM1,
37  _DEPRECATED_DEVICE_CLASS_PM10,
38  _DEPRECATED_DEVICE_CLASS_PM25,
39  _DEPRECATED_DEVICE_CLASS_POWER,
40  _DEPRECATED_DEVICE_CLASS_POWER_FACTOR,
41  _DEPRECATED_DEVICE_CLASS_PRESSURE,
42  _DEPRECATED_DEVICE_CLASS_SIGNAL_STRENGTH,
43  _DEPRECATED_DEVICE_CLASS_SULPHUR_DIOXIDE,
44  _DEPRECATED_DEVICE_CLASS_TEMPERATURE,
45  _DEPRECATED_DEVICE_CLASS_TIMESTAMP,
46  _DEPRECATED_DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS,
47  _DEPRECATED_DEVICE_CLASS_VOLTAGE,
48  ATTR_UNIT_OF_MEASUREMENT,
49  CONF_UNIT_OF_MEASUREMENT,
50  EntityCategory,
51  UnitOfTemperature,
52 )
53 from homeassistant.core import HomeAssistant, State, callback
54 from homeassistant.exceptions import HomeAssistantError
55 from homeassistant.helpers import config_validation as cv, entity_registry as er
57  all_with_deprecated_constants,
58  check_if_deprecated_constant,
59  dir_with_deprecated_constants,
60 )
61 from homeassistant.helpers.entity import Entity, EntityDescription
62 from homeassistant.helpers.entity_component import EntityComponent
63 from homeassistant.helpers.entity_platform import EntityPlatform
64 from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity
65 from homeassistant.helpers.typing import UNDEFINED, ConfigType, StateType, UndefinedType
66 from homeassistant.util import dt as dt_util
67 from homeassistant.util.enum import try_parse_enum
68 from homeassistant.util.hass_dict import HassKey
69 
70 from .const import ( # noqa: F401
71  _DEPRECATED_STATE_CLASS_MEASUREMENT,
72  _DEPRECATED_STATE_CLASS_TOTAL,
73  _DEPRECATED_STATE_CLASS_TOTAL_INCREASING,
74  ATTR_LAST_RESET,
75  ATTR_OPTIONS,
76  ATTR_STATE_CLASS,
77  CONF_STATE_CLASS,
78  DEVICE_CLASS_STATE_CLASSES,
79  DEVICE_CLASS_UNITS,
80  DEVICE_CLASSES,
81  DEVICE_CLASSES_SCHEMA,
82  DOMAIN,
83  NON_NUMERIC_DEVICE_CLASSES,
84  STATE_CLASSES,
85  STATE_CLASSES_SCHEMA,
86  UNIT_CONVERTERS,
87  SensorDeviceClass,
88  SensorStateClass,
89 )
90 from .websocket_api import async_setup as async_setup_ws_api
91 
92 _LOGGER: Final = logging.getLogger(__name__)
93 
94 DATA_COMPONENT: HassKey[EntityComponent[SensorEntity]] = HassKey(DOMAIN)
95 ENTITY_ID_FORMAT: Final = DOMAIN + ".{}"
96 PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
97 PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
98 SCAN_INTERVAL: Final = timedelta(seconds=30)
99 
100 __all__ = [
101  "ATTR_LAST_RESET",
102  "ATTR_OPTIONS",
103  "ATTR_STATE_CLASS",
104  "CONF_STATE_CLASS",
105  "DEVICE_CLASS_STATE_CLASSES",
106  "DOMAIN",
107  "PLATFORM_SCHEMA_BASE",
108  "PLATFORM_SCHEMA",
109  "RestoreSensor",
110  "SensorDeviceClass",
111  "SensorEntity",
112  "SensorEntityDescription",
113  "SensorExtraStoredData",
114  "SensorStateClass",
115 ]
116 
117 # mypy: disallow-any-generics
118 
119 
120 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
121  """Track states and offer events for sensors."""
122  component = hass.data[DATA_COMPONENT] = EntityComponent[SensorEntity](
123  _LOGGER, DOMAIN, hass, SCAN_INTERVAL
124  )
125 
126  async_setup_ws_api(hass)
127  await component.async_setup(config)
128  return True
129 
130 
131 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
132  """Set up a config entry."""
133  return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
134 
135 
136 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
137  """Unload a config entry."""
138  return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
139 
140 
141 class SensorEntityDescription(EntityDescription, frozen_or_thawed=True):
142  """A class that describes sensor entities."""
143 
144  device_class: SensorDeviceClass | None = None
145  last_reset: datetime | None = None
146  native_unit_of_measurement: str | None = None
147  options: list[str] | None = None
148  state_class: SensorStateClass | str | None = None
149  suggested_display_precision: int | None = None
150  suggested_unit_of_measurement: str | None = None
151  unit_of_measurement: None = None # Type override, use native_unit_of_measurement
152 
153 
155  device_class: SensorDeviceClass | None,
156  state_class: SensorStateClass | str | None,
157  native_unit_of_measurement: str | None,
158  suggested_display_precision: int | None,
159 ) -> bool:
160  """Return true if the sensor must be numeric."""
161  # Note: the order of the checks needs to be kept aligned
162  # with the checks in `state` property.
163  if device_class in NON_NUMERIC_DEVICE_CLASSES:
164  return False
165  if (
166  state_class is not None
167  or native_unit_of_measurement is not None
168  or suggested_display_precision is not None
169  ):
170  return True
171  # Sensors with custom device classes will have the device class
172  # converted to None and are not considered numeric
173  return device_class is not None
174 
175 
176 CACHED_PROPERTIES_WITH_ATTR_ = {
177  "device_class",
178  "last_reset",
179  "native_unit_of_measurement",
180  "native_value",
181  "options",
182  "state_class",
183  "suggested_display_precision",
184  "suggested_unit_of_measurement",
185 }
186 
187 TEMPERATURE_UNITS = {UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT}
188 
189 
190 class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
191  """Base class for sensor entities."""
192 
193  _entity_component_unrecorded_attributes = frozenset({ATTR_OPTIONS})
194 
195  entity_description: SensorEntityDescription
196  _attr_device_class: SensorDeviceClass | None
197  _attr_last_reset: datetime | None
198  _attr_native_unit_of_measurement: str | None
199  _attr_native_value: StateType | date | datetime | Decimal = None
200  _attr_options: list[str] | None
201  _attr_state_class: SensorStateClass | str | None
202  _attr_state: None = None # Subclasses of SensorEntity should not set this
203  _attr_suggested_display_precision: int | None
204  _attr_suggested_unit_of_measurement: str | None
205  _attr_unit_of_measurement: None = (
206  None # Subclasses of SensorEntity should not set this
207  )
208  _invalid_state_class_reported = False
209  _invalid_unit_of_measurement_reported = False
210  _last_reset_reported = False
211  _sensor_option_display_precision: int | None = None
212  _sensor_option_unit_of_measurement: str | None | UndefinedType = UNDEFINED
213  _invalid_suggested_unit_of_measurement_reported = False
214 
215  @callback
217  self,
218  hass: HomeAssistant,
219  platform: EntityPlatform,
220  parallel_updates: asyncio.Semaphore | None,
221  ) -> None:
222  """Start adding an entity to a platform.
223 
224  Allows integrations to remove legacy custom unit conversion which is no longer
225  needed without breaking existing sensors. Only works for sensors which are in
226  the entity registry.
227 
228  This can be removed once core integrations have dropped unneeded custom unit
229  conversion.
230  """
231  super().add_to_platform_start(hass, platform, parallel_updates)
232 
233  # Bail out if the sensor doesn't have a unique_id or a device class
234  if self.unique_idunique_id is None or self.device_classdevice_classdevice_class is None:
235  return
236  registry = er.async_get(self.hasshass)
237 
238  # Bail out if the entity is not yet registered
239  if not (
240  entity_id := registry.async_get_entity_id(
241  platform.domain, platform.platform_name, self.unique_idunique_id
242  )
243  ):
244  # Prime _sensor_option_unit_of_measurement to ensure the correct unit
245  # is stored in the entity registry.
246  self._sensor_option_unit_of_measurement_sensor_option_unit_of_measurement = self._get_initial_suggested_unit_get_initial_suggested_unit()
247  return
248 
249  registry_entry = registry.async_get(entity_id)
250  assert registry_entry
251 
252  # Prime _sensor_option_unit_of_measurement to ensure the correct unit
253  # is stored in the entity registry.
254  self.registry_entryregistry_entryregistry_entry = registry_entry
255  self._async_read_entity_options_async_read_entity_options()
256 
257  # If the sensor has 'unit_of_measurement' in its sensor options, the user has
258  # overridden the unit.
259  # If the sensor has 'sensor.private' in its entity options, it already has a
260  # suggested_unit.
261  registry_unit = registry_entry.unit_of_measurement
262  if (
263  (
264  (sensor_options := registry_entry.options.get(DOMAIN))
265  and CONF_UNIT_OF_MEASUREMENT in sensor_options
266  )
267  or f"{DOMAIN}.private" in registry_entry.options
268  or self.unit_of_measurementunit_of_measurementunit_of_measurementunit_of_measurement == registry_unit
269  ):
270  return
271 
272  # Make sure we can convert the units
273  if (
274  (unit_converter := UNIT_CONVERTERS.get(self.device_classdevice_classdevice_class)) is None
275  or registry_unit not in unit_converter.VALID_UNITS
276  or self.unit_of_measurementunit_of_measurementunit_of_measurementunit_of_measurement not in unit_converter.VALID_UNITS
277  ):
278  return
279 
280  # Set suggested_unit_of_measurement to the old unit to enable automatic
281  # conversion
282  self.registry_entryregistry_entryregistry_entry = registry.async_update_entity_options(
283  entity_id,
284  f"{DOMAIN}.private",
285  {"suggested_unit_of_measurement": registry_unit},
286  )
287  # Update _sensor_option_unit_of_measurement to ensure the correct unit
288  # is stored in the entity registry.
289  self._async_read_entity_options_async_read_entity_options()
290 
291  async def async_internal_added_to_hass(self) -> None:
292  """Call when the sensor entity is added to hass."""
293  await super().async_internal_added_to_hass()
294  if self.entity_categoryentity_categoryentity_category == EntityCategory.CONFIG:
295  raise HomeAssistantError(
296  f"Entity {self.entity_id} cannot be added as the entity category is set to config"
297  )
298 
299  if not self.registry_entryregistry_entryregistry_entry:
300  return
301  self._async_read_entity_options_async_read_entity_options()
302  self._update_suggested_precision_update_suggested_precision()
303 
304  def _default_to_device_class_name(self) -> bool:
305  """Return True if an unnamed entity should be named by its device class.
306 
307  For sensors this is True if the entity has a device class.
308  """
309  return self.device_classdevice_classdevice_class not in (None, SensorDeviceClass.ENUM)
310 
311  @cached_property
312  @override
313  def device_class(self) -> SensorDeviceClass | None:
314  """Return the class of this entity."""
315  if hasattr(self, "_attr_device_class"):
316  return self._attr_device_class
317  if hasattr(self, "entity_description"):
318  return self.entity_description.device_class
319  return None
320 
321  @final
322  @property
323  def _numeric_state_expected(self) -> bool:
324  """Return true if the sensor must be numeric."""
326  try_parse_enum(SensorDeviceClass, self.device_classdevice_classdevice_class),
327  self.state_classstate_class,
328  self.native_unit_of_measurementnative_unit_of_measurement,
329  self.suggested_display_precisionsuggested_display_precision,
330  )
331 
332  @cached_property
333  def options(self) -> list[str] | None:
334  """Return a set of possible options."""
335  if hasattr(self, "_attr_options"):
336  return self._attr_options
337  if hasattr(self, "entity_description"):
338  return self.entity_description.options
339  return None
340 
341  @cached_property
342  def state_class(self) -> SensorStateClass | str | None:
343  """Return the state class of this entity, if any."""
344  if hasattr(self, "_attr_state_class"):
345  return self._attr_state_class
346  if hasattr(self, "entity_description"):
347  return self.entity_description.state_class
348  return None
349 
350  @cached_property
351  def last_reset(self) -> datetime | None:
352  """Return the time when the sensor was last reset, if any."""
353  if hasattr(self, "_attr_last_reset"):
354  return self._attr_last_reset
355  if hasattr(self, "entity_description"):
356  return self.entity_description.last_reset
357  return None
358 
359  @property
360  @override
361  def capability_attributes(self) -> dict[str, Any] | None:
362  """Return the capability attributes."""
363  if state_class := self.state_classstate_class:
364  return {ATTR_STATE_CLASS: state_class}
365 
366  if options := self.optionsoptions:
367  return {ATTR_OPTIONS: options}
368 
369  return None
370 
371  def _is_valid_suggested_unit(self, suggested_unit_of_measurement: str) -> bool:
372  """Validate the suggested unit.
373 
374  Validate that a unit converter exists for the sensor's device class and that the
375  unit converter supports both the native and the suggested units of measurement.
376  """
377  # Make sure we can convert the units
378  if (
379  (unit_converter := UNIT_CONVERTERS.get(self.device_classdevice_classdevice_class)) is None
380  or self.native_unit_of_measurementnative_unit_of_measurement not in unit_converter.VALID_UNITS
381  or suggested_unit_of_measurement not in unit_converter.VALID_UNITS
382  ):
383  if not self._invalid_suggested_unit_of_measurement_reported_invalid_suggested_unit_of_measurement_reported:
384  self._invalid_suggested_unit_of_measurement_reported_invalid_suggested_unit_of_measurement_reported = True
385  raise ValueError(
386  f"Entity {type(self)} suggest an incorrect "
387  f"unit of measurement: {suggested_unit_of_measurement}."
388  )
389  return False
390 
391  return True
392 
393  def _get_initial_suggested_unit(self) -> str | UndefinedType:
394  """Return the initial unit."""
395  # Unit suggested by the integration
396  suggested_unit_of_measurement = self.suggested_unit_of_measurementsuggested_unit_of_measurement
397 
398  if suggested_unit_of_measurement is None:
399  # Fallback to unit suggested by the unit conversion rules from device class
400  suggested_unit_of_measurement = self.hasshass.config.units.get_converted_unit(
401  self.device_classdevice_classdevice_class, self.native_unit_of_measurementnative_unit_of_measurement
402  )
403 
404  if suggested_unit_of_measurement is None and (
405  unit_converter := UNIT_CONVERTERS.get(self.device_classdevice_classdevice_class)
406  ):
407  # If the device class is not known by the unit system but has a unit converter,
408  # fall back to the unit suggested by the unit converter's unit class.
409  suggested_unit_of_measurement = self.hasshass.config.units.get_converted_unit(
410  unit_converter.UNIT_CLASS, self.native_unit_of_measurementnative_unit_of_measurement
411  )
412 
413  if suggested_unit_of_measurement is None:
414  return UNDEFINED
415 
416  # Make sure we can convert the units
417  if not self._is_valid_suggested_unit_is_valid_suggested_unit(suggested_unit_of_measurement):
418  return UNDEFINED
419 
420  return suggested_unit_of_measurement
421 
422  def get_initial_entity_options(self) -> er.EntityOptionsType | None:
423  """Return initial entity options.
424 
425  These will be stored in the entity registry the first time the entity is seen,
426  and then only updated if the unit system is changed.
427  """
428  suggested_unit_of_measurement = self._get_initial_suggested_unit_get_initial_suggested_unit()
429 
430  if suggested_unit_of_measurement is UNDEFINED:
431  return None
432 
433  return {
434  f"{DOMAIN}.private": {
435  "suggested_unit_of_measurement": suggested_unit_of_measurement
436  }
437  }
438 
439  @final
440  @property
441  @override
442  def state_attributes(self) -> dict[str, Any] | None:
443  """Return state attributes."""
444  if last_reset := self.last_resetlast_reset:
445  state_class = self.state_classstate_class
446  if state_class != SensorStateClass.TOTAL:
447  raise ValueError(
448  f"Entity {self.entity_id} ({type(self)}) with state_class {state_class}"
449  " has set last_reset. Setting last_reset for entities with state_class"
450  " other than 'total' is not supported. Please update your configuration"
451  " if state_class is manually configured."
452  )
453 
454  if state_class == SensorStateClass.TOTAL:
455  return {ATTR_LAST_RESET: last_reset.isoformat()}
456 
457  return None
458 
459  @cached_property
460  def native_value(self) -> StateType | date | datetime | Decimal:
461  """Return the value reported by the sensor."""
462  return self._attr_native_value
463 
464  @cached_property
465  def suggested_display_precision(self) -> int | None:
466  """Return the suggested number of decimal digits for display."""
467  if hasattr(self, "_attr_suggested_display_precision"):
468  return self._attr_suggested_display_precision
469  if hasattr(self, "entity_description"):
470  return self.entity_description.suggested_display_precision
471  return None
472 
473  @cached_property
474  def native_unit_of_measurement(self) -> str | None:
475  """Return the unit of measurement of the sensor, if any."""
476  if hasattr(self, "_attr_native_unit_of_measurement"):
477  return self._attr_native_unit_of_measurement
478  if hasattr(self, "entity_description"):
479  return self.entity_description.native_unit_of_measurement
480  return None
481 
482  @cached_property
483  def suggested_unit_of_measurement(self) -> str | None:
484  """Return the unit which should be used for the sensor's state.
485 
486  This can be used by integrations to override automatic unit conversion rules,
487  for example to make a temperature sensor display in °C even if the configured
488  unit system prefers °F.
489 
490  For sensors without a `unique_id`, this takes precedence over legacy
491  temperature conversion rules only.
492 
493  For sensors with a `unique_id`, this is applied only if the unit is not set by
494  the user, and takes precedence over automatic device-class conversion rules.
495 
496  Note:
497  suggested_unit_of_measurement is stored in the entity registry the first
498  time the entity is seen, and then never updated.
499 
500  """
501  if hasattr(self, "_attr_suggested_unit_of_measurement"):
502  return self._attr_suggested_unit_of_measurement
503  if hasattr(self, "entity_description"):
504  return self.entity_description.suggested_unit_of_measurement
505  return None
506 
507  @final
508  @property
509  @override
510  def unit_of_measurement(self) -> str | None:
511  """Return the unit of measurement of the entity, after unit conversion."""
512  # Highest priority, for registered entities: unit set by user,with fallback to
513  # unit suggested by integration or secondary fallback to unit conversion rules
514  if self._sensor_option_unit_of_measurement_sensor_option_unit_of_measurement is not UNDEFINED:
515  return self._sensor_option_unit_of_measurement_sensor_option_unit_of_measurement
516 
517  native_unit_of_measurement = self.native_unit_of_measurementnative_unit_of_measurement
518 
519  # Second priority, for non registered entities: unit suggested by integration
520  if not self.registry_entryregistry_entryregistry_entry and (
521  suggested_unit_of_measurement := self.suggested_unit_of_measurementsuggested_unit_of_measurement
522  ):
523  if self._is_valid_suggested_unit_is_valid_suggested_unit(suggested_unit_of_measurement):
524  return suggested_unit_of_measurement
525 
526  # Third priority: Legacy temperature conversion, which applies
527  # to both registered and non registered entities
528  if (
529  native_unit_of_measurement in TEMPERATURE_UNITS
530  and self.device_classdevice_classdevice_class is SensorDeviceClass.TEMPERATURE
531  ):
532  return self.hasshass.config.units.temperature_unit
533 
534  # Fourth priority: Unit translation
535  if (translation_key := self._unit_of_measurement_translation_key_unit_of_measurement_translation_key) and (
536  unit_of_measurement
537  := self.platformplatform.default_language_platform_translations.get(translation_key)
538  ):
539  if native_unit_of_measurement is not None:
540  raise ValueError(
541  f"Sensor {type(self)} from integration '{self.platform.platform_name}' "
542  f"has a translation key for unit_of_measurement '{unit_of_measurement}', "
543  f"but also has a native_unit_of_measurement '{native_unit_of_measurement}'"
544  )
545  return unit_of_measurement
546 
547  # Lowest priority: Native unit
548  return native_unit_of_measurement
549 
550  @final
551  @property
552  @override
553  def state(self) -> Any:
554  """Return the state of the sensor and perform unit conversions, if needed."""
555  native_unit_of_measurement = self.native_unit_of_measurementnative_unit_of_measurement
556  unit_of_measurement = self.unit_of_measurementunit_of_measurementunit_of_measurementunit_of_measurement
557  value = self.native_valuenative_value
558  # For the sake of validation, we can ignore custom device classes
559  # (customization and legacy style translations)
560  device_class = try_parse_enum(SensorDeviceClass, self.device_classdevice_classdevice_class)
561  state_class = self.state_classstate_class
562 
563  # Sensors with device classes indicating a non-numeric value
564  # should not have a unit of measurement
565  if device_class in NON_NUMERIC_DEVICE_CLASSES and unit_of_measurement:
566  raise ValueError(
567  f"Sensor {self.entity_id} has a unit of measurement and thus "
568  "indicating it has a numeric value; however, it has the "
569  f"non-numeric device class: {device_class}"
570  )
571 
572  # Validate state class for sensors with a device class
573  if (
574  state_class
575  and not self._invalid_state_class_reported_invalid_state_class_reported
576  and device_class
577  and (classes := DEVICE_CLASS_STATE_CLASSES.get(device_class)) is not None
578  and state_class not in classes
579  ):
580  self._invalid_state_class_reported_invalid_state_class_reported = True
581  report_issue = self._suggest_report_issue_suggest_report_issue()
582 
583  # This should raise in Home Assistant Core 2023.6
584  _LOGGER.warning(
585  "Entity %s (%s) is using state class '%s' which "
586  "is impossible considering device class ('%s') it is using; "
587  "expected %s%s; "
588  "Please update your configuration if your entity is manually "
589  "configured, otherwise %s",
590  self.entity_identity_id,
591  type(self),
592  state_class,
593  device_class,
594  "None or one of " if classes else "None",
595  ", ".join(f"'{value.value}'" for value in classes),
596  report_issue,
597  )
598 
599  # Checks below only apply if there is a value
600  if value is None:
601  return None
602 
603  # Received a datetime
604  if device_class is SensorDeviceClass.TIMESTAMP:
605  try:
606  # We cast the value, to avoid using isinstance, but satisfy
607  # typechecking. The errors are guarded in this try.
608  value = cast(datetime, value)
609  if value.tzinfo is None:
610  raise ValueError(
611  f"Invalid datetime: {self.entity_id} provides state '{value}', "
612  "which is missing timezone information"
613  )
614 
615  if value.tzinfo != UTC:
616  value = value.astimezone(UTC)
617 
618  return value.isoformat(timespec="seconds")
619  except (AttributeError, OverflowError, TypeError) as err:
620  raise ValueError(
621  f"Invalid datetime: {self.entity_id} has timestamp device class "
622  f"but provides state {value}:{type(value)} resulting in '{err}'"
623  ) from err
624 
625  # Received a date value
626  if device_class is SensorDeviceClass.DATE:
627  try:
628  # We cast the value, to avoid using isinstance, but satisfy
629  # typechecking. The errors are guarded in this try.
630  value = cast(date, value)
631  return value.isoformat()
632  except (AttributeError, TypeError) as err:
633  raise ValueError(
634  f"Invalid date: {self.entity_id} has date device class "
635  f"but provides state {value}:{type(value)} resulting in '{err}'"
636  ) from err
637 
638  # Enum checks
639  if (
640  options := self.optionsoptions
641  ) is not None or device_class is SensorDeviceClass.ENUM:
642  if device_class is not SensorDeviceClass.ENUM:
643  reason = "is missing the enum device class"
644  if device_class is not None:
645  reason = f"has device class '{device_class}' instead of 'enum'"
646  raise ValueError(
647  f"Sensor {self.entity_id} is providing enum options, but {reason}"
648  )
649 
650  if options and value not in options:
651  raise ValueError(
652  f"Sensor {self.entity_id} provides state value '{value}', "
653  "which is not in the list of options provided"
654  )
655  return value
656 
657  suggested_precision = self.suggested_display_precisionsuggested_display_precision
658 
659  # If the sensor has neither a device class, a state class, a unit of measurement
660  # nor a precision then there are no further checks or conversions
662  device_class, state_class, native_unit_of_measurement, suggested_precision
663  ):
664  return value
665 
666  # From here on a numerical value is expected
667  numerical_value: int | float | Decimal
668  if not isinstance(value, (int, float, Decimal)):
669  try:
670  if isinstance(value, str) and "." not in value and "e" not in value:
671  try:
672  numerical_value = int(value)
673  except ValueError:
674  # Handle nan, inf
675  numerical_value = float(value)
676  else:
677  numerical_value = float(value) # type:ignore[arg-type]
678  except (TypeError, ValueError) as err:
679  raise ValueError(
680  f"Sensor {self.entity_id} has device class '{device_class}', "
681  f"state class '{state_class}' unit '{unit_of_measurement}' and "
682  f"suggested precision '{suggested_precision}' thus indicating it "
683  f"has a numeric value; however, it has the non-numeric value: "
684  f"'{value}' ({type(value)})"
685  ) from err
686  else:
687  numerical_value = value
688 
689  if not isfinite(numerical_value):
690  raise ValueError(
691  f"Sensor {self.entity_id} has device class '{device_class}', "
692  f"state class '{state_class}' unit '{unit_of_measurement}' and "
693  f"suggested precision '{suggested_precision}' thus indicating it "
694  f"has a numeric value; however, it has the non-finite value: "
695  f"'{numerical_value}'"
696  )
697 
698  if native_unit_of_measurement != unit_of_measurement and (
699  converter := UNIT_CONVERTERS.get(device_class)
700  ):
701  # Unit conversion needed
702  converted_numerical_value = converter.converter_factory(
703  native_unit_of_measurement,
704  unit_of_measurement,
705  )(float(numerical_value))
706 
707  # If unit conversion is happening, and there's no rounding for display,
708  # do a best effort rounding here.
709  if (
710  suggested_precision is None
711  and self._sensor_option_display_precision_sensor_option_display_precision is None
712  ):
713  # Deduce the precision by finding the decimal point, if any
714  value_s = str(value)
715  precision = (
716  len(value_s) - value_s.index(".") - 1 if "." in value_s else 0
717  )
718 
719  # Scale the precision when converting to a larger unit
720  # For example 1.1 Wh should be rendered as 0.0011 kWh, not 0.0 kWh
721  ratio_log = max(
722  0,
723  log10(
724  converter.get_unit_ratio(
725  native_unit_of_measurement, unit_of_measurement
726  )
727  ),
728  )
729  precision = precision + floor(ratio_log)
730 
731  value = f"{converted_numerical_value:z.{precision}f}"
732  else:
733  value = converted_numerical_value
734 
735  # Validate unit of measurement used for sensors with a device class
736  if (
737  not self._invalid_unit_of_measurement_reported_invalid_unit_of_measurement_reported
738  and device_class
739  and (units := DEVICE_CLASS_UNITS.get(device_class)) is not None
740  and native_unit_of_measurement not in units
741  ):
742  self._invalid_unit_of_measurement_reported_invalid_unit_of_measurement_reported = True
743  report_issue = self._suggest_report_issue_suggest_report_issue()
744 
745  # This should raise in Home Assistant Core 2023.6
746  _LOGGER.warning(
747  (
748  "Entity %s (%s) is using native unit of measurement '%s' which "
749  "is not a valid unit for the device class ('%s') it is using; "
750  "expected one of %s; "
751  "Please update your configuration if your entity is manually "
752  "configured, otherwise %s"
753  ),
754  self.entity_identity_id,
755  type(self),
756  native_unit_of_measurement,
757  device_class,
758  [str(unit) if unit else "no unit of measurement" for unit in units],
759  report_issue,
760  )
761 
762  return value
763 
764  def _display_precision_or_none(self) -> int | None:
765  """Return display precision, or None if not set."""
766  assert self.registry_entryregistry_entryregistry_entry
767  if not (sensor_options := self.registry_entryregistry_entryregistry_entry.options.get(DOMAIN)):
768  return None
769 
770  for option in ("display_precision", "suggested_display_precision"):
771  if (precision := sensor_options.get(option)) is not None:
772  return cast(int, precision)
773  return None
774 
775  def _update_suggested_precision(self) -> None:
776  """Update suggested display precision stored in registry."""
777  assert self.registry_entryregistry_entryregistry_entry
778 
779  device_class = self.device_classdevice_classdevice_class
780  display_precision = self.suggested_display_precisionsuggested_display_precision
781  default_unit_of_measurement = (
782  self.suggested_unit_of_measurementsuggested_unit_of_measurement or self.native_unit_of_measurementnative_unit_of_measurement
783  )
784  unit_of_measurement = self.unit_of_measurementunit_of_measurementunit_of_measurementunit_of_measurement
785 
786  if (
787  display_precision is not None
788  and default_unit_of_measurement != unit_of_measurement
789  and device_class in UNIT_CONVERTERS
790  ):
791  converter = UNIT_CONVERTERS[device_class]
792 
793  # Scale the precision when converting to a larger or smaller unit
794  # For example 1.1 Wh should be rendered as 0.0011 kWh, not 0.0 kWh
795  ratio_log = log10(
796  converter.get_unit_ratio(
797  default_unit_of_measurement, unit_of_measurement
798  )
799  )
800  ratio_log = floor(ratio_log) if ratio_log > 0 else ceil(ratio_log)
801  display_precision = max(0, display_precision + ratio_log)
802 
803  sensor_options: Mapping[str, Any] = self.registry_entryregistry_entryregistry_entry.options.get(DOMAIN, {})
804  if "suggested_display_precision" not in sensor_options:
805  if display_precision is None:
806  return
807  elif sensor_options["suggested_display_precision"] == display_precision:
808  return
809 
810  registry = er.async_get(self.hasshass)
811  sensor_options = dict(sensor_options)
812  sensor_options.pop("suggested_display_precision", None)
813  if display_precision is not None:
814  sensor_options["suggested_display_precision"] = display_precision
815  registry.async_update_entity_options(
816  self.entity_identity_id, DOMAIN, sensor_options or None
817  )
818 
820  self, primary_key: str, secondary_key: str
821  ) -> str | None | UndefinedType:
822  """Return a custom unit, or UNDEFINED if not compatible with the native unit."""
823  assert self.registry_entryregistry_entryregistry_entry
824  if (
825  (sensor_options := self.registry_entryregistry_entryregistry_entry.options.get(primary_key))
826  and secondary_key in sensor_options
827  and (device_class := self.device_classdevice_classdevice_class) in UNIT_CONVERTERS
828  and self.native_unit_of_measurementnative_unit_of_measurement
829  in UNIT_CONVERTERS[device_class].VALID_UNITS
830  and (custom_unit := sensor_options[secondary_key])
831  in UNIT_CONVERTERS[device_class].VALID_UNITS
832  ):
833  return cast(str, custom_unit)
834  return UNDEFINED
835 
836  @callback
837  def async_registry_entry_updated(self) -> None:
838  """Run when the entity registry entry has been updated."""
839  self._async_read_entity_options_async_read_entity_options()
840  self._update_suggested_precision_update_suggested_precision()
841 
842  @callback
843  def _async_read_entity_options(self) -> None:
844  """Read entity options from entity registry.
845 
846  Called when the entity registry entry has been updated and before the sensor is
847  added to the state machine.
848  """
849  self._sensor_option_display_precision_sensor_option_display_precision = self._display_precision_or_none_display_precision_or_none()
850  assert self.registry_entryregistry_entryregistry_entry
851  if (
852  sensor_options := self.registry_entryregistry_entryregistry_entry.options.get(f"{DOMAIN}.private")
853  ) and "refresh_initial_entity_options" in sensor_options:
854  registry = er.async_get(self.hasshass)
855  initial_options = self.get_initial_entity_optionsget_initial_entity_optionsget_initial_entity_options() or {}
856  registry.async_update_entity_options(
857  self.registry_entryregistry_entryregistry_entry.entity_id,
858  f"{DOMAIN}.private",
859  initial_options.get(f"{DOMAIN}.private"),
860  )
861  self._sensor_option_unit_of_measurement_sensor_option_unit_of_measurement = self._custom_unit_or_undef_custom_unit_or_undef(
862  DOMAIN, CONF_UNIT_OF_MEASUREMENT
863  )
864  if self._sensor_option_unit_of_measurement_sensor_option_unit_of_measurement is UNDEFINED:
865  self._sensor_option_unit_of_measurement_sensor_option_unit_of_measurement = self._custom_unit_or_undef_custom_unit_or_undef(
866  f"{DOMAIN}.private", "suggested_unit_of_measurement"
867  )
868 
869 
870 @dataclass
871 class SensorExtraStoredData(ExtraStoredData):
872  """Object to hold extra stored data."""
873 
874  native_value: StateType | date | datetime | Decimal
875  native_unit_of_measurement: str | None
876 
877  def as_dict(self) -> dict[str, Any]:
878  """Return a dict representation of the sensor data."""
879  native_value: StateType | date | datetime | Decimal | dict[str, str] = (
880  self.native_value
881  )
882  if isinstance(native_value, (date, datetime)):
883  native_value = {
884  "__type": str(type(native_value)),
885  "isoformat": native_value.isoformat(),
886  }
887  if isinstance(native_value, Decimal):
888  native_value = {
889  "__type": str(type(native_value)),
890  "decimal_str": str(native_value),
891  }
892  return {
893  "native_value": native_value,
894  "native_unit_of_measurement": self.native_unit_of_measurement,
895  }
896 
897  @classmethod
898  def from_dict(cls, restored: dict[str, Any]) -> Self | None:
899  """Initialize a stored sensor state from a dict."""
900  try:
901  native_value = restored["native_value"]
902  native_unit_of_measurement = restored["native_unit_of_measurement"]
903  except KeyError:
904  return None
905  try:
906  type_ = native_value["__type"]
907  if type_ == "<class 'datetime.datetime'>":
908  native_value = dt_util.parse_datetime(native_value["isoformat"])
909  elif type_ == "<class 'datetime.date'>":
910  native_value = dt_util.parse_date(native_value["isoformat"])
911  elif type_ == "<class 'decimal.Decimal'>":
912  native_value = Decimal(native_value["decimal_str"])
913  except TypeError:
914  # native_value is not a dict
915  pass
916  except KeyError:
917  # native_value is a dict, but does not have all values
918  return None
919  except DecimalInvalidOperation:
920  # native_value couldn't be returned from decimal_str
921  return None
922 
923  return cls(native_value, native_unit_of_measurement)
924 
925 
926 class RestoreSensor(SensorEntity, RestoreEntity):
927  """Mixin class for restoring previous sensor state."""
928 
929  @property
930  def extra_restore_state_data(self) -> SensorExtraStoredData:
931  """Return sensor specific state data to be restored."""
932  return SensorExtraStoredData(self.native_valuenative_value, self.native_unit_of_measurementnative_unit_of_measurement)
933 
934  async def async_get_last_sensor_data(self) -> SensorExtraStoredData | None:
935  """Restore native_value and native_unit_of_measurement."""
936  if (restored_last_extra_data := await self.async_get_last_extra_data()) is None:
937  return None
938  return SensorExtraStoredData.from_dict(restored_last_extra_data.as_dict())
939 
940 
941 @callback
942 def async_update_suggested_units(hass: HomeAssistant) -> None:
943  """Update the suggested_unit_of_measurement according to the unit system."""
944  registry = er.async_get(hass)
945 
946  for entry in registry.entities.values():
947  if entry.domain != DOMAIN:
948  continue
949 
950  sensor_private_options = dict(entry.options.get(f"{DOMAIN}.private", {}))
951  sensor_private_options["refresh_initial_entity_options"] = True
952  registry.async_update_entity_options(
953  entry.entity_id,
954  f"{DOMAIN}.private",
955  sensor_private_options,
956  )
957 
958 
959 def _display_precision(hass: HomeAssistant, entity_id: str) -> int | None:
960  """Return the display precision."""
961  if not (entry := er.async_get(hass).async_get(entity_id)) or not (
962  sensor_options := entry.options.get(DOMAIN)
963  ):
964  return None
965  if (display_precision := sensor_options.get("display_precision")) is not None:
966  return cast(int, display_precision)
967  return sensor_options.get("suggested_display_precision")
968 
969 
970 @callback
971 def async_rounded_state(hass: HomeAssistant, entity_id: str, state: State) -> str:
972  """Return the state rounded for presentation."""
973  value = state.state
974  if (precision := _display_precision(hass, entity_id)) is None:
975  return value
976 
977  with suppress(TypeError, ValueError):
978  numerical_value = float(value)
979  value = f"{numerical_value:z.{precision}f}"
980 
981  return value
982 
983 
984 # As we import deprecated constants from the const module, we need to add these two functions
985 # otherwise this module will be logged for using deprecated constants and not the custom component
986 # These can be removed if no deprecated constant are in this module anymore
987 __getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
988 __dir__ = partial(
989  dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
990 )
991 __all__ = all_with_deprecated_constants(globals())
SensorExtraStoredData extra_restore_state_data(self)
Definition: __init__.py:930
SensorExtraStoredData|None async_get_last_sensor_data(self)
Definition: __init__.py:934
SensorDeviceClass|None device_class(self)
Definition: __init__.py:313
dict[str, Any]|None capability_attributes(self)
Definition: __init__.py:361
dict[str, Any]|None state_attributes(self)
Definition: __init__.py:442
SensorStateClass|str|None state_class(self)
Definition: __init__.py:342
StateType|date|datetime|Decimal native_value(self)
Definition: __init__.py:460
er.EntityOptionsType|None get_initial_entity_options(self)
Definition: __init__.py:422
str|None|UndefinedType _custom_unit_or_undef(self, str primary_key, str secondary_key)
Definition: __init__.py:821
None add_to_platform_start(self, HomeAssistant hass, EntityPlatform platform, asyncio.Semaphore|None parallel_updates)
Definition: __init__.py:221
bool _is_valid_suggested_unit(self, str suggested_unit_of_measurement)
Definition: __init__.py:371
str|UndefinedType _get_initial_suggested_unit(self)
Definition: __init__.py:393
Self|None from_dict(cls, dict[str, Any] restored)
Definition: __init__.py:898
EntityCategory|None entity_category(self)
Definition: entity.py:895
er.EntityOptionsType|None get_initial_entity_options(self)
Definition: entity.py:765
str|None _unit_of_measurement_translation_key(self)
Definition: entity.py:651
str|None unit_of_measurement(self)
Definition: entity.py:815
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:136
None async_update_suggested_units(HomeAssistant hass)
Definition: __init__.py:942
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:120
str async_rounded_state(HomeAssistant hass, str entity_id, State state)
Definition: __init__.py:971
int|None _display_precision(HomeAssistant hass, str entity_id)
Definition: __init__.py:959
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:131
bool _numeric_state_expected(SensorDeviceClass|None device_class, SensorStateClass|str|None state_class, str|None native_unit_of_measurement, int|None suggested_display_precision)
Definition: __init__.py:159
AreaRegistry async_get(HomeAssistant hass)
list[str] all_with_deprecated_constants(dict[str, Any] module_globals)
Definition: deprecation.py:356