Make async_track_time_change smarter (#17199)
* Make async_track_time_change smarter * Move to util/dt * Remove unnecessary check * Lint * Remove tzinfo check * Remove check * Add comment about async_track_point_in_utc_time * Fix typing check * Lint
This commit is contained in:
parent
9190fe1c21
commit
26cf5acd5b
5 changed files with 502 additions and 119 deletions
|
@ -322,13 +322,13 @@ track_sunset = threaded_listener_factory(async_track_sunset)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
@bind_hass
|
@bind_hass
|
||||||
def async_track_utc_time_change(hass, action, year=None, month=None, day=None,
|
def async_track_utc_time_change(hass, action,
|
||||||
hour=None, minute=None, second=None,
|
hour=None, minute=None, second=None,
|
||||||
local=False):
|
local=False):
|
||||||
"""Add a listener that will fire if time matches a pattern."""
|
"""Add a listener that will fire if time matches a pattern."""
|
||||||
# We do not have to wrap the function with time pattern matching logic
|
# We do not have to wrap the function with time pattern matching logic
|
||||||
# if no pattern given
|
# if no pattern given
|
||||||
if all(val is None for val in (year, month, day, hour, minute, second)):
|
if all(val is None for val in (hour, minute, second)):
|
||||||
@callback
|
@callback
|
||||||
def time_change_listener(event):
|
def time_change_listener(event):
|
||||||
"""Fire every time event that comes in."""
|
"""Fire every time event that comes in."""
|
||||||
|
@ -336,24 +336,45 @@ def async_track_utc_time_change(hass, action, year=None, month=None, day=None,
|
||||||
|
|
||||||
return hass.bus.async_listen(EVENT_TIME_CHANGED, time_change_listener)
|
return hass.bus.async_listen(EVENT_TIME_CHANGED, time_change_listener)
|
||||||
|
|
||||||
pmp = _process_time_match
|
matching_seconds = dt_util.parse_time_expression(second, 0, 59)
|
||||||
year, month, day = pmp(year), pmp(month), pmp(day)
|
matching_minutes = dt_util.parse_time_expression(minute, 0, 59)
|
||||||
hour, minute, second = pmp(hour), pmp(minute), pmp(second)
|
matching_hours = dt_util.parse_time_expression(hour, 0, 23)
|
||||||
|
|
||||||
|
next_time = None
|
||||||
|
|
||||||
|
def calculate_next(now):
|
||||||
|
"""Calculate and set the next time the trigger should fire."""
|
||||||
|
nonlocal next_time
|
||||||
|
|
||||||
|
localized_now = dt_util.as_local(now) if local else now
|
||||||
|
next_time = 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
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def pattern_time_change_listener(event):
|
def pattern_time_change_listener(event):
|
||||||
"""Listen for matching time_changed events."""
|
"""Listen for matching time_changed events."""
|
||||||
|
nonlocal next_time, last_now
|
||||||
|
|
||||||
now = event.data[ATTR_NOW]
|
now = event.data[ATTR_NOW]
|
||||||
|
|
||||||
if local:
|
if last_now is None or now < last_now:
|
||||||
now = dt_util.as_local(now)
|
# Time rolled back or next time not yet calculated
|
||||||
|
calculate_next(now)
|
||||||
|
|
||||||
# pylint: disable=too-many-boolean-expressions
|
last_now = now
|
||||||
if second(now.second) and minute(now.minute) and hour(now.hour) and \
|
|
||||||
day(now.day) and month(now.month) and year(now.year):
|
|
||||||
|
|
||||||
hass.async_run_job(action, now)
|
if next_time <= now:
|
||||||
|
hass.async_run_job(action, event.data[ATTR_NOW])
|
||||||
|
calculate_next(now + 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,
|
return hass.bus.async_listen(EVENT_TIME_CHANGED,
|
||||||
pattern_time_change_listener)
|
pattern_time_change_listener)
|
||||||
|
|
||||||
|
@ -363,11 +384,10 @@ track_utc_time_change = threaded_listener_factory(async_track_utc_time_change)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
@bind_hass
|
@bind_hass
|
||||||
def async_track_time_change(hass, action, year=None, month=None, day=None,
|
def async_track_time_change(hass, action, hour=None, minute=None, second=None):
|
||||||
hour=None, minute=None, second=None):
|
|
||||||
"""Add a listener that will fire if UTC time matches a pattern."""
|
"""Add a listener that will fire if UTC time matches a pattern."""
|
||||||
return async_track_utc_time_change(hass, action, year, month, day, hour,
|
return async_track_utc_time_change(hass, action, hour, minute, second,
|
||||||
minute, second, local=True)
|
local=True)
|
||||||
|
|
||||||
|
|
||||||
track_time_change = threaded_listener_factory(async_track_time_change)
|
track_time_change = threaded_listener_factory(async_track_time_change)
|
||||||
|
@ -383,19 +403,3 @@ def _process_state_match(parameter):
|
||||||
|
|
||||||
parameter = tuple(parameter)
|
parameter = tuple(parameter)
|
||||||
return lambda state: state in parameter
|
return lambda state: state in parameter
|
||||||
|
|
||||||
|
|
||||||
def _process_time_match(parameter):
|
|
||||||
"""Wrap parameter in a tuple if it is not one and returns it."""
|
|
||||||
if parameter is None or parameter == MATCH_ALL:
|
|
||||||
return lambda _: True
|
|
||||||
|
|
||||||
if isinstance(parameter, str) and parameter.startswith('/'):
|
|
||||||
parameter = float(parameter[1:])
|
|
||||||
return lambda time: time % parameter == 0
|
|
||||||
|
|
||||||
if isinstance(parameter, str) or not hasattr(parameter, '__iter__'):
|
|
||||||
return lambda time: time == parameter
|
|
||||||
|
|
||||||
parameter = tuple(parameter)
|
|
||||||
return lambda time: time in parameter
|
|
||||||
|
|
|
@ -1,10 +1,14 @@
|
||||||
"""Helper methods to handle the time in Home Assistant."""
|
"""Helper methods to handle the time in Home Assistant."""
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import re
|
import re
|
||||||
from typing import Any, Dict, Union, Optional, Tuple # noqa pylint: disable=unused-import
|
from typing import (Any, Union, Optional, # noqa pylint: disable=unused-import
|
||||||
|
Tuple, List, cast, Dict)
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
import pytz.exceptions as pytzexceptions
|
import pytz.exceptions as pytzexceptions
|
||||||
|
import pytz.tzinfo as pytzinfo # noqa pylint: disable=unused-import
|
||||||
|
|
||||||
|
from homeassistant.const import MATCH_ALL
|
||||||
|
|
||||||
DATE_STR_FORMAT = "%Y-%m-%d"
|
DATE_STR_FORMAT = "%Y-%m-%d"
|
||||||
UTC = pytz.utc
|
UTC = pytz.utc
|
||||||
|
@ -209,3 +213,162 @@ def get_age(date: dt.datetime) -> str:
|
||||||
return formatn(minute, 'minute')
|
return formatn(minute, 'minute')
|
||||||
|
|
||||||
return formatn(second, 'second')
|
return formatn(second, 'second')
|
||||||
|
|
||||||
|
|
||||||
|
def parse_time_expression(parameter: Any, min_value: int, max_value: int) \
|
||||||
|
-> List[int]:
|
||||||
|
"""Parse the time expression part and return a list of times to match."""
|
||||||
|
if parameter is None or parameter == MATCH_ALL:
|
||||||
|
res = [x for x in range(min_value, max_value + 1)]
|
||||||
|
elif isinstance(parameter, str) and parameter.startswith('/'):
|
||||||
|
parameter = float(parameter[1:])
|
||||||
|
res = [x for x in range(min_value, max_value + 1)
|
||||||
|
if x % parameter == 0]
|
||||||
|
elif not hasattr(parameter, '__iter__'):
|
||||||
|
res = [int(parameter)]
|
||||||
|
else:
|
||||||
|
res = list(sorted(int(x) for x in parameter))
|
||||||
|
|
||||||
|
for val in res:
|
||||||
|
if val < min_value or val > max_value:
|
||||||
|
raise ValueError(
|
||||||
|
"Time expression '{}': parameter {} out of range ({} to {})"
|
||||||
|
"".format(parameter, val, min_value, max_value)
|
||||||
|
)
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=redefined-outer-name
|
||||||
|
def find_next_time_expression_time(now: dt.datetime,
|
||||||
|
seconds: List[int], minutes: List[int],
|
||||||
|
hours: List[int]) -> dt.datetime:
|
||||||
|
"""Find the next datetime from now for which the time expression matches.
|
||||||
|
|
||||||
|
The algorithm looks at each time unit separately and tries to find the
|
||||||
|
next one that matches for each. If any of them would roll over, all
|
||||||
|
time units below that are reset to the first matching value.
|
||||||
|
|
||||||
|
Timezones are also handled (the tzinfo of the now object is used),
|
||||||
|
including daylight saving time.
|
||||||
|
"""
|
||||||
|
if not seconds or not minutes or not hours:
|
||||||
|
raise ValueError("Cannot find a next time: Time expression never "
|
||||||
|
"matches!")
|
||||||
|
|
||||||
|
def _lower_bound(arr: List[int], cmp: int) -> Optional[int]:
|
||||||
|
"""Return the first value in arr greater or equal to cmp.
|
||||||
|
|
||||||
|
Return None if no such value exists.
|
||||||
|
"""
|
||||||
|
left = 0
|
||||||
|
right = len(arr)
|
||||||
|
while left < right:
|
||||||
|
mid = (left + right) // 2
|
||||||
|
if arr[mid] < cmp:
|
||||||
|
left = mid + 1
|
||||||
|
else:
|
||||||
|
right = mid
|
||||||
|
|
||||||
|
if left == len(arr):
|
||||||
|
return None
|
||||||
|
return arr[left]
|
||||||
|
|
||||||
|
result = now.replace(microsecond=0)
|
||||||
|
|
||||||
|
# Match next second
|
||||||
|
next_second = _lower_bound(seconds, result.second)
|
||||||
|
if next_second is None:
|
||||||
|
# No second to match in this minute. Roll-over to next minute.
|
||||||
|
next_second = seconds[0]
|
||||||
|
result += dt.timedelta(minutes=1)
|
||||||
|
|
||||||
|
result = result.replace(second=next_second)
|
||||||
|
|
||||||
|
# Match next minute
|
||||||
|
next_minute = _lower_bound(minutes, result.minute)
|
||||||
|
if next_minute != result.minute:
|
||||||
|
# We're in the next minute. Seconds needs to be reset.
|
||||||
|
result = result.replace(second=seconds[0])
|
||||||
|
|
||||||
|
if next_minute is None:
|
||||||
|
# No minute to match in this hour. Roll-over to next hour.
|
||||||
|
next_minute = minutes[0]
|
||||||
|
result += dt.timedelta(hours=1)
|
||||||
|
|
||||||
|
result = result.replace(minute=next_minute)
|
||||||
|
|
||||||
|
# Match next hour
|
||||||
|
next_hour = _lower_bound(hours, result.hour)
|
||||||
|
if next_hour != result.hour:
|
||||||
|
# We're in the next hour. Seconds+minutes needs to be reset.
|
||||||
|
result.replace(second=seconds[0], minute=minutes[0])
|
||||||
|
|
||||||
|
if next_hour is None:
|
||||||
|
# No minute to match in this day. Roll-over to next day.
|
||||||
|
next_hour = hours[0]
|
||||||
|
result += dt.timedelta(days=1)
|
||||||
|
|
||||||
|
result = result.replace(hour=next_hour)
|
||||||
|
|
||||||
|
if result.tzinfo is None:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Now we need to handle timezones. We will make this datetime object
|
||||||
|
# "naive" first and then re-convert it to the target timezone.
|
||||||
|
# This is so that we can call pytz's localize and handle DST changes.
|
||||||
|
tzinfo = result.tzinfo # type: pytzinfo.DstTzInfo
|
||||||
|
result = result.replace(tzinfo=None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = tzinfo.localize(result, is_dst=None)
|
||||||
|
except pytzexceptions.AmbiguousTimeError:
|
||||||
|
# This happens when we're leaving daylight saving time and local
|
||||||
|
# clocks are rolled back. In this case, we want to trigger
|
||||||
|
# on both the DST and non-DST time. So when "now" is in the DST
|
||||||
|
# use the DST-on time, and if not, use the DST-off time.
|
||||||
|
use_dst = bool(now.dst())
|
||||||
|
result = tzinfo.localize(result, is_dst=use_dst)
|
||||||
|
except pytzexceptions.NonExistentTimeError:
|
||||||
|
# This happens when we're entering daylight saving time and local
|
||||||
|
# clocks are rolled forward, thus there are local times that do
|
||||||
|
# not exist. In this case, we want to trigger on the next time
|
||||||
|
# that *does* exist.
|
||||||
|
# In the worst case, this will run through all the seconds in the
|
||||||
|
# time shift, but that's max 3600 operations for once per year
|
||||||
|
result = result.replace(tzinfo=tzinfo) + dt.timedelta(seconds=1)
|
||||||
|
return find_next_time_expression_time(result, seconds, minutes, hours)
|
||||||
|
|
||||||
|
result_dst = cast(dt.timedelta, result.dst())
|
||||||
|
now_dst = cast(dt.timedelta, now.dst())
|
||||||
|
if result_dst >= now_dst:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Another edge-case when leaving DST:
|
||||||
|
# When now is in DST and ambiguous *and* the next trigger time we *should*
|
||||||
|
# trigger is ambiguous and outside DST, the excepts above won't catch it.
|
||||||
|
# For example: if triggering on 2:30 and now is 28.10.2018 2:30 (in DST)
|
||||||
|
# we should trigger next on 28.10.2018 2:30 (out of DST), but our
|
||||||
|
# algorithm above would produce 29.10.2018 2:30 (out of DST)
|
||||||
|
|
||||||
|
# Step 1: Check if now is ambiguous
|
||||||
|
try:
|
||||||
|
tzinfo.localize(now.replace(tzinfo=None), is_dst=None)
|
||||||
|
return result
|
||||||
|
except pytzexceptions.AmbiguousTimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Step 2: Check if result of (now - DST) is ambiguous.
|
||||||
|
check = now - now_dst
|
||||||
|
check_result = find_next_time_expression_time(
|
||||||
|
check, seconds, minutes, hours)
|
||||||
|
try:
|
||||||
|
tzinfo.localize(check_result.replace(tzinfo=None), is_dst=None)
|
||||||
|
return result
|
||||||
|
except pytzexceptions.AmbiguousTimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# OK, edge case does apply. We must override the DST to DST-off
|
||||||
|
check_result = tzinfo.localize(check_result.replace(tzinfo=None),
|
||||||
|
is_dst=False)
|
||||||
|
return check_result
|
||||||
|
|
|
@ -251,7 +251,7 @@ fire_mqtt_message = threadsafe_callback_factory(async_fire_mqtt_message)
|
||||||
@ha.callback
|
@ha.callback
|
||||||
def async_fire_time_changed(hass, time):
|
def async_fire_time_changed(hass, time):
|
||||||
"""Fire a time changes event."""
|
"""Fire a time changes event."""
|
||||||
hass.bus.async_fire(EVENT_TIME_CHANGED, {'now': time})
|
hass.bus.async_fire(EVENT_TIME_CHANGED, {'now': date_util.as_utc(time)})
|
||||||
|
|
||||||
|
|
||||||
fire_time_changed = threadsafe_callback_factory(async_fire_time_changed)
|
fire_time_changed = threadsafe_callback_factory(async_fire_time_changed)
|
||||||
|
|
|
@ -85,38 +85,6 @@ class TestEventHelpers(unittest.TestCase):
|
||||||
self.hass.block_till_done()
|
self.hass.block_till_done()
|
||||||
self.assertEqual(2, len(runs))
|
self.assertEqual(2, len(runs))
|
||||||
|
|
||||||
def test_track_time_change(self):
|
|
||||||
"""Test tracking time change."""
|
|
||||||
wildcard_runs = []
|
|
||||||
specific_runs = []
|
|
||||||
|
|
||||||
unsub = track_time_change(self.hass, lambda x: wildcard_runs.append(1))
|
|
||||||
unsub_utc = track_utc_time_change(
|
|
||||||
self.hass, lambda x: specific_runs.append(1), second=[0, 30])
|
|
||||||
|
|
||||||
self._send_time_changed(datetime(2014, 5, 24, 12, 0, 0))
|
|
||||||
self.hass.block_till_done()
|
|
||||||
self.assertEqual(1, len(specific_runs))
|
|
||||||
self.assertEqual(1, len(wildcard_runs))
|
|
||||||
|
|
||||||
self._send_time_changed(datetime(2014, 5, 24, 12, 0, 15))
|
|
||||||
self.hass.block_till_done()
|
|
||||||
self.assertEqual(1, len(specific_runs))
|
|
||||||
self.assertEqual(2, len(wildcard_runs))
|
|
||||||
|
|
||||||
self._send_time_changed(datetime(2014, 5, 24, 12, 0, 30))
|
|
||||||
self.hass.block_till_done()
|
|
||||||
self.assertEqual(2, len(specific_runs))
|
|
||||||
self.assertEqual(3, len(wildcard_runs))
|
|
||||||
|
|
||||||
unsub()
|
|
||||||
unsub_utc()
|
|
||||||
|
|
||||||
self._send_time_changed(datetime(2014, 5, 24, 12, 0, 30))
|
|
||||||
self.hass.block_till_done()
|
|
||||||
self.assertEqual(2, len(specific_runs))
|
|
||||||
self.assertEqual(3, len(wildcard_runs))
|
|
||||||
|
|
||||||
def test_track_state_change(self):
|
def test_track_state_change(self):
|
||||||
"""Test track_state_change."""
|
"""Test track_state_change."""
|
||||||
# 2 lists to track how often our callbacks get called
|
# 2 lists to track how often our callbacks get called
|
||||||
|
@ -526,12 +494,64 @@ class TestEventHelpers(unittest.TestCase):
|
||||||
"""Send a time changed event."""
|
"""Send a time changed event."""
|
||||||
self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: now})
|
self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: now})
|
||||||
|
|
||||||
|
|
||||||
|
class TestTrackTimeChange(unittest.TestCase):
|
||||||
|
"""Test track time change methods."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up the tests."""
|
||||||
|
self.orig_default_time_zone = dt_util.DEFAULT_TIME_ZONE
|
||||||
|
self.hass = get_test_home_assistant()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""Stop everything that was started."""
|
||||||
|
dt_util.set_default_time_zone(self.orig_default_time_zone)
|
||||||
|
self.hass.stop()
|
||||||
|
|
||||||
|
def _send_time_changed(self, now):
|
||||||
|
"""Send a time changed event."""
|
||||||
|
self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: now})
|
||||||
|
|
||||||
|
def test_track_time_change(self):
|
||||||
|
"""Test tracking time change."""
|
||||||
|
wildcard_runs = []
|
||||||
|
specific_runs = []
|
||||||
|
|
||||||
|
unsub = track_time_change(self.hass,
|
||||||
|
lambda x: wildcard_runs.append(1))
|
||||||
|
unsub_utc = track_utc_time_change(
|
||||||
|
self.hass, lambda x: specific_runs.append(1), second=[0, 30])
|
||||||
|
|
||||||
|
self._send_time_changed(datetime(2014, 5, 24, 12, 0, 0))
|
||||||
|
self.hass.block_till_done()
|
||||||
|
self.assertEqual(1, len(specific_runs))
|
||||||
|
self.assertEqual(1, len(wildcard_runs))
|
||||||
|
|
||||||
|
self._send_time_changed(datetime(2014, 5, 24, 12, 0, 15))
|
||||||
|
self.hass.block_till_done()
|
||||||
|
self.assertEqual(1, len(specific_runs))
|
||||||
|
self.assertEqual(2, len(wildcard_runs))
|
||||||
|
|
||||||
|
self._send_time_changed(datetime(2014, 5, 24, 12, 0, 30))
|
||||||
|
self.hass.block_till_done()
|
||||||
|
self.assertEqual(2, len(specific_runs))
|
||||||
|
self.assertEqual(3, len(wildcard_runs))
|
||||||
|
|
||||||
|
unsub()
|
||||||
|
unsub_utc()
|
||||||
|
|
||||||
|
self._send_time_changed(datetime(2014, 5, 24, 12, 0, 30))
|
||||||
|
self.hass.block_till_done()
|
||||||
|
self.assertEqual(2, len(specific_runs))
|
||||||
|
self.assertEqual(3, len(wildcard_runs))
|
||||||
|
|
||||||
def test_periodic_task_minute(self):
|
def test_periodic_task_minute(self):
|
||||||
"""Test periodic tasks per minute."""
|
"""Test periodic tasks per minute."""
|
||||||
specific_runs = []
|
specific_runs = []
|
||||||
|
|
||||||
unsub = track_utc_time_change(
|
unsub = track_utc_time_change(
|
||||||
self.hass, lambda x: specific_runs.append(1), minute='/5')
|
self.hass, lambda x: specific_runs.append(1), minute='/5',
|
||||||
|
second=0)
|
||||||
|
|
||||||
self._send_time_changed(datetime(2014, 5, 24, 12, 0, 0))
|
self._send_time_changed(datetime(2014, 5, 24, 12, 0, 0))
|
||||||
self.hass.block_till_done()
|
self.hass.block_till_done()
|
||||||
|
@ -556,7 +576,8 @@ class TestEventHelpers(unittest.TestCase):
|
||||||
specific_runs = []
|
specific_runs = []
|
||||||
|
|
||||||
unsub = track_utc_time_change(
|
unsub = track_utc_time_change(
|
||||||
self.hass, lambda x: specific_runs.append(1), hour='/2')
|
self.hass, lambda x: specific_runs.append(1), hour='/2',
|
||||||
|
minute=0, second=0)
|
||||||
|
|
||||||
self._send_time_changed(datetime(2014, 5, 24, 22, 0, 0))
|
self._send_time_changed(datetime(2014, 5, 24, 22, 0, 0))
|
||||||
self.hass.block_till_done()
|
self.hass.block_till_done()
|
||||||
|
@ -566,7 +587,7 @@ class TestEventHelpers(unittest.TestCase):
|
||||||
self.hass.block_till_done()
|
self.hass.block_till_done()
|
||||||
self.assertEqual(1, len(specific_runs))
|
self.assertEqual(1, len(specific_runs))
|
||||||
|
|
||||||
self._send_time_changed(datetime(2014, 5, 24, 0, 0, 0))
|
self._send_time_changed(datetime(2014, 5, 25, 0, 0, 0))
|
||||||
self.hass.block_till_done()
|
self.hass.block_till_done()
|
||||||
self.assertEqual(2, len(specific_runs))
|
self.assertEqual(2, len(specific_runs))
|
||||||
|
|
||||||
|
@ -584,68 +605,138 @@ class TestEventHelpers(unittest.TestCase):
|
||||||
self.hass.block_till_done()
|
self.hass.block_till_done()
|
||||||
self.assertEqual(3, len(specific_runs))
|
self.assertEqual(3, len(specific_runs))
|
||||||
|
|
||||||
def test_periodic_task_day(self):
|
|
||||||
"""Test periodic tasks per day."""
|
|
||||||
specific_runs = []
|
|
||||||
|
|
||||||
unsub = track_utc_time_change(
|
|
||||||
self.hass, lambda x: specific_runs.append(1), day='/2')
|
|
||||||
|
|
||||||
self._send_time_changed(datetime(2014, 5, 2, 0, 0, 0))
|
|
||||||
self.hass.block_till_done()
|
|
||||||
self.assertEqual(1, len(specific_runs))
|
|
||||||
|
|
||||||
self._send_time_changed(datetime(2014, 5, 3, 12, 0, 0))
|
|
||||||
self.hass.block_till_done()
|
|
||||||
self.assertEqual(1, len(specific_runs))
|
|
||||||
|
|
||||||
self._send_time_changed(datetime(2014, 5, 4, 0, 0, 0))
|
|
||||||
self.hass.block_till_done()
|
|
||||||
self.assertEqual(2, len(specific_runs))
|
|
||||||
|
|
||||||
unsub()
|
|
||||||
|
|
||||||
self._send_time_changed(datetime(2014, 5, 4, 0, 0, 0))
|
|
||||||
self.hass.block_till_done()
|
|
||||||
self.assertEqual(2, len(specific_runs))
|
|
||||||
|
|
||||||
def test_periodic_task_year(self):
|
|
||||||
"""Test periodic tasks per year."""
|
|
||||||
specific_runs = []
|
|
||||||
|
|
||||||
unsub = track_utc_time_change(
|
|
||||||
self.hass, lambda x: specific_runs.append(1), year='/2')
|
|
||||||
|
|
||||||
self._send_time_changed(datetime(2014, 5, 2, 0, 0, 0))
|
|
||||||
self.hass.block_till_done()
|
|
||||||
self.assertEqual(1, len(specific_runs))
|
|
||||||
|
|
||||||
self._send_time_changed(datetime(2015, 5, 2, 0, 0, 0))
|
|
||||||
self.hass.block_till_done()
|
|
||||||
self.assertEqual(1, len(specific_runs))
|
|
||||||
|
|
||||||
self._send_time_changed(datetime(2016, 5, 2, 0, 0, 0))
|
|
||||||
self.hass.block_till_done()
|
|
||||||
self.assertEqual(2, len(specific_runs))
|
|
||||||
|
|
||||||
unsub()
|
|
||||||
|
|
||||||
self._send_time_changed(datetime(2016, 5, 2, 0, 0, 0))
|
|
||||||
self.hass.block_till_done()
|
|
||||||
self.assertEqual(2, len(specific_runs))
|
|
||||||
|
|
||||||
def test_periodic_task_wrong_input(self):
|
def test_periodic_task_wrong_input(self):
|
||||||
"""Test periodic tasks with wrong input."""
|
"""Test periodic tasks with wrong input."""
|
||||||
specific_runs = []
|
specific_runs = []
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
track_utc_time_change(
|
track_utc_time_change(
|
||||||
self.hass, lambda x: specific_runs.append(1), year='/two')
|
self.hass, lambda x: specific_runs.append(1), hour='/two')
|
||||||
|
|
||||||
self._send_time_changed(datetime(2014, 5, 2, 0, 0, 0))
|
self._send_time_changed(datetime(2014, 5, 2, 0, 0, 0))
|
||||||
self.hass.block_till_done()
|
self.hass.block_till_done()
|
||||||
self.assertEqual(0, len(specific_runs))
|
self.assertEqual(0, len(specific_runs))
|
||||||
|
|
||||||
|
def test_periodic_task_clock_rollback(self):
|
||||||
|
"""Test periodic tasks with the time rolling backwards."""
|
||||||
|
specific_runs = []
|
||||||
|
|
||||||
|
unsub = track_utc_time_change(
|
||||||
|
self.hass, lambda x: specific_runs.append(1), hour='/2', minute=0,
|
||||||
|
second=0)
|
||||||
|
|
||||||
|
self._send_time_changed(datetime(2014, 5, 24, 22, 0, 0))
|
||||||
|
self.hass.block_till_done()
|
||||||
|
self.assertEqual(1, len(specific_runs))
|
||||||
|
|
||||||
|
self._send_time_changed(datetime(2014, 5, 24, 23, 0, 0))
|
||||||
|
self.hass.block_till_done()
|
||||||
|
self.assertEqual(1, len(specific_runs))
|
||||||
|
|
||||||
|
self._send_time_changed(datetime(2014, 5, 24, 22, 0, 0))
|
||||||
|
self.hass.block_till_done()
|
||||||
|
self.assertEqual(2, len(specific_runs))
|
||||||
|
|
||||||
|
self._send_time_changed(datetime(2014, 5, 24, 0, 0, 0))
|
||||||
|
self.hass.block_till_done()
|
||||||
|
self.assertEqual(3, len(specific_runs))
|
||||||
|
|
||||||
|
self._send_time_changed(datetime(2014, 5, 25, 2, 0, 0))
|
||||||
|
self.hass.block_till_done()
|
||||||
|
self.assertEqual(4, len(specific_runs))
|
||||||
|
|
||||||
|
unsub()
|
||||||
|
|
||||||
|
self._send_time_changed(datetime(2014, 5, 25, 2, 0, 0))
|
||||||
|
self.hass.block_till_done()
|
||||||
|
self.assertEqual(4, len(specific_runs))
|
||||||
|
|
||||||
|
def test_periodic_task_duplicate_time(self):
|
||||||
|
"""Test periodic tasks not triggering on duplicate time."""
|
||||||
|
specific_runs = []
|
||||||
|
|
||||||
|
unsub = track_utc_time_change(
|
||||||
|
self.hass, lambda x: specific_runs.append(1), hour='/2', minute=0,
|
||||||
|
second=0)
|
||||||
|
|
||||||
|
self._send_time_changed(datetime(2014, 5, 24, 22, 0, 0))
|
||||||
|
self.hass.block_till_done()
|
||||||
|
self.assertEqual(1, len(specific_runs))
|
||||||
|
|
||||||
|
self._send_time_changed(datetime(2014, 5, 24, 22, 0, 0))
|
||||||
|
self.hass.block_till_done()
|
||||||
|
self.assertEqual(1, len(specific_runs))
|
||||||
|
|
||||||
|
self._send_time_changed(datetime(2014, 5, 25, 0, 0, 0))
|
||||||
|
self.hass.block_till_done()
|
||||||
|
self.assertEqual(2, len(specific_runs))
|
||||||
|
|
||||||
|
unsub()
|
||||||
|
|
||||||
|
def test_periodic_task_entering_dst(self):
|
||||||
|
"""Test periodic task behavior when entering dst."""
|
||||||
|
tz = dt_util.get_time_zone('Europe/Vienna')
|
||||||
|
dt_util.set_default_time_zone(tz)
|
||||||
|
specific_runs = []
|
||||||
|
|
||||||
|
unsub = track_time_change(
|
||||||
|
self.hass, lambda x: specific_runs.append(1), hour=2, minute=30,
|
||||||
|
second=0)
|
||||||
|
|
||||||
|
self._send_time_changed(
|
||||||
|
tz.localize(datetime(2018, 3, 25, 1, 50, 0)))
|
||||||
|
self.hass.block_till_done()
|
||||||
|
self.assertEqual(0, len(specific_runs))
|
||||||
|
|
||||||
|
self._send_time_changed(
|
||||||
|
tz.localize(datetime(2018, 3, 25, 3, 50, 0)))
|
||||||
|
self.hass.block_till_done()
|
||||||
|
self.assertEqual(0, len(specific_runs))
|
||||||
|
|
||||||
|
self._send_time_changed(
|
||||||
|
tz.localize(datetime(2018, 3, 26, 1, 50, 0)))
|
||||||
|
self.hass.block_till_done()
|
||||||
|
self.assertEqual(0, len(specific_runs))
|
||||||
|
|
||||||
|
self._send_time_changed(
|
||||||
|
tz.localize(datetime(2018, 3, 26, 2, 50, 0)))
|
||||||
|
self.hass.block_till_done()
|
||||||
|
self.assertEqual(1, len(specific_runs))
|
||||||
|
|
||||||
|
unsub()
|
||||||
|
|
||||||
|
def test_periodic_task_leaving_dst(self):
|
||||||
|
"""Test periodic task behavior when leaving dst."""
|
||||||
|
tz = dt_util.get_time_zone('Europe/Vienna')
|
||||||
|
dt_util.set_default_time_zone(tz)
|
||||||
|
specific_runs = []
|
||||||
|
|
||||||
|
unsub = track_time_change(
|
||||||
|
self.hass, lambda x: specific_runs.append(1), hour=2, minute=30,
|
||||||
|
second=0)
|
||||||
|
|
||||||
|
self._send_time_changed(
|
||||||
|
tz.localize(datetime(2018, 10, 28, 2, 5, 0), is_dst=False))
|
||||||
|
self.hass.block_till_done()
|
||||||
|
self.assertEqual(0, len(specific_runs))
|
||||||
|
|
||||||
|
self._send_time_changed(
|
||||||
|
tz.localize(datetime(2018, 10, 28, 2, 55, 0), is_dst=False))
|
||||||
|
self.hass.block_till_done()
|
||||||
|
self.assertEqual(1, len(specific_runs))
|
||||||
|
|
||||||
|
self._send_time_changed(
|
||||||
|
tz.localize(datetime(2018, 10, 28, 2, 5, 0), is_dst=True))
|
||||||
|
self.hass.block_till_done()
|
||||||
|
self.assertEqual(1, len(specific_runs))
|
||||||
|
|
||||||
|
self._send_time_changed(
|
||||||
|
tz.localize(datetime(2018, 10, 28, 2, 55, 0), is_dst=True))
|
||||||
|
self.hass.block_till_done()
|
||||||
|
self.assertEqual(2, len(specific_runs))
|
||||||
|
|
||||||
|
unsub()
|
||||||
|
|
||||||
def test_call_later(self):
|
def test_call_later(self):
|
||||||
"""Test calling an action later."""
|
"""Test calling an action later."""
|
||||||
def action(): pass
|
def action(): pass
|
||||||
|
|
|
@ -164,3 +164,128 @@ class TestDateUtil(unittest.TestCase):
|
||||||
|
|
||||||
diff = dt_util.now() - timedelta(minutes=365*60*24)
|
diff = dt_util.now() - timedelta(minutes=365*60*24)
|
||||||
self.assertEqual(dt_util.get_age(diff), "1 year")
|
self.assertEqual(dt_util.get_age(diff), "1 year")
|
||||||
|
|
||||||
|
def test_parse_time_expression(self):
|
||||||
|
"""Test parse_time_expression."""
|
||||||
|
self.assertEqual(
|
||||||
|
[x for x in range(60)],
|
||||||
|
dt_util.parse_time_expression('*', 0, 59)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[x for x in range(60)],
|
||||||
|
dt_util.parse_time_expression(None, 0, 59)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[x for x in range(0, 60, 5)],
|
||||||
|
dt_util.parse_time_expression('/5', 0, 59)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[1, 2, 3],
|
||||||
|
dt_util.parse_time_expression([2, 1, 3], 0, 59)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[x for x in range(24)],
|
||||||
|
dt_util.parse_time_expression('*', 0, 23)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[42],
|
||||||
|
dt_util.parse_time_expression(42, 0, 59)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRaises(ValueError, dt_util.parse_time_expression, 61, 0, 60)
|
||||||
|
|
||||||
|
def test_find_next_time_expression_time_basic(self):
|
||||||
|
"""Test basic stuff for find_next_time_expression_time."""
|
||||||
|
def find(dt, hour, minute, second):
|
||||||
|
"""Call test_find_next_time_expression_time."""
|
||||||
|
seconds = dt_util.parse_time_expression(second, 0, 59)
|
||||||
|
minutes = dt_util.parse_time_expression(minute, 0, 59)
|
||||||
|
hours = dt_util.parse_time_expression(hour, 0, 23)
|
||||||
|
|
||||||
|
return dt_util.find_next_time_expression_time(
|
||||||
|
dt, seconds, minutes, hours)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
datetime(2018, 10, 7, 10, 30, 0),
|
||||||
|
find(datetime(2018, 10, 7, 10, 20, 0), '*', '/30', 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
datetime(2018, 10, 7, 10, 30, 0),
|
||||||
|
find(datetime(2018, 10, 7, 10, 30, 0), '*', '/30', 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
datetime(2018, 10, 7, 12, 30, 30),
|
||||||
|
find(datetime(2018, 10, 7, 10, 30, 0), '/3', '/30', [30, 45])
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
datetime(2018, 10, 8, 5, 0, 0),
|
||||||
|
find(datetime(2018, 10, 7, 10, 30, 0), 5, 0, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_find_next_time_expression_time_dst(self):
|
||||||
|
"""Test daylight saving time for find_next_time_expression_time."""
|
||||||
|
tz = dt_util.get_time_zone('Europe/Vienna')
|
||||||
|
dt_util.set_default_time_zone(tz)
|
||||||
|
|
||||||
|
def find(dt, hour, minute, second):
|
||||||
|
"""Call test_find_next_time_expression_time."""
|
||||||
|
seconds = dt_util.parse_time_expression(second, 0, 59)
|
||||||
|
minutes = dt_util.parse_time_expression(minute, 0, 59)
|
||||||
|
hours = dt_util.parse_time_expression(hour, 0, 23)
|
||||||
|
|
||||||
|
return dt_util.find_next_time_expression_time(
|
||||||
|
dt, seconds, minutes, hours)
|
||||||
|
|
||||||
|
# Entering DST, clocks are rolled forward
|
||||||
|
self.assertEqual(
|
||||||
|
tz.localize(datetime(2018, 3, 26, 2, 30, 0)),
|
||||||
|
find(tz.localize(datetime(2018, 3, 25, 1, 50, 0)), 2, 30, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
tz.localize(datetime(2018, 3, 26, 2, 30, 0)),
|
||||||
|
find(tz.localize(datetime(2018, 3, 25, 3, 50, 0)), 2, 30, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
tz.localize(datetime(2018, 3, 26, 2, 30, 0)),
|
||||||
|
find(tz.localize(datetime(2018, 3, 26, 1, 50, 0)), 2, 30, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Leaving DST, clocks are rolled back
|
||||||
|
self.assertEqual(
|
||||||
|
tz.localize(datetime(2018, 10, 28, 2, 30, 0), is_dst=False),
|
||||||
|
find(tz.localize(datetime(2018, 10, 28, 2, 5, 0), is_dst=False),
|
||||||
|
2, 30, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
tz.localize(datetime(2018, 10, 28, 2, 30, 0), is_dst=False),
|
||||||
|
find(tz.localize(datetime(2018, 10, 28, 2, 55, 0), is_dst=True),
|
||||||
|
2, 30, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
tz.localize(datetime(2018, 10, 28, 4, 30, 0), is_dst=False),
|
||||||
|
find(tz.localize(datetime(2018, 10, 28, 2, 55, 0), is_dst=True),
|
||||||
|
4, 30, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
tz.localize(datetime(2018, 10, 28, 2, 30, 0), is_dst=True),
|
||||||
|
find(tz.localize(datetime(2018, 10, 28, 2, 5, 0), is_dst=True),
|
||||||
|
2, 30, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
tz.localize(datetime(2018, 10, 29, 2, 30, 0)),
|
||||||
|
find(tz.localize(datetime(2018, 10, 28, 2, 55, 0), is_dst=False),
|
||||||
|
2, 30, 0)
|
||||||
|
)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue