From 0199697af54dce19eec6d3122df01cc67172d7bb Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 28 Oct 2024 18:54:22 +0000 Subject: [PATCH 1/7] Use shorthand attribute for extra state attributes in statistics --- homeassistant/components/statistics/sensor.py | 27 +++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 50d07d4e466..6ca53c86bf4 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -364,7 +364,7 @@ class StatisticsSensor(SensorEntity): 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] = {} + self._attr_extra_state_attributes = {} self._state_characteristic_fn: Callable[[], float | int | datetime | None] = ( self._callable_characteristic_fn(self._state_characteristic) @@ -462,10 +462,10 @@ class StatisticsSensor(SensorEntity): # Here we make a copy the current value, which is okay. self._attr_available = new_state.state != STATE_UNAVAILABLE if new_state.state == STATE_UNAVAILABLE: - self.attributes[STAT_SOURCE_VALUE_VALID] = None + self._attr_extra_state_attributes[STAT_SOURCE_VALUE_VALID] = None return if new_state.state in (STATE_UNKNOWN, None, ""): - self.attributes[STAT_SOURCE_VALUE_VALID] = False + self._attr_extra_state_attributes[STAT_SOURCE_VALUE_VALID] = False return try: @@ -475,9 +475,9 @@ class StatisticsSensor(SensorEntity): else: self.states.append(float(new_state.state)) self.ages.append(new_state.last_reported) - self.attributes[STAT_SOURCE_VALUE_VALID] = True + self._attr_extra_state_attributes[STAT_SOURCE_VALUE_VALID] = True except ValueError: - self.attributes[STAT_SOURCE_VALUE_VALID] = False + self._attr_extra_state_attributes[STAT_SOURCE_VALUE_VALID] = False _LOGGER.error( "%s: parsing error. Expected number or binary state, but received '%s'", self.entity_id, @@ -584,13 +584,6 @@ class StatisticsSensor(SensorEntity): return None return SensorStateClass.MEASUREMENT - @property - def extra_state_attributes(self) -> dict[str, StateType] | None: - """Return the state attributes of the sensor.""" - return { - key: value for key, value in self.attributes.items() if value is not None - } - def _purge_old_states(self, max_age: timedelta) -> None: """Remove states which are older than a given age.""" now = dt_util.utcnow() @@ -657,7 +650,7 @@ class StatisticsSensor(SensorEntity): if self._samples_max_age is not None: self._purge_old_states(self._samples_max_age) - self._update_attributes() + self._update_extra_state_attributes() self._update_value() # If max_age is set, ensure to update again after the defined interval. @@ -738,22 +731,22 @@ class StatisticsSensor(SensorEntity): self.async_write_ha_state() _LOGGER.debug("%s: initializing from database completed", self.entity_id) - def _update_attributes(self) -> None: + def _update_extra_state_attributes(self) -> None: """Calculate and update the various attributes.""" if self._samples_max_buffer_size is not None: - self.attributes[STAT_BUFFER_USAGE_RATIO] = round( + self._attr_extra_state_attributes[STAT_BUFFER_USAGE_RATIO] = round( len(self.states) / self._samples_max_buffer_size, 2 ) if self._samples_max_age is not None: if len(self.states) >= 1: - self.attributes[STAT_AGE_COVERAGE_RATIO] = round( + self._attr_extra_state_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 + self._attr_extra_state_attributes[STAT_AGE_COVERAGE_RATIO] = None def _update_value(self) -> None: """Front to call the right statistical characteristics functions. From 2427a52d46b167df843187fa9701934afeaee5f1 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 28 Oct 2024 21:46:23 +0000 Subject: [PATCH 2/7] Mod _update_extra_state_attributes --- homeassistant/components/statistics/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 6ca53c86bf4..c4f901fc6be 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -733,6 +733,8 @@ class StatisticsSensor(SensorEntity): def _update_extra_state_attributes(self) -> None: """Calculate and update the various attributes.""" + self._attr_extra_state_attributes[STAT_BUFFER_USAGE_RATIO] = None + self._attr_extra_state_attributes[STAT_AGE_COVERAGE_RATIO] = None if self._samples_max_buffer_size is not None: self._attr_extra_state_attributes[STAT_BUFFER_USAGE_RATIO] = round( len(self.states) / self._samples_max_buffer_size, 2 @@ -745,8 +747,6 @@ class StatisticsSensor(SensorEntity): / self._samples_max_age.total_seconds(), 2, ) - else: - self._attr_extra_state_attributes[STAT_AGE_COVERAGE_RATIO] = None def _update_value(self) -> None: """Front to call the right statistical characteristics functions. From 4f7f119e2ff7095550acf1e11e62ea5e109b5939 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 28 Oct 2024 22:02:02 +0000 Subject: [PATCH 3/7] Add test --- tests/components/statistics/test_sensor.py | 66 +++++++++++++++++++++- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index 7e2bc1cb16b..adb13d38d7a 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -117,7 +117,7 @@ async def test_sensor_defaults_numeric(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) assert state.attributes.get("source_value_valid") is True - assert "age_coverage_ratio" not in state.attributes + assert state.attributes.get("age_coverage_ratio") is None # Source sensor turns unavailable, then available with valid value, # statistics sensor should follow @@ -210,7 +210,7 @@ async def test_sensor_loaded_from_config_entry( assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) assert state.attributes.get("source_value_valid") is True - assert "age_coverage_ratio" not in state.attributes + assert state.attributes.get("age_coverage_ratio") is None async def test_sensor_defaults_binary(hass: HomeAssistant) -> None: @@ -247,7 +247,7 @@ async def test_sensor_defaults_binary(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) assert state.attributes.get("source_value_valid") is True - assert "age_coverage_ratio" not in state.attributes + assert state.attributes.get("age_coverage_ratio") is None async def test_sensor_state_reported(hass: HomeAssistant) -> None: @@ -2032,3 +2032,63 @@ async def test_not_valid_device_class(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None assert state.attributes.get(ATTR_DEVICE_CLASS) is None assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + + +async def test_attributes_remains(recorder_mock: Recorder, hass: HomeAssistant) -> None: + """Test attributes are always present.""" + for value in VALUES_NUMERIC: + hass.states.async_set( + "sensor.test_monitored", + str(value), + {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, + ) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + current_time = dt_util.utcnow() + with freeze_time(current_time) as freezer: + assert await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "statistics", + "name": "test", + "entity_id": "sensor.test_monitored", + "state_characteristic": "mean", + "max_age": {"seconds": 10}, + }, + ] + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test") + assert state is not None + assert state.state == str(round(sum(VALUES_NUMERIC) / len(VALUES_NUMERIC), 2)) + assert state.attributes == { + "age_coverage_ratio": 0.0, + "buffer_usage_ratio": None, + "friendly_name": "test", + "icon": "mdi:calculator", + "source_value_valid": True, + "state_class": SensorStateClass.MEASUREMENT, + "unit_of_measurement": "°C", + } + + freezer.move_to(current_time + timedelta(minutes=1)) + async_fire_time_changed(hass) + + state = hass.states.get("sensor.test") + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.attributes == { + "age_coverage_ratio": None, + "buffer_usage_ratio": None, + "friendly_name": "test", + "icon": "mdi:calculator", + "source_value_valid": True, + "state_class": SensorStateClass.MEASUREMENT, + "unit_of_measurement": "°C", + } From 70068499ac8124618db247e4ebb9538650a173b9 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 28 Oct 2024 22:06:53 +0000 Subject: [PATCH 4/7] Fix test --- tests/components/statistics/snapshots/test_config_flow.ambr | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/statistics/snapshots/test_config_flow.ambr b/tests/components/statistics/snapshots/test_config_flow.ambr index 5f79c56dec7..a515b4252b4 100644 --- a/tests/components/statistics/snapshots/test_config_flow.ambr +++ b/tests/components/statistics/snapshots/test_config_flow.ambr @@ -11,6 +11,7 @@ # name: test_config_flow_preview_success[success] dict({ 'attributes': dict({ + 'age_coverage_ratio': None, 'buffer_usage_ratio': 0.1, 'friendly_name': 'Statistical characteristic', 'icon': 'mdi:calculator', From b1540a6f029e34635fdd3bbf10be99d78b000380 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 29 Oct 2024 18:35:00 +0000 Subject: [PATCH 5/7] age_coverage_ratio = 0 as default --- homeassistant/components/statistics/sensor.py | 2 +- .../statistics/snapshots/test_config_flow.ambr | 2 +- tests/components/statistics/test_sensor.py | 11 +++++------ 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index c4f901fc6be..229a4ba78ff 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -734,7 +734,7 @@ class StatisticsSensor(SensorEntity): def _update_extra_state_attributes(self) -> None: """Calculate and update the various attributes.""" self._attr_extra_state_attributes[STAT_BUFFER_USAGE_RATIO] = None - self._attr_extra_state_attributes[STAT_AGE_COVERAGE_RATIO] = None + self._attr_extra_state_attributes[STAT_AGE_COVERAGE_RATIO] = 0 if self._samples_max_buffer_size is not None: self._attr_extra_state_attributes[STAT_BUFFER_USAGE_RATIO] = round( len(self.states) / self._samples_max_buffer_size, 2 diff --git a/tests/components/statistics/snapshots/test_config_flow.ambr b/tests/components/statistics/snapshots/test_config_flow.ambr index a515b4252b4..07fae750110 100644 --- a/tests/components/statistics/snapshots/test_config_flow.ambr +++ b/tests/components/statistics/snapshots/test_config_flow.ambr @@ -11,7 +11,7 @@ # name: test_config_flow_preview_success[success] dict({ 'attributes': dict({ - 'age_coverage_ratio': None, + 'age_coverage_ratio': 0, 'buffer_usage_ratio': 0.1, 'friendly_name': 'Statistical characteristic', 'icon': 'mdi:calculator', diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index adb13d38d7a..b1fea7a8f37 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -117,8 +117,7 @@ async def test_sensor_defaults_numeric(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) assert state.attributes.get("source_value_valid") is True - assert state.attributes.get("age_coverage_ratio") is None - + assert state.attributes.get("age_coverage_ratio") == 0 # Source sensor turns unavailable, then available with valid value, # statistics sensor should follow state = hass.states.get("sensor.test") @@ -210,7 +209,7 @@ async def test_sensor_loaded_from_config_entry( assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) assert state.attributes.get("source_value_valid") is True - assert state.attributes.get("age_coverage_ratio") is None + assert state.attributes.get("age_coverage_ratio") == 0 async def test_sensor_defaults_binary(hass: HomeAssistant) -> None: @@ -247,7 +246,7 @@ async def test_sensor_defaults_binary(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) assert state.attributes.get("source_value_valid") is True - assert state.attributes.get("age_coverage_ratio") is None + assert state.attributes.get("age_coverage_ratio") == 0 async def test_sensor_state_reported(hass: HomeAssistant) -> None: @@ -576,7 +575,7 @@ async def test_age_limit_expiry(hass: HomeAssistant) -> None: assert state is not None assert state.state == STATE_UNKNOWN assert state.attributes.get("buffer_usage_ratio") == round(0 / 20, 2) - assert state.attributes.get("age_coverage_ratio") is None + assert state.attributes.get("age_coverage_ratio") == 0 async def test_age_limit_expiry_with_keep_last_sample(hass: HomeAssistant) -> None: @@ -2084,7 +2083,7 @@ async def test_attributes_remains(recorder_mock: Recorder, hass: HomeAssistant) assert state is not None assert state.state == STATE_UNKNOWN assert state.attributes == { - "age_coverage_ratio": None, + "age_coverage_ratio": 0, "buffer_usage_ratio": None, "friendly_name": "test", "icon": "mdi:calculator", From 88836d33189fa647978bba9c8d58ff1da75bd80b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 3 Nov 2024 16:36:14 +0000 Subject: [PATCH 6/7] Fix --- homeassistant/components/statistics/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 229a4ba78ff..b6f1844f774 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -733,8 +733,6 @@ class StatisticsSensor(SensorEntity): def _update_extra_state_attributes(self) -> None: """Calculate and update the various attributes.""" - self._attr_extra_state_attributes[STAT_BUFFER_USAGE_RATIO] = None - self._attr_extra_state_attributes[STAT_AGE_COVERAGE_RATIO] = 0 if self._samples_max_buffer_size is not None: self._attr_extra_state_attributes[STAT_BUFFER_USAGE_RATIO] = round( len(self.states) / self._samples_max_buffer_size, 2 @@ -747,6 +745,8 @@ class StatisticsSensor(SensorEntity): / self._samples_max_age.total_seconds(), 2, ) + else: + self._attr_extra_state_attributes[STAT_AGE_COVERAGE_RATIO] = 0 def _update_value(self) -> None: """Front to call the right statistical characteristics functions. From c7df6d531c43f1754f0a7d4ee12cfc274ca6017d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 3 Nov 2024 18:23:12 +0000 Subject: [PATCH 7/7] Fix tests --- .../components/statistics/snapshots/test_config_flow.ambr | 1 - tests/components/statistics/test_sensor.py | 8 +++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/components/statistics/snapshots/test_config_flow.ambr b/tests/components/statistics/snapshots/test_config_flow.ambr index 07fae750110..5f79c56dec7 100644 --- a/tests/components/statistics/snapshots/test_config_flow.ambr +++ b/tests/components/statistics/snapshots/test_config_flow.ambr @@ -11,7 +11,6 @@ # name: test_config_flow_preview_success[success] dict({ 'attributes': dict({ - 'age_coverage_ratio': 0, 'buffer_usage_ratio': 0.1, 'friendly_name': 'Statistical characteristic', 'icon': 'mdi:calculator', diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index b1fea7a8f37..1dff13bb21a 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -117,7 +117,7 @@ async def test_sensor_defaults_numeric(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) assert state.attributes.get("source_value_valid") is True - assert state.attributes.get("age_coverage_ratio") == 0 + assert "age_coverage_ratio" not in state.attributes # Source sensor turns unavailable, then available with valid value, # statistics sensor should follow state = hass.states.get("sensor.test") @@ -209,7 +209,7 @@ async def test_sensor_loaded_from_config_entry( assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) assert state.attributes.get("source_value_valid") is True - assert state.attributes.get("age_coverage_ratio") == 0 + assert "age_coverage_ratio" not in state.attributes async def test_sensor_defaults_binary(hass: HomeAssistant) -> None: @@ -246,7 +246,7 @@ async def test_sensor_defaults_binary(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) assert state.attributes.get("source_value_valid") is True - assert state.attributes.get("age_coverage_ratio") == 0 + assert "age_coverage_ratio" not in state.attributes async def test_sensor_state_reported(hass: HomeAssistant) -> None: @@ -2068,7 +2068,6 @@ async def test_attributes_remains(recorder_mock: Recorder, hass: HomeAssistant) assert state.state == str(round(sum(VALUES_NUMERIC) / len(VALUES_NUMERIC), 2)) assert state.attributes == { "age_coverage_ratio": 0.0, - "buffer_usage_ratio": None, "friendly_name": "test", "icon": "mdi:calculator", "source_value_valid": True, @@ -2084,7 +2083,6 @@ async def test_attributes_remains(recorder_mock: Recorder, hass: HomeAssistant) assert state.state == STATE_UNKNOWN assert state.attributes == { "age_coverage_ratio": 0, - "buffer_usage_ratio": None, "friendly_name": "test", "icon": "mdi:calculator", "source_value_valid": True,