"""Platform allowing several sensors to be grouped into one sensor to provide numeric combinations.""" from __future__ import annotations from collections.abc import Callable from datetime import datetime import logging import statistics from typing import TYPE_CHECKING, Any import voluptuous as vol from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASS_UNITS, DEVICE_CLASSES_SCHEMA, DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, STATE_CLASSES_SCHEMA, UNIT_CONVERTERS, SensorDeviceClass, SensorEntity, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_CLASS, CONF_ENTITIES, CONF_NAME, CONF_TYPE, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import ( get_capability, get_device_class, get_unit_of_measurement, ) from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) from homeassistant.helpers.issue_registry import ( IssueSeverity, async_create_issue, async_delete_issue, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from .const import CONF_IGNORE_NON_NUMERIC, DOMAIN as GROUP_DOMAIN from .entity import GroupEntity DEFAULT_NAME = "Sensor Group" ATTR_MIN_VALUE = "min_value" ATTR_MIN_ENTITY_ID = "min_entity_id" ATTR_MAX_VALUE = "max_value" ATTR_MAX_ENTITY_ID = "max_entity_id" ATTR_MEAN = "mean" ATTR_MEDIAN = "median" ATTR_LAST = "last" ATTR_LAST_ENTITY_ID = "last_entity_id" ATTR_RANGE = "range" ATTR_STDEV = "stdev" ATTR_SUM = "sum" ATTR_PRODUCT = "product" SENSOR_TYPES = { ATTR_MIN_VALUE: "min", ATTR_MAX_VALUE: "max", ATTR_MEAN: "mean", ATTR_MEDIAN: "median", ATTR_LAST: "last", ATTR_RANGE: "range", ATTR_STDEV: "stdev", ATTR_SUM: "sum", ATTR_PRODUCT: "product", } SENSOR_TYPE_TO_ATTR = {v: k for k, v in SENSOR_TYPES.items()} # No limit on parallel updates to enable a group calling another group PARALLEL_UPDATES = 0 PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( { vol.Required(CONF_ENTITIES): cv.entities_domain( [SENSOR_DOMAIN, NUMBER_DOMAIN, INPUT_NUMBER_DOMAIN] ), vol.Required(CONF_TYPE): vol.All(cv.string, vol.In(SENSOR_TYPES.values())), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_IGNORE_NON_NUMERIC, default=False): cv.boolean, vol.Optional(CONF_UNIT_OF_MEASUREMENT): str, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, } ) _LOGGER = logging.getLogger(__name__) async def async_setup_platform( hass: HomeAssistant, config: ConfigType, async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Switch Group platform.""" async_add_entities( [ SensorGroup( hass, config.get(CONF_UNIQUE_ID), config[CONF_NAME], config[CONF_ENTITIES], config[CONF_IGNORE_NON_NUMERIC], config[CONF_TYPE], config.get(CONF_UNIT_OF_MEASUREMENT), config.get(CONF_STATE_CLASS), config.get(CONF_DEVICE_CLASS), ) ] ) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Switch Group config entry.""" registry = er.async_get(hass) entities = er.async_validate_entity_ids( registry, config_entry.options[CONF_ENTITIES] ) async_add_entities( [ SensorGroup( hass, config_entry.entry_id, config_entry.title, entities, config_entry.options.get(CONF_IGNORE_NON_NUMERIC, True), config_entry.options[CONF_TYPE], None, None, None, ) ] ) @callback def async_create_preview_sensor( hass: HomeAssistant, name: str, validated_config: dict[str, Any] ) -> SensorGroup: """Create a preview sensor.""" return SensorGroup( hass, None, name, validated_config[CONF_ENTITIES], validated_config.get(CONF_IGNORE_NON_NUMERIC, False), validated_config[CONF_TYPE], None, None, None, ) def _has_numeric_state(hass: HomeAssistant, entity_id: str) -> bool: """Test if state is numeric.""" if not (state := hass.states.get(entity_id)): return False try: float(state.state) except ValueError: return False return True def calc_min( sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float | None]: """Calculate min value.""" val: float | None = None entity_id: str | None = None for sensor_id, sensor_value, _ in sensor_values: if val is None or val > sensor_value: entity_id, val = sensor_id, sensor_value attributes = {ATTR_MIN_ENTITY_ID: entity_id} if TYPE_CHECKING: assert val is not None return attributes, val def calc_max( sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float | None]: """Calculate max value.""" val: float | None = None entity_id: str | None = None for sensor_id, sensor_value, _ in sensor_values: if val is None or val < sensor_value: entity_id, val = sensor_id, sensor_value attributes = {ATTR_MAX_ENTITY_ID: entity_id} if TYPE_CHECKING: assert val is not None return attributes, val def calc_mean( sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float | None]: """Calculate mean value.""" result = (sensor_value for _, sensor_value, _ in sensor_values) value: float = statistics.mean(result) return {}, value def calc_median( sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float | None]: """Calculate median value.""" result = (sensor_value for _, sensor_value, _ in sensor_values) value: float = statistics.median(result) return {}, value def calc_last( sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float | None]: """Calculate last value.""" last_updated: datetime | None = None last_entity_id: str | None = None last: float | None = None for entity_id, state_f, state in sensor_values: if last_updated is None or state.last_updated > last_updated: last_updated = state.last_updated last = state_f last_entity_id = entity_id attributes = {ATTR_LAST_ENTITY_ID: last_entity_id} return attributes, last def calc_range( sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float]: """Calculate range value.""" max_result = max((sensor_value for _, sensor_value, _ in sensor_values)) min_result = min((sensor_value for _, sensor_value, _ in sensor_values)) value: float = max_result - min_result return {}, value def calc_stdev( sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float]: """Calculate standard deviation value.""" result = (sensor_value for _, sensor_value, _ in sensor_values) value: float = statistics.stdev(result) return {}, value def calc_sum( sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float]: """Calculate a sum of values.""" result = 0.0 for _, sensor_value, _ in sensor_values: result += sensor_value return {}, result def calc_product( sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float]: """Calculate a product of values.""" result = 1.0 for _, sensor_value, _ in sensor_values: result *= sensor_value return {}, result CALC_TYPES: dict[ str, Callable[ [list[tuple[str, float, State]]], tuple[dict[str, str | None], float | None] ], ] = { "min": calc_min, "max": calc_max, "mean": calc_mean, "median": calc_median, "last": calc_last, "range": calc_range, "stdev": calc_stdev, "sum": calc_sum, "product": calc_product, } class SensorGroup(GroupEntity, SensorEntity): """Representation of a sensor group.""" _attr_available = False _attr_should_poll = False def __init__( self, hass: HomeAssistant, unique_id: str | None, name: str, entity_ids: list[str], ignore_non_numeric: bool, sensor_type: str, unit_of_measurement: str | None, state_class: SensorStateClass | None, device_class: SensorDeviceClass | None, ) -> None: """Initialize a sensor group.""" self.hass = hass self._entity_ids = entity_ids self._sensor_type = sensor_type self._configured_state_class = state_class self._configured_device_class = device_class self._configured_unit_of_measurement = unit_of_measurement self._valid_units: set[str | None] = set() self._can_convert: bool = False self._attr_name = name if name == DEFAULT_NAME: self._attr_name = f"{DEFAULT_NAME} {sensor_type}".capitalize() self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids} self._attr_unique_id = unique_id self._ignore_non_numeric = ignore_non_numeric self.mode = all if ignore_non_numeric is False else any self._state_calc: Callable[ [list[tuple[str, float, State]]], tuple[dict[str, str | None], float | None], ] = CALC_TYPES[self._sensor_type] self._state_incorrect: set[str] = set() self._extra_state_attribute: dict[str, Any] = {} def calculate_state_attributes(self, valid_state_entities: list[str]) -> None: """Calculate state attributes.""" self._attr_state_class = self._calculate_state_class( self._configured_state_class, valid_state_entities ) self._attr_device_class = self._calculate_device_class( self._configured_device_class, valid_state_entities ) self._attr_native_unit_of_measurement = self._calculate_unit_of_measurement( self._configured_unit_of_measurement, valid_state_entities ) self._valid_units = self._get_valid_units() @callback def async_update_group_state(self) -> None: """Query all members and determine the sensor group state.""" self.calculate_state_attributes(self._get_valid_entities()) states: list[StateType] = [] valid_units = self._valid_units valid_states: list[bool] = [] sensor_values: list[tuple[str, float, State]] = [] for entity_id in self._entity_ids: if (state := self.hass.states.get(entity_id)) is not None: states.append(state.state) try: numeric_state = float(state.state) uom = state.attributes.get("unit_of_measurement") # Convert the state to the native unit of measurement when we have valid units # and a correct device class if valid_units and uom in valid_units and self._can_convert is True: numeric_state = UNIT_CONVERTERS[self.device_class].convert( numeric_state, uom, self.native_unit_of_measurement ) # If we have valid units and the entity's unit does not match # we raise which skips the state and log a warning once if valid_units and uom not in valid_units: raise HomeAssistantError("Not a valid unit") # noqa: TRY301 sensor_values.append((entity_id, numeric_state, state)) if entity_id in self._state_incorrect: self._state_incorrect.remove(entity_id) valid_states.append(True) except ValueError: valid_states.append(False) # Log invalid states unless ignoring non numeric values if ( not self._ignore_non_numeric and entity_id not in self._state_incorrect ): self._state_incorrect.add(entity_id) _LOGGER.warning( "Unable to use state. Only numerical states are supported," " entity %s with value %s excluded from calculation in %s", entity_id, state.state, self.entity_id, ) continue except (KeyError, HomeAssistantError): # This exception handling can be simplified # once sensor entity doesn't allow incorrect unit of measurement # with a device class, implementation see PR #107639 valid_states.append(False) if entity_id not in self._state_incorrect: self._state_incorrect.add(entity_id) _LOGGER.warning( "Unable to use state. Only entities with correct unit of measurement" " is supported," " entity %s, value %s with device class %s" " and unit of measurement %s excluded from calculation in %s", entity_id, state.state, self.device_class, state.attributes.get("unit_of_measurement"), self.entity_id, ) # Set group as unavailable if all members do not have numeric values self._attr_available = any(numeric_state for numeric_state in valid_states) valid_state = self.mode( state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in states ) valid_state_numeric = self.mode(numeric_state for numeric_state in valid_states) if not valid_state or not valid_state_numeric: self._attr_native_value = None return # Calculate values self._extra_state_attribute, self._attr_native_value = self._state_calc( sensor_values ) @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the sensor.""" return {ATTR_ENTITY_ID: self._entity_ids, **self._extra_state_attribute} @property def icon(self) -> str | None: """Return the icon. Only override the icon if the device class is not set. """ if not self.device_class: return "mdi:calculator" return None def _calculate_state_class( self, state_class: SensorStateClass | None, valid_state_entities: list[str], ) -> SensorStateClass | None: """Calculate state class. If user has configured a state class we will use that. If a state class is not set then test if same state class on source entities and use that. Otherwise return no state class. """ if state_class: return state_class if not valid_state_entities: return None if not self._ignore_non_numeric and len(valid_state_entities) < len( self._entity_ids ): # Only return state class if all states are valid when not ignoring non numeric return None state_classes: list[SensorStateClass] = [] for entity_id in valid_state_entities: try: _state_class = get_capability(self.hass, entity_id, "state_class") except HomeAssistantError: return None if not _state_class: return None state_classes.append(_state_class) if all(x == state_classes[0] for x in state_classes): async_delete_issue( self.hass, SENSOR_DOMAIN, f"{self.entity_id}_state_classes_not_matching" ) return state_classes[0] async_create_issue( self.hass, GROUP_DOMAIN, f"{self.entity_id}_state_classes_not_matching", is_fixable=False, is_persistent=False, severity=IssueSeverity.WARNING, translation_key="state_classes_not_matching", translation_placeholders={ "entity_id": self.entity_id, "source_entities": ", ".join(self._entity_ids), "state_classes": ", ".join(state_classes), }, ) return None def _calculate_device_class( self, device_class: SensorDeviceClass | None, valid_state_entities: list[str], ) -> SensorDeviceClass | None: """Calculate device class. If user has configured a device class we will use that. If a device class is not set then test if same device class on source entities and use that. Otherwise return no device class. """ if device_class: return device_class if not valid_state_entities: return None if not self._ignore_non_numeric and len(valid_state_entities) < len( self._entity_ids ): # Only return device class if all states are valid when not ignoring non numeric return None device_classes: list[SensorDeviceClass] = [] for entity_id in valid_state_entities: try: _device_class = get_device_class(self.hass, entity_id) except HomeAssistantError: return None if not _device_class: return None device_classes.append(SensorDeviceClass(_device_class)) if all(x == device_classes[0] for x in device_classes): async_delete_issue( self.hass, SENSOR_DOMAIN, f"{self.entity_id}_device_classes_not_matching", ) return device_classes[0] async_create_issue( self.hass, GROUP_DOMAIN, f"{self.entity_id}_device_classes_not_matching", is_fixable=False, is_persistent=False, severity=IssueSeverity.WARNING, translation_key="device_classes_not_matching", translation_placeholders={ "entity_id": self.entity_id, "source_entities": ", ".join(self._entity_ids), "device_classes": ", ".join(device_classes), }, ) return None def _calculate_unit_of_measurement( self, unit_of_measurement: str | None, valid_state_entities: list[str], ) -> str | None: """Calculate the unit of measurement. If user has configured a unit of measurement we will use that. If a device class is set then test if unit of measurements are compatible. If no device class or uom's not compatible we will use no unit of measurement. """ if unit_of_measurement: return unit_of_measurement if not valid_state_entities: return None if not self._ignore_non_numeric and len(valid_state_entities) < len( self._entity_ids ): # Only return device class if all states are valid when not ignoring non numeric return None unit_of_measurements: list[str] = [] for entity_id in valid_state_entities: try: _unit_of_measurement = get_unit_of_measurement(self.hass, entity_id) except HomeAssistantError: return None if not _unit_of_measurement: return None unit_of_measurements.append(_unit_of_measurement) # Ensure only valid unit of measurements for the specific device class can be used if ( ( # Test if uom's in device class is convertible (device_class := self.device_class) in UNIT_CONVERTERS and all( uom in UNIT_CONVERTERS[device_class].VALID_UNITS for uom in unit_of_measurements ) ) or ( # Test if uom's in device class is not convertible device_class and device_class not in UNIT_CONVERTERS and device_class in DEVICE_CLASS_UNITS and all( uom in DEVICE_CLASS_UNITS[device_class] for uom in unit_of_measurements ) ) or ( # Test no device class and all uom's are same device_class is None and all(x == unit_of_measurements[0] for x in unit_of_measurements) ) ): async_delete_issue( self.hass, SENSOR_DOMAIN, f"{self.entity_id}_uoms_not_matching_device_class", ) async_delete_issue( self.hass, SENSOR_DOMAIN, f"{self.entity_id}_uoms_not_matching_no_device_class", ) return unit_of_measurements[0] if device_class: async_create_issue( self.hass, GROUP_DOMAIN, f"{self.entity_id}_uoms_not_matching_device_class", is_fixable=False, is_persistent=False, severity=IssueSeverity.WARNING, translation_key="uoms_not_matching_device_class", translation_placeholders={ "entity_id": self.entity_id, "device_class": device_class, "source_entities": ", ".join(self._entity_ids), "uoms": ", ".join(unit_of_measurements), }, ) else: async_create_issue( self.hass, GROUP_DOMAIN, f"{self.entity_id}_uoms_not_matching_no_device_class", is_fixable=False, is_persistent=False, severity=IssueSeverity.WARNING, translation_key="uoms_not_matching_no_device_class", translation_placeholders={ "entity_id": self.entity_id, "source_entities": ", ".join(self._entity_ids), "uoms": ", ".join(unit_of_measurements), }, ) return None def _get_valid_units(self) -> set[str | None]: """Return valid units. If device class is set and compatible unit of measurements. If device class is not set, use one unit of measurement. Only calculate valid units if there are no valid units set. """ if (valid_units := self._valid_units) and not self._ignore_non_numeric: # If we have valid units already and not using ignore_non_numeric # we should not recalculate. return valid_units native_uom = self.native_unit_of_measurement if (device_class := self.device_class) in UNIT_CONVERTERS and native_uom: self._can_convert = True return UNIT_CONVERTERS[device_class].VALID_UNITS if device_class and (device_class) in DEVICE_CLASS_UNITS and native_uom: valid_uoms: set = DEVICE_CLASS_UNITS[device_class] return valid_uoms if device_class is None and native_uom: return {native_uom} return set() def _get_valid_entities( self, ) -> list[str]: """Return list of valid entities.""" return [ entity_id for entity_id in self._entity_ids if _has_numeric_state(self.hass, entity_id) ]