mirror of
https://github.com/home-assistant/core.git
synced 2025-07-24 21:57:51 +00:00
Refactor history_stats to minimize database access (part 1) (#70134)
This commit is contained in:
parent
6c75eaa1bc
commit
e70c8fa359
@ -14,16 +14,16 @@ from homeassistant.const import (
|
|||||||
CONF_NAME,
|
CONF_NAME,
|
||||||
CONF_STATE,
|
CONF_STATE,
|
||||||
CONF_TYPE,
|
CONF_TYPE,
|
||||||
EVENT_HOMEASSISTANT_START,
|
|
||||||
PERCENTAGE,
|
PERCENTAGE,
|
||||||
TIME_HOURS,
|
TIME_HOURS,
|
||||||
)
|
)
|
||||||
from homeassistant.core import CoreState, HomeAssistant, callback
|
from homeassistant.core import Event, HomeAssistant, State, callback
|
||||||
from homeassistant.exceptions import TemplateError
|
from homeassistant.exceptions import TemplateError
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.event import async_track_state_change_event
|
from homeassistant.helpers.event import async_track_state_change_event
|
||||||
from homeassistant.helpers.reload import async_setup_reload_service
|
from homeassistant.helpers.reload import async_setup_reload_service
|
||||||
|
from homeassistant.helpers.start import async_at_start
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
import homeassistant.util.dt as dt_util
|
import homeassistant.util.dt as dt_util
|
||||||
|
|
||||||
@ -124,35 +124,34 @@ class HistoryStatsSensor(SensorEntity):
|
|||||||
self._name = name
|
self._name = name
|
||||||
self._unit_of_measurement = UNITS[sensor_type]
|
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.value = None
|
||||||
self.count = 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):
|
async def async_added_to_hass(self):
|
||||||
"""Create listeners when the entity is added."""
|
"""Create listeners when the entity is added."""
|
||||||
|
self.async_on_remove(async_at_start(self.hass, self._async_start_refresh))
|
||||||
|
|
||||||
@callback
|
async def async_update(self) -> None:
|
||||||
def start_refresh(*args):
|
"""Get the latest data and updates the states."""
|
||||||
"""Register state tracking."""
|
await self._async_update(None)
|
||||||
|
|
||||||
@callback
|
async def _async_update_from_event(self, event: Event) -> None:
|
||||||
def force_refresh(*args):
|
"""Do an update and write the state if its changed."""
|
||||||
"""Force the component to refresh."""
|
await self._async_update(event)
|
||||||
self.async_schedule_update_ha_state(True)
|
self.async_write_ha_state()
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
@ -193,9 +192,10 @@ class HistoryStatsSensor(SensorEntity):
|
|||||||
"""Return the icon to use in the frontend, if any."""
|
"""Return the icon to use in the frontend, if any."""
|
||||||
return ICON
|
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 the latest data and updates the states."""
|
||||||
# Get previous values of start and end
|
# Get previous values of start and end
|
||||||
|
|
||||||
p_start, p_end = self._period
|
p_start, p_end = self._period
|
||||||
|
|
||||||
# Parse templates
|
# Parse templates
|
||||||
@ -216,39 +216,89 @@ class HistoryStatsSensor(SensorEntity):
|
|||||||
p_end_timestamp = math.floor(dt_util.as_timestamp(p_end))
|
p_end_timestamp = math.floor(dt_util.as_timestamp(p_end))
|
||||||
now_timestamp = math.floor(dt_util.as_timestamp(now))
|
now_timestamp = math.floor(dt_util.as_timestamp(now))
|
||||||
|
|
||||||
# If period has not changed and current time after the period end...
|
if now_timestamp < start_timestamp:
|
||||||
if (
|
# History cannot tell the future
|
||||||
start_timestamp == p_start_timestamp
|
self._history_current_period = []
|
||||||
and end_timestamp == p_end_timestamp
|
self._previous_run_before_start = True
|
||||||
and end_timestamp <= now_timestamp
|
#
|
||||||
|
# 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
|
return
|
||||||
|
|
||||||
await get_instance(self.hass).async_add_executor_job(
|
self._async_compute_hours_and_changes(
|
||||||
self._update, start, end, now_timestamp, start_timestamp, end_timestamp
|
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
|
# Get history between start and end
|
||||||
history_list = history.state_changes_during_period(
|
return history.state_changes_during_period(
|
||||||
self.hass, start, end, str(self._entity_id), no_attributes=True
|
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:
|
def _async_compute_hours_and_changes(
|
||||||
return
|
self, now_timestamp: float, start_timestamp: float, end_timestamp: float
|
||||||
|
) -> None:
|
||||||
# Get the first state
|
"""Compute the hours matched and changes from the history list and first state."""
|
||||||
last_state = history.get_state(
|
# state_changes_during_period is called with include_start_time_state=True
|
||||||
self.hass, start, self._entity_id, no_attributes=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
|
last_time = start_timestamp
|
||||||
elapsed = 0
|
elapsed = 0.0
|
||||||
count = 0
|
count = 0
|
||||||
|
|
||||||
# Make calculations
|
# 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_state = item.state in self._entity_states
|
||||||
current_time = item.last_changed.timestamp()
|
current_time = item.last_changed.timestamp()
|
||||||
|
|
||||||
@ -322,13 +372,6 @@ class HistoryStatsSensor(SensorEntity):
|
|||||||
if end is None:
|
if end is None:
|
||||||
end = start + self._duration
|
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
|
self._period = start, end
|
||||||
|
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ from datetime import datetime, timedelta
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from freezegun import freeze_time
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant import config as hass_config
|
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
|
import homeassistant.util.dt as dt_util
|
||||||
|
|
||||||
from tests.common import (
|
from tests.common import (
|
||||||
|
async_fire_time_changed,
|
||||||
async_init_recorder_component,
|
async_init_recorder_component,
|
||||||
get_fixture_path,
|
get_fixture_path,
|
||||||
get_test_home_assistant,
|
get_test_home_assistant,
|
||||||
@ -274,21 +276,26 @@ async def test_measure_multiple(hass):
|
|||||||
"""Test the history statistics sensor measure for multiple ."""
|
"""Test the history statistics sensor measure for multiple ."""
|
||||||
await async_init_recorder_component(hass)
|
await async_init_recorder_component(hass)
|
||||||
|
|
||||||
t0 = dt_util.utcnow() - timedelta(minutes=40)
|
start_time = dt_util.utcnow() - timedelta(minutes=60)
|
||||||
t1 = t0 + timedelta(minutes=20)
|
t0 = start_time + timedelta(minutes=20)
|
||||||
t2 = dt_util.utcnow() - timedelta(minutes=10)
|
t1 = t0 + timedelta(minutes=10)
|
||||||
|
t2 = t1 + timedelta(minutes=10)
|
||||||
|
|
||||||
# Start t0 t1 t2 End
|
# Start t0 t1 t2 End
|
||||||
# |--20min--|--20min--|--10min--|--10min--|
|
# |--20min--|--20min--|--10min--|--10min--|
|
||||||
# |---------|--orange-|-default-|---blue--|
|
# |---------|--orange-|-default-|---blue--|
|
||||||
|
|
||||||
fake_states = {
|
def _fake_states(*args, **kwargs):
|
||||||
"input_select.test_id": [
|
return {
|
||||||
ha.State("input_select.test_id", "orange", last_changed=t0),
|
"input_select.test_id": [
|
||||||
ha.State("input_select.test_id", "default", last_changed=t1),
|
# Because we use include_start_time_state we need to mock
|
||||||
ha.State("input_select.test_id", "blue", last_changed=t2),
|
# 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(
|
await async_setup_component(
|
||||||
hass,
|
hass,
|
||||||
@ -337,8 +344,8 @@ async def test_measure_multiple(hass):
|
|||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.recorder.history.state_changes_during_period",
|
"homeassistant.components.recorder.history.state_changes_during_period",
|
||||||
return_value=fake_states,
|
_fake_states,
|
||||||
), patch("homeassistant.components.recorder.history.get_state", return_value=None):
|
):
|
||||||
for i in range(1, 5):
|
for i in range(1, 5):
|
||||||
await async_update_entity(hass, f"sensor.sensor{i}")
|
await async_update_entity(hass, f"sensor.sensor{i}")
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
@ -351,21 +358,25 @@ async def test_measure_multiple(hass):
|
|||||||
|
|
||||||
async def async_test_measure(hass):
|
async def async_test_measure(hass):
|
||||||
"""Test the history statistics sensor measure."""
|
"""Test the history statistics sensor measure."""
|
||||||
t0 = dt_util.utcnow() - timedelta(minutes=40)
|
await async_init_recorder_component(hass)
|
||||||
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
|
# Start t0 t1 t2 End
|
||||||
# |--20min--|--20min--|--10min--|--10min--|
|
# |--20min--|--20min--|--10min--|--10min--|
|
||||||
# |---off---|---on----|---off---|---on----|
|
# |---off---|---on----|---off---|---on----|
|
||||||
|
|
||||||
fake_states = {
|
def _fake_states(*args, **kwargs):
|
||||||
"binary_sensor.test_id": [
|
return {
|
||||||
ha.State("binary_sensor.test_id", "on", last_changed=t0),
|
"binary_sensor.test_id": [
|
||||||
ha.State("binary_sensor.test_id", "off", last_changed=t1),
|
ha.State("binary_sensor.test_id", "on", last_changed=t0),
|
||||||
ha.State("binary_sensor.test_id", "on", last_changed=t2),
|
ha.State("binary_sensor.test_id", "off", last_changed=t1),
|
||||||
]
|
ha.State("binary_sensor.test_id", "on", last_changed=t2),
|
||||||
}
|
]
|
||||||
|
}
|
||||||
|
|
||||||
await async_setup_component(
|
await async_setup_component(
|
||||||
hass,
|
hass,
|
||||||
@ -414,8 +425,8 @@ async def async_test_measure(hass):
|
|||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.recorder.history.state_changes_during_period",
|
"homeassistant.components.recorder.history.state_changes_during_period",
|
||||||
return_value=fake_states,
|
_fake_states,
|
||||||
), patch("homeassistant.components.recorder.history.get_state", return_value=None):
|
):
|
||||||
for i in range(1, 5):
|
for i in range(1, 5):
|
||||||
await async_update_entity(hass, f"sensor.sensor{i}")
|
await async_update_entity(hass, f"sensor.sensor{i}")
|
||||||
await hass.async_block_till_done()
|
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.sensor2").state == STATE_UNKNOWN
|
||||||
assert hass.states.get("sensor.sensor3").state == "2"
|
assert hass.states.get("sensor.sensor3").state == "2"
|
||||||
assert hass.states.get("sensor.sensor4").state == "50.0"
|
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"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user