Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make async_track_time_change smarter #17199

Merged
merged 9 commits into from
Oct 9, 2018
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
66 changes: 35 additions & 31 deletions homeassistant/helpers/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,38 +322,59 @@ def remove_listener():

@callback
@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,
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 (year, month, day, hour, minute, second)):
if all(val is None for val in (hour, minute, second)):
@callback
def time_change_listener(event):
"""Fire every time event that comes in."""
hass.async_run_job(action, event.data[ATTR_NOW])

return hass.bus.async_listen(EVENT_TIME_CHANGED, time_change_listener)

pmp = _process_time_match
year, month, day = pmp(year), pmp(month), pmp(day)
hour, minute, second = pmp(hour), pmp(minute), pmp(second)
matching_seconds = dt_util.parse_time_expression(second, 0, 59)
matching_minutes = dt_util.parse_time_expression(minute, 0, 59)
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
def pattern_time_change_listener(event):
"""Listen for matching time_changed events."""
nonlocal next_time, last_now

now = event.data[ATTR_NOW]

if local:
now = dt_util.as_local(now)
if last_now is None or now < last_now:
# Time rolled back or next time not yet calculated
calculate_next(now)

# pylint: disable=too-many-boolean-expressions
if second(now.second) and minute(now.minute) and hour(now.hour) and \
day(now.day) and month(now.month) and year(now.year):
last_now = now

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,
OttoWinter marked this conversation as resolved.
Show resolved Hide resolved
pattern_time_change_listener)

Expand All @@ -363,11 +384,10 @@ def pattern_time_change_listener(event):

@callback
@bind_hass
def async_track_time_change(hass, action, year=None, month=None, day=None,
hour=None, minute=None, second=None):
def async_track_time_change(hass, action, hour=None, minute=None, second=None):
"""Add a listener that will fire if UTC time matches a pattern."""
return async_track_utc_time_change(hass, action, year, month, day, hour,
minute, second, local=True)
return async_track_utc_time_change(hass, action, hour, minute, second,
local=True)


track_time_change = threaded_listener_factory(async_track_time_change)
Expand All @@ -383,19 +403,3 @@ def _process_state_match(parameter):

parameter = tuple(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
165 changes: 164 additions & 1 deletion homeassistant/util/dt.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
"""Helper methods to handle the time in Home Assistant."""
import datetime as dt
import re
from typing import Any, Dict, Union, Optional, Tuple # noqa pylint: disable=unused-import
from typing import Any, Union, Optional, Tuple, \

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'typing.Dict' imported but unused

List, cast, Dict # noqa pylint: disable=unused-import

import pytz
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"
UTC = pytz.utc
Expand Down Expand Up @@ -209,3 +213,162 @@ def q_n_r(first: int, second: int) -> Tuple[int, int]:
return formatn(minute, 'minute')

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
2 changes: 1 addition & 1 deletion tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ def async_fire_mqtt_message(hass, topic, payload, qos=0, retain=False):
@ha.callback
def async_fire_time_changed(hass, time):
"""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)
Expand Down