1 """Platform allowing several sensors to be grouped into one sensor to provide numeric combinations."""
3 from __future__
import annotations
5 from collections.abc
import Callable
6 from datetime
import datetime
9 from typing
import TYPE_CHECKING, Any
11 import voluptuous
as vol
18 DEVICE_CLASSES_SCHEMA,
19 DOMAIN
as SENSOR_DOMAIN,
20 PLATFORM_SCHEMA
as SENSOR_PLATFORM_SCHEMA,
35 CONF_UNIT_OF_MEASUREMENT,
45 get_unit_of_measurement,
55 from .const
import CONF_IGNORE_NON_NUMERIC, DOMAIN
as GROUP_DOMAIN
56 from .entity
import GroupEntity
58 DEFAULT_NAME =
"Sensor Group"
60 ATTR_MIN_VALUE =
"min_value"
61 ATTR_MIN_ENTITY_ID =
"min_entity_id"
62 ATTR_MAX_VALUE =
"max_value"
63 ATTR_MAX_ENTITY_ID =
"max_entity_id"
65 ATTR_MEDIAN =
"median"
67 ATTR_LAST_ENTITY_ID =
"last_entity_id"
71 ATTR_PRODUCT =
"product"
73 ATTR_MIN_VALUE:
"min",
74 ATTR_MAX_VALUE:
"max",
76 ATTR_MEDIAN:
"median",
81 ATTR_PRODUCT:
"product",
83 SENSOR_TYPE_TO_ATTR = {v: k
for k, v
in SENSOR_TYPES.items()}
88 PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
90 vol.Required(CONF_ENTITIES): cv.entities_domain(
91 [SENSOR_DOMAIN, NUMBER_DOMAIN, INPUT_NUMBER_DOMAIN]
93 vol.Required(CONF_TYPE): vol.All(cv.string, vol.In(SENSOR_TYPES.values())),
94 vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
95 vol.Optional(CONF_UNIQUE_ID): cv.string,
96 vol.Optional(CONF_IGNORE_NON_NUMERIC, default=
False): cv.boolean,
97 vol.Optional(CONF_UNIT_OF_MEASUREMENT): str,
98 vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
99 vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA,
103 _LOGGER = logging.getLogger(__name__)
109 async_add_entities: AddEntitiesCallback,
110 discovery_info: DiscoveryInfoType |
None =
None,
112 """Set up the Switch Group platform."""
117 config.get(CONF_UNIQUE_ID),
119 config[CONF_ENTITIES],
120 config[CONF_IGNORE_NON_NUMERIC],
122 config.get(CONF_UNIT_OF_MEASUREMENT),
123 config.get(CONF_STATE_CLASS),
124 config.get(CONF_DEVICE_CLASS),
132 config_entry: ConfigEntry,
133 async_add_entities: AddEntitiesCallback,
135 """Initialize Switch Group config entry."""
136 registry = er.async_get(hass)
137 entities = er.async_validate_entity_ids(
138 registry, config_entry.options[CONF_ENTITIES]
144 config_entry.entry_id,
147 config_entry.options.get(CONF_IGNORE_NON_NUMERIC,
True),
148 config_entry.options[CONF_TYPE],
159 hass: HomeAssistant, name: str, validated_config: dict[str, Any]
161 """Create a preview sensor."""
166 validated_config[CONF_ENTITIES],
167 validated_config.get(CONF_IGNORE_NON_NUMERIC,
False),
168 validated_config[CONF_TYPE],
176 """Test if state is numeric."""
177 if not (state := hass.states.get(entity_id)):
187 sensor_values: list[tuple[str, float, State]],
188 ) -> tuple[dict[str, str |
None], float |
None]:
189 """Calculate min value."""
190 val: float |
None =
None
191 entity_id: str |
None =
None
192 for sensor_id, sensor_value, _
in sensor_values:
193 if val
is None or val > sensor_value:
194 entity_id, val = sensor_id, sensor_value
196 attributes = {ATTR_MIN_ENTITY_ID: entity_id}
198 assert val
is not None
199 return attributes, val
203 sensor_values: list[tuple[str, float, State]],
204 ) -> tuple[dict[str, str |
None], float |
None]:
205 """Calculate max value."""
206 val: float |
None =
None
207 entity_id: str |
None =
None
208 for sensor_id, sensor_value, _
in sensor_values:
209 if val
is None or val < sensor_value:
210 entity_id, val = sensor_id, sensor_value
212 attributes = {ATTR_MAX_ENTITY_ID: entity_id}
214 assert val
is not None
215 return attributes, val
219 sensor_values: list[tuple[str, float, State]],
220 ) -> tuple[dict[str, str |
None], float |
None]:
221 """Calculate mean value."""
222 result = (sensor_value
for _, sensor_value, _
in sensor_values)
224 value: float = statistics.mean(result)
229 sensor_values: list[tuple[str, float, State]],
230 ) -> tuple[dict[str, str |
None], float |
None]:
231 """Calculate median value."""
232 result = (sensor_value
for _, sensor_value, _
in sensor_values)
234 value: float = statistics.median(result)
239 sensor_values: list[tuple[str, float, State]],
240 ) -> tuple[dict[str, str |
None], float |
None]:
241 """Calculate last value."""
242 last_updated: datetime |
None =
None
243 last_entity_id: str |
None =
None
244 last: float |
None =
None
245 for entity_id, state_f, state
in sensor_values:
246 if last_updated
is None or state.last_updated > last_updated:
247 last_updated = state.last_updated
249 last_entity_id = entity_id
251 attributes = {ATTR_LAST_ENTITY_ID: last_entity_id}
252 return attributes, last
256 sensor_values: list[tuple[str, float, State]],
257 ) -> tuple[dict[str, str |
None], float]:
258 """Calculate range value."""
259 max_result =
max((sensor_value
for _, sensor_value, _
in sensor_values))
260 min_result =
min((sensor_value
for _, sensor_value, _
in sensor_values))
262 value: float = max_result - min_result
267 sensor_values: list[tuple[str, float, State]],
268 ) -> tuple[dict[str, str |
None], float]:
269 """Calculate standard deviation value."""
270 result = (sensor_value
for _, sensor_value, _
in sensor_values)
272 value: float = statistics.stdev(result)
277 sensor_values: list[tuple[str, float, State]],
278 ) -> tuple[dict[str, str |
None], float]:
279 """Calculate a sum of values."""
281 for _, sensor_value, _
in sensor_values:
282 result += sensor_value
288 sensor_values: list[tuple[str, float, State]],
289 ) -> tuple[dict[str, str |
None], float]:
290 """Calculate a product of values."""
292 for _, sensor_value, _
in sensor_values:
293 result *= sensor_value
301 [list[tuple[str, float, State]]], tuple[dict[str, str |
None], float |
None]
307 "median": calc_median,
312 "product": calc_product,
317 """Representation of a sensor group."""
319 _attr_available =
False
320 _attr_should_poll =
False
325 unique_id: str |
None,
327 entity_ids: list[str],
328 ignore_non_numeric: bool,
330 unit_of_measurement: str |
None,
331 state_class: SensorStateClass |
None,
332 device_class: SensorDeviceClass |
None,
334 """Initialize a sensor group."""
344 if name == DEFAULT_NAME:
345 self.
_attr_name_attr_name = f
"{DEFAULT_NAME} {sensor_type}".capitalize()
349 self.
modemode = all
if ignore_non_numeric
is False else any
350 self._state_calc: Callable[
351 [list[tuple[str, float, State]]],
352 tuple[dict[str, str |
None], float |
None],
354 self._state_incorrect: set[str] = set()
355 self._extra_state_attribute: dict[str, Any] = {}
358 """Calculate state attributes."""
372 """Query all members and determine the sensor group state."""
374 states: list[StateType] = []
376 valid_states: list[bool] = []
377 sensor_values: list[tuple[str, float, State]] = []
379 if (state := self.
hasshasshass.states.get(entity_id))
is not None:
380 states.append(state.state)
382 numeric_state =
float(state.state)
383 uom = state.attributes.get(
"unit_of_measurement")
387 if valid_units
and uom
in valid_units
and self.
_can_convert_can_convert
is True:
394 if valid_units
and uom
not in valid_units:
397 sensor_values.append((entity_id, numeric_state, state))
398 if entity_id
in self._state_incorrect:
399 self._state_incorrect.
remove(entity_id)
400 valid_states.append(
True)
402 valid_states.append(
False)
406 and entity_id
not in self._state_incorrect
408 self._state_incorrect.
add(entity_id)
410 "Unable to use state. Only numerical states are supported,"
411 " entity %s with value %s excluded from calculation in %s",
417 except (KeyError, HomeAssistantError):
421 valid_states.append(
False)
422 if entity_id
not in self._state_incorrect:
423 self._state_incorrect.
add(entity_id)
425 "Unable to use state. Only entities with correct unit of measurement"
427 " entity %s, value %s with device class %s"
428 " and unit of measurement %s excluded from calculation in %s",
432 state.attributes.get(
"unit_of_measurement"),
439 valid_state = self.
modemode(
440 state
not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
for state
in states
442 valid_state_numeric = self.
modemode(numeric_state
for numeric_state
in valid_states)
444 if not valid_state
or not valid_state_numeric:
449 self._extra_state_attribute, self.
_attr_native_value_attr_native_value = self._state_calc(
455 """Return the state attributes of the sensor."""
456 return {ATTR_ENTITY_ID: self.
_entity_ids_entity_ids, **self._extra_state_attribute}
462 Only override the icon if the device class is not set.
465 return "mdi:calculator"
470 state_class: SensorStateClass |
None,
471 valid_state_entities: list[str],
472 ) -> SensorStateClass |
None:
473 """Calculate state class.
475 If user has configured a state class we will use that.
476 If a state class is not set then test if same state class
477 on source entities and use that.
478 Otherwise return no state class.
483 if not valid_state_entities:
492 state_classes: list[SensorStateClass] = []
493 for entity_id
in valid_state_entities:
496 except HomeAssistantError:
500 state_classes.append(_state_class)
502 if all(x == state_classes[0]
for x
in state_classes):
504 self.
hasshasshass, SENSOR_DOMAIN, f
"{self.entity_id}_state_classes_not_matching"
506 return state_classes[0]
510 f
"{self.entity_id}_state_classes_not_matching",
513 severity=IssueSeverity.WARNING,
514 translation_key=
"state_classes_not_matching",
515 translation_placeholders={
517 "source_entities":
", ".join(self.
_entity_ids_entity_ids),
518 "state_classes":
", ".join(state_classes),
525 device_class: SensorDeviceClass |
None,
526 valid_state_entities: list[str],
527 ) -> SensorDeviceClass |
None:
528 """Calculate device class.
530 If user has configured a device class we will use that.
531 If a device class is not set then test if same device class
532 on source entities and use that.
533 Otherwise return no device class.
538 if not valid_state_entities:
547 device_classes: list[SensorDeviceClass] = []
548 for entity_id
in valid_state_entities:
551 except HomeAssistantError:
553 if not _device_class:
555 device_classes.append(SensorDeviceClass(_device_class))
557 if all(x == device_classes[0]
for x
in device_classes):
561 f
"{self.entity_id}_device_classes_not_matching",
563 return device_classes[0]
567 f
"{self.entity_id}_device_classes_not_matching",
570 severity=IssueSeverity.WARNING,
571 translation_key=
"device_classes_not_matching",
572 translation_placeholders={
574 "source_entities":
", ".join(self.
_entity_ids_entity_ids),
575 "device_classes":
", ".join(device_classes),
582 unit_of_measurement: str |
None,
583 valid_state_entities: list[str],
585 """Calculate the unit of measurement.
587 If user has configured a unit of measurement we will use that.
588 If a device class is set then test if unit of measurements are compatible.
589 If no device class or uom's not compatible we will use no unit of measurement.
591 if unit_of_measurement:
592 return unit_of_measurement
594 if not valid_state_entities:
603 unit_of_measurements: list[str] = []
604 for entity_id
in valid_state_entities:
607 except HomeAssistantError:
609 if not _unit_of_measurement:
611 unit_of_measurements.append(_unit_of_measurement)
619 uom
in UNIT_CONVERTERS[device_class].VALID_UNITS
620 for uom
in unit_of_measurements
626 and device_class
not in UNIT_CONVERTERS
627 and device_class
in DEVICE_CLASS_UNITS
629 uom
in DEVICE_CLASS_UNITS[device_class]
630 for uom
in unit_of_measurements
636 and all(x == unit_of_measurements[0]
for x
in unit_of_measurements)
642 f
"{self.entity_id}_uoms_not_matching_device_class",
647 f
"{self.entity_id}_uoms_not_matching_no_device_class",
649 return unit_of_measurements[0]
655 f
"{self.entity_id}_uoms_not_matching_device_class",
658 severity=IssueSeverity.WARNING,
659 translation_key=
"uoms_not_matching_device_class",
660 translation_placeholders={
662 "device_class": device_class,
663 "source_entities":
", ".join(self.
_entity_ids_entity_ids),
664 "uoms":
", ".join(unit_of_measurements),
671 f
"{self.entity_id}_uoms_not_matching_no_device_class",
674 severity=IssueSeverity.WARNING,
675 translation_key=
"uoms_not_matching_no_device_class",
676 translation_placeholders={
678 "source_entities":
", ".join(self.
_entity_ids_entity_ids),
679 "uoms":
", ".join(unit_of_measurements),
685 """Return valid units.
687 If device class is set and compatible unit of measurements.
688 If device class is not set, use one unit of measurement.
689 Only calculate valid units if there are no valid units set.
699 return UNIT_CONVERTERS[device_class].VALID_UNITS
700 if device_class
and (device_class)
in DEVICE_CLASS_UNITS
and native_uom:
701 valid_uoms: set = DEVICE_CLASS_UNITS[device_class]
703 if device_class
is None and native_uom:
710 """Return list of valid entities."""
str|None _calculate_unit_of_measurement(self, str|None unit_of_measurement, list[str] valid_state_entities)
None __init__(self, HomeAssistant hass, str|None unique_id, str name, list[str] entity_ids, bool ignore_non_numeric, str sensor_type, str|None unit_of_measurement, SensorStateClass|None state_class, SensorDeviceClass|None device_class)
dict[str, Any] extra_state_attributes(self)
None async_update_group_state(self)
_attr_native_unit_of_measurement
SensorStateClass|None _calculate_state_class(self, SensorStateClass|None state_class, list[str] valid_state_entities)
_attr_extra_state_attributes
SensorDeviceClass|None _calculate_device_class(self, SensorDeviceClass|None device_class, list[str] valid_state_entities)
set[str|None] _get_valid_units(self)
list[str] _get_valid_entities(self)
None calculate_state_attributes(self, list[str] valid_state_entities)
_configured_unit_of_measurement
SensorDeviceClass|None device_class(self)
str|None native_unit_of_measurement(self)
str|None device_class(self)
bool add(self, _T matcher)
bool remove(self, _T matcher)
tuple[dict[str, str|None], float] calc_stdev(list[tuple[str, float, State]] sensor_values)
tuple[dict[str, str|None], float|None] calc_min(list[tuple[str, float, State]] sensor_values)
tuple[dict[str, str|None], float|None] calc_mean(list[tuple[str, float, State]] sensor_values)
SensorGroup async_create_preview_sensor(HomeAssistant hass, str name, dict[str, Any] validated_config)
tuple[dict[str, str|None], float|None] calc_max(list[tuple[str, float, State]] sensor_values)
tuple[dict[str, str|None], float] calc_sum(list[tuple[str, float, State]] sensor_values)
tuple[dict[str, str|None], float|None] calc_median(list[tuple[str, float, State]] sensor_values)
None async_setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback async_add_entities, DiscoveryInfoType|None discovery_info=None)
tuple[dict[str, str|None], float] calc_range(list[tuple[str, float, State]] sensor_values)
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
tuple[dict[str, str|None], float|None] calc_last(list[tuple[str, float, State]] sensor_values)
bool _has_numeric_state(HomeAssistant hass, str entity_id)
tuple[dict[str, str|None], float] calc_product(list[tuple[str, float, State]] sensor_values)
None async_create_issue(HomeAssistant hass, str entry_id)
None async_delete_issue(HomeAssistant hass, str entry_id)
str|None get_device_class(HomeAssistant hass, str entity_id)
Any|None get_capability(HomeAssistant hass, str entity_id, str capability)
str|None get_unit_of_measurement(HomeAssistant hass, str entity_id)