From 74529980817d90cd66d9e81441d7162b183ab36c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 24 Sep 2021 09:16:50 +0200 Subject: [PATCH] Convert last_reset timestamps to UTC (#56561) * Convert last_reset timestamps to UTC * Add test * Apply suggestion from code review --- homeassistant/components/sensor/recorder.py | 21 ++- tests/components/sensor/test_recorder.py | 136 ++++++++++++++++++-- 2 files changed, 148 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 28e2f0c774b..fbf0992573f 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -312,6 +312,21 @@ def _wanted_statistics( return wanted_statistics +def _last_reset_as_utc_isoformat( + last_reset_s: str | None, entity_id: str +) -> str | None: + """Parse last_reset and convert it to UTC.""" + if last_reset_s is None: + return None + last_reset = dt_util.parse_datetime(last_reset_s) + if last_reset is None: + _LOGGER.warning( + "Ignoring invalid last reset '%s' for %s", last_reset_s, entity_id + ) + return None + return dt_util.as_utc(last_reset).isoformat() + + def compile_statistics( # noqa: C901 hass: HomeAssistant, start: datetime.datetime, end: datetime.datetime ) -> list[StatisticResult]: @@ -424,7 +439,11 @@ def compile_statistics( # noqa: C901 reset = False if ( state_class != STATE_CLASS_TOTAL_INCREASING - and (last_reset := state.attributes.get("last_reset")) + and ( + last_reset := _last_reset_as_utc_isoformat( + state.attributes.get("last_reset"), entity_id + ) + ) != old_last_reset ): if old_state is None: diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index c1278202443..609b3576570 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -50,6 +50,16 @@ GAS_SENSOR_ATTRIBUTES = { } +@pytest.fixture(autouse=True) +def set_time_zone(): + """Set the time zone for the tests.""" + # Set our timezone to CST/Regina so we can check calculations + # This keeps UTC-6 all year round + dt_util.set_default_time_zone(dt_util.get_time_zone("America/Regina")) + yield + dt_util.set_default_time_zone(dt_util.get_time_zone("UTC")) + + @pytest.mark.parametrize( "device_class,unit,native_unit,mean,min,max", [ @@ -338,14 +348,121 @@ def test_compile_hourly_sum_statistics_amount_reset_every_state_change( "unit_of_measurement": unit, "last_reset": None, } - seq = [10, 15, 15, 15, 20, 20, 20, 10] + seq = [10, 15, 15, 15, 20, 20, 20, 25] # Make sure the sequence has consecutive equal states assert seq[1] == seq[2] == seq[3] + # Make sure the first and last state differ + assert seq[0] != seq[-1] + states = {"sensor.test1": []} + + # Insert states for a 1st statistics period one = zero for i in range(len(seq)): one = one + timedelta(seconds=5) + attributes = dict(attributes) + attributes["last_reset"] = dt_util.as_local(one).isoformat() + _states = record_meter_state( + hass, one, "sensor.test1", attributes, seq[i : i + 1] + ) + states["sensor.test1"].extend(_states["sensor.test1"]) + + # Insert states for a 2nd statistics period + two = zero + timedelta(minutes=5) + for i in range(len(seq)): + two = two + timedelta(seconds=5) + attributes = dict(attributes) + attributes["last_reset"] = dt_util.as_local(two).isoformat() + _states = record_meter_state( + hass, two, "sensor.test1", attributes, seq[i : i + 1] + ) + states["sensor.test1"].extend(_states["sensor.test1"]) + + hist = history.get_significant_states( + hass, + zero - timedelta.resolution, + two + timedelta.resolution, + significant_changes_only=False, + ) + assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] + + recorder.do_adhoc_statistics(start=zero) + recorder.do_adhoc_statistics(start=zero + timedelta(minutes=5)) + 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, period="5minute") + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), + "max": None, + "mean": None, + "min": None, + "last_reset": process_timestamp_to_utc_isoformat(dt_util.as_local(one)), + "state": approx(factor * seq[7]), + "sum": approx(factor * (sum(seq) - seq[0])), + "sum_decrease": approx(factor * 0.0), + "sum_increase": approx(factor * (sum(seq) - seq[0])), + }, + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat( + zero + timedelta(minutes=5) + ), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=10)), + "max": None, + "mean": None, + "min": None, + "last_reset": process_timestamp_to_utc_isoformat(dt_util.as_local(two)), + "state": approx(factor * seq[7]), + "sum": approx(factor * (2 * sum(seq) - seq[0])), + "sum_decrease": approx(factor * 0.0), + "sum_increase": approx(factor * (2 * sum(seq) - seq[0])), + }, + ] + } + 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_amount_invalid_last_reset( + hass_recorder, caplog, state_class, device_class, unit, native_unit, factor +): + """Test compiling hourly statistics.""" + 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, 15, 15, 15, 20, 20, 20, 25] + + states = {"sensor.test1": []} + + # Insert states + one = zero + for i in range(len(seq)): + one = one + timedelta(seconds=5) + attributes = dict(attributes) + attributes["last_reset"] = dt_util.as_local(one).isoformat() + if i == 3: + attributes["last_reset"] = "festivus" # not a valid time _states = record_meter_state( hass, one, "sensor.test1", attributes, seq[i : i + 1] ) @@ -375,7 +492,7 @@ def test_compile_hourly_sum_statistics_amount_reset_every_state_change( "max": None, "mean": None, "min": None, - "last_reset": process_timestamp_to_utc_isoformat(one), + "last_reset": process_timestamp_to_utc_isoformat(dt_util.as_local(one)), "state": approx(factor * seq[7]), "sum": approx(factor * (sum(seq) - seq[0])), "sum_decrease": approx(factor * 0.0), @@ -384,6 +501,7 @@ def test_compile_hourly_sum_statistics_amount_reset_every_state_change( ] } assert "Error while processing event StatisticsTask" not in caplog.text + assert "Ignoring invalid last reset 'festivus' for sensor.test1" in caplog.text @pytest.mark.parametrize("state_class", ["measurement"]) @@ -413,6 +531,8 @@ def test_compile_hourly_sum_statistics_nan_inf_state( one = zero for i in range(len(seq)): one = one + timedelta(seconds=5) + attributes = dict(attributes) + attributes["last_reset"] = dt_util.as_local(one).isoformat() _states = record_meter_state( hass, one, "sensor.test1", attributes, seq[i : i + 1] ) @@ -1685,7 +1805,11 @@ def test_compile_statistics_hourly_summary(hass_recorder, caplog): start_meter = start for j in range(len(seq)): _states = record_meter_state( - hass, start_meter, "sensor.test4", sum_attributes, seq[j : j + 1] + hass, + start_meter, + "sensor.test4", + sum_attributes, + seq[j : j + 1], ) start_meter = start + timedelta(minutes=1) states["sensor.test4"] += _states["sensor.test4"] @@ -1955,7 +2079,7 @@ def record_meter_states(hass, zero, entity_id, _attributes, seq): return four, eight, states -def record_meter_state(hass, zero, entity_id, _attributes, seq): +def record_meter_state(hass, zero, entity_id, attributes, seq): """Record test state. We inject a state update for meter sensor. @@ -1967,10 +2091,6 @@ def record_meter_state(hass, zero, entity_id, _attributes, seq): wait_recording_done(hass) return hass.states.get(entity_id) - attributes = dict(_attributes) - if "last_reset" in _attributes: - attributes["last_reset"] = zero.isoformat() - states = {entity_id: []} with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=zero): states[entity_id].append(set_state(entity_id, seq[0], attributes=attributes))