Skip to content

Commit

Permalink
Fix a history stats bug when window and tracked state change simultan…
Browse files Browse the repository at this point in the history
…eously (#133770)
  • Loading branch information
karwosts authored and frenck committed Dec 24, 2024
1 parent 7ce563b commit bb371c8
Show file tree
Hide file tree
Showing 2 changed files with 110 additions and 3 deletions.
14 changes: 11 additions & 3 deletions homeassistant/components/history_stats/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,7 @@ async def async_update(
<= current_period_end_timestamp
):
self._history_current_period.append(
HistoryState(
new_state.state, new_state.last_changed.timestamp()
)
HistoryState(new_state.state, new_state.last_changed_timestamp)
)
new_data = True
if not new_data and current_period_end_timestamp < now_timestamp:
Expand All @@ -131,6 +129,16 @@ async def async_update(
await self._async_history_from_db(
current_period_start_timestamp, current_period_end_timestamp
)
if event and (new_state := event.data["new_state"]) is not None:
if (
current_period_start_timestamp
<= floored_timestamp(new_state.last_changed)
<= current_period_end_timestamp
):
self._history_current_period.append(
HistoryState(new_state.state, new_state.last_changed_timestamp)
)

self._previous_run_before_start = False

seconds_matched, match_count = self._async_compute_seconds_and_changes(
Expand Down
99 changes: 99 additions & 0 deletions tests/components/history_stats/test_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1465,6 +1465,105 @@ def _fake_states(*args, **kwargs):
assert hass.states.get("sensor.sensor4").state == "50.0"


async def test_state_change_during_window_rollover(
recorder_mock: Recorder,
hass: HomeAssistant,
) -> None:
"""Test when the tracked sensor and the start/end window change during the same update."""
await hass.config.async_set_time_zone("UTC")
utcnow = dt_util.utcnow()
start_time = utcnow.replace(hour=23, minute=0, second=0, microsecond=0)

def _fake_states(*args, **kwargs):
return {
"binary_sensor.state": [
ha.State(
"binary_sensor.state",
"on",
last_changed=start_time - timedelta(hours=11),
last_updated=start_time - timedelta(hours=11),
),
]
}

# The test begins at 23:00, and queries from the database that the sensor has been on since 12:00.
with (
patch(
"homeassistant.components.recorder.history.state_changes_during_period",
_fake_states,
),
freeze_time(start_time),
):
await async_setup_component(
hass,
"sensor",
{
"sensor": [
{
"platform": "history_stats",
"entity_id": "binary_sensor.state",
"name": "sensor1",
"state": "on",
"start": "{{ today_at() }}",
"end": "{{ now() }}",
"type": "time",
}
]
},
)
await hass.async_block_till_done()

await async_update_entity(hass, "sensor.sensor1")
await hass.async_block_till_done()

assert hass.states.get("sensor.sensor1").state == "11.0"

# Advance 59 minutes, to record the last minute update just before midnight, just like a real system would do.
t2 = start_time + timedelta(minutes=59, microseconds=300)
with freeze_time(t2):
async_fire_time_changed(hass, t2)
await hass.async_block_till_done()

assert hass.states.get("sensor.sensor1").state == "11.98"

# One minute has passed and the time has now rolled over into a new day, resetting the recorder window. The sensor will then query the database for updates,
# and will see that the sensor is ON starting from midnight.
t3 = t2 + timedelta(minutes=1)

def _fake_states_t3(*args, **kwargs):
return {
"binary_sensor.state": [
ha.State(
"binary_sensor.state",
"on",
last_changed=t3.replace(hour=0, minute=0, second=0, microsecond=0),
last_updated=t3.replace(hour=0, minute=0, second=0, microsecond=0),
),
]
}

with (
patch(
"homeassistant.components.recorder.history.state_changes_during_period",
_fake_states_t3,
),
freeze_time(t3),
):
# The sensor turns off around this time, before the sensor does its normal polled update.
hass.states.async_set("binary_sensor.state", "off")
await hass.async_block_till_done(wait_background_tasks=True)

assert hass.states.get("sensor.sensor1").state == "0.0"

# More time passes, and the history stats does a polled update again. It should be 0 since the sensor has been off since midnight.
t4 = t3 + timedelta(minutes=10)
with freeze_time(t4):
async_fire_time_changed(hass, t4)
await hass.async_block_till_done()

assert hass.states.get("sensor.sensor1").state == "0.0"


@pytest.mark.parametrize("time_zone", ["Europe/Berlin", "America/Chicago", "US/Hawaii"])
async def test_end_time_with_microseconds_zeroed(
time_zone: str,
Expand Down

0 comments on commit bb371c8

Please sign in to comment.