diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 55a225eebb8..64e967604c1 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -34,11 +34,10 @@ from homeassistant.core import ( Event, HomeAssistant, State, - async_get_hass, callback, split_entity_id, ) -from homeassistant.helpers import config_validation as cv, issue_registry +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( async_track_point_in_utc_time, @@ -179,7 +178,7 @@ CONF_MAX_AGE = "max_age" CONF_PRECISION = "precision" CONF_PERCENTILE = "percentile" -DEFAULT_NAME = "Stats" +DEFAULT_NAME = "Statistical characteristic" DEFAULT_PRECISION = 2 ICON = "mdi:calculator" @@ -187,24 +186,6 @@ ICON = "mdi:calculator" 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.""" 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 - issue_registry.async_create_issue( - hass=async_get_hass(), - domain=DOMAIN, - issue_id=f"{config[CONF_ENTITY_ID]}_default_characteristic", - breaks_in_ha_version="2022.12.0", - is_fixable=False, - severity=issue_registry.IssueSeverity.WARNING, - translation_key="deprecation_warning_characteristic", - translation_placeholders={ - "entity": config[CONF_NAME], - "characteristic": config[CONF_STATE_CHARACTERISTIC], - }, - learn_more_url="https://github.com/home-assistant/core/pull/60402", - ) - 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 @@ -218,20 +199,14 @@ def valid_state_characteristic_configuration(config: dict[str, Any]) -> dict[str def valid_boundary_configuration(config: dict[str, Any]) -> dict[str, Any]: - """Validate that sampling_size, max_age, or both are provided.""" + """Validate that max_age, sampling_size, or both are provided.""" - if config.get(CONF_SAMPLES_MAX_BUFFER_SIZE) is None: - config[CONF_SAMPLES_MAX_BUFFER_SIZE] = 20 - issue_registry.async_create_issue( - hass=async_get_hass(), - domain=DOMAIN, - issue_id=f"{config[CONF_ENTITY_ID]}_invalid_boundary_config", - breaks_in_ha_version="2022.12.0", - is_fixable=False, - severity=issue_registry.IssueSeverity.WARNING, - translation_key="deprecation_warning_size", - translation_placeholders={"entity": config[CONF_NAME]}, - learn_more_url="https://github.com/home-assistant/core/pull/69700", + if ( + config.get(CONF_SAMPLES_MAX_BUFFER_SIZE) is None + and config.get(CONF_MAX_AGE) is None + ): + raise vol.RequiredFieldInvalid( + "The sensor configuration must provide 'max_age' and/or 'sampling_size'" ) return config @@ -241,8 +216,10 @@ _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): cv.string, - vol.Optional(CONF_SAMPLES_MAX_BUFFER_SIZE): vol.Coerce(int), + vol.Required(CONF_STATE_CHARACTERISTIC): cv.string, + vol.Optional(CONF_SAMPLES_MAX_BUFFER_SIZE): vol.All( + vol.Coerce(int), vol.Range(min=1) + ), vol.Optional(CONF_MAX_AGE): cv.time_period, vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): vol.Coerce(int), vol.Optional(CONF_PERCENTILE, default=50): vol.All( @@ -274,7 +251,7 @@ async def async_setup_platform( name=config[CONF_NAME], unique_id=config.get(CONF_UNIQUE_ID), state_characteristic=config[CONF_STATE_CHARACTERISTIC], - samples_max_buffer_size=config[CONF_SAMPLES_MAX_BUFFER_SIZE], + samples_max_buffer_size=config.get(CONF_SAMPLES_MAX_BUFFER_SIZE), samples_max_age=config.get(CONF_MAX_AGE), precision=config[CONF_PRECISION], percentile=config[CONF_PERCENTILE], @@ -293,7 +270,7 @@ class StatisticsSensor(SensorEntity): name: str, unique_id: str | None, state_characteristic: str, - samples_max_buffer_size: int, + samples_max_buffer_size: int | None, samples_max_age: timedelta | None, precision: int, percentile: int, @@ -308,20 +285,17 @@ class StatisticsSensor(SensorEntity): split_entity_id(self._source_entity_id)[0] == BINARY_SENSOR_DOMAIN ) self._state_characteristic: str = state_characteristic - self._samples_max_buffer_size: int = samples_max_buffer_size + self._samples_max_buffer_size: int | None = samples_max_buffer_size self._samples_max_age: timedelta | None = samples_max_age self._precision: int = precision self._percentile: int = percentile self._value: StateType | datetime = None self._unit_of_measurement: str | None = None self._available: bool = False + self.states: deque[float | bool] = deque(maxlen=self._samples_max_buffer_size) self.ages: deque[datetime] = deque(maxlen=self._samples_max_buffer_size) - self.attributes: dict[str, StateType] = { - STAT_AGE_COVERAGE_RATIO: None, - STAT_BUFFER_USAGE_RATIO: None, - STAT_SOURCE_VALUE_VALID: None, - } + self.attributes: dict[str, StateType] = {} self._state_characteristic_fn: Callable[[], StateType | datetime] if self.is_binary: @@ -496,11 +470,8 @@ class StatisticsSensor(SensorEntity): self._update_value() # If max_age is set, ensure to update again after the defined interval. - next_to_purge_timestamp = self._next_to_purge_timestamp() - if next_to_purge_timestamp: - _LOGGER.debug( - "%s: scheduling update at %s", self.entity_id, next_to_purge_timestamp - ) + if timestamp := self._next_to_purge_timestamp(): + _LOGGER.debug("%s: scheduling update at %s", self.entity_id, timestamp) if self._update_listener: self._update_listener() self._update_listener = None @@ -513,7 +484,7 @@ class StatisticsSensor(SensorEntity): self._update_listener = None self._update_listener = async_track_point_in_utc_time( - self.hass, _scheduled_update, next_to_purge_timestamp + self.hass, _scheduled_update, timestamp ) def _fetch_states_from_database(self) -> list[State]: @@ -563,18 +534,20 @@ class StatisticsSensor(SensorEntity): def _update_attributes(self) -> None: """Calculate and update the various attributes.""" - self.attributes[STAT_BUFFER_USAGE_RATIO] = round( - len(self.states) / self._samples_max_buffer_size, 2 - ) - - if len(self.states) >= 1 and self._samples_max_age is not None: - self.attributes[STAT_AGE_COVERAGE_RATIO] = round( - (self.ages[-1] - self.ages[0]).total_seconds() - / self._samples_max_age.total_seconds(), - 2, + if self._samples_max_buffer_size is not None: + self.attributes[STAT_BUFFER_USAGE_RATIO] = round( + len(self.states) / self._samples_max_buffer_size, 2 ) - else: - self.attributes[STAT_AGE_COVERAGE_RATIO] = None + + if self._samples_max_age is not None: + if len(self.states) >= 1: + self.attributes[STAT_AGE_COVERAGE_RATIO] = round( + (self.ages[-1] - self.ages[0]).total_seconds() + / self._samples_max_age.total_seconds(), + 2, + ) + else: + self.attributes[STAT_AGE_COVERAGE_RATIO] = None def _update_value(self) -> None: """Front to call the right statistical characteristics functions. diff --git a/homeassistant/components/statistics/strings.json b/homeassistant/components/statistics/strings.json deleted file mode 100644 index 0cca71f172f..00000000000 --- a/homeassistant/components/statistics/strings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "issues": { - "deprecation_warning_characteristic": { - "description": "The configuration parameter `state_characteristic` of the statistics integration will become mandatory.\n\nPlease add `state_characteristic: {characteristic}` to the configuration of sensor `{entity}` to keep the current behavior.\n\nRead the documentation of the statistics integration for further details: https://www.home-assistant.io/integrations/statistics/", - "title": "Mandatory 'state_characteristic' assumed for a Statistics entity" - }, - "deprecation_warning_size": { - "description": "The configuration parameter `sampling_size` of the statistics integration defaulted to the value 20 so far, which will change.\n\nPlease check the configuration for sensor `{entity}` and add suited boundaries, e.g., `sampling_size: 20` to keep the current behavior. The configuration of the statistics integration will become more flexible with version 2022.12.0 and accept either `sampling_size` or `max_age`, or both settings. The request above prepares your configuration for this otherwise breaking change.\n\nRead the documentation of the statistics integration for further details: https://www.home-assistant.io/integrations/statistics/", - "title": "Implicit 'sampling_size' assumed for a Statistics entity" - } - } -} diff --git a/tests/components/statistics/fixtures/configuration.yaml b/tests/components/statistics/fixtures/configuration.yaml index 4708910b53e..b7a10756281 100644 --- a/tests/components/statistics/fixtures/configuration.yaml +++ b/tests/components/statistics/fixtures/configuration.yaml @@ -3,3 +3,4 @@ sensor: entity_id: sensor.cpu name: cputest state_characteristic: mean + sampling_size: 20 diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index be771d8be88..bd73216d69e 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -45,8 +45,10 @@ async def test_unique_id(hass: HomeAssistant): { "platform": "statistics", "name": "test", - "entity_id": "sensor.test_monitored", "unique_id": "uniqueid_sensor_test", + "entity_id": "sensor.test_monitored", + "state_characteristic": "mean", + "sampling_size": 20, }, ] }, @@ -71,6 +73,8 @@ async def test_sensor_defaults_numeric(hass: HomeAssistant): "platform": "statistics", "name": "test", "entity_id": "sensor.test_monitored", + "state_characteristic": "mean", + "sampling_size": 20, }, ] }, @@ -162,6 +166,8 @@ async def test_sensor_defaults_binary(hass: HomeAssistant): "platform": "statistics", "name": "test", "entity_id": "binary_sensor.test_monitored", + "state_characteristic": "count", + "sampling_size": 20, }, ] }, @@ -199,12 +205,14 @@ async def test_sensor_source_with_force_update(hass: HomeAssistant): "name": "test_normal", "entity_id": "sensor.test_monitored_normal", "state_characteristic": "mean", + "sampling_size": 20, }, { "platform": "statistics", "name": "test_force", "entity_id": "sensor.test_monitored_force", "state_characteristic": "mean", + "sampling_size": 20, }, ] }, @@ -234,8 +242,8 @@ async def test_sensor_source_with_force_update(hass: HomeAssistant): assert state_force.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) -async def test_sampling_size_non_default(hass: HomeAssistant): - """Test rotation.""" +async def test_sampling_size_reduced(hass: HomeAssistant): + """Test limited buffer size.""" assert await async_setup_component( hass, "sensor", @@ -287,7 +295,7 @@ async def test_sampling_size_1(hass: HomeAssistant): ) await hass.async_block_till_done() - for value in VALUES_NUMERIC[-3:]: # just the last 3 will do + for value in VALUES_NUMERIC: hass.states.async_set( "sensor.test_monitored", str(value), @@ -303,7 +311,7 @@ async def test_sampling_size_1(hass: HomeAssistant): async def test_age_limit_expiry(hass: HomeAssistant): - """Test that values are removed after certain age.""" + """Test that values are removed with given max age.""" now = dt_util.utcnow() mock_data = { "return_time": datetime(now.year + 1, 8, 2, 12, 23, tzinfo=dt_util.UTC) @@ -325,6 +333,7 @@ async def test_age_limit_expiry(hass: HomeAssistant): "name": "test", "entity_id": "sensor.test_monitored", "state_characteristic": "mean", + "sampling_size": 20, "max_age": {"minutes": 4}, }, ] @@ -402,6 +411,7 @@ async def test_precision(hass: HomeAssistant): "name": "test_precision_0", "entity_id": "sensor.test_monitored", "state_characteristic": "mean", + "sampling_size": 20, "precision": 0, }, { @@ -409,6 +419,7 @@ async def test_precision(hass: HomeAssistant): "name": "test_precision_3", "entity_id": "sensor.test_monitored", "state_characteristic": "mean", + "sampling_size": 20, "precision": 3, }, ] @@ -500,6 +511,7 @@ async def test_device_class(hass: HomeAssistant): "name": "test_source_class", "entity_id": "sensor.test_monitored", "state_characteristic": "mean", + "sampling_size": 20, }, { # Device class is set to None for characteristics with special meaning @@ -507,6 +519,7 @@ async def test_device_class(hass: HomeAssistant): "name": "test_none", "entity_id": "sensor.test_monitored", "state_characteristic": "count", + "sampling_size": 20, }, { # Device class is set to timestamp for datetime characteristics @@ -514,6 +527,7 @@ async def test_device_class(hass: HomeAssistant): "name": "test_timestamp", "entity_id": "sensor.test_monitored", "state_characteristic": "datetime_oldest", + "sampling_size": 20, }, ] }, @@ -554,12 +568,14 @@ async def test_state_class(hass: HomeAssistant): "name": "test_normal", "entity_id": "sensor.test_monitored", "state_characteristic": "count", + "sampling_size": 20, }, { "platform": "statistics", "name": "test_nan", "entity_id": "sensor.test_monitored", "state_characteristic": "datetime_oldest", + "sampling_size": 20, }, ] }, @@ -594,29 +610,35 @@ async def test_unitless_source_sensor(hass: HomeAssistant): "name": "test_unitless_1", "entity_id": "sensor.test_monitored_unitless", "state_characteristic": "count", + "sampling_size": 20, }, { "platform": "statistics", "name": "test_unitless_2", "entity_id": "sensor.test_monitored_unitless", "state_characteristic": "mean", + "sampling_size": 20, }, { "platform": "statistics", "name": "test_unitless_3", "entity_id": "sensor.test_monitored_unitless", "state_characteristic": "change_second", + "sampling_size": 20, }, { "platform": "statistics", "name": "test_unitless_4", "entity_id": "binary_sensor.test_monitored_unitless", + "state_characteristic": "count", + "sampling_size": 20, }, { "platform": "statistics", "name": "test_unitless_5", "entity_id": "binary_sensor.test_monitored_unitless", "state_characteristic": "mean", + "sampling_size": 20, }, ] }, @@ -1087,12 +1109,14 @@ async def test_invalid_state_characteristic(hass: HomeAssistant): "name": "test_numeric", "entity_id": "sensor.test_monitored", "state_characteristic": "invalid", + "sampling_size": 20, }, { "platform": "statistics", "name": "test_binary", "entity_id": "binary_sensor.test_monitored", "state_characteristic": "variance", + "sampling_size": 20, }, ] }, @@ -1192,8 +1216,8 @@ async def test_initialize_from_database_with_maxage(recorder_mock, hass: HomeAss "platform": "statistics", "name": "test", "entity_id": "sensor.test_monitored", - "sampling_size": 100, "state_characteristic": "datetime_newest", + "sampling_size": 100, "max_age": {"hours": 3}, }, ]