From e70c8fa35984de5e7c65aef176e6f0b01c40bfd0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Apr 2022 13:18:38 -1000 Subject: [PATCH] Refactor history_stats to minimize database access (part 1) (#70134) --- .../components/history_stats/sensor.py | 149 ++-- tests/components/history_stats/test_sensor.py | 688 +++++++++++++++++- 2 files changed, 760 insertions(+), 77 deletions(-) diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index 5177d5f5239..abd6c097af7 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -14,16 +14,16 @@ from homeassistant.const import ( CONF_NAME, CONF_STATE, CONF_TYPE, - EVENT_HOMEASSISTANT_START, PERCENTAGE, TIME_HOURS, ) -from homeassistant.core import CoreState, HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.start import async_at_start from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util @@ -124,35 +124,34 @@ class HistoryStatsSensor(SensorEntity): self._name = name self._unit_of_measurement = UNITS[sensor_type] - self._period = (datetime.datetime.now(), datetime.datetime.now()) + self._period = (datetime.datetime.min, datetime.datetime.min) self.value = None self.count = None + self._history_current_period: list[State] = [] + self._previous_run_before_start = False + + @callback + def _async_start_refresh(self, *_) -> None: + """Register state tracking.""" + self.async_schedule_update_ha_state(True) + self.async_on_remove( + async_track_state_change_event( + self.hass, [self._entity_id], self._async_update_from_event + ) + ) async def async_added_to_hass(self): """Create listeners when the entity is added.""" + self.async_on_remove(async_at_start(self.hass, self._async_start_refresh)) - @callback - def start_refresh(*args): - """Register state tracking.""" + async def async_update(self) -> None: + """Get the latest data and updates the states.""" + await self._async_update(None) - @callback - def force_refresh(*args): - """Force the component to refresh.""" - self.async_schedule_update_ha_state(True) - - force_refresh() - self.async_on_remove( - async_track_state_change_event( - self.hass, [self._entity_id], force_refresh - ) - ) - - if self.hass.state == CoreState.running: - start_refresh() - return - - # Delay first refresh to keep startup fast - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_refresh) + async def _async_update_from_event(self, event: Event) -> None: + """Do an update and write the state if its changed.""" + await self._async_update(event) + self.async_write_ha_state() @property def name(self): @@ -193,9 +192,10 @@ class HistoryStatsSensor(SensorEntity): """Return the icon to use in the frontend, if any.""" return ICON - async def async_update(self): + async def _async_update(self, event: Event | None) -> None: """Get the latest data and updates the states.""" # Get previous values of start and end + p_start, p_end = self._period # Parse templates @@ -216,39 +216,89 @@ class HistoryStatsSensor(SensorEntity): p_end_timestamp = math.floor(dt_util.as_timestamp(p_end)) now_timestamp = math.floor(dt_util.as_timestamp(now)) - # If period has not changed and current time after the period end... - if ( - start_timestamp == p_start_timestamp - and end_timestamp == p_end_timestamp - and end_timestamp <= now_timestamp + if now_timestamp < start_timestamp: + # History cannot tell the future + self._history_current_period = [] + self._previous_run_before_start = True + # + # We avoid querying the database if the below did NOT happen: + # + # - The previous run happened before the start time + # - The start time changed + # - The period shrank in size + # - The previous period ended before now + # + elif ( + not self._previous_run_before_start + and start_timestamp == p_start_timestamp + and ( + end_timestamp == p_end_timestamp + or ( + end_timestamp >= p_end_timestamp + and p_end_timestamp <= now_timestamp + ) + ) ): - # Don't compute anything as the value cannot have changed + new_data = False + if event and event.data["new_state"] is not None: + new_state: State = event.data["new_state"] + if start <= new_state.last_changed <= end: + self._history_current_period.append(new_state) + new_data = True + if not new_data and end_timestamp < now_timestamp: + # If period has not changed and current time after the period end... + # Don't compute anything as the value cannot have changed + return + else: + self._history_current_period = await get_instance( + self.hass + ).async_add_executor_job( + self._update, + start, + end, + ) + self._previous_run_before_start = False + + if not self._history_current_period: + self.value = None + self.count = None return - await get_instance(self.hass).async_add_executor_job( - self._update, start, end, now_timestamp, start_timestamp, end_timestamp + self._async_compute_hours_and_changes( + now_timestamp, + start_timestamp, + end_timestamp, ) - def _update(self, start, end, now_timestamp, start_timestamp, end_timestamp): + def _update(self, start: datetime.datetime, end: datetime.datetime) -> list[State]: + """Update from the database.""" # Get history between start and end - history_list = history.state_changes_during_period( - self.hass, start, end, str(self._entity_id), no_attributes=True - ) + return history.state_changes_during_period( + self.hass, + start, + end, + self._entity_id, + include_start_time_state=True, + no_attributes=True, + ).get(self._entity_id, []) - if self._entity_id not in history_list: - return - - # Get the first state - last_state = history.get_state( - self.hass, start, self._entity_id, no_attributes=True + def _async_compute_hours_and_changes( + self, now_timestamp: float, start_timestamp: float, end_timestamp: float + ) -> None: + """Compute the hours matched and changes from the history list and first state.""" + # state_changes_during_period is called with include_start_time_state=True + # which is the default and always provides the state at the start + # of the period + last_state = ( + self._history_current_period + and self._history_current_period[0].state in self._entity_states ) - last_state = last_state is not None and last_state in self._entity_states last_time = start_timestamp - elapsed = 0 + elapsed = 0.0 count = 0 # Make calculations - for item in history_list.get(self._entity_id): + for item in self._history_current_period: current_state = item.state in self._entity_states current_time = item.last_changed.timestamp() @@ -322,13 +372,6 @@ class HistoryStatsSensor(SensorEntity): if end is None: end = start + self._duration - if start > dt_util.now(): - # History hasn't been written yet for this period - return - if dt_util.now() < end: - # No point in making stats of the future - end = dt_util.now() - self._period = start, end diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index c018553efc6..a13412e1820 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta import unittest from unittest.mock import patch +from freezegun import freeze_time import pytest from homeassistant import config as hass_config @@ -17,6 +18,7 @@ from homeassistant.setup import async_setup_component, setup_component import homeassistant.util.dt as dt_util from tests.common import ( + async_fire_time_changed, async_init_recorder_component, get_fixture_path, get_test_home_assistant, @@ -274,21 +276,26 @@ async def test_measure_multiple(hass): """Test the history statistics sensor measure for multiple .""" await async_init_recorder_component(hass) - t0 = dt_util.utcnow() - timedelta(minutes=40) - t1 = t0 + timedelta(minutes=20) - t2 = dt_util.utcnow() - timedelta(minutes=10) + start_time = dt_util.utcnow() - timedelta(minutes=60) + t0 = start_time + timedelta(minutes=20) + t1 = t0 + timedelta(minutes=10) + t2 = t1 + timedelta(minutes=10) # Start t0 t1 t2 End # |--20min--|--20min--|--10min--|--10min--| # |---------|--orange-|-default-|---blue--| - fake_states = { - "input_select.test_id": [ - ha.State("input_select.test_id", "orange", last_changed=t0), - ha.State("input_select.test_id", "default", last_changed=t1), - ha.State("input_select.test_id", "blue", last_changed=t2), - ] - } + def _fake_states(*args, **kwargs): + return { + "input_select.test_id": [ + # Because we use include_start_time_state we need to mock + # value at start + ha.State("input_select.test_id", "", last_changed=start_time), + ha.State("input_select.test_id", "orange", last_changed=t0), + ha.State("input_select.test_id", "default", last_changed=t1), + ha.State("input_select.test_id", "blue", last_changed=t2), + ] + } await async_setup_component( hass, @@ -337,8 +344,8 @@ async def test_measure_multiple(hass): with patch( "homeassistant.components.recorder.history.state_changes_during_period", - return_value=fake_states, - ), patch("homeassistant.components.recorder.history.get_state", return_value=None): + _fake_states, + ): for i in range(1, 5): await async_update_entity(hass, f"sensor.sensor{i}") await hass.async_block_till_done() @@ -351,21 +358,25 @@ async def test_measure_multiple(hass): async def async_test_measure(hass): """Test the history statistics sensor measure.""" - t0 = dt_util.utcnow() - timedelta(minutes=40) - t1 = t0 + timedelta(minutes=20) - t2 = dt_util.utcnow() - timedelta(minutes=10) + await async_init_recorder_component(hass) + + start_time = dt_util.utcnow() - timedelta(minutes=60) + t0 = start_time + timedelta(minutes=20) + t1 = t0 + timedelta(minutes=10) + t2 = t1 + timedelta(minutes=10) # Start t0 t1 t2 End # |--20min--|--20min--|--10min--|--10min--| # |---off---|---on----|---off---|---on----| - fake_states = { - "binary_sensor.test_id": [ - ha.State("binary_sensor.test_id", "on", last_changed=t0), - ha.State("binary_sensor.test_id", "off", last_changed=t1), - ha.State("binary_sensor.test_id", "on", last_changed=t2), - ] - } + def _fake_states(*args, **kwargs): + return { + "binary_sensor.test_id": [ + ha.State("binary_sensor.test_id", "on", last_changed=t0), + ha.State("binary_sensor.test_id", "off", last_changed=t1), + ha.State("binary_sensor.test_id", "on", last_changed=t2), + ] + } await async_setup_component( hass, @@ -414,8 +425,8 @@ async def async_test_measure(hass): with patch( "homeassistant.components.recorder.history.state_changes_during_period", - return_value=fake_states, - ), patch("homeassistant.components.recorder.history.get_state", return_value=None): + _fake_states, + ): for i in range(1, 5): await async_update_entity(hass, f"sensor.sensor{i}") await hass.async_block_till_done() @@ -424,3 +435,632 @@ async def async_test_measure(hass): assert hass.states.get("sensor.sensor2").state == STATE_UNKNOWN assert hass.states.get("sensor.sensor3").state == "2" assert hass.states.get("sensor.sensor4").state == "50.0" + + +async def test_async_on_entire_period(hass): + """Test the history statistics sensor measuring as on the entire period.""" + await async_init_recorder_component(hass) + + start_time = dt_util.utcnow() - timedelta(minutes=60) + t0 = start_time + timedelta(minutes=20) + t1 = t0 + timedelta(minutes=10) + t2 = t1 + timedelta(minutes=10) + + # Start t0 t1 t2 End + # |--20min--|--20min--|--10min--|--10min--| + # |---on----|--off----|---on----|--off----| + + def _fake_states(*args, **kwargs): + return { + "binary_sensor.test_on_id": [ + ha.State("binary_sensor.test_on_id", "on", last_changed=start_time), + ha.State("binary_sensor.test_on_id", "on", last_changed=t0), + ha.State("binary_sensor.test_on_id", "on", last_changed=t1), + ha.State("binary_sensor.test_on_id", "on", last_changed=t2), + ] + } + + await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_on_id", + "name": "on_sensor1", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "time", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_on_id", + "name": "on_sensor2", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "time", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_on_id", + "name": "on_sensor3", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "count", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_on_id", + "name": "on_sensor4", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "ratio", + }, + ] + }, + ) + + with patch( + "homeassistant.components.recorder.history.state_changes_during_period", + _fake_states, + ): + for i in range(1, 5): + await async_update_entity(hass, f"sensor.on_sensor{i}") + await hass.async_block_till_done() + + assert hass.states.get("sensor.on_sensor1").state == "1.0" + assert hass.states.get("sensor.on_sensor2").state == "1.0" + assert hass.states.get("sensor.on_sensor3").state == "0" + assert hass.states.get("sensor.on_sensor4").state == "100.0" + + +async def test_async_off_entire_period(hass): + """Test the history statistics sensor measuring as off the entire period.""" + await async_init_recorder_component(hass) + + start_time = dt_util.utcnow() - timedelta(minutes=60) + t0 = start_time + timedelta(minutes=20) + t1 = t0 + timedelta(minutes=10) + t2 = t1 + timedelta(minutes=10) + + # Start t0 t1 t2 End + # |--20min--|--20min--|--10min--|--10min--| + # |---off----|--off----|---off----|--off----| + + def _fake_states(*args, **kwargs): + return { + "binary_sensor.test_on_id": [ + ha.State("binary_sensor.test_on_id", "off", last_changed=start_time), + ha.State("binary_sensor.test_on_id", "off", last_changed=t0), + ha.State("binary_sensor.test_on_id", "off", last_changed=t1), + ha.State("binary_sensor.test_on_id", "off", last_changed=t2), + ] + } + + await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_on_id", + "name": "on_sensor1", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "time", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_on_id", + "name": "on_sensor2", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "time", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_on_id", + "name": "on_sensor3", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "count", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_on_id", + "name": "on_sensor4", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "ratio", + }, + ] + }, + ) + + with patch( + "homeassistant.components.recorder.history.state_changes_during_period", + _fake_states, + ): + for i in range(1, 5): + await async_update_entity(hass, f"sensor.on_sensor{i}") + await hass.async_block_till_done() + + assert hass.states.get("sensor.on_sensor1").state == "0.0" + assert hass.states.get("sensor.on_sensor2").state == "0.0" + assert hass.states.get("sensor.on_sensor3").state == "0" + assert hass.states.get("sensor.on_sensor4").state == "0.0" + + +async def test_async_start_from_history_and_switch_to_watching_state_changes_single( + hass, +): + """Test we startup from history and switch to watching state changes.""" + await async_init_recorder_component(hass) + + hass.config.set_time_zone("UTC") + utcnow = dt_util.utcnow() + start_time = utcnow.replace(hour=0, minute=0, second=0, microsecond=0) + + # Start t0 t1 t2 Startup End + # |--20min--|--20min--|--10min--|--10min--|---------30min---------|---15min--|---15min--| + # |---on----|---on----|---on----|---on----|----------on-----------|---off----|----on----| + + def _fake_states(*args, **kwargs): + return { + "binary_sensor.state": [ + ha.State( + "binary_sensor.state", + "on", + last_changed=start_time, + last_updated=start_time, + ), + ] + } + + with patch( + "homeassistant.components.recorder.history.state_changes_during_period", + _fake_states, + ): + + with freeze_time(start_time): + await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "history_stats", + "entity_id": "binary_sensor.state", + "name": "sensor1", + "state": "on", + "start": "{{ utcnow().replace(hour=0, minute=0, second=0) }}", + "duration": {"hours": 2}, + "type": "time", + } + ] + }, + ) + + await async_update_entity(hass, "sensor.sensor1") + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "0.0" + + one_hour_in = start_time + timedelta(minutes=60) + with freeze_time(one_hour_in): + async_fire_time_changed(hass, one_hour_in) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "1.0" + + turn_off_time = start_time + timedelta(minutes=90) + with freeze_time(turn_off_time): + hass.states.async_set("binary_sensor.state", "off") + await hass.async_block_till_done() + async_fire_time_changed(hass, turn_off_time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "1.5" + + turn_back_on_time = start_time + timedelta(minutes=105) + with freeze_time(turn_back_on_time): + async_fire_time_changed(hass, turn_back_on_time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "1.5" + + with freeze_time(turn_back_on_time): + hass.states.async_set("binary_sensor.state", "on") + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "1.5" + + end_time = start_time + timedelta(minutes=120) + with freeze_time(end_time): + async_fire_time_changed(hass, end_time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "1.75" + + +async def test_async_start_from_history_and_switch_to_watching_state_changes_single_expanding_window( + hass, +): + """Test we startup from history and switch to watching state changes with an expanding end time.""" + await async_init_recorder_component(hass) + + hass.config.set_time_zone("UTC") + utcnow = dt_util.utcnow() + start_time = utcnow.replace(hour=0, minute=0, second=0, microsecond=0) + + # Start t0 t1 t2 Startup End + # |--20min--|--20min--|--10min--|--10min--|---------30min---------|---15min--|---15min--| + # |---on----|---on----|---on----|---on----|----------on-----------|---off----|----on----| + + def _fake_states(*args, **kwargs): + return { + "binary_sensor.state": [ + ha.State( + "binary_sensor.state", + "on", + last_changed=start_time, + last_updated=start_time, + ), + ] + } + + with patch( + "homeassistant.components.recorder.history.state_changes_during_period", + _fake_states, + ): + + with freeze_time(start_time): + await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "history_stats", + "entity_id": "binary_sensor.state", + "name": "sensor1", + "state": "on", + "start": "{{ utcnow().replace(hour=0, minute=0, second=0) }}", + "end": "{{ utcnow() }}", + "type": "time", + } + ] + }, + ) + + await async_update_entity(hass, "sensor.sensor1") + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "0.0" + + one_hour_in = start_time + timedelta(minutes=60) + with freeze_time(one_hour_in): + async_fire_time_changed(hass, one_hour_in) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "1.0" + + turn_off_time = start_time + timedelta(minutes=90) + with freeze_time(turn_off_time): + hass.states.async_set("binary_sensor.state", "off") + await hass.async_block_till_done() + async_fire_time_changed(hass, turn_off_time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "1.5" + + turn_back_on_time = start_time + timedelta(minutes=105) + with freeze_time(turn_back_on_time): + async_fire_time_changed(hass, turn_back_on_time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "1.5" + + with freeze_time(turn_back_on_time): + hass.states.async_set("binary_sensor.state", "on") + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "1.5" + + end_time = start_time + timedelta(minutes=120) + with freeze_time(end_time): + async_fire_time_changed(hass, end_time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "1.75" + + +async def test_async_start_from_history_and_switch_to_watching_state_changes_multiple( + hass, +): + """Test we startup from history and switch to watching state changes.""" + await async_init_recorder_component(hass) + + hass.config.set_time_zone("UTC") + utcnow = dt_util.utcnow() + start_time = utcnow.replace(hour=0, minute=0, second=0, microsecond=0) + + # Start t0 t1 t2 Startup End + # |--20min--|--20min--|--10min--|--10min--|---------30min---------|---15min--|---15min--| + # |---on----|---on----|---on----|---on----|----------on-----------|---off----|----on----| + + def _fake_states(*args, **kwargs): + return { + "binary_sensor.state": [ + ha.State( + "binary_sensor.state", + "on", + last_changed=start_time, + last_updated=start_time, + ), + ] + } + + with patch( + "homeassistant.components.recorder.history.state_changes_during_period", + _fake_states, + ): + + with freeze_time(start_time): + await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "history_stats", + "entity_id": "binary_sensor.state", + "name": "sensor1", + "state": "on", + "start": "{{ utcnow().replace(hour=0, minute=0, second=0) }}", + "duration": {"hours": 2}, + "type": "time", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.state", + "name": "sensor2", + "state": "on", + "start": "{{ utcnow().replace(hour=0, minute=0, second=0) }}", + "duration": {"hours": 2}, + "type": "time", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.state", + "name": "sensor3", + "state": "on", + "start": "{{ utcnow().replace(hour=0, minute=0, second=0) }}", + "duration": {"hours": 2}, + "type": "count", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.state", + "name": "sensor4", + "state": "on", + "start": "{{ utcnow().replace(hour=0, minute=0, second=0) }}", + "duration": {"hours": 2}, + "type": "ratio", + }, + ] + }, + ) + for i in range(1, 5): + await async_update_entity(hass, f"sensor.sensor{i}") + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "0.0" + assert hass.states.get("sensor.sensor2").state == "0.0" + assert hass.states.get("sensor.sensor3").state == "0" + assert hass.states.get("sensor.sensor4").state == "0.0" + + one_hour_in = start_time + timedelta(minutes=60) + with freeze_time(one_hour_in): + async_fire_time_changed(hass, one_hour_in) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "1.0" + assert hass.states.get("sensor.sensor2").state == "1.0" + assert hass.states.get("sensor.sensor3").state == "0" + assert hass.states.get("sensor.sensor4").state == "50.0" + + turn_off_time = start_time + timedelta(minutes=90) + with freeze_time(turn_off_time): + hass.states.async_set("binary_sensor.state", "off") + await hass.async_block_till_done() + async_fire_time_changed(hass, turn_off_time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "1.5" + assert hass.states.get("sensor.sensor2").state == "1.5" + assert hass.states.get("sensor.sensor3").state == "0" + assert hass.states.get("sensor.sensor4").state == "75.0" + + turn_back_on_time = start_time + timedelta(minutes=105) + with freeze_time(turn_back_on_time): + async_fire_time_changed(hass, turn_back_on_time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "1.5" + assert hass.states.get("sensor.sensor2").state == "1.5" + assert hass.states.get("sensor.sensor3").state == "0" + assert hass.states.get("sensor.sensor4").state == "75.0" + + with freeze_time(turn_back_on_time): + hass.states.async_set("binary_sensor.state", "on") + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "1.5" + assert hass.states.get("sensor.sensor2").state == "1.5" + assert hass.states.get("sensor.sensor3").state == "1" + assert hass.states.get("sensor.sensor4").state == "75.0" + + end_time = start_time + timedelta(minutes=120) + with freeze_time(end_time): + async_fire_time_changed(hass, end_time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "1.75" + assert hass.states.get("sensor.sensor2").state == "1.75" + assert hass.states.get("sensor.sensor3").state == "1" + assert hass.states.get("sensor.sensor4").state == "87.5" + + +async def test_does_not_work_into_the_future( + hass, +): + """Test history cannot tell the future. + + Verifies we do not regress https://github.com/home-assistant/core/pull/20589 + """ + await async_init_recorder_component(hass) + + hass.config.set_time_zone("UTC") + utcnow = dt_util.utcnow() + start_time = utcnow.replace(hour=0, minute=0, second=0, microsecond=0) + + # Start t0 t1 t2 Startup End + # |--20min--|--20min--|--10min--|--10min--|---------30min---------|---15min--|---15min--| + # |---on----|---on----|---on----|---on----|----------on-----------|---off----|----on----| + + def _fake_states(*args, **kwargs): + return { + "binary_sensor.state": [ + ha.State( + "binary_sensor.state", + "on", + last_changed=start_time, + last_updated=start_time, + ), + ] + } + + with patch( + "homeassistant.components.recorder.history.state_changes_during_period", + _fake_states, + ): + + with freeze_time(start_time): + await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "history_stats", + "entity_id": "binary_sensor.state", + "name": "sensor1", + "state": "on", + "start": "{{ utcnow().replace(hour=23, minute=0, second=0) }}", + "duration": {"hours": 1}, + "type": "time", + } + ] + }, + ) + + await async_update_entity(hass, "sensor.sensor1") + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == STATE_UNKNOWN + + one_hour_in = start_time + timedelta(minutes=60) + with freeze_time(one_hour_in): + async_fire_time_changed(hass, one_hour_in) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == STATE_UNKNOWN + + turn_off_time = start_time + timedelta(minutes=90) + with freeze_time(turn_off_time): + hass.states.async_set("binary_sensor.state", "off") + await hass.async_block_till_done() + async_fire_time_changed(hass, turn_off_time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == STATE_UNKNOWN + + turn_back_on_time = start_time + timedelta(minutes=105) + with freeze_time(turn_back_on_time): + async_fire_time_changed(hass, turn_back_on_time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == STATE_UNKNOWN + + with freeze_time(turn_back_on_time): + hass.states.async_set("binary_sensor.state", "on") + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == STATE_UNKNOWN + + end_time = start_time + timedelta(minutes=120) + with freeze_time(end_time): + async_fire_time_changed(hass, end_time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == STATE_UNKNOWN + + in_the_window = start_time + timedelta(hours=23, minutes=5) + with freeze_time(in_the_window): + async_fire_time_changed(hass, in_the_window) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "0.08" + + past_the_window = start_time + timedelta(hours=25) + with patch( + "homeassistant.components.recorder.history.state_changes_during_period", + return_value=[], + ), freeze_time(past_the_window): + async_fire_time_changed(hass, past_the_window) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == STATE_UNKNOWN + + def _fake_off_states(*args, **kwargs): + return { + "binary_sensor.state": [ + ha.State( + "binary_sensor.state", + "off", + last_changed=start_time, + last_updated=start_time, + ), + ] + } + + past_the_window_with_data = start_time + timedelta(hours=26) + with patch( + "homeassistant.components.recorder.history.state_changes_during_period", + _fake_off_states, + ), freeze_time(past_the_window_with_data): + async_fire_time_changed(hass, past_the_window_with_data) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == STATE_UNKNOWN + + at_the_next_window_with_data = start_time + timedelta(days=1, hours=23) + with patch( + "homeassistant.components.recorder.history.state_changes_during_period", + _fake_off_states, + ), freeze_time(at_the_next_window_with_data): + async_fire_time_changed(hass, at_the_next_window_with_data) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == "0.0"