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.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."""
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue