mirror of
https://github.com/home-assistant/core.git
synced 2025-07-22 04:37:06 +00:00
Group sensor calculate attributes (#106972)
* Group sensor calculate attributes * Use entity helpers * Fix sensor tests * Test change of uom * Add tests and fix UoM issue * Fix test * Fix state class * repair and logs * delete issues * pass through hass * Update descriotion text to be more descriptive * Comments * Add pr to comment * fix if in updating * Fix test valid units * Fix strings * Fix issues
This commit is contained in:
parent
329eca4918
commit
f9a4840ce2
@ -89,7 +89,7 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_create_preview_binary_sensor(
|
def async_create_preview_binary_sensor(
|
||||||
name: str, validated_config: dict[str, Any]
|
hass: HomeAssistant, name: str, validated_config: dict[str, Any]
|
||||||
) -> BinarySensorGroup:
|
) -> BinarySensorGroup:
|
||||||
"""Create a preview sensor."""
|
"""Create a preview sensor."""
|
||||||
return BinarySensorGroup(
|
return BinarySensorGroup(
|
||||||
|
@ -269,7 +269,7 @@ PREVIEW_OPTIONS_SCHEMA: dict[str, vol.Schema] = {}
|
|||||||
|
|
||||||
CREATE_PREVIEW_ENTITY: dict[
|
CREATE_PREVIEW_ENTITY: dict[
|
||||||
str,
|
str,
|
||||||
Callable[[str, dict[str, Any]], GroupEntity | MediaPlayerGroup],
|
Callable[[HomeAssistant, str, dict[str, Any]], GroupEntity | MediaPlayerGroup],
|
||||||
] = {
|
] = {
|
||||||
"binary_sensor": async_create_preview_binary_sensor,
|
"binary_sensor": async_create_preview_binary_sensor,
|
||||||
"cover": async_create_preview_cover,
|
"cover": async_create_preview_cover,
|
||||||
@ -392,7 +392,9 @@ def ws_start_preview(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
preview_entity = CREATE_PREVIEW_ENTITY[group_type](name, validated)
|
preview_entity: GroupEntity | MediaPlayerGroup = CREATE_PREVIEW_ENTITY[group_type](
|
||||||
|
hass, name, validated
|
||||||
|
)
|
||||||
preview_entity.hass = hass
|
preview_entity.hass = hass
|
||||||
preview_entity.registry_entry = entity_registry_entry
|
preview_entity.registry_entry = entity_registry_entry
|
||||||
|
|
||||||
|
@ -97,7 +97,7 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_create_preview_cover(
|
def async_create_preview_cover(
|
||||||
name: str, validated_config: dict[str, Any]
|
hass: HomeAssistant, name: str, validated_config: dict[str, Any]
|
||||||
) -> CoverGroup:
|
) -> CoverGroup:
|
||||||
"""Create a preview sensor."""
|
"""Create a preview sensor."""
|
||||||
return CoverGroup(
|
return CoverGroup(
|
||||||
|
@ -90,7 +90,7 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_create_preview_event(
|
def async_create_preview_event(
|
||||||
name: str, validated_config: dict[str, Any]
|
hass: HomeAssistant, name: str, validated_config: dict[str, Any]
|
||||||
) -> EventGroup:
|
) -> EventGroup:
|
||||||
"""Create a preview sensor."""
|
"""Create a preview sensor."""
|
||||||
return EventGroup(
|
return EventGroup(
|
||||||
|
@ -91,7 +91,9 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_create_preview_fan(name: str, validated_config: dict[str, Any]) -> FanGroup:
|
def async_create_preview_fan(
|
||||||
|
hass: HomeAssistant, name: str, validated_config: dict[str, Any]
|
||||||
|
) -> FanGroup:
|
||||||
"""Create a preview sensor."""
|
"""Create a preview sensor."""
|
||||||
return FanGroup(
|
return FanGroup(
|
||||||
None,
|
None,
|
||||||
|
@ -112,7 +112,7 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_create_preview_light(
|
def async_create_preview_light(
|
||||||
name: str, validated_config: dict[str, Any]
|
hass: HomeAssistant, name: str, validated_config: dict[str, Any]
|
||||||
) -> LightGroup:
|
) -> LightGroup:
|
||||||
"""Create a preview sensor."""
|
"""Create a preview sensor."""
|
||||||
return LightGroup(
|
return LightGroup(
|
||||||
|
@ -91,7 +91,9 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_create_preview_lock(name: str, validated_config: dict[str, Any]) -> LockGroup:
|
def async_create_preview_lock(
|
||||||
|
hass: HomeAssistant, name: str, validated_config: dict[str, Any]
|
||||||
|
) -> LockGroup:
|
||||||
"""Create a preview sensor."""
|
"""Create a preview sensor."""
|
||||||
return LockGroup(
|
return LockGroup(
|
||||||
None,
|
None,
|
||||||
|
@ -109,7 +109,7 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_create_preview_media_player(
|
def async_create_preview_media_player(
|
||||||
name: str, validated_config: dict[str, Any]
|
hass: HomeAssistant, name: str, validated_config: dict[str, Any]
|
||||||
) -> MediaPlayerGroup:
|
) -> MediaPlayerGroup:
|
||||||
"""Create a preview sensor."""
|
"""Create a preview sensor."""
|
||||||
return MediaPlayerGroup(
|
return MediaPlayerGroup(
|
||||||
|
@ -17,6 +17,7 @@ from homeassistant.components.sensor import (
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA,
|
PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA,
|
||||||
STATE_CLASSES_SCHEMA,
|
STATE_CLASSES_SCHEMA,
|
||||||
|
UNIT_CONVERTERS,
|
||||||
SensorDeviceClass,
|
SensorDeviceClass,
|
||||||
SensorEntity,
|
SensorEntity,
|
||||||
SensorStateClass,
|
SensorStateClass,
|
||||||
@ -34,11 +35,22 @@ from homeassistant.const import (
|
|||||||
STATE_UNKNOWN,
|
STATE_UNKNOWN,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, State, callback
|
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 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 AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.issue_registry import (
|
||||||
|
IssueSeverity,
|
||||||
|
async_create_issue,
|
||||||
|
async_delete_issue,
|
||||||
|
)
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
|
||||||
|
|
||||||
from . import GroupEntity
|
from . import DOMAIN as GROUP_DOMAIN, GroupEntity
|
||||||
from .const import CONF_IGNORE_NON_NUMERIC
|
from .const import CONF_IGNORE_NON_NUMERIC
|
||||||
|
|
||||||
DEFAULT_NAME = "Sensor Group"
|
DEFAULT_NAME = "Sensor Group"
|
||||||
@ -97,6 +109,7 @@ async def async_setup_platform(
|
|||||||
async_add_entities(
|
async_add_entities(
|
||||||
[
|
[
|
||||||
SensorGroup(
|
SensorGroup(
|
||||||
|
hass,
|
||||||
config.get(CONF_UNIQUE_ID),
|
config.get(CONF_UNIQUE_ID),
|
||||||
config[CONF_NAME],
|
config[CONF_NAME],
|
||||||
config[CONF_ENTITIES],
|
config[CONF_ENTITIES],
|
||||||
@ -123,6 +136,7 @@ async def async_setup_entry(
|
|||||||
async_add_entities(
|
async_add_entities(
|
||||||
[
|
[
|
||||||
SensorGroup(
|
SensorGroup(
|
||||||
|
hass,
|
||||||
config_entry.entry_id,
|
config_entry.entry_id,
|
||||||
config_entry.title,
|
config_entry.title,
|
||||||
entities,
|
entities,
|
||||||
@ -138,10 +152,11 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_create_preview_sensor(
|
def async_create_preview_sensor(
|
||||||
name: str, validated_config: dict[str, Any]
|
hass: HomeAssistant, name: str, validated_config: dict[str, Any]
|
||||||
) -> SensorGroup:
|
) -> SensorGroup:
|
||||||
"""Create a preview sensor."""
|
"""Create a preview sensor."""
|
||||||
return SensorGroup(
|
return SensorGroup(
|
||||||
|
hass,
|
||||||
None,
|
None,
|
||||||
name,
|
name,
|
||||||
validated_config[CONF_ENTITIES],
|
validated_config[CONF_ENTITIES],
|
||||||
@ -280,6 +295,7 @@ class SensorGroup(GroupEntity, SensorEntity):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
unique_id: str | None,
|
unique_id: str | None,
|
||||||
name: str,
|
name: str,
|
||||||
entity_ids: list[str],
|
entity_ids: list[str],
|
||||||
@ -290,14 +306,13 @@ class SensorGroup(GroupEntity, SensorEntity):
|
|||||||
device_class: SensorDeviceClass | None,
|
device_class: SensorDeviceClass | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize a sensor group."""
|
"""Initialize a sensor group."""
|
||||||
|
self.hass = hass
|
||||||
self._entity_ids = entity_ids
|
self._entity_ids = entity_ids
|
||||||
self._sensor_type = sensor_type
|
self._sensor_type = sensor_type
|
||||||
self._attr_state_class = state_class
|
self._state_class = state_class
|
||||||
self.calc_state_class: SensorStateClass | None = None
|
self._device_class = device_class
|
||||||
self._attr_device_class = device_class
|
self._native_unit_of_measurement = unit_of_measurement
|
||||||
self.calc_device_class: SensorDeviceClass | None = None
|
self._valid_units: set[str | None] = set()
|
||||||
self._attr_native_unit_of_measurement = unit_of_measurement
|
|
||||||
self.calc_unit_of_measurement: str | None = None
|
|
||||||
self._attr_name = name
|
self._attr_name = name
|
||||||
if name == DEFAULT_NAME:
|
if name == DEFAULT_NAME:
|
||||||
self._attr_name = f"{DEFAULT_NAME} {sensor_type}".capitalize()
|
self._attr_name = f"{DEFAULT_NAME} {sensor_type}".capitalize()
|
||||||
@ -311,6 +326,16 @@ class SensorGroup(GroupEntity, SensorEntity):
|
|||||||
self._state_incorrect: set[str] = set()
|
self._state_incorrect: set[str] = set()
|
||||||
self._extra_state_attribute: dict[str, Any] = {}
|
self._extra_state_attribute: dict[str, Any] = {}
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
"""When added to hass."""
|
||||||
|
self._attr_state_class = self._calculate_state_class(self._state_class)
|
||||||
|
self._attr_device_class = self._calculate_device_class(self._device_class)
|
||||||
|
self._attr_native_unit_of_measurement = self._calculate_unit_of_measurement(
|
||||||
|
self._native_unit_of_measurement
|
||||||
|
)
|
||||||
|
self._valid_units = self._get_valid_units()
|
||||||
|
await super().async_added_to_hass()
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_update_group_state(self) -> None:
|
def async_update_group_state(self) -> None:
|
||||||
"""Query all members and determine the sensor group state."""
|
"""Query all members and determine the sensor group state."""
|
||||||
@ -321,7 +346,16 @@ class SensorGroup(GroupEntity, SensorEntity):
|
|||||||
if (state := self.hass.states.get(entity_id)) is not None:
|
if (state := self.hass.states.get(entity_id)) is not None:
|
||||||
states.append(state.state)
|
states.append(state.state)
|
||||||
try:
|
try:
|
||||||
sensor_values.append((entity_id, float(state.state), state))
|
numeric_state = float(state.state)
|
||||||
|
if (
|
||||||
|
self._valid_units
|
||||||
|
and (uom := state.attributes["unit_of_measurement"])
|
||||||
|
in self._valid_units
|
||||||
|
):
|
||||||
|
numeric_state = UNIT_CONVERTERS[self.device_class].convert(
|
||||||
|
numeric_state, uom, self.native_unit_of_measurement
|
||||||
|
)
|
||||||
|
sensor_values.append((entity_id, numeric_state, state))
|
||||||
if entity_id in self._state_incorrect:
|
if entity_id in self._state_incorrect:
|
||||||
self._state_incorrect.remove(entity_id)
|
self._state_incorrect.remove(entity_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@ -330,9 +364,29 @@ class SensorGroup(GroupEntity, SensorEntity):
|
|||||||
self._state_incorrect.add(entity_id)
|
self._state_incorrect.add(entity_id)
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Unable to use state. Only numerical states are supported,"
|
"Unable to use state. Only numerical states are supported,"
|
||||||
" entity %s with value %s excluded from calculation",
|
" entity %s with value %s excluded from calculation in %s",
|
||||||
entity_id,
|
entity_id,
|
||||||
state.state,
|
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 when having a device class,"
|
||||||
|
" 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,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
valid_states.append(True)
|
valid_states.append(True)
|
||||||
@ -350,7 +404,6 @@ class SensorGroup(GroupEntity, SensorEntity):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Calculate values
|
# Calculate values
|
||||||
self._calculate_entity_properties()
|
|
||||||
self._extra_state_attribute, self._attr_native_value = self._state_calc(
|
self._extra_state_attribute, self._attr_native_value = self._state_calc(
|
||||||
sensor_values
|
sensor_values
|
||||||
)
|
)
|
||||||
@ -360,13 +413,6 @@ class SensorGroup(GroupEntity, SensorEntity):
|
|||||||
"""Return the state attributes of the sensor."""
|
"""Return the state attributes of the sensor."""
|
||||||
return {ATTR_ENTITY_ID: self._entity_ids, **self._extra_state_attribute}
|
return {ATTR_ENTITY_ID: self._entity_ids, **self._extra_state_attribute}
|
||||||
|
|
||||||
@property
|
|
||||||
def device_class(self) -> SensorDeviceClass | None:
|
|
||||||
"""Return device class."""
|
|
||||||
if self._attr_device_class is not None:
|
|
||||||
return self._attr_device_class
|
|
||||||
return self.calc_device_class
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def icon(self) -> str | None:
|
def icon(self) -> str | None:
|
||||||
"""Return the icon.
|
"""Return the icon.
|
||||||
@ -377,59 +423,165 @@ class SensorGroup(GroupEntity, SensorEntity):
|
|||||||
return "mdi:calculator"
|
return "mdi:calculator"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
def _calculate_state_class(
|
||||||
def state_class(self) -> SensorStateClass | str | None:
|
self, state_class: SensorStateClass | None
|
||||||
"""Return state class."""
|
) -> SensorStateClass | None:
|
||||||
if self._attr_state_class is not None:
|
"""Calculate state class.
|
||||||
return self._attr_state_class
|
|
||||||
return self.calc_state_class
|
|
||||||
|
|
||||||
@property
|
|
||||||
def native_unit_of_measurement(self) -> str | None:
|
|
||||||
"""Return native unit of measurement."""
|
|
||||||
if self._attr_native_unit_of_measurement is not None:
|
|
||||||
return self._attr_native_unit_of_measurement
|
|
||||||
return self.calc_unit_of_measurement
|
|
||||||
|
|
||||||
def _calculate_entity_properties(self) -> None:
|
|
||||||
"""Calculate device_class, state_class and unit of measurement."""
|
|
||||||
device_classes = []
|
|
||||||
state_classes = []
|
|
||||||
unit_of_measurements = []
|
|
||||||
|
|
||||||
if (
|
|
||||||
self._attr_device_class
|
|
||||||
and self._attr_state_class
|
|
||||||
and self._attr_native_unit_of_measurement
|
|
||||||
):
|
|
||||||
return
|
|
||||||
|
|
||||||
|
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
|
||||||
|
state_classes: list[SensorStateClass] = []
|
||||||
for entity_id in self._entity_ids:
|
for entity_id in self._entity_ids:
|
||||||
if (state := self.hass.states.get(entity_id)) is not None:
|
try:
|
||||||
device_classes.append(state.attributes.get("device_class"))
|
_state_class = get_capability(self.hass, entity_id, "state_class")
|
||||||
state_classes.append(state.attributes.get("state_class"))
|
except HomeAssistantError:
|
||||||
unit_of_measurements.append(state.attributes.get("unit_of_measurement"))
|
return None
|
||||||
|
if not _state_class:
|
||||||
|
return None
|
||||||
|
state_classes.append(_state_class)
|
||||||
|
|
||||||
self.calc_device_class = None
|
if all(x == state_classes[0] for x in state_classes):
|
||||||
self.calc_state_class = None
|
async_delete_issue(
|
||||||
self.calc_unit_of_measurement = None
|
self.hass, 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
|
||||||
|
|
||||||
# Calculate properties and save if all same
|
def _calculate_device_class(
|
||||||
if (
|
self, device_class: SensorDeviceClass | None
|
||||||
not self._attr_device_class
|
) -> SensorDeviceClass | None:
|
||||||
and device_classes
|
"""Calculate device class.
|
||||||
and all(x == device_classes[0] for x in device_classes)
|
|
||||||
|
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
|
||||||
|
device_classes: list[SensorDeviceClass] = []
|
||||||
|
for entity_id in self._entity_ids:
|
||||||
|
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, 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
|
||||||
|
) -> 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
|
||||||
|
|
||||||
|
unit_of_measurements: list[str] = []
|
||||||
|
for entity_id in self._entity_ids:
|
||||||
|
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 (device_class := self.device_class) in UNIT_CONVERTERS and all(
|
||||||
|
x in UNIT_CONVERTERS[device_class].VALID_UNITS for x in unit_of_measurements
|
||||||
):
|
):
|
||||||
self.calc_device_class = device_classes[0]
|
async_delete_issue(
|
||||||
|
self.hass, DOMAIN, f"{self.entity_id}_uoms_not_matching_device_class"
|
||||||
|
)
|
||||||
|
async_delete_issue(
|
||||||
|
self.hass, 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 (
|
if (
|
||||||
not self._attr_state_class
|
device_class := self.device_class
|
||||||
and state_classes
|
) in UNIT_CONVERTERS and self.native_unit_of_measurement:
|
||||||
and all(x == state_classes[0] for x in state_classes)
|
return UNIT_CONVERTERS[device_class].VALID_UNITS
|
||||||
):
|
return set()
|
||||||
self.calc_state_class = state_classes[0]
|
|
||||||
if (
|
|
||||||
not self._attr_unit_of_measurement
|
|
||||||
and unit_of_measurements
|
|
||||||
and all(x == unit_of_measurements[0] for x in unit_of_measurements)
|
|
||||||
):
|
|
||||||
self.calc_unit_of_measurement = unit_of_measurements[0]
|
|
||||||
|
@ -249,5 +249,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"issues": {
|
||||||
|
"uoms_not_matching_device_class": {
|
||||||
|
"title": "Unit of measurements are not correct",
|
||||||
|
"description": "Unit of measurements `{uoms}` of input sensors `{source_entities}` are not compatible and can't be converted with the device class `{device_class}` of sensor group `{entity_id}`.\n\nPlease correct the unit of measurements on the source entities and reload the group sensor to fix this issue."
|
||||||
|
},
|
||||||
|
"uoms_not_matching_no_device_class": {
|
||||||
|
"title": "Unit of measurements is not correct",
|
||||||
|
"description": "Unit of measurements `{uoms}` of input sensors `{source_entities}` are not compatible using no device class of sensor group `{entity_id}`.\n\nPlease correct the unit of measurements on the source entities or set a proper device class on the sensor group and reload the group sensor to fix this issue."
|
||||||
|
},
|
||||||
|
"device_classes_not_matching": {
|
||||||
|
"title": "Device classes is not correct",
|
||||||
|
"description": "Device classes `{device_classes}` on source entities `{source_entities}` needs to be same for sensor group `{entity_id}`.\n\nPlease correct the device classes on the source entities and reload the group sensor to fix this issue."
|
||||||
|
},
|
||||||
|
"state_classes_not_matching": {
|
||||||
|
"title": "State classes is not correct",
|
||||||
|
"description": "Device classes `{state_classes}` on source entities `{source_entities}` needs to be same for sensor group `{entity_id}`.\n\nPlease correct the state classes on the source entities and reload the group sensor to fix this issue."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -87,7 +87,7 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_create_preview_switch(
|
def async_create_preview_switch(
|
||||||
name: str, validated_config: dict[str, Any]
|
hass: HomeAssistant, name: str, validated_config: dict[str, Any]
|
||||||
) -> SwitchGroup:
|
) -> SwitchGroup:
|
||||||
"""Create a preview sensor."""
|
"""Create a preview sensor."""
|
||||||
return SwitchGroup(
|
return SwitchGroup(
|
||||||
|
@ -32,6 +32,7 @@ from homeassistant.const import (
|
|||||||
STATE_UNKNOWN,
|
STATE_UNKNOWN,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import issue_registry as ir
|
||||||
import homeassistant.helpers.entity_registry as er
|
import homeassistant.helpers.entity_registry as er
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
@ -62,7 +63,7 @@ PRODUCT_VALUE = prod(VALUES)
|
|||||||
("product", PRODUCT_VALUE, {}),
|
("product", PRODUCT_VALUE, {}),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_sensors(
|
async def test_sensors2(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entity_registry: er.EntityRegistry,
|
entity_registry: er.EntityRegistry,
|
||||||
sensor_type: str,
|
sensor_type: str,
|
||||||
@ -88,7 +89,7 @@ async def test_sensors(
|
|||||||
value,
|
value,
|
||||||
{
|
{
|
||||||
ATTR_DEVICE_CLASS: SensorDeviceClass.VOLUME,
|
ATTR_DEVICE_CLASS: SensorDeviceClass.VOLUME,
|
||||||
ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT,
|
ATTR_STATE_CLASS: SensorStateClass.TOTAL,
|
||||||
ATTR_UNIT_OF_MEASUREMENT: "L",
|
ATTR_UNIT_OF_MEASUREMENT: "L",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -105,7 +106,7 @@ async def test_sensors(
|
|||||||
assert state.attributes.get(key) == value
|
assert state.attributes.get(key) == value
|
||||||
assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLUME
|
assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLUME
|
||||||
assert state.attributes.get(ATTR_ICON) is None
|
assert state.attributes.get(ATTR_ICON) is None
|
||||||
assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT
|
assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL
|
||||||
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "L"
|
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "L"
|
||||||
|
|
||||||
entity = entity_registry.async_get(f"sensor.sensor_group_{sensor_type}")
|
entity = entity_registry.async_get(f"sensor.sensor_group_{sensor_type}")
|
||||||
@ -146,7 +147,8 @@ async def test_sensors_attributes_defined(hass: HomeAssistant) -> None:
|
|||||||
|
|
||||||
state = hass.states.get("sensor.sensor_group_sum")
|
state = hass.states.get("sensor.sensor_group_sum")
|
||||||
|
|
||||||
assert state.state == str(float(SUM_VALUE))
|
# Liter to M3 = 1:0.001
|
||||||
|
assert state.state == str(float(SUM_VALUE * 0.001))
|
||||||
assert state.attributes.get(ATTR_ENTITY_ID) == entity_ids
|
assert state.attributes.get(ATTR_ENTITY_ID) == entity_ids
|
||||||
assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER
|
assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER
|
||||||
assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING
|
assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING
|
||||||
@ -324,9 +326,6 @@ async def test_sensor_calculated_properties(hass: HomeAssistant) -> None:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assert await async_setup_component(hass, "sensor", config)
|
|
||||||
await hass.async_block_till_done()
|
|
||||||
|
|
||||||
entity_ids = config["sensor"]["entities"]
|
entity_ids = config["sensor"]["entities"]
|
||||||
|
|
||||||
hass.states.async_set(
|
hass.states.async_set(
|
||||||
@ -334,7 +333,7 @@ async def test_sensor_calculated_properties(hass: HomeAssistant) -> None:
|
|||||||
VALUES[0],
|
VALUES[0],
|
||||||
{
|
{
|
||||||
"device_class": SensorDeviceClass.ENERGY,
|
"device_class": SensorDeviceClass.ENERGY,
|
||||||
"state_class": SensorStateClass.MEASUREMENT,
|
"state_class": SensorStateClass.TOTAL,
|
||||||
"unit_of_measurement": "kWh",
|
"unit_of_measurement": "kWh",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -343,35 +342,181 @@ async def test_sensor_calculated_properties(hass: HomeAssistant) -> None:
|
|||||||
VALUES[1],
|
VALUES[1],
|
||||||
{
|
{
|
||||||
"device_class": SensorDeviceClass.ENERGY,
|
"device_class": SensorDeviceClass.ENERGY,
|
||||||
"state_class": SensorStateClass.MEASUREMENT,
|
"state_class": SensorStateClass.TOTAL,
|
||||||
|
"unit_of_measurement": "kWh",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
hass.states.async_set(
|
||||||
|
entity_ids[2],
|
||||||
|
VALUES[2],
|
||||||
|
{
|
||||||
|
"device_class": SensorDeviceClass.ENERGY,
|
||||||
|
"state_class": SensorStateClass.TOTAL,
|
||||||
|
"unit_of_measurement": "Wh",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert await async_setup_component(hass, "sensor", config)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get("sensor.test_sum")
|
||||||
|
assert state.state == str(float(sum([VALUES[0], VALUES[1], VALUES[2] / 1000])))
|
||||||
|
assert state.attributes.get("device_class") == "energy"
|
||||||
|
assert state.attributes.get("state_class") == "total"
|
||||||
|
assert state.attributes.get("unit_of_measurement") == "kWh"
|
||||||
|
|
||||||
|
# Test that a change of source entity's unit of measurement
|
||||||
|
# is converted correctly by the group sensor
|
||||||
|
hass.states.async_set(
|
||||||
|
entity_ids[2],
|
||||||
|
VALUES[2],
|
||||||
|
{
|
||||||
|
"device_class": SensorDeviceClass.ENERGY,
|
||||||
|
"state_class": SensorStateClass.TOTAL,
|
||||||
"unit_of_measurement": "kWh",
|
"unit_of_measurement": "kWh",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
state = hass.states.get("sensor.test_sum")
|
state = hass.states.get("sensor.test_sum")
|
||||||
assert state.state == str(float(sum([VALUES[0], VALUES[1]])))
|
assert state.state == str(float(sum(VALUES)))
|
||||||
assert state.attributes.get("device_class") == "energy"
|
|
||||||
assert state.attributes.get("state_class") == "measurement"
|
|
||||||
assert state.attributes.get("unit_of_measurement") == "kWh"
|
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sensor_calculated_properties_not_same(
|
||||||
|
hass: HomeAssistant, issue_registry: ir.IssueRegistry
|
||||||
|
) -> None:
|
||||||
|
"""Test the sensor calculating device_class, state_class and unit of measurement not same."""
|
||||||
|
config = {
|
||||||
|
SENSOR_DOMAIN: {
|
||||||
|
"platform": GROUP_DOMAIN,
|
||||||
|
"name": "test_sum",
|
||||||
|
"type": "sum",
|
||||||
|
"entities": ["sensor.test_1", "sensor.test_2", "sensor.test_3"],
|
||||||
|
"unique_id": "very_unique_id_sum_sensor",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entity_ids = config["sensor"]["entities"]
|
||||||
|
|
||||||
|
hass.states.async_set(
|
||||||
|
entity_ids[0],
|
||||||
|
VALUES[0],
|
||||||
|
{
|
||||||
|
"device_class": SensorDeviceClass.ENERGY,
|
||||||
|
"state_class": SensorStateClass.TOTAL,
|
||||||
|
"unit_of_measurement": "kWh",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
hass.states.async_set(
|
||||||
|
entity_ids[1],
|
||||||
|
VALUES[1],
|
||||||
|
{
|
||||||
|
"device_class": SensorDeviceClass.ENERGY,
|
||||||
|
"state_class": SensorStateClass.TOTAL,
|
||||||
|
"unit_of_measurement": "kWh",
|
||||||
|
},
|
||||||
|
)
|
||||||
hass.states.async_set(
|
hass.states.async_set(
|
||||||
entity_ids[2],
|
entity_ids[2],
|
||||||
VALUES[2],
|
VALUES[2],
|
||||||
{
|
{
|
||||||
"device_class": SensorDeviceClass.BATTERY,
|
"device_class": SensorDeviceClass.CURRENT,
|
||||||
"state_class": SensorStateClass.TOTAL,
|
"state_class": SensorStateClass.MEASUREMENT,
|
||||||
"unit_of_measurement": None,
|
"unit_of_measurement": "A",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert await async_setup_component(hass, "sensor", config)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
state = hass.states.get("sensor.test_sum")
|
state = hass.states.get("sensor.test_sum")
|
||||||
assert state.state == str(sum(VALUES))
|
assert state.state == str(float(sum(VALUES)))
|
||||||
assert state.attributes.get("device_class") is None
|
assert state.attributes.get("device_class") is None
|
||||||
assert state.attributes.get("state_class") is None
|
assert state.attributes.get("state_class") is None
|
||||||
assert state.attributes.get("unit_of_measurement") is None
|
assert state.attributes.get("unit_of_measurement") is None
|
||||||
|
|
||||||
|
assert issue_registry.async_get_issue(
|
||||||
|
GROUP_DOMAIN, "sensor.test_sum_uoms_not_matching_no_device_class"
|
||||||
|
)
|
||||||
|
assert issue_registry.async_get_issue(
|
||||||
|
GROUP_DOMAIN, "sensor.test_sum_device_classes_not_matching"
|
||||||
|
)
|
||||||
|
assert issue_registry.async_get_issue(
|
||||||
|
GROUP_DOMAIN, "sensor.test_sum_state_classes_not_matching"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sensor_calculated_result_fails_on_uom(hass: HomeAssistant) -> None:
|
||||||
|
"""Test the sensor calculating fails as UoM not part of device class."""
|
||||||
|
config = {
|
||||||
|
SENSOR_DOMAIN: {
|
||||||
|
"platform": GROUP_DOMAIN,
|
||||||
|
"name": "test_sum",
|
||||||
|
"type": "sum",
|
||||||
|
"entities": ["sensor.test_1", "sensor.test_2", "sensor.test_3"],
|
||||||
|
"unique_id": "very_unique_id_sum_sensor",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entity_ids = config["sensor"]["entities"]
|
||||||
|
|
||||||
|
hass.states.async_set(
|
||||||
|
entity_ids[0],
|
||||||
|
VALUES[0],
|
||||||
|
{
|
||||||
|
"device_class": SensorDeviceClass.ENERGY,
|
||||||
|
"state_class": SensorStateClass.TOTAL,
|
||||||
|
"unit_of_measurement": "kWh",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
hass.states.async_set(
|
||||||
|
entity_ids[1],
|
||||||
|
VALUES[1],
|
||||||
|
{
|
||||||
|
"device_class": SensorDeviceClass.ENERGY,
|
||||||
|
"state_class": SensorStateClass.TOTAL,
|
||||||
|
"unit_of_measurement": "kWh",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
hass.states.async_set(
|
||||||
|
entity_ids[2],
|
||||||
|
VALUES[2],
|
||||||
|
{
|
||||||
|
"device_class": SensorDeviceClass.ENERGY,
|
||||||
|
"state_class": SensorStateClass.TOTAL,
|
||||||
|
"unit_of_measurement": "kWh",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert await async_setup_component(hass, "sensor", config)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get("sensor.test_sum")
|
||||||
|
assert state.state == str(float(sum(VALUES)))
|
||||||
|
assert state.attributes.get("device_class") == "energy"
|
||||||
|
assert state.attributes.get("state_class") == "total"
|
||||||
|
assert state.attributes.get("unit_of_measurement") == "kWh"
|
||||||
|
|
||||||
|
hass.states.async_set(
|
||||||
|
entity_ids[2],
|
||||||
|
12,
|
||||||
|
{
|
||||||
|
"device_class": SensorDeviceClass.ENERGY,
|
||||||
|
"state_class": SensorStateClass.TOTAL,
|
||||||
|
},
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get("sensor.test_sum")
|
||||||
|
assert state.state == STATE_UNKNOWN
|
||||||
|
assert state.attributes.get("device_class") == "energy"
|
||||||
|
assert state.attributes.get("state_class") == "total"
|
||||||
|
assert state.attributes.get("unit_of_measurement") == "kWh"
|
||||||
|
|
||||||
|
|
||||||
async def test_last_sensor(hass: HomeAssistant) -> None:
|
async def test_last_sensor(hass: HomeAssistant) -> None:
|
||||||
"""Test the last sensor."""
|
"""Test the last sensor."""
|
||||||
|
Loading…
x
Reference in New Issue
Block a user