Add SensorDeviceClass to statistics component, improve structures (#62629)

* Improve config checking, add device_class timestamp

* Move additional characteristics into separate branch

* Move deprecation warning for sampling_size default to other branch

* Add supports list description
This commit is contained in:
Thomas Dietrich 2022-01-04 10:25:37 +01:00 committed by GitHub
parent 04606f05a4
commit dc15c9ed75
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 93 additions and 58 deletions

View file

@ -16,6 +16,7 @@ from homeassistant.components.recorder.models import States
from homeassistant.components.recorder.util import execute, session_scope from homeassistant.components.recorder.util import execute, session_scope
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
PLATFORM_SCHEMA, PLATFORM_SCHEMA,
SensorDeviceClass,
SensorEntity, SensorEntity,
SensorStateClass, SensorStateClass,
) )
@ -50,10 +51,12 @@ from . import DOMAIN, PLATFORMS
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# Stats for attributes only
STAT_AGE_COVERAGE_RATIO = "age_coverage_ratio" STAT_AGE_COVERAGE_RATIO = "age_coverage_ratio"
STAT_BUFFER_USAGE_RATIO = "buffer_usage_ratio" STAT_BUFFER_USAGE_RATIO = "buffer_usage_ratio"
STAT_SOURCE_VALUE_VALID = "source_value_valid" STAT_SOURCE_VALUE_VALID = "source_value_valid"
# All sensor statistics
STAT_AVERAGE_LINEAR = "average_linear" STAT_AVERAGE_LINEAR = "average_linear"
STAT_AVERAGE_STEP = "average_step" STAT_AVERAGE_STEP = "average_step"
STAT_AVERAGE_TIMELESS = "average_timeless" STAT_AVERAGE_TIMELESS = "average_timeless"
@ -76,28 +79,57 @@ STAT_VALUE_MAX = "value_max"
STAT_VALUE_MIN = "value_min" STAT_VALUE_MIN = "value_min"
STAT_VARIANCE = "variance" STAT_VARIANCE = "variance"
STAT_DEFAULT = "default" DEPRECATION_WARNING_CHARACTERISTIC = (
DEPRECATION_WARNING = (
"The configuration parameter 'state_characteristic' will become " "The configuration parameter 'state_characteristic' will become "
"mandatory in a future release of the statistics integration. " "mandatory in a future release of the statistics integration. "
"Please add 'state_characteristic: %s' to the configuration of " "Please add 'state_characteristic: %s' to the configuration of "
'sensor "%s" to keep the current behavior. Read the documentation ' "sensor '%s' to keep the current behavior. Read the documentation "
"for further details: " "for further details: "
"https://www.home-assistant.io/integrations/statistics/" "https://www.home-assistant.io/integrations/statistics/"
) )
STATS_NOT_A_NUMBER = ( # Statistics supported by a sensor source (numeric)
STAT_DATETIME_OLDEST, STATS_NUMERIC_SUPPORT = (
STAT_AVERAGE_LINEAR,
STAT_AVERAGE_STEP,
STAT_AVERAGE_TIMELESS,
STAT_CHANGE_SAMPLE,
STAT_CHANGE_SECOND,
STAT_CHANGE,
STAT_COUNT,
STAT_DATETIME_NEWEST, STAT_DATETIME_NEWEST,
STAT_DATETIME_OLDEST,
STAT_DISTANCE_95P,
STAT_DISTANCE_99P,
STAT_DISTANCE_ABSOLUTE,
STAT_MEAN,
STAT_MEDIAN,
STAT_NOISINESS,
STAT_QUANTILES, STAT_QUANTILES,
STAT_STANDARD_DEVIATION,
STAT_TOTAL,
STAT_VALUE_MAX,
STAT_VALUE_MIN,
STAT_VARIANCE,
) )
# Statistics supported by a binary_sensor source
STATS_BINARY_SUPPORT = ( STATS_BINARY_SUPPORT = (
STAT_AVERAGE_STEP, STAT_AVERAGE_STEP,
STAT_AVERAGE_TIMELESS, STAT_AVERAGE_TIMELESS,
STAT_COUNT, STAT_COUNT,
STAT_MEAN, STAT_MEAN,
STAT_DEFAULT, )
STATS_NOT_A_NUMBER = (
STAT_DATETIME_NEWEST,
STAT_DATETIME_OLDEST,
STAT_QUANTILES,
)
STATS_DATETIME = (
STAT_DATETIME_NEWEST,
STAT_DATETIME_OLDEST,
) )
CONF_STATE_CHARACTERISTIC = "state_characteristic" CONF_STATE_CHARACTERISTIC = "state_characteristic"
@ -115,15 +147,27 @@ DEFAULT_QUANTILE_METHOD = "exclusive"
ICON = "mdi:calculator" ICON = "mdi:calculator"
def valid_binary_characteristic_configuration(config: dict[str, Any]) -> dict[str, Any]: def valid_state_characteristic_configuration(config: dict[str, Any]) -> dict[str, Any]:
"""Validate that the characteristic selected is valid for the source sensor type, throw if it isn't.""" """Validate that the characteristic selected is valid for the source sensor type, throw if it isn't."""
if split_entity_id(str(config.get(CONF_ENTITY_ID)))[0] == BINARY_SENSOR_DOMAIN: is_binary = split_entity_id(config[CONF_ENTITY_ID])[0] == BINARY_SENSOR_DOMAIN
if config.get(CONF_STATE_CHARACTERISTIC) not in STATS_BINARY_SUPPORT:
raise ValueError( if config.get(CONF_STATE_CHARACTERISTIC) is None:
"The configured characteristic '" config[CONF_STATE_CHARACTERISTIC] = STAT_COUNT if is_binary else STAT_MEAN
+ str(config.get(CONF_STATE_CHARACTERISTIC)) _LOGGER.warning(
+ "' is not supported for a binary source sensor." DEPRECATION_WARNING_CHARACTERISTIC,
config[CONF_STATE_CHARACTERISTIC],
config[CONF_NAME],
)
characteristic = cast(str, config[CONF_STATE_CHARACTERISTIC])
if (is_binary and characteristic not in STATS_BINARY_SUPPORT) or (
not is_binary and characteristic not in STATS_NUMERIC_SUPPORT
):
raise vol.ValueInvalid(
"The configured characteristic '{}' is not supported for the configured source sensor".format(
characteristic
) )
)
return config return config
@ -132,32 +176,7 @@ _PLATFORM_SCHEMA_BASE = PLATFORM_SCHEMA.extend(
vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Required(CONF_ENTITY_ID): cv.entity_id,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string,
vol.Optional(CONF_STATE_CHARACTERISTIC, default=STAT_DEFAULT): vol.In( vol.Optional(CONF_STATE_CHARACTERISTIC): cv.string,
[
STAT_AVERAGE_LINEAR,
STAT_AVERAGE_STEP,
STAT_AVERAGE_TIMELESS,
STAT_CHANGE_SAMPLE,
STAT_CHANGE_SECOND,
STAT_CHANGE,
STAT_COUNT,
STAT_DATETIME_NEWEST,
STAT_DATETIME_OLDEST,
STAT_DISTANCE_95P,
STAT_DISTANCE_99P,
STAT_DISTANCE_ABSOLUTE,
STAT_MEAN,
STAT_MEDIAN,
STAT_NOISINESS,
STAT_QUANTILES,
STAT_STANDARD_DEVIATION,
STAT_TOTAL,
STAT_VALUE_MAX,
STAT_VALUE_MIN,
STAT_VARIANCE,
STAT_DEFAULT,
]
),
vol.Optional( vol.Optional(
CONF_SAMPLES_MAX_BUFFER_SIZE, default=DEFAULT_BUFFER_SIZE CONF_SAMPLES_MAX_BUFFER_SIZE, default=DEFAULT_BUFFER_SIZE
): vol.All(vol.Coerce(int), vol.Range(min=1)), ): vol.All(vol.Coerce(int), vol.Range(min=1)),
@ -173,7 +192,7 @@ _PLATFORM_SCHEMA_BASE = PLATFORM_SCHEMA.extend(
) )
PLATFORM_SCHEMA = vol.All( PLATFORM_SCHEMA = vol.All(
_PLATFORM_SCHEMA_BASE, _PLATFORM_SCHEMA_BASE,
valid_binary_characteristic_configuration, valid_state_characteristic_configuration,
) )
@ -230,9 +249,6 @@ class StatisticsSensor(SensorEntity):
split_entity_id(self._source_entity_id)[0] == BINARY_SENSOR_DOMAIN split_entity_id(self._source_entity_id)[0] == BINARY_SENSOR_DOMAIN
) )
self._state_characteristic: str = state_characteristic self._state_characteristic: str = state_characteristic
if self._state_characteristic == STAT_DEFAULT:
self._state_characteristic = STAT_COUNT if self.is_binary else STAT_MEAN
_LOGGER.warning(DEPRECATION_WARNING, self._state_characteristic, name)
self._samples_max_buffer_size: int = samples_max_buffer_size self._samples_max_buffer_size: int = samples_max_buffer_size
self._samples_max_age: timedelta | None = samples_max_age self._samples_max_age: timedelta | None = samples_max_age
self._precision: int = precision self._precision: int = precision
@ -346,12 +362,9 @@ class StatisticsSensor(SensorEntity):
STAT_VALUE_MIN, STAT_VALUE_MIN,
): ):
unit = base_unit unit = base_unit
elif self._state_characteristic in ( elif self._state_characteristic in STATS_NOT_A_NUMBER:
STAT_COUNT, unit = None
STAT_DATETIME_NEWEST, elif self._state_characteristic == STAT_COUNT:
STAT_DATETIME_OLDEST,
STAT_QUANTILES,
):
unit = None unit = None
elif self._state_characteristic == STAT_VARIANCE: elif self._state_characteristic == STAT_VARIANCE:
unit = base_unit + "²" unit = base_unit + "²"
@ -361,6 +374,13 @@ class StatisticsSensor(SensorEntity):
unit = base_unit + "/s" unit = base_unit + "/s"
return unit return unit
@property
def device_class(self) -> Literal[SensorDeviceClass.TIMESTAMP] | None:
"""Return the class of this device."""
if self._state_characteristic in STATS_DATETIME:
return SensorDeviceClass.TIMESTAMP
return None
@property @property
def state_class(self) -> Literal[SensorStateClass.MEASUREMENT] | None: def state_class(self) -> Literal[SensorStateClass.MEASUREMENT] | None:
"""Return the state class of this entity.""" """Return the state class of this entity."""

View file

@ -1,6 +1,9 @@
"""The test for the statistics sensor platform.""" """The test for the statistics sensor platform."""
from __future__ import annotations
from datetime import datetime, timedelta from datetime import datetime, timedelta
import statistics import statistics
from typing import Any, Sequence
from unittest.mock import patch from unittest.mock import patch
from homeassistant import config as hass_config from homeassistant import config as hass_config
@ -542,7 +545,7 @@ async def test_state_characteristics(hass: HomeAssistant):
def mock_now(): def mock_now():
return mock_data["return_time"] return mock_data["return_time"]
characteristics = ( characteristics: Sequence[dict[str, Any]] = (
{ {
"source_sensor_domain": "sensor", "source_sensor_domain": "sensor",
"name": "average_linear", "name": "average_linear",
@ -615,16 +618,16 @@ async def test_state_characteristics(hass: HomeAssistant):
"source_sensor_domain": "sensor", "source_sensor_domain": "sensor",
"name": "datetime_newest", "name": "datetime_newest",
"value_0": STATE_UNKNOWN, "value_0": STATE_UNKNOWN,
"value_1": start_datetime + timedelta(minutes=9), "value_1": (start_datetime + timedelta(minutes=9)).isoformat(),
"value_9": start_datetime + timedelta(minutes=9), "value_9": (start_datetime + timedelta(minutes=9)).isoformat(),
"unit": None, "unit": None,
}, },
{ {
"source_sensor_domain": "sensor", "source_sensor_domain": "sensor",
"name": "datetime_oldest", "name": "datetime_oldest",
"value_0": STATE_UNKNOWN, "value_0": STATE_UNKNOWN,
"value_1": start_datetime + timedelta(minutes=9), "value_1": (start_datetime + timedelta(minutes=9)).isoformat(),
"value_9": start_datetime + timedelta(minutes=1), "value_9": (start_datetime + timedelta(minutes=1)).isoformat(),
"unit": None, "unit": None,
}, },
{ {
@ -805,7 +808,11 @@ async def test_state_characteristics(hass: HomeAssistant):
state = hass.states.get( state = hass.states.get(
f"sensor.test_{characteristic['source_sensor_domain']}_{characteristic['name']}" f"sensor.test_{characteristic['source_sensor_domain']}_{characteristic['name']}"
) )
assert state is not None assert state is not None, (
f"no state object for characteristic "
f"'{characteristic['source_sensor_domain']}/{characteristic['name']}' "
f"(buffer filled)"
)
assert state.state == str(characteristic["value_9"]), ( assert state.state == str(characteristic["value_9"]), (
f"value mismatch for characteristic " f"value mismatch for characteristic "
f"'{characteristic['source_sensor_domain']}/{characteristic['name']}' " f"'{characteristic['source_sensor_domain']}/{characteristic['name']}' "
@ -826,7 +833,11 @@ async def test_state_characteristics(hass: HomeAssistant):
state = hass.states.get( state = hass.states.get(
f"sensor.test_{characteristic['source_sensor_domain']}_{characteristic['name']}" f"sensor.test_{characteristic['source_sensor_domain']}_{characteristic['name']}"
) )
assert state is not None assert state is not None, (
f"no state object for characteristic "
f"'{characteristic['source_sensor_domain']}/{characteristic['name']}' "
f"(one stored value)"
)
assert state.state == str(characteristic["value_1"]), ( assert state.state == str(characteristic["value_1"]), (
f"value mismatch for characteristic " f"value mismatch for characteristic "
f"'{characteristic['source_sensor_domain']}/{characteristic['name']}' " f"'{characteristic['source_sensor_domain']}/{characteristic['name']}' "
@ -844,7 +855,11 @@ async def test_state_characteristics(hass: HomeAssistant):
state = hass.states.get( state = hass.states.get(
f"sensor.test_{characteristic['source_sensor_domain']}_{characteristic['name']}" f"sensor.test_{characteristic['source_sensor_domain']}_{characteristic['name']}"
) )
assert state is not None assert state is not None, (
f"no state object for characteristic "
f"'{characteristic['source_sensor_domain']}/{characteristic['name']}' "
f"(buffer empty)"
)
assert state.state == str(characteristic["value_0"]), ( assert state.state == str(characteristic["value_0"]), (
f"value mismatch for characteristic " f"value mismatch for characteristic "
f"'{characteristic['source_sensor_domain']}/{characteristic['name']}' " f"'{characteristic['source_sensor_domain']}/{characteristic['name']}' "
@ -987,7 +1002,7 @@ async def test_initialize_from_database_with_maxage(hass: HomeAssistant):
# The max_age timestamp should be 1 hour before what we have right # The max_age timestamp should be 1 hour before what we have right
# now in mock_data['return_time']. # now in mock_data['return_time'].
assert mock_data["return_time"] == datetime.strptime( assert mock_data["return_time"] == datetime.strptime(
state.state, "%Y-%m-%d %H:%M:%S%z" state.state, "%Y-%m-%dT%H:%M:%S%z"
) + timedelta(hours=1) ) + timedelta(hours=1)