core/homeassistant/components/integration/sensor.py

500 lines
16 KiB
Python

"""Numeric integration of data coming from a source sensor over time."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from decimal import Decimal, DecimalException, InvalidOperation
import logging
from typing import Any, Final, Self
import voluptuous as vol
from homeassistant.components.sensor import (
PLATFORM_SCHEMA,
RestoreSensor,
SensorDeviceClass,
SensorExtraStoredData,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_UNIT_OF_MEASUREMENT,
CONF_METHOD,
CONF_NAME,
CONF_UNIQUE_ID,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
UnitOfTime,
)
from homeassistant.core import (
Event,
EventStateChangedData,
HomeAssistant,
State,
callback,
)
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import (
CONF_ROUND_DIGITS,
CONF_SOURCE_SENSOR,
CONF_UNIT_OF_MEASUREMENT,
CONF_UNIT_PREFIX,
CONF_UNIT_TIME,
INTEGRATION_METHODS,
METHOD_LEFT,
METHOD_RIGHT,
METHOD_TRAPEZOIDAL,
)
_LOGGER = logging.getLogger(__name__)
ATTR_SOURCE_ID: Final = "source"
# SI Metric prefixes
UNIT_PREFIXES = {None: 1, "k": 10**3, "M": 10**6, "G": 10**9, "T": 10**12}
# SI Time prefixes
UNIT_TIME = {
UnitOfTime.SECONDS: 1,
UnitOfTime.MINUTES: 60,
UnitOfTime.HOURS: 60 * 60,
UnitOfTime.DAYS: 24 * 60 * 60,
}
DEFAULT_ROUND = 3
PLATFORM_SCHEMA = vol.All(
cv.removed(CONF_UNIT_OF_MEASUREMENT),
PLATFORM_SCHEMA.extend(
{
vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_UNIQUE_ID): cv.string,
vol.Required(CONF_SOURCE_SENSOR): cv.entity_id,
vol.Optional(CONF_ROUND_DIGITS, default=DEFAULT_ROUND): vol.Any(
None, vol.Coerce(int)
),
vol.Optional(CONF_UNIT_PREFIX): vol.In(UNIT_PREFIXES),
vol.Optional(CONF_UNIT_TIME, default=UnitOfTime.HOURS): vol.In(UNIT_TIME),
vol.Remove(CONF_UNIT_OF_MEASUREMENT): cv.string,
vol.Optional(CONF_METHOD, default=METHOD_TRAPEZOIDAL): vol.In(
INTEGRATION_METHODS
),
}
),
)
class _IntegrationMethod(ABC):
@staticmethod
def from_name(method_name: str) -> _IntegrationMethod:
return _NAME_TO_INTEGRATION_METHOD[method_name]()
@abstractmethod
def validate_states(
self, left: State, right: State
) -> tuple[Decimal, Decimal] | None:
"""Check state requirements for integration."""
@abstractmethod
def calculate_area_with_two_states(
self, elapsed_time: Decimal, left: Decimal, right: Decimal
) -> Decimal:
"""Calculate area given two states."""
def calculate_area_with_one_state(
self, elapsed_time: Decimal, constant_state: Decimal
) -> Decimal:
return constant_state * elapsed_time
class _Trapezoidal(_IntegrationMethod):
def calculate_area_with_two_states(
self, elapsed_time: Decimal, left: Decimal, right: Decimal
) -> Decimal:
return elapsed_time * (left + right) / 2
def validate_states(
self, left: State, right: State
) -> tuple[Decimal, Decimal] | None:
if (left_dec := _decimal_state(left.state)) is None or (
right_dec := _decimal_state(right.state)
) is None:
return None
return (left_dec, right_dec)
class _Left(_IntegrationMethod):
def calculate_area_with_two_states(
self, elapsed_time: Decimal, left: Decimal, right: Decimal
) -> Decimal:
return self.calculate_area_with_one_state(elapsed_time, left)
def validate_states(
self, left: State, right: State
) -> tuple[Decimal, Decimal] | None:
if (left_dec := _decimal_state(left.state)) is None:
return None
return (left_dec, left_dec)
class _Right(_IntegrationMethod):
def calculate_area_with_two_states(
self, elapsed_time: Decimal, left: Decimal, right: Decimal
) -> Decimal:
return self.calculate_area_with_one_state(elapsed_time, right)
def validate_states(
self, left: State, right: State
) -> tuple[Decimal, Decimal] | None:
if (right_dec := _decimal_state(right.state)) is None:
return None
return (right_dec, right_dec)
def _decimal_state(state: str) -> Decimal | None:
try:
return Decimal(state)
except (InvalidOperation, TypeError):
return None
_NAME_TO_INTEGRATION_METHOD: dict[str, type[_IntegrationMethod]] = {
METHOD_LEFT: _Left,
METHOD_RIGHT: _Right,
METHOD_TRAPEZOIDAL: _Trapezoidal,
}
@dataclass
class IntegrationSensorExtraStoredData(SensorExtraStoredData):
"""Object to hold extra stored data."""
source_entity: str | None
last_valid_state: Decimal | None
def as_dict(self) -> dict[str, Any]:
"""Return a dict representation of the utility sensor data."""
data = super().as_dict()
data["source_entity"] = self.source_entity
data["last_valid_state"] = (
str(self.last_valid_state) if self.last_valid_state else None
)
return data
@classmethod
def from_dict(cls, restored: dict[str, Any]) -> Self | None:
"""Initialize a stored sensor state from a dict."""
extra = SensorExtraStoredData.from_dict(restored)
if extra is None:
return None
source_entity = restored.get(ATTR_SOURCE_ID)
try:
last_valid_state = (
Decimal(str(restored.get("last_valid_state")))
if restored.get("last_valid_state")
else None
)
except InvalidOperation:
# last_period is corrupted
_LOGGER.error("Could not use last_valid_state")
return None
if last_valid_state is None:
return None
return cls(
extra.native_value,
extra.native_unit_of_measurement,
source_entity,
last_valid_state,
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Initialize Integration - Riemann sum integral config entry."""
registry = er.async_get(hass)
# Validate + resolve entity registry id to entity_id
source_entity_id = er.async_validate_entity_id(
registry, config_entry.options[CONF_SOURCE_SENSOR]
)
source_entity = er.EntityRegistry.async_get(registry, source_entity_id)
dev_reg = dr.async_get(hass)
# Resolve source entity device
if (
(source_entity is not None)
and (source_entity.device_id is not None)
and (
(
device := dev_reg.async_get(
device_id=source_entity.device_id,
)
)
is not None
)
):
device_info = DeviceInfo(
identifiers=device.identifiers,
connections=device.connections,
)
else:
device_info = None
if (unit_prefix := config_entry.options.get(CONF_UNIT_PREFIX)) == "none":
# Before we had support for optional selectors, "none" was used for selecting nothing
unit_prefix = None
round_digits = config_entry.options.get(CONF_ROUND_DIGITS)
if round_digits:
round_digits = int(round_digits)
integral = IntegrationSensor(
integration_method=config_entry.options[CONF_METHOD],
name=config_entry.title,
round_digits=round_digits,
source_entity=source_entity_id,
unique_id=config_entry.entry_id,
unit_prefix=unit_prefix,
unit_time=config_entry.options[CONF_UNIT_TIME],
device_info=device_info,
)
async_add_entities([integral])
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the integration sensor."""
integral = IntegrationSensor(
integration_method=config[CONF_METHOD],
name=config.get(CONF_NAME),
round_digits=config.get(CONF_ROUND_DIGITS),
source_entity=config[CONF_SOURCE_SENSOR],
unique_id=config.get(CONF_UNIQUE_ID),
unit_prefix=config.get(CONF_UNIT_PREFIX),
unit_time=config[CONF_UNIT_TIME],
)
async_add_entities([integral])
class IntegrationSensor(RestoreSensor):
"""Representation of an integration sensor."""
_attr_state_class = SensorStateClass.TOTAL
_attr_should_poll = False
def __init__(
self,
*,
integration_method: str,
name: str | None,
round_digits: int | None,
source_entity: str,
unique_id: str | None,
unit_prefix: str | None,
unit_time: UnitOfTime,
device_info: DeviceInfo | None = None,
) -> None:
"""Initialize the integration sensor."""
self._attr_unique_id = unique_id
self._sensor_source_id = source_entity
self._round_digits = round_digits
self._state: Decimal | None = None
self._method = _IntegrationMethod.from_name(integration_method)
self._attr_name = name if name is not None else f"{source_entity} integral"
self._unit_prefix_string = "" if unit_prefix is None else unit_prefix
self._unit_of_measurement: str | None = None
self._unit_prefix = UNIT_PREFIXES[unit_prefix]
self._unit_time = UNIT_TIME[unit_time]
self._unit_time_str = unit_time
self._attr_icon = "mdi:chart-histogram"
self._source_entity: str = source_entity
self._last_valid_state: Decimal | None = None
self._attr_device_info = device_info
self._attr_suggested_display_precision = round_digits or 2
def _calculate_unit(self, source_unit: str) -> str:
"""Multiply source_unit with time unit of the integral.
Possibly cancelling out a time unit in the denominator of the source_unit.
Note that this is a heuristic string manipulation method and might not
transform all source units in a sensible way.
Examples:
- Speed to distance: 'km/h' and 'h' will be transformed to 'km'
- Power to energy: 'W' and 'h' will be transformed to 'Wh'
"""
unit_time = self._unit_time_str
if source_unit.endswith(f"/{unit_time}"):
integral_unit = source_unit[0 : (-(1 + len(unit_time)))]
else:
integral_unit = f"{source_unit}{unit_time}"
return f"{self._unit_prefix_string}{integral_unit}"
def _derive_and_set_attributes_from_state(self, source_state: State) -> None:
source_unit = source_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
if source_unit is not None:
self._unit_of_measurement = self._calculate_unit(source_unit)
else:
# If the source has no defined unit we cannot derive a unit for the integral
self._unit_of_measurement = None
if (
self.device_class is None
and source_state.attributes.get(ATTR_DEVICE_CLASS)
== SensorDeviceClass.POWER
):
self._attr_device_class = SensorDeviceClass.ENERGY
self._attr_icon = None # Remove this sensors icon default and allow to fallback to the ENERGY default
def _update_integral(self, area: Decimal) -> None:
area_scaled = area / (self._unit_prefix * self._unit_time)
if isinstance(self._state, Decimal):
self._state += area_scaled
else:
self._state = area_scaled
_LOGGER.debug(
"area = %s, area_scaled = %s new state = %s", area, area_scaled, self._state
)
self._last_valid_state = self._state
async def async_added_to_hass(self) -> None:
"""Handle entity which will be added."""
await super().async_added_to_hass()
if (last_sensor_data := await self.async_get_last_sensor_data()) is not None:
self._state = (
Decimal(str(last_sensor_data.native_value))
if last_sensor_data.native_value
else last_sensor_data.last_valid_state
)
self._attr_native_value = last_sensor_data.native_value
self._unit_of_measurement = last_sensor_data.native_unit_of_measurement
self._last_valid_state = last_sensor_data.last_valid_state
_LOGGER.debug(
"Restored state %s and last_valid_state %s",
self._state,
self._last_valid_state,
)
elif (state := await self.async_get_last_state()) is not None:
# legacy to be removed on 2023.10 (we are keeping this to avoid losing data during the transition)
if state.state in [STATE_UNAVAILABLE, STATE_UNKNOWN]:
if state.state == STATE_UNAVAILABLE:
self._attr_available = False
else:
try:
self._state = Decimal(state.state)
except (DecimalException, ValueError) as err:
_LOGGER.warning(
"%s could not restore last state %s: %s",
self.entity_id,
state.state,
err,
)
self._attr_device_class = state.attributes.get(ATTR_DEVICE_CLASS)
self._unit_of_measurement = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
self.async_on_remove(
async_track_state_change_event(
self.hass,
[self._sensor_source_id],
self._handle_state_change,
)
)
@callback
def _handle_state_change(self, event: Event[EventStateChangedData]) -> None:
old_state = event.data["old_state"]
new_state = event.data["new_state"]
if old_state is None or new_state is None:
return
if new_state.state == STATE_UNAVAILABLE:
self._attr_available = False
self.async_write_ha_state()
return
self._attr_available = True
self._derive_and_set_attributes_from_state(new_state)
if not (states := self._method.validate_states(old_state, new_state)):
self.async_write_ha_state()
return
elapsed_seconds = Decimal(
(new_state.last_updated - old_state.last_updated).total_seconds()
)
area = self._method.calculate_area_with_two_states(elapsed_seconds, *states)
self._update_integral(area)
self.async_write_ha_state()
@property
def native_value(self) -> Decimal | None:
"""Return the state of the sensor."""
if isinstance(self._state, Decimal) and self._round_digits:
return round(self._state, self._round_digits)
return self._state
@property
def native_unit_of_measurement(self) -> str | None:
"""Return the unit the value is expressed in."""
return self._unit_of_measurement
@property
def extra_state_attributes(self) -> dict[str, str] | None:
"""Return the state attributes of the sensor."""
return {
ATTR_SOURCE_ID: self._source_entity,
}
@property
def extra_restore_state_data(self) -> IntegrationSensorExtraStoredData:
"""Return sensor specific state data to be restored."""
return IntegrationSensorExtraStoredData(
self.native_value,
self.native_unit_of_measurement,
self._source_entity,
self._last_valid_state,
)
async def async_get_last_sensor_data(
self,
) -> IntegrationSensorExtraStoredData | None:
"""Restore Utility Meter Sensor Extra Stored Data."""
if (restored_last_extra_data := await self.async_get_last_extra_data()) is None:
return None
return IntegrationSensorExtraStoredData.from_dict(
restored_last_extra_data.as_dict()
)