From 9f1e503784127e3847396daf4f7e9a95fa47f706 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Sep 2021 17:05:16 +0200 Subject: [PATCH] Do not allow `inf` or `nan` sensor states in statistics (#55943) --- homeassistant/components/sensor/recorder.py | 38 +++++++----- tests/components/sensor/test_recorder.py | 65 +++++++++++++++++++++ 2 files changed, 89 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index c6c8482669e..0b24744406e 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -4,6 +4,7 @@ from __future__ import annotations import datetime import itertools import logging +import math from typing import Callable from homeassistant.components.recorder import history, statistics @@ -175,6 +176,14 @@ def _get_units(fstates: list[tuple[float, State]]) -> set[str | None]: return {item[1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) for item in fstates} +def _parse_float(state: str) -> float: + """Parse a float string, throw on inf or nan.""" + fstate = float(state) + if math.isnan(fstate) or math.isinf(fstate): + raise ValueError + return fstate + + def _normalize_states( hass: HomeAssistant, entity_history: list[State], @@ -189,9 +198,10 @@ def _normalize_states( fstates = [] for state in entity_history: try: - fstates.append((float(state.state), state)) - except ValueError: + fstate = _parse_float(state.state) + except (ValueError, TypeError): # TypeError to guard for NULL state in DB continue + fstates.append((fstate, state)) if fstates: all_units = _get_units(fstates) @@ -221,20 +231,20 @@ def _normalize_states( for state in entity_history: try: - fstate = float(state.state) - unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - # Exclude unsupported units from statistics - if unit not in UNIT_CONVERSIONS[device_class]: - if WARN_UNSUPPORTED_UNIT not in hass.data: - hass.data[WARN_UNSUPPORTED_UNIT] = set() - if entity_id not in hass.data[WARN_UNSUPPORTED_UNIT]: - hass.data[WARN_UNSUPPORTED_UNIT].add(entity_id) - _LOGGER.warning("%s has unknown unit %s", entity_id, unit) - continue - - fstates.append((UNIT_CONVERSIONS[device_class][unit](fstate), state)) + fstate = _parse_float(state.state) except ValueError: continue + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + # Exclude unsupported units from statistics + if unit not in UNIT_CONVERSIONS[device_class]: + if WARN_UNSUPPORTED_UNIT not in hass.data: + hass.data[WARN_UNSUPPORTED_UNIT] = set() + if entity_id not in hass.data[WARN_UNSUPPORTED_UNIT]: + hass.data[WARN_UNSUPPORTED_UNIT].add(entity_id) + _LOGGER.warning("%s has unknown unit %s", entity_id, unit) + continue + + fstates.append((UNIT_CONVERSIONS[device_class][unit](fstate), state)) return DEVICE_CLASS_UNITS[device_class], fstates diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 41fa80d3f24..8b1283a3653 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -1,6 +1,7 @@ """The tests for sensor recorder platform.""" # pylint: disable=protected-access,invalid-name from datetime import timedelta +import math from unittest.mock import patch import pytest @@ -349,6 +350,70 @@ def test_compile_hourly_sum_statistics_amount_reset_every_state_change( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize("state_class", ["measurement"]) +@pytest.mark.parametrize( + "device_class,unit,native_unit,factor", + [ + ("energy", "kWh", "kWh", 1), + ], +) +def test_compile_hourly_sum_statistics_nan_inf_state( + hass_recorder, caplog, state_class, device_class, unit, native_unit, factor +): + """Test compiling hourly statistics with nan and inf states.""" + zero = dt_util.utcnow() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + attributes = { + "device_class": device_class, + "state_class": state_class, + "unit_of_measurement": unit, + "last_reset": None, + } + seq = [10, math.nan, 15, 15, 20, math.inf, 20, 10] + + states = {"sensor.test1": []} + one = zero + for i in range(len(seq)): + one = one + timedelta(minutes=1) + _states = record_meter_state( + hass, one, "sensor.test1", attributes, seq[i : i + 1] + ) + states["sensor.test1"].extend(_states["sensor.test1"]) + + hist = history.get_significant_states( + hass, + zero - timedelta.resolution, + one + timedelta.resolution, + significant_changes_only=False, + ) + assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] + + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + stats = statistics_during_period(hass, zero) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "max": None, + "mean": None, + "min": None, + "last_reset": process_timestamp_to_utc_isoformat(one), + "state": approx(factor * seq[7]), + "sum": approx(factor * (seq[2] + seq[3] + seq[4] + seq[6] + seq[7])), + }, + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + @pytest.mark.parametrize( "device_class,unit,native_unit,factor", [