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:
parent
04606f05a4
commit
dc15c9ed75
2 changed files with 93 additions and 58 deletions
|
@ -16,6 +16,7 @@ from homeassistant.components.recorder.models import States
|
|||
from homeassistant.components.recorder.util import execute, session_scope
|
||||
from homeassistant.components.sensor import (
|
||||
PLATFORM_SCHEMA,
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
|
@ -50,10 +51,12 @@ from . import DOMAIN, PLATFORMS
|
|||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Stats for attributes only
|
||||
STAT_AGE_COVERAGE_RATIO = "age_coverage_ratio"
|
||||
STAT_BUFFER_USAGE_RATIO = "buffer_usage_ratio"
|
||||
STAT_SOURCE_VALUE_VALID = "source_value_valid"
|
||||
|
||||
# All sensor statistics
|
||||
STAT_AVERAGE_LINEAR = "average_linear"
|
||||
STAT_AVERAGE_STEP = "average_step"
|
||||
STAT_AVERAGE_TIMELESS = "average_timeless"
|
||||
|
@ -76,28 +79,57 @@ STAT_VALUE_MAX = "value_max"
|
|||
STAT_VALUE_MIN = "value_min"
|
||||
STAT_VARIANCE = "variance"
|
||||
|
||||
STAT_DEFAULT = "default"
|
||||
DEPRECATION_WARNING = (
|
||||
DEPRECATION_WARNING_CHARACTERISTIC = (
|
||||
"The configuration parameter 'state_characteristic' will become "
|
||||
"mandatory in a future release of the statistics integration. "
|
||||
"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: "
|
||||
"https://www.home-assistant.io/integrations/statistics/"
|
||||
)
|
||||
|
||||
STATS_NOT_A_NUMBER = (
|
||||
STAT_DATETIME_OLDEST,
|
||||
# Statistics supported by a sensor source (numeric)
|
||||
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_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,
|
||||
)
|
||||
|
||||
# Statistics supported by a binary_sensor source
|
||||
STATS_BINARY_SUPPORT = (
|
||||
STAT_AVERAGE_STEP,
|
||||
STAT_AVERAGE_TIMELESS,
|
||||
STAT_COUNT,
|
||||
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"
|
||||
|
@ -115,15 +147,27 @@ DEFAULT_QUANTILE_METHOD = "exclusive"
|
|||
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."""
|
||||
if split_entity_id(str(config.get(CONF_ENTITY_ID)))[0] == BINARY_SENSOR_DOMAIN:
|
||||
if config.get(CONF_STATE_CHARACTERISTIC) not in STATS_BINARY_SUPPORT:
|
||||
raise ValueError(
|
||||
"The configured characteristic '"
|
||||
+ str(config.get(CONF_STATE_CHARACTERISTIC))
|
||||
+ "' is not supported for a binary source sensor."
|
||||
is_binary = split_entity_id(config[CONF_ENTITY_ID])[0] == BINARY_SENSOR_DOMAIN
|
||||
|
||||
if config.get(CONF_STATE_CHARACTERISTIC) is None:
|
||||
config[CONF_STATE_CHARACTERISTIC] = STAT_COUNT if is_binary else STAT_MEAN
|
||||
_LOGGER.warning(
|
||||
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
|
||||
|
||||
|
||||
|
@ -132,32 +176,7 @@ _PLATFORM_SCHEMA_BASE = PLATFORM_SCHEMA.extend(
|
|||
vol.Required(CONF_ENTITY_ID): cv.entity_id,
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_UNIQUE_ID): cv.string,
|
||||
vol.Optional(CONF_STATE_CHARACTERISTIC, default=STAT_DEFAULT): vol.In(
|
||||
[
|
||||
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(CONF_STATE_CHARACTERISTIC): cv.string,
|
||||
vol.Optional(
|
||||
CONF_SAMPLES_MAX_BUFFER_SIZE, default=DEFAULT_BUFFER_SIZE
|
||||
): 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_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
|
||||
)
|
||||
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_age: timedelta | None = samples_max_age
|
||||
self._precision: int = precision
|
||||
|
@ -346,12 +362,9 @@ class StatisticsSensor(SensorEntity):
|
|||
STAT_VALUE_MIN,
|
||||
):
|
||||
unit = base_unit
|
||||
elif self._state_characteristic in (
|
||||
STAT_COUNT,
|
||||
STAT_DATETIME_NEWEST,
|
||||
STAT_DATETIME_OLDEST,
|
||||
STAT_QUANTILES,
|
||||
):
|
||||
elif self._state_characteristic in STATS_NOT_A_NUMBER:
|
||||
unit = None
|
||||
elif self._state_characteristic == STAT_COUNT:
|
||||
unit = None
|
||||
elif self._state_characteristic == STAT_VARIANCE:
|
||||
unit = base_unit + "²"
|
||||
|
@ -361,6 +374,13 @@ class StatisticsSensor(SensorEntity):
|
|||
unit = base_unit + "/s"
|
||||
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
|
||||
def state_class(self) -> Literal[SensorStateClass.MEASUREMENT] | None:
|
||||
"""Return the state class of this entity."""
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
"""The test for the statistics sensor platform."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import statistics
|
||||
from typing import Any, Sequence
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant import config as hass_config
|
||||
|
@ -542,7 +545,7 @@ async def test_state_characteristics(hass: HomeAssistant):
|
|||
def mock_now():
|
||||
return mock_data["return_time"]
|
||||
|
||||
characteristics = (
|
||||
characteristics: Sequence[dict[str, Any]] = (
|
||||
{
|
||||
"source_sensor_domain": "sensor",
|
||||
"name": "average_linear",
|
||||
|
@ -615,16 +618,16 @@ async def test_state_characteristics(hass: HomeAssistant):
|
|||
"source_sensor_domain": "sensor",
|
||||
"name": "datetime_newest",
|
||||
"value_0": STATE_UNKNOWN,
|
||||
"value_1": start_datetime + timedelta(minutes=9),
|
||||
"value_9": start_datetime + timedelta(minutes=9),
|
||||
"value_1": (start_datetime + timedelta(minutes=9)).isoformat(),
|
||||
"value_9": (start_datetime + timedelta(minutes=9)).isoformat(),
|
||||
"unit": None,
|
||||
},
|
||||
{
|
||||
"source_sensor_domain": "sensor",
|
||||
"name": "datetime_oldest",
|
||||
"value_0": STATE_UNKNOWN,
|
||||
"value_1": start_datetime + timedelta(minutes=9),
|
||||
"value_9": start_datetime + timedelta(minutes=1),
|
||||
"value_1": (start_datetime + timedelta(minutes=9)).isoformat(),
|
||||
"value_9": (start_datetime + timedelta(minutes=1)).isoformat(),
|
||||
"unit": None,
|
||||
},
|
||||
{
|
||||
|
@ -805,7 +808,11 @@ async def test_state_characteristics(hass: HomeAssistant):
|
|||
state = hass.states.get(
|
||||
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"]), (
|
||||
f"value mismatch for characteristic "
|
||||
f"'{characteristic['source_sensor_domain']}/{characteristic['name']}' "
|
||||
|
@ -826,7 +833,11 @@ async def test_state_characteristics(hass: HomeAssistant):
|
|||
state = hass.states.get(
|
||||
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"]), (
|
||||
f"value mismatch for characteristic "
|
||||
f"'{characteristic['source_sensor_domain']}/{characteristic['name']}' "
|
||||
|
@ -844,7 +855,11 @@ async def test_state_characteristics(hass: HomeAssistant):
|
|||
state = hass.states.get(
|
||||
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"]), (
|
||||
f"value mismatch for characteristic "
|
||||
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
|
||||
# now in mock_data['return_time'].
|
||||
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)
|
||||
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue