Use event loop scheduling for tracking time patterns (#38021)

* Use event loop scheduling for tracking time patterns

* make patching of time targetable

* patch time tests since time can tick to match during the test

* fix more tests

* time can only move forward

* time can only move forward

* back to 100% coverage

* simplify since the event loop time cannot move backwards

* simplify some more

* revert simplify

* Revert "revert simplify"

This reverts commit bd42f232f6.

* Revert "simplify some more"

This reverts commit 2a6c57d514.

* Revert "simplify since the event loop time cannot move backwards"

This reverts commit 3b13714ef4.

* Attempt another simplify

* time does not move backwards in the last two

* remove next_time <= now check

* fix previous merge error
This commit is contained in:
J. Nick Koston 2020-07-20 20:18:31 -10:00 committed by GitHub
parent 7bc8caca96
commit 60009ec2f9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 404 additions and 199 deletions

View file

@ -1,4 +1,5 @@
"""Helpers for listening to events.""" """Helpers for listening to events."""
import asyncio
from datetime import datetime, timedelta from datetime import datetime, timedelta
import functools as ft import functools as ft
import logging import logging
@ -563,6 +564,9 @@ def async_track_sunset(
track_sunset = threaded_listener_factory(async_track_sunset) track_sunset = threaded_listener_factory(async_track_sunset)
# For targeted patching in tests
pattern_utc_now = dt_util.utcnow
@callback @callback
@bind_hass @bind_hass
@ -590,7 +594,7 @@ def async_track_utc_time_change(
matching_minutes = dt_util.parse_time_expression(minute, 0, 59) matching_minutes = dt_util.parse_time_expression(minute, 0, 59)
matching_hours = dt_util.parse_time_expression(hour, 0, 23) matching_hours = dt_util.parse_time_expression(hour, 0, 23)
next_time = None next_time: datetime = dt_util.utcnow()
def calculate_next(now: datetime) -> None: def calculate_next(now: datetime) -> None:
"""Calculate and set the next time the trigger should fire.""" """Calculate and set the next time the trigger should fire."""
@ -603,29 +607,37 @@ def async_track_utc_time_change(
# Make sure rolling back the clock doesn't prevent the timer from # Make sure rolling back the clock doesn't prevent the timer from
# triggering. # triggering.
last_now: Optional[datetime] = None cancel_callback: Optional[asyncio.TimerHandle] = None
calculate_next(next_time)
@callback @callback
def pattern_time_change_listener(event: Event) -> None: def pattern_time_change_listener() -> None:
"""Listen for matching time_changed events.""" """Listen for matching time_changed events."""
nonlocal next_time, last_now nonlocal next_time, cancel_callback
now = event.data[ATTR_NOW] now = pattern_utc_now()
hass.async_run_job(action, dt_util.as_local(now) if local else now)
if last_now is None or now < last_now: calculate_next(now + timedelta(seconds=1))
# Time rolled back or next time not yet calculated
calculate_next(now)
last_now = now cancel_callback = hass.loop.call_at(
hass.loop.time() + next_time.timestamp() - time.time(),
pattern_time_change_listener,
)
if next_time <= now: cancel_callback = hass.loop.call_at(
hass.async_run_job(action, dt_util.as_local(now) if local else now) hass.loop.time() + next_time.timestamp() - time.time(),
calculate_next(now + timedelta(seconds=1)) pattern_time_change_listener,
)
# We can't use async_track_point_in_utc_time here because it would @callback
# break in the case that the system time abruptly jumps backwards. def unsub_pattern_time_change_listener() -> None:
# Our custom last_now logic takes care of resolving that scenario. """Cancel the call_later."""
return hass.bus.async_listen(EVENT_TIME_CHANGED, pattern_time_change_listener) nonlocal cancel_callback
assert cancel_callback is not None
cancel_callback.cancel()
return unsub_pattern_time_change_listener
track_utc_time_change = threaded_listener_factory(async_track_utc_time_change) track_utc_time_change = threaded_listener_factory(async_track_utc_time_change)

View file

@ -285,7 +285,7 @@ fire_mqtt_message = threadsafe_callback_factory(async_fire_mqtt_message)
@ha.callback @ha.callback
def async_fire_time_changed(hass, datetime_): def async_fire_time_changed(hass, datetime_, fire_all=False):
"""Fire a time changes event.""" """Fire a time changes event."""
hass.bus.async_fire(EVENT_TIME_CHANGED, {"now": date_util.as_utc(datetime_)}) hass.bus.async_fire(EVENT_TIME_CHANGED, {"now": date_util.as_utc(datetime_)})
@ -298,9 +298,13 @@ def async_fire_time_changed(hass, datetime_):
future_seconds = task.when() - hass.loop.time() future_seconds = task.when() - hass.loop.time()
mock_seconds_into_future = datetime_.timestamp() - time.time() mock_seconds_into_future = datetime_.timestamp() - time.time()
if mock_seconds_into_future >= future_seconds: if fire_all or mock_seconds_into_future >= future_seconds:
task._run() with patch(
task.cancel() "homeassistant.helpers.event.pattern_utc_now",
return_value=date_util.as_utc(datetime_),
):
task._run()
task.cancel()
fire_time_changed = threadsafe_callback_factory(async_fire_time_changed) fire_time_changed = threadsafe_callback_factory(async_fire_time_changed)

View file

@ -46,7 +46,11 @@ async def test_if_fires_using_at(hass, calls):
}, },
) )
async_fire_time_changed(hass, dt_util.utcnow().replace(hour=5, minute=0, second=0)) now = dt_util.utcnow()
async_fire_time_changed(
hass, now.replace(year=now.year + 1, hour=5, minute=0, second=0)
)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(calls) == 1 assert len(calls) == 1

View file

@ -1,4 +1,5 @@
"""The tests for the time_pattern automation.""" """The tests for the time_pattern automation."""
from asynctest.mock import patch
import pytest import pytest
import homeassistant.components.automation as automation import homeassistant.components.automation as automation
@ -23,53 +24,67 @@ def setup_comp(hass):
async def test_if_fires_when_hour_matches(hass, calls): async def test_if_fires_when_hour_matches(hass, calls):
"""Test for firing if hour is matching.""" """Test for firing if hour is matching."""
assert await async_setup_component( now = dt_util.utcnow()
hass, time_that_will_not_match_right_away = dt_util.utcnow().replace(
automation.DOMAIN, year=now.year + 1, hour=3
{
automation.DOMAIN: {
"trigger": {
"platform": "time_pattern",
"hours": 0,
"minutes": "*",
"seconds": "*",
},
"action": {"service": "test.automation"},
}
},
) )
with patch(
"homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away
):
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
"platform": "time_pattern",
"hours": 0,
"minutes": "*",
"seconds": "*",
},
"action": {"service": "test.automation"},
}
},
)
async_fire_time_changed(hass, dt_util.utcnow().replace(hour=0)) async_fire_time_changed(hass, now.replace(year=now.year + 2, hour=0))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(calls) == 1 assert len(calls) == 1
await common.async_turn_off(hass) await common.async_turn_off(hass)
await hass.async_block_till_done() await hass.async_block_till_done()
async_fire_time_changed(hass, dt_util.utcnow().replace(hour=0)) async_fire_time_changed(hass, now.replace(year=now.year + 1, hour=0))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(calls) == 1 assert len(calls) == 1
async def test_if_fires_when_minute_matches(hass, calls): async def test_if_fires_when_minute_matches(hass, calls):
"""Test for firing if minutes are matching.""" """Test for firing if minutes are matching."""
assert await async_setup_component( now = dt_util.utcnow()
hass, time_that_will_not_match_right_away = dt_util.utcnow().replace(
automation.DOMAIN, year=now.year + 1, minute=30
{
automation.DOMAIN: {
"trigger": {
"platform": "time_pattern",
"hours": "*",
"minutes": 0,
"seconds": "*",
},
"action": {"service": "test.automation"},
}
},
) )
with patch(
"homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away
):
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
"platform": "time_pattern",
"hours": "*",
"minutes": 0,
"seconds": "*",
},
"action": {"service": "test.automation"},
}
},
)
async_fire_time_changed(hass, dt_util.utcnow().replace(minute=0)) async_fire_time_changed(hass, now.replace(year=now.year + 2, minute=0))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(calls) == 1 assert len(calls) == 1
@ -77,23 +92,30 @@ async def test_if_fires_when_minute_matches(hass, calls):
async def test_if_fires_when_second_matches(hass, calls): async def test_if_fires_when_second_matches(hass, calls):
"""Test for firing if seconds are matching.""" """Test for firing if seconds are matching."""
assert await async_setup_component( now = dt_util.utcnow()
hass, time_that_will_not_match_right_away = dt_util.utcnow().replace(
automation.DOMAIN, year=now.year + 1, second=30
{
automation.DOMAIN: {
"trigger": {
"platform": "time_pattern",
"hours": "*",
"minutes": "*",
"seconds": 0,
},
"action": {"service": "test.automation"},
}
},
) )
with patch(
"homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away
):
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
"platform": "time_pattern",
"hours": "*",
"minutes": "*",
"seconds": 0,
},
"action": {"service": "test.automation"},
}
},
)
async_fire_time_changed(hass, dt_util.utcnow().replace(second=0)) async_fire_time_changed(hass, now.replace(year=now.year + 2, second=0))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(calls) == 1 assert len(calls) == 1
@ -101,23 +123,32 @@ async def test_if_fires_when_second_matches(hass, calls):
async def test_if_fires_when_all_matches(hass, calls): async def test_if_fires_when_all_matches(hass, calls):
"""Test for firing if everything matches.""" """Test for firing if everything matches."""
assert await async_setup_component( now = dt_util.utcnow()
hass, time_that_will_not_match_right_away = dt_util.utcnow().replace(
automation.DOMAIN, year=now.year + 1, hour=4
{
automation.DOMAIN: {
"trigger": {
"platform": "time_pattern",
"hours": 1,
"minutes": 2,
"seconds": 3,
},
"action": {"service": "test.automation"},
}
},
) )
with patch(
"homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away
):
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
"platform": "time_pattern",
"hours": 1,
"minutes": 2,
"seconds": 3,
},
"action": {"service": "test.automation"},
}
},
)
async_fire_time_changed(hass, dt_util.utcnow().replace(hour=1, minute=2, second=3)) async_fire_time_changed(
hass, now.replace(year=now.year + 2, hour=1, minute=2, second=3)
)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(calls) == 1 assert len(calls) == 1
@ -125,47 +156,66 @@ async def test_if_fires_when_all_matches(hass, calls):
async def test_if_fires_periodic_seconds(hass, calls): async def test_if_fires_periodic_seconds(hass, calls):
"""Test for firing periodically every second.""" """Test for firing periodically every second."""
assert await async_setup_component( now = dt_util.utcnow()
hass, time_that_will_not_match_right_away = dt_util.utcnow().replace(
automation.DOMAIN, year=now.year + 1, second=1
{ )
automation.DOMAIN: { with patch(
"trigger": { "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away
"platform": "time_pattern", ):
"hours": "*", assert await async_setup_component(
"minutes": "*", hass,
"seconds": "/2", automation.DOMAIN,
}, {
"action": {"service": "test.automation"}, automation.DOMAIN: {
} "trigger": {
}, "platform": "time_pattern",
"hours": "*",
"minutes": "*",
"seconds": "/10",
},
"action": {"service": "test.automation"},
}
},
)
async_fire_time_changed(
hass, now.replace(year=now.year + 2, hour=0, minute=0, second=10)
) )
async_fire_time_changed(hass, dt_util.utcnow().replace(hour=0, minute=0, second=2))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(calls) == 1 assert len(calls) >= 1
async def test_if_fires_periodic_minutes(hass, calls): async def test_if_fires_periodic_minutes(hass, calls):
"""Test for firing periodically every minute.""" """Test for firing periodically every minute."""
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
"platform": "time_pattern",
"hours": "*",
"minutes": "/2",
"seconds": "*",
},
"action": {"service": "test.automation"},
}
},
)
async_fire_time_changed(hass, dt_util.utcnow().replace(hour=0, minute=2, second=0)) now = dt_util.utcnow()
time_that_will_not_match_right_away = dt_util.utcnow().replace(
year=now.year + 1, minute=1
)
with patch(
"homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away
):
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
"platform": "time_pattern",
"hours": "*",
"minutes": "/2",
"seconds": "*",
},
"action": {"service": "test.automation"},
}
},
)
async_fire_time_changed(
hass, now.replace(year=now.year + 2, hour=0, minute=2, second=0)
)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(calls) == 1 assert len(calls) == 1
@ -173,23 +223,32 @@ async def test_if_fires_periodic_minutes(hass, calls):
async def test_if_fires_periodic_hours(hass, calls): async def test_if_fires_periodic_hours(hass, calls):
"""Test for firing periodically every hour.""" """Test for firing periodically every hour."""
assert await async_setup_component( now = dt_util.utcnow()
hass, time_that_will_not_match_right_away = dt_util.utcnow().replace(
automation.DOMAIN, year=now.year + 1, hour=1
{
automation.DOMAIN: {
"trigger": {
"platform": "time_pattern",
"hours": "/2",
"minutes": "*",
"seconds": "*",
},
"action": {"service": "test.automation"},
}
},
) )
with patch(
"homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away
):
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
"platform": "time_pattern",
"hours": "/2",
"minutes": "*",
"seconds": "*",
},
"action": {"service": "test.automation"},
}
},
)
async_fire_time_changed(hass, dt_util.utcnow().replace(hour=2, minute=0, second=0)) async_fire_time_changed(
hass, now.replace(year=now.year + 2, hour=2, minute=0, second=0)
)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(calls) == 1 assert len(calls) == 1
@ -197,28 +256,41 @@ async def test_if_fires_periodic_hours(hass, calls):
async def test_default_values(hass, calls): async def test_default_values(hass, calls):
"""Test for firing at 2 minutes every hour.""" """Test for firing at 2 minutes every hour."""
assert await async_setup_component( now = dt_util.utcnow()
hass, time_that_will_not_match_right_away = dt_util.utcnow().replace(
automation.DOMAIN, year=now.year + 1, minute=1
{ )
automation.DOMAIN: { with patch(
"trigger": {"platform": "time_pattern", "minutes": "2"}, "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away
"action": {"service": "test.automation"}, ):
} assert await async_setup_component(
}, hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {"platform": "time_pattern", "minutes": "2"},
"action": {"service": "test.automation"},
}
},
)
async_fire_time_changed(
hass, now.replace(year=now.year + 2, hour=1, minute=2, second=0)
) )
async_fire_time_changed(hass, dt_util.utcnow().replace(hour=1, minute=2, second=0)) await hass.async_block_till_done()
assert len(calls) == 1
async_fire_time_changed(
hass, now.replace(year=now.year + 2, hour=1, minute=2, second=1)
)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(calls) == 1 assert len(calls) == 1
async_fire_time_changed(hass, dt_util.utcnow().replace(hour=1, minute=2, second=1)) async_fire_time_changed(
hass, now.replace(year=now.year + 2, hour=2, minute=2, second=0)
await hass.async_block_till_done() )
assert len(calls) == 1
async_fire_time_changed(hass, dt_util.utcnow().replace(hour=2, minute=2, second=0))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(calls) == 2 assert len(calls) == 2

View file

@ -17,14 +17,18 @@ from homeassistant.components.recorder.const import DATA_INSTANCE
from homeassistant.components.recorder.models import Events, RecorderRuns, States from homeassistant.components.recorder.models import Events, RecorderRuns, States
from homeassistant.components.recorder.util import session_scope from homeassistant.components.recorder.util import session_scope
from homeassistant.const import MATCH_ALL, STATE_LOCKED, STATE_UNLOCKED from homeassistant.const import MATCH_ALL, STATE_LOCKED, STATE_UNLOCKED
from homeassistant.core import ATTR_NOW, EVENT_TIME_CHANGED, Context, callback from homeassistant.core import Context, callback
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from .common import wait_recording_done from .common import wait_recording_done
from tests.async_mock import patch from tests.async_mock import patch
from tests.common import get_test_home_assistant, init_recorder_component from tests.common import (
async_fire_time_changed,
get_test_home_assistant,
init_recorder_component,
)
class TestRecorder(unittest.TestCase): class TestRecorder(unittest.TestCase):
@ -335,15 +339,15 @@ def test_auto_purge(hass_recorder):
tz = dt_util.get_time_zone("Europe/Copenhagen") tz = dt_util.get_time_zone("Europe/Copenhagen")
dt_util.set_default_time_zone(tz) dt_util.set_default_time_zone(tz)
test_time = tz.localize(datetime(2020, 1, 1, 4, 12, 0)) now = dt_util.utcnow()
test_time = tz.localize(datetime(now.year + 1, 1, 1, 4, 12, 0))
async_fire_time_changed(hass, test_time)
with patch( with patch(
"homeassistant.components.recorder.purge.purge_old_data", return_value=True "homeassistant.components.recorder.purge.purge_old_data", return_value=True
) as purge_old_data: ) as purge_old_data:
for delta in (-1, 0, 1): for delta in (-1, 0, 1):
hass.bus.fire( async_fire_time_changed(hass, test_time + timedelta(seconds=delta))
EVENT_TIME_CHANGED, {ATTR_NOW: test_time + timedelta(seconds=delta)}
)
hass.block_till_done() hass.block_till_done()
hass.data[DATA_INSTANCE].block_till_done() hass.data[DATA_INSTANCE].block_till_done()

View file

@ -260,49 +260,49 @@ async def _test_self_reset(hass, config, start_time, expect_reset=True):
assert state.state == "5" assert state.state == "5"
async def test_self_reset_hourly(hass): async def test_self_reset_hourly(hass, legacy_patchable_time):
"""Test hourly reset of meter.""" """Test hourly reset of meter."""
await _test_self_reset( await _test_self_reset(
hass, gen_config("hourly"), "2017-12-31T23:59:00.000000+00:00" hass, gen_config("hourly"), "2017-12-31T23:59:00.000000+00:00"
) )
async def test_self_reset_daily(hass): async def test_self_reset_daily(hass, legacy_patchable_time):
"""Test daily reset of meter.""" """Test daily reset of meter."""
await _test_self_reset( await _test_self_reset(
hass, gen_config("daily"), "2017-12-31T23:59:00.000000+00:00" hass, gen_config("daily"), "2017-12-31T23:59:00.000000+00:00"
) )
async def test_self_reset_weekly(hass): async def test_self_reset_weekly(hass, legacy_patchable_time):
"""Test weekly reset of meter.""" """Test weekly reset of meter."""
await _test_self_reset( await _test_self_reset(
hass, gen_config("weekly"), "2017-12-31T23:59:00.000000+00:00" hass, gen_config("weekly"), "2017-12-31T23:59:00.000000+00:00"
) )
async def test_self_reset_monthly(hass): async def test_self_reset_monthly(hass, legacy_patchable_time):
"""Test monthly reset of meter.""" """Test monthly reset of meter."""
await _test_self_reset( await _test_self_reset(
hass, gen_config("monthly"), "2017-12-31T23:59:00.000000+00:00" hass, gen_config("monthly"), "2017-12-31T23:59:00.000000+00:00"
) )
async def test_self_reset_quarterly(hass): async def test_self_reset_quarterly(hass, legacy_patchable_time):
"""Test quarterly reset of meter.""" """Test quarterly reset of meter."""
await _test_self_reset( await _test_self_reset(
hass, gen_config("quarterly"), "2017-03-31T23:59:00.000000+00:00" hass, gen_config("quarterly"), "2017-03-31T23:59:00.000000+00:00"
) )
async def test_self_reset_yearly(hass): async def test_self_reset_yearly(hass, legacy_patchable_time):
"""Test yearly reset of meter.""" """Test yearly reset of meter."""
await _test_self_reset( await _test_self_reset(
hass, gen_config("yearly"), "2017-12-31T23:59:00.000000+00:00" hass, gen_config("yearly"), "2017-12-31T23:59:00.000000+00:00"
) )
async def test_self_no_reset_yearly(hass): async def test_self_no_reset_yearly(hass, legacy_patchable_time):
"""Test yearly reset of meter does not occur after 1st January.""" """Test yearly reset of meter does not occur after 1st January."""
await _test_self_reset( await _test_self_reset(
hass, hass,
@ -312,7 +312,7 @@ async def test_self_no_reset_yearly(hass):
) )
async def test_reset_yearly_offset(hass): async def test_reset_yearly_offset(hass, legacy_patchable_time):
"""Test yearly reset of meter.""" """Test yearly reset of meter."""
await _test_self_reset( await _test_self_reset(
hass, hass,
@ -321,7 +321,7 @@ async def test_reset_yearly_offset(hass):
) )
async def test_no_reset_yearly_offset(hass): async def test_no_reset_yearly_offset(hass, legacy_patchable_time):
"""Test yearly reset of meter.""" """Test yearly reset of meter."""
await _test_self_reset( await _test_self_reset(
hass, hass,

View file

@ -119,7 +119,7 @@ async def test_erronous_network_key_fails_validation(hass, mock_openzwave):
zwave.CONFIG_SCHEMA({"zwave": {"network_key": value}}) zwave.CONFIG_SCHEMA({"zwave": {"network_key": value}})
async def test_auto_heal_midnight(hass, mock_openzwave): async def test_auto_heal_midnight(hass, mock_openzwave, legacy_patchable_time):
"""Test network auto-heal at midnight.""" """Test network auto-heal at midnight."""
await async_setup_component(hass, "zwave", {"zwave": {"autoheal": True}}) await async_setup_component(hass, "zwave", {"zwave": {"autoheal": True}})
await hass.async_block_till_done() await hass.async_block_till_done()

View file

@ -1,5 +1,6 @@
"""Set up some common test helper things.""" """Set up some common test helper things."""
import asyncio import asyncio
import datetime
import functools import functools
import logging import logging
import threading import threading
@ -387,8 +388,71 @@ def legacy_patchable_time():
return async_unsub return async_unsub
@ha.callback
@loader.bind_hass
def async_track_utc_time_change(
hass, action, hour=None, minute=None, second=None, local=False
):
"""Add a listener that will fire if time matches a pattern."""
# We do not have to wrap the function with time pattern matching logic
# if no pattern given
if all(val is None for val in (hour, minute, second)):
@ha.callback
def time_change_listener(ev) -> None:
"""Fire every time event that comes in."""
hass.async_run_job(action, ev.data[ATTR_NOW])
return hass.bus.async_listen(EVENT_TIME_CHANGED, time_change_listener)
matching_seconds = event.dt_util.parse_time_expression(second, 0, 59)
matching_minutes = event.dt_util.parse_time_expression(minute, 0, 59)
matching_hours = event.dt_util.parse_time_expression(hour, 0, 23)
next_time = None
def calculate_next(now) -> None:
"""Calculate and set the next time the trigger should fire."""
nonlocal next_time
localized_now = event.dt_util.as_local(now) if local else now
next_time = event.dt_util.find_next_time_expression_time(
localized_now, matching_seconds, matching_minutes, matching_hours
)
# Make sure rolling back the clock doesn't prevent the timer from
# triggering.
last_now = None
@ha.callback
def pattern_time_change_listener(ev) -> None:
"""Listen for matching time_changed events."""
nonlocal next_time, last_now
now = ev.data[ATTR_NOW]
if last_now is None or now < last_now:
# Time rolled back or next time not yet calculated
calculate_next(now)
last_now = now
if next_time <= now:
hass.async_run_job(
action, event.dt_util.as_local(now) if local else now
)
calculate_next(now + datetime.timedelta(seconds=1))
# We can't use async_track_point_in_utc_time here because it would
# break in the case that the system time abruptly jumps backwards.
# Our custom last_now logic takes care of resolving that scenario.
return hass.bus.async_listen(EVENT_TIME_CHANGED, pattern_time_change_listener)
with patch( with patch(
"homeassistant.helpers.event.async_track_point_in_utc_time", "homeassistant.helpers.event.async_track_point_in_utc_time",
async_track_point_in_utc_time, async_track_point_in_utc_time,
), patch(
"homeassistant.helpers.event.async_track_utc_time_change",
async_track_utc_time_change,
): ):
yield yield

View file

@ -748,22 +748,24 @@ async def test_async_track_time_change(hass):
wildcard_runs = [] wildcard_runs = []
specific_runs = [] specific_runs = []
now = dt_util.utcnow()
unsub = async_track_time_change(hass, lambda x: wildcard_runs.append(1)) unsub = async_track_time_change(hass, lambda x: wildcard_runs.append(1))
unsub_utc = async_track_utc_time_change( unsub_utc = async_track_utc_time_change(
hass, lambda x: specific_runs.append(1), second=[0, 30] hass, lambda x: specific_runs.append(1), second=[0, 30]
) )
async_fire_time_changed(hass, datetime(2014, 5, 24, 12, 0, 0)) async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 0, 0))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 1 assert len(specific_runs) == 1
assert len(wildcard_runs) == 1 assert len(wildcard_runs) == 1
async_fire_time_changed(hass, datetime(2014, 5, 24, 12, 0, 15)) async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 0, 15))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 1 assert len(specific_runs) == 1
assert len(wildcard_runs) == 2 assert len(wildcard_runs) == 2
async_fire_time_changed(hass, datetime(2014, 5, 24, 12, 0, 30)) async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 0, 30))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 2 assert len(specific_runs) == 2
assert len(wildcard_runs) == 3 assert len(wildcard_runs) == 3
@ -771,7 +773,7 @@ async def test_async_track_time_change(hass):
unsub() unsub()
unsub_utc() unsub_utc()
async_fire_time_changed(hass, datetime(2014, 5, 24, 12, 0, 30)) async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 0, 30))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 2 assert len(specific_runs) == 2
assert len(wildcard_runs) == 3 assert len(wildcard_runs) == 3
@ -781,25 +783,27 @@ async def test_periodic_task_minute(hass):
"""Test periodic tasks per minute.""" """Test periodic tasks per minute."""
specific_runs = [] specific_runs = []
now = dt_util.utcnow()
unsub = async_track_utc_time_change( unsub = async_track_utc_time_change(
hass, lambda x: specific_runs.append(1), minute="/5", second=0 hass, lambda x: specific_runs.append(1), minute="/5", second=0
) )
async_fire_time_changed(hass, datetime(2014, 5, 24, 12, 0, 0)) async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 0, 0))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 1 assert len(specific_runs) == 1
async_fire_time_changed(hass, datetime(2014, 5, 24, 12, 3, 0)) async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 3, 0))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 1 assert len(specific_runs) == 1
async_fire_time_changed(hass, datetime(2014, 5, 24, 12, 5, 0)) async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 5, 0))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 2 assert len(specific_runs) == 2
unsub() unsub()
async_fire_time_changed(hass, datetime(2014, 5, 24, 12, 5, 0)) async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 5, 0))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 2 assert len(specific_runs) == 2
@ -808,33 +812,35 @@ async def test_periodic_task_hour(hass):
"""Test periodic tasks per hour.""" """Test periodic tasks per hour."""
specific_runs = [] specific_runs = []
now = dt_util.utcnow()
unsub = async_track_utc_time_change( unsub = async_track_utc_time_change(
hass, lambda x: specific_runs.append(1), hour="/2", minute=0, second=0 hass, lambda x: specific_runs.append(1), hour="/2", minute=0, second=0
) )
async_fire_time_changed(hass, datetime(2014, 5, 24, 22, 0, 0)) async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 22, 0, 0))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 1 assert len(specific_runs) == 1
async_fire_time_changed(hass, datetime(2014, 5, 24, 23, 0, 0)) async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 23, 0, 0))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 1 assert len(specific_runs) == 1
async_fire_time_changed(hass, datetime(2014, 5, 25, 0, 0, 0)) async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 0, 0, 0))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 2 assert len(specific_runs) == 2
async_fire_time_changed(hass, datetime(2014, 5, 25, 1, 0, 0)) async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 1, 0, 0))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 2 assert len(specific_runs) == 2
async_fire_time_changed(hass, datetime(2014, 5, 25, 2, 0, 0)) async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 2, 0, 0))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 3 assert len(specific_runs) == 3
unsub() unsub()
async_fire_time_changed(hass, datetime(2014, 5, 25, 2, 0, 0)) async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 2, 0, 0))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 3 assert len(specific_runs) == 3
@ -843,12 +849,14 @@ async def test_periodic_task_wrong_input(hass):
"""Test periodic tasks with wrong input.""" """Test periodic tasks with wrong input."""
specific_runs = [] specific_runs = []
now = dt_util.utcnow()
with pytest.raises(ValueError): with pytest.raises(ValueError):
async_track_utc_time_change( async_track_utc_time_change(
hass, lambda x: specific_runs.append(1), hour="/two" hass, lambda x: specific_runs.append(1), hour="/two"
) )
async_fire_time_changed(hass, datetime(2014, 5, 2, 0, 0, 0)) async_fire_time_changed(hass, datetime(now.year + 1, 5, 2, 0, 0, 0))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 0 assert len(specific_runs) == 0
@ -857,33 +865,37 @@ async def test_periodic_task_clock_rollback(hass):
"""Test periodic tasks with the time rolling backwards.""" """Test periodic tasks with the time rolling backwards."""
specific_runs = [] specific_runs = []
now = dt_util.utcnow()
unsub = async_track_utc_time_change( unsub = async_track_utc_time_change(
hass, lambda x: specific_runs.append(1), hour="/2", minute=0, second=0 hass, lambda x: specific_runs.append(1), hour="/2", minute=0, second=0
) )
async_fire_time_changed(hass, datetime(2014, 5, 24, 22, 0, 0)) async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 22, 0, 0))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 1 assert len(specific_runs) == 1
async_fire_time_changed(hass, datetime(2014, 5, 24, 23, 0, 0)) async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 23, 0, 0))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 1 assert len(specific_runs) == 1
async_fire_time_changed(hass, datetime(2014, 5, 24, 22, 0, 0)) async_fire_time_changed(
hass, datetime(now.year + 1, 5, 24, 22, 0, 0), fire_all=True
)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 2 assert len(specific_runs) == 2
async_fire_time_changed(hass, datetime(2014, 5, 24, 0, 0, 0)) async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 0, 0, 0), fire_all=True)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 3 assert len(specific_runs) == 3
async_fire_time_changed(hass, datetime(2014, 5, 25, 2, 0, 0)) async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 2, 0, 0))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 4 assert len(specific_runs) == 4
unsub() unsub()
async_fire_time_changed(hass, datetime(2014, 5, 25, 2, 0, 0)) async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 2, 0, 0))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 4 assert len(specific_runs) == 4
@ -892,19 +904,21 @@ async def test_periodic_task_duplicate_time(hass):
"""Test periodic tasks not triggering on duplicate time.""" """Test periodic tasks not triggering on duplicate time."""
specific_runs = [] specific_runs = []
now = dt_util.utcnow()
unsub = async_track_utc_time_change( unsub = async_track_utc_time_change(
hass, lambda x: specific_runs.append(1), hour="/2", minute=0, second=0 hass, lambda x: specific_runs.append(1), hour="/2", minute=0, second=0
) )
async_fire_time_changed(hass, datetime(2014, 5, 24, 22, 0, 0)) async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 22, 0, 0))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 1 assert len(specific_runs) == 1
async_fire_time_changed(hass, datetime(2014, 5, 24, 22, 0, 0)) async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 22, 0, 0))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 1 assert len(specific_runs) == 1
async_fire_time_changed(hass, datetime(2014, 5, 25, 0, 0, 0)) async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 0, 0, 0))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 2 assert len(specific_runs) == 2
@ -917,23 +931,39 @@ async def test_periodic_task_entering_dst(hass):
dt_util.set_default_time_zone(timezone) dt_util.set_default_time_zone(timezone)
specific_runs = [] specific_runs = []
unsub = async_track_time_change( now = dt_util.utcnow()
hass, lambda x: specific_runs.append(1), hour=2, minute=30, second=0 time_that_will_not_match_right_away = timezone.localize(
datetime(now.year + 1, 3, 25, 2, 31, 0)
) )
async_fire_time_changed(hass, timezone.localize(datetime(2018, 3, 25, 1, 50, 0))) with patch(
"homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away
):
unsub = async_track_time_change(
hass, lambda x: specific_runs.append(1), hour=2, minute=30, second=0
)
async_fire_time_changed(
hass, timezone.localize(datetime(now.year + 1, 3, 25, 1, 50, 0))
)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 0 assert len(specific_runs) == 0
async_fire_time_changed(hass, timezone.localize(datetime(2018, 3, 25, 3, 50, 0))) async_fire_time_changed(
hass, timezone.localize(datetime(now.year + 1, 3, 25, 3, 50, 0))
)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 0 assert len(specific_runs) == 0
async_fire_time_changed(hass, timezone.localize(datetime(2018, 3, 26, 1, 50, 0))) async_fire_time_changed(
hass, timezone.localize(datetime(now.year + 1, 3, 26, 1, 50, 0))
)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 0 assert len(specific_runs) == 0
async_fire_time_changed(hass, timezone.localize(datetime(2018, 3, 26, 2, 50, 0))) async_fire_time_changed(
hass, timezone.localize(datetime(now.year + 1, 3, 26, 2, 50, 0))
)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 1 assert len(specific_runs) == 1
@ -946,30 +976,45 @@ async def test_periodic_task_leaving_dst(hass):
dt_util.set_default_time_zone(timezone) dt_util.set_default_time_zone(timezone)
specific_runs = [] specific_runs = []
unsub = async_track_time_change( now = dt_util.utcnow()
hass, lambda x: specific_runs.append(1), hour=2, minute=30, second=0
time_that_will_not_match_right_away = timezone.localize(
datetime(now.year + 1, 10, 28, 2, 28, 0), is_dst=True
) )
with patch(
"homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away
):
unsub = async_track_time_change(
hass, lambda x: specific_runs.append(1), hour=2, minute=30, second=0
)
async_fire_time_changed( async_fire_time_changed(
hass, timezone.localize(datetime(2018, 10, 28, 2, 5, 0), is_dst=False) hass, timezone.localize(datetime(now.year + 1, 10, 28, 2, 5, 0), is_dst=False)
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 0 assert len(specific_runs) == 0
async_fire_time_changed( async_fire_time_changed(
hass, timezone.localize(datetime(2018, 10, 28, 2, 55, 0), is_dst=False) hass, timezone.localize(datetime(now.year + 1, 10, 28, 2, 55, 0), is_dst=False)
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 1 assert len(specific_runs) == 1
async_fire_time_changed( async_fire_time_changed(
hass, timezone.localize(datetime(2018, 10, 28, 2, 5, 0), is_dst=True) hass, timezone.localize(datetime(now.year + 2, 10, 28, 2, 45, 0), is_dst=True)
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 1 assert len(specific_runs) == 2
async_fire_time_changed( async_fire_time_changed(
hass, timezone.localize(datetime(2018, 10, 28, 2, 55, 0), is_dst=True) hass, timezone.localize(datetime(now.year + 2, 10, 28, 2, 55, 0), is_dst=True)
)
await hass.async_block_till_done()
assert len(specific_runs) == 2
async_fire_time_changed(
hass, timezone.localize(datetime(now.year + 2, 10, 28, 2, 55, 0), is_dst=True)
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(specific_runs) == 2 assert len(specific_runs) == 2