New apps Added
This commit is contained in:
@@ -0,0 +1,509 @@
|
||||
import logging
|
||||
import datetime
|
||||
|
||||
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.const import (
|
||||
WEEKDAYS,
|
||||
STATE_ON,
|
||||
STATE_OFF,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.helpers.event import (
|
||||
async_track_point_in_time,
|
||||
async_track_state_change_event,
|
||||
)
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_connect,
|
||||
async_dispatcher_send,
|
||||
)
|
||||
|
||||
|
||||
from . import const
|
||||
from .store import async_get_registry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_NEXT_RISING = "next_rising"
|
||||
ATTR_NEXT_SETTING = "next_setting"
|
||||
ATTR_WORKDAYS = "workdays"
|
||||
|
||||
|
||||
def has_sun(time_str: str):
|
||||
return const.OffsetTimePattern.match(time_str)
|
||||
|
||||
|
||||
def is_same_day(dateA: datetime.datetime, dateB: datetime.datetime):
|
||||
return dateA.date() == dateB.date()
|
||||
|
||||
|
||||
def days_until_date(date_string: str, ts: datetime.datetime):
|
||||
date = dt_util.parse_date(date_string)
|
||||
diff = date - ts.date()
|
||||
return diff.days
|
||||
|
||||
|
||||
def find_closest_from_now(date_arr: list):
|
||||
now = dt_util.as_local(dt_util.utcnow())
|
||||
minimum = None
|
||||
for item in date_arr:
|
||||
if item is not None:
|
||||
if minimum is None:
|
||||
minimum = item
|
||||
elif item > now:
|
||||
if item < minimum or minimum < now:
|
||||
minimum = item
|
||||
else:
|
||||
if item < minimum and minimum < now:
|
||||
minimum = item
|
||||
return minimum
|
||||
|
||||
|
||||
class TimerHandler:
|
||||
def __init__(self, hass: HomeAssistant, id: str):
|
||||
"""init"""
|
||||
self.hass = hass
|
||||
self.id = id
|
||||
self._weekdays = []
|
||||
self._start_date = None
|
||||
self._end_date = None
|
||||
self._timeslots = []
|
||||
self._timer = None
|
||||
self._next_trigger = None
|
||||
self._next_slot = None
|
||||
self._sun_tracker = None
|
||||
self._workday_tracker = None
|
||||
self._watched_times = []
|
||||
|
||||
self.slot_queue = []
|
||||
self.timestamps = []
|
||||
self.current_slot = None
|
||||
|
||||
self.hass.loop.create_task(self.async_reload_data())
|
||||
|
||||
@callback
|
||||
async def async_item_updated(id: str):
|
||||
if id == self.id:
|
||||
await self.async_reload_data()
|
||||
|
||||
self._update_listener = async_dispatcher_connect(
|
||||
self.hass, const.EVENT_ITEM_UPDATED, async_item_updated
|
||||
)
|
||||
|
||||
async def async_reload_data(self):
|
||||
"""load schedule data into timer class object and start timer"""
|
||||
store = await async_get_registry(self.hass)
|
||||
data = store.async_get_schedule(self.id)
|
||||
|
||||
self._weekdays = data[const.ATTR_WEEKDAYS]
|
||||
self._start_date = data[const.ATTR_START_DATE]
|
||||
self._end_date = data[const.ATTR_END_DATE]
|
||||
self._timeslots = [
|
||||
dict((k, slot[k]) for k in [const.ATTR_START, const.ATTR_STOP] if k in slot)
|
||||
for slot in data[const.ATTR_TIMESLOTS]
|
||||
]
|
||||
await self.async_start_timer()
|
||||
|
||||
async def async_unload(self):
|
||||
"""unload a timer class object"""
|
||||
await self.async_stop_timer()
|
||||
self._update_listener()
|
||||
self._next_trigger = None
|
||||
|
||||
async def async_start_timer(self):
|
||||
[current_slot, timestamp_end] = self.current_timeslot()
|
||||
[next_slot, timestamp_next] = self.next_timeslot()
|
||||
|
||||
self._watched_times = []
|
||||
if timestamp_next is not None:
|
||||
self._watched_times.append(self._timeslots[next_slot][const.ATTR_START])
|
||||
if timestamp_end is not None:
|
||||
self._watched_times.append(self._timeslots[current_slot][const.ATTR_STOP])
|
||||
|
||||
# the next trigger time is next slot or end of current slot (whichever comes first)
|
||||
timestamp = find_closest_from_now([timestamp_end, timestamp_next])
|
||||
self._timer_is_endpoint = (
|
||||
timestamp != timestamp_next and timestamp == timestamp_end
|
||||
)
|
||||
if timestamp == timestamp_next and timestamp is not None:
|
||||
self._next_slot = next_slot
|
||||
else:
|
||||
self._next_slot = None
|
||||
|
||||
self.current_slot = current_slot
|
||||
self._next_trigger = timestamp
|
||||
|
||||
await self.async_start_sun_tracker()
|
||||
now = dt_util.as_local(dt_util.utcnow())
|
||||
|
||||
if timestamp is not None:
|
||||
if self._timer:
|
||||
self._timer()
|
||||
|
||||
if (timestamp - now).total_seconds() < 0:
|
||||
self._timer = None
|
||||
_LOGGER.debug(
|
||||
"Timer of {} is not set because it is in the past ({})".format(
|
||||
self.id, timestamp
|
||||
)
|
||||
)
|
||||
else:
|
||||
self._timer = async_track_point_in_time(
|
||||
self.hass, self.async_timer_finished, timestamp
|
||||
)
|
||||
_LOGGER.debug("Timer of {} set for {}".format(self.id, timestamp))
|
||||
await self.async_start_workday_tracker()
|
||||
|
||||
async_dispatcher_send(self.hass, const.EVENT_TIMER_UPDATED, self.id)
|
||||
|
||||
async def async_stop_timer(self):
|
||||
"""stop the timer"""
|
||||
if self._timer:
|
||||
self._timer()
|
||||
self._timer = None
|
||||
await self.async_stop_sun_tracker()
|
||||
await self.async_stop_workday_tracker()
|
||||
|
||||
async def async_start_sun_tracker(self):
|
||||
"""check for changes in the sun sensor"""
|
||||
if (
|
||||
self._next_trigger is not None
|
||||
and any(has_sun(x) for x in self._watched_times)
|
||||
) or (
|
||||
self._next_trigger is None
|
||||
and all(has_sun(x[const.ATTR_START]) for x in self._timeslots)
|
||||
):
|
||||
# install sun tracker for updating timer when sun changes
|
||||
# initially the time calculation may fail due to the sun entity being unavailable
|
||||
|
||||
if self._sun_tracker is not None:
|
||||
# the tracker is already running
|
||||
return
|
||||
|
||||
@callback
|
||||
async def async_sun_updated(_event):
|
||||
"""the sun entity was updated"""
|
||||
# sun entity changed
|
||||
if self._next_trigger is None:
|
||||
# sun entity has initialized
|
||||
await self.async_start_timer()
|
||||
return
|
||||
ts = find_closest_from_now(
|
||||
self.calculate_timestamp(x) for x in self._watched_times
|
||||
)
|
||||
if not ts or not self._next_trigger:
|
||||
# sun entity became unavailable (or other corner case)
|
||||
await self.async_start_timer()
|
||||
return
|
||||
# we are re-scheduling an existing timer
|
||||
delta = (ts - self._next_trigger).total_seconds()
|
||||
if abs(delta) >= 60 and abs(delta) < 2000:
|
||||
# only reschedule if the difference is at least a minute
|
||||
# only reschedule if this doesnt cause the timer to shift to another day (+/- 24 hrs delta)
|
||||
# only reschedule if this doesnt cause the timer to shift to another hour (due to DST change)
|
||||
await self.async_start_timer()
|
||||
|
||||
self._sun_tracker = async_track_state_change_event(
|
||||
self.hass, const.SUN_ENTITY, async_sun_updated
|
||||
)
|
||||
else:
|
||||
# clear existing tracker
|
||||
await self.async_stop_sun_tracker()
|
||||
|
||||
async def async_stop_sun_tracker(self):
|
||||
"""stop checking for changes in the sun sensor"""
|
||||
if self._sun_tracker:
|
||||
self._sun_tracker()
|
||||
self._sun_tracker = None
|
||||
|
||||
async def async_start_workday_tracker(self):
|
||||
"""check for changes in the workday sensor"""
|
||||
if (
|
||||
const.DAY_TYPE_WORKDAY in self._weekdays
|
||||
or const.DAY_TYPE_WEEKEND in self._weekdays
|
||||
):
|
||||
# install tracker for updating timer when workday sensor changes
|
||||
|
||||
if self._workday_tracker is not None:
|
||||
# the tracker is already running
|
||||
return
|
||||
|
||||
@callback
|
||||
async def async_workday_updated():
|
||||
"""the workday sensor was updated"""
|
||||
[current_slot, timestamp_end] = self.current_timeslot()
|
||||
[next_slot, timestamp_next] = self.next_timeslot()
|
||||
ts_next = find_closest_from_now([timestamp_end, timestamp_next])
|
||||
|
||||
# workday entity changed
|
||||
if not ts_next or not self._next_trigger:
|
||||
# timer was not yet set
|
||||
await self.async_start_timer()
|
||||
else:
|
||||
# we are re-scheduling an existing timer
|
||||
delta = (ts_next - self._next_trigger).total_seconds()
|
||||
if abs(delta) >= 60:
|
||||
# only reschedule if the difference is at least a minute
|
||||
await self.async_start_timer()
|
||||
|
||||
self._workday_tracker = async_dispatcher_connect(
|
||||
self.hass, const.EVENT_WORKDAY_SENSOR_UPDATED, async_workday_updated
|
||||
)
|
||||
else:
|
||||
# clear existing tracker
|
||||
await self.async_stop_workday_tracker()
|
||||
|
||||
async def async_stop_workday_tracker(self):
|
||||
"""stop checking for changes in the workday sensor"""
|
||||
if self._workday_tracker:
|
||||
self._workday_tracker()
|
||||
self._workday_tracker = None
|
||||
|
||||
async def async_timer_finished(self, _time):
|
||||
"""the timer is finished"""
|
||||
if not self._timer_is_endpoint:
|
||||
# timer marks the start of a new timeslot
|
||||
self.current_slot = self._next_slot
|
||||
_LOGGER.debug(
|
||||
"Timer {} has reached slot {}".format(self.id, self.current_slot)
|
||||
)
|
||||
async_dispatcher_send(self.hass, const.EVENT_TIMER_FINISHED, self.id)
|
||||
# don't automatically reset, wait for external reset after 1 minute
|
||||
# await self.async_start_timer()
|
||||
await self.async_stop_timer()
|
||||
else:
|
||||
# timer marks the end of a timeslot
|
||||
_LOGGER.debug(
|
||||
"Timer {} has reached end of timeslot, resetting..".format(self.id)
|
||||
)
|
||||
await self.async_start_timer()
|
||||
|
||||
def day_in_weekdays(self, ts: datetime.datetime) -> bool:
|
||||
"""check if the day of a datetime object is in the allowed list of days"""
|
||||
day = WEEKDAYS[ts.weekday()]
|
||||
workday_sensor = self.hass.states.get(const.WORKDAY_ENTITY)
|
||||
|
||||
if (
|
||||
workday_sensor
|
||||
and workday_sensor.state in [STATE_ON, STATE_OFF]
|
||||
and is_same_day(ts, dt_util.as_local(dt_util.utcnow()))
|
||||
):
|
||||
# state of workday sensor is used for evaluating workday vs weekend
|
||||
if const.DAY_TYPE_WORKDAY in self._weekdays:
|
||||
return workday_sensor.state == STATE_ON
|
||||
elif const.DAY_TYPE_WEEKEND in self._weekdays:
|
||||
return workday_sensor.state == STATE_OFF
|
||||
|
||||
if workday_sensor and ATTR_WORKDAYS in workday_sensor.attributes:
|
||||
# workday sensor defines a list of workdays
|
||||
workday_list = workday_sensor.attributes[ATTR_WORKDAYS]
|
||||
weekend_list = [e for e in WEEKDAYS if e not in workday_list]
|
||||
else:
|
||||
# assume workdays are mon-fri
|
||||
workday_list = WEEKDAYS[0:5]
|
||||
weekend_list = WEEKDAYS[5:7]
|
||||
|
||||
if const.DAY_TYPE_DAILY in self._weekdays or not len(self._weekdays):
|
||||
return True
|
||||
elif const.DAY_TYPE_WORKDAY in self._weekdays and day in workday_list:
|
||||
return True
|
||||
elif const.DAY_TYPE_WEEKEND in self._weekdays and day in weekend_list:
|
||||
return True
|
||||
return day in self._weekdays
|
||||
|
||||
def calculate_timestamp(
|
||||
self,
|
||||
time_str,
|
||||
now: datetime.datetime = None,
|
||||
iteration: int = 0,
|
||||
reverse_direction: bool = False,
|
||||
) -> datetime.datetime:
|
||||
"""calculate the next occurence of a time string"""
|
||||
if time_str is None:
|
||||
return None
|
||||
if now is None:
|
||||
now = dt_util.as_local(dt_util.utcnow())
|
||||
|
||||
res = has_sun(time_str)
|
||||
if not res:
|
||||
# fixed time
|
||||
time = dt_util.parse_time(time_str)
|
||||
ts = dt_util.find_next_time_expression_time(
|
||||
now, [time.second], [time.minute], [time.hour]
|
||||
)
|
||||
else:
|
||||
# relative to sunrise/sunset
|
||||
sun = self.hass.states.get(const.SUN_ENTITY)
|
||||
if not sun:
|
||||
return None
|
||||
ts = None
|
||||
if (
|
||||
res.group(1) == const.SUN_EVENT_SUNRISE
|
||||
and ATTR_NEXT_RISING in sun.attributes
|
||||
):
|
||||
ts = dt_util.parse_datetime(sun.attributes[ATTR_NEXT_RISING])
|
||||
elif (
|
||||
res.group(1) == const.SUN_EVENT_SUNSET
|
||||
and ATTR_NEXT_SETTING in sun.attributes
|
||||
):
|
||||
ts = dt_util.parse_datetime(sun.attributes[ATTR_NEXT_SETTING])
|
||||
if not ts:
|
||||
return None
|
||||
ts = dt_util.as_local(ts)
|
||||
ts = ts.replace(second=0)
|
||||
time_sun = datetime.timedelta(
|
||||
hours=ts.hour, minutes=ts.minute, seconds=ts.second
|
||||
)
|
||||
offset = dt_util.parse_time(res.group(3))
|
||||
offset = datetime.timedelta(
|
||||
hours=offset.hour, minutes=offset.minute, seconds=offset.second
|
||||
)
|
||||
if res.group(2) == "-":
|
||||
if (time_sun - offset).total_seconds() >= 0:
|
||||
ts = ts - offset
|
||||
else:
|
||||
# prevent offset to shift the time past the extends of the day
|
||||
ts = ts.replace(hour=0, minute=0, second=0)
|
||||
else:
|
||||
if (time_sun + offset).total_seconds() <= 86340:
|
||||
ts = ts + offset
|
||||
else:
|
||||
# prevent offset to shift the time past the extends of the day
|
||||
ts = ts.replace(hour=23, minute=59, second=0)
|
||||
ts = dt_util.find_next_time_expression_time(
|
||||
now, [ts.second], [ts.minute], [ts.hour]
|
||||
)
|
||||
|
||||
time_delta = datetime.timedelta(seconds=1)
|
||||
|
||||
if self.day_in_weekdays(ts) and (
|
||||
(ts - now).total_seconds() > 0 or iteration > 0
|
||||
):
|
||||
|
||||
if self._start_date and days_until_date(self._start_date, ts) > 0:
|
||||
# start date is in the future, jump to start date
|
||||
end_of_day = ts.replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(days=1)
|
||||
days_delta = days_until_date(self._start_date, end_of_day)
|
||||
if days_delta:
|
||||
time_delta = datetime.timedelta(days=days_delta)
|
||||
|
||||
elif self._end_date and days_until_date(self._end_date, ts) < 0:
|
||||
# end date is in the past, jump to end date
|
||||
time_delta = datetime.timedelta(
|
||||
days=days_until_date(self._end_date, ts)
|
||||
)
|
||||
reverse_direction = True
|
||||
|
||||
else:
|
||||
# date restrictions are met
|
||||
return ts
|
||||
elif reverse_direction:
|
||||
time_delta = datetime.timedelta(days=-1)
|
||||
|
||||
# calculate next timestamp
|
||||
next_day = dt_util.find_next_time_expression_time(
|
||||
now + time_delta, [0], [0], [0]
|
||||
)
|
||||
if iteration > 15:
|
||||
_LOGGER.warning(
|
||||
"failed to calculate next timeslot for schedule {}".format(self.id)
|
||||
)
|
||||
return None
|
||||
return self.calculate_timestamp(
|
||||
time_str, next_day, iteration + 1, reverse_direction
|
||||
)
|
||||
|
||||
def next_timeslot(self):
|
||||
"""calculate the closest timeslot from now"""
|
||||
now = dt_util.as_local(dt_util.utcnow())
|
||||
# calculate next start of all timeslots
|
||||
timestamps = [
|
||||
self.calculate_timestamp(slot[const.ATTR_START], now)
|
||||
for slot in self._timeslots
|
||||
]
|
||||
|
||||
# calculate timeslot that will start soonest (or closest in the past)
|
||||
remaining = [
|
||||
abs((ts - now).total_seconds()) if ts is not None else now.timestamp()
|
||||
for ts in timestamps
|
||||
]
|
||||
slot_order = sorted(range(len(remaining)), key=lambda k: remaining[k])
|
||||
|
||||
# filter out timeslots that cannot be computed
|
||||
for i in range(len(timestamps)):
|
||||
if timestamps[i] is None:
|
||||
slot_order.remove(i)
|
||||
timestamps = [e for e in timestamps if e is not None]
|
||||
|
||||
self.slot_queue = slot_order
|
||||
self.timestamps = timestamps
|
||||
|
||||
next_slot = slot_order[0] if len(slot_order) > 0 else None
|
||||
|
||||
return (next_slot, timestamps[next_slot] if next_slot is not None else None)
|
||||
|
||||
def current_timeslot(self, now: datetime.datetime = None):
|
||||
"""calculate the end of the timeslot that is overlapping now"""
|
||||
if now is None:
|
||||
now = dt_util.as_local(dt_util.utcnow())
|
||||
|
||||
def unwrap_end_of_day(time_str: str):
|
||||
if time_str == "00:00:00":
|
||||
return "23:59:59"
|
||||
else:
|
||||
return time_str
|
||||
|
||||
# calculate next stop of all timeslots
|
||||
timestamps = []
|
||||
for slot in self._timeslots:
|
||||
if slot[const.ATTR_STOP] is not None:
|
||||
timestamps.append(
|
||||
self.calculate_timestamp(
|
||||
unwrap_end_of_day(slot[const.ATTR_STOP]), now
|
||||
)
|
||||
)
|
||||
else:
|
||||
ts = self.calculate_timestamp(slot[const.ATTR_START], now)
|
||||
if ts is None:
|
||||
timestamps.append(None)
|
||||
else:
|
||||
ts = ts + datetime.timedelta(minutes=1)
|
||||
timestamps.append(
|
||||
self.calculate_timestamp(ts.strftime("%H:%M:%S"), now)
|
||||
)
|
||||
|
||||
# calculate timeslot that will end soonest
|
||||
remaining = [
|
||||
(ts - now).total_seconds() if ts is not None else now.timestamp()
|
||||
for ts in timestamps
|
||||
]
|
||||
(next_slot_end, val) = sorted(
|
||||
enumerate(remaining), key=lambda i: (i[1] < 0, abs(i[1]))
|
||||
)[0]
|
||||
|
||||
stop = timestamps[next_slot_end]
|
||||
if stop is not None:
|
||||
# calculate last start of timeslot that will end soonest
|
||||
if (stop - now).total_seconds() < 0:
|
||||
# end of timeslot is in the past
|
||||
return (None, None)
|
||||
|
||||
start = self.calculate_timestamp(
|
||||
self._timeslots[next_slot_end][const.ATTR_START],
|
||||
stop - datetime.timedelta(days=1),
|
||||
)
|
||||
|
||||
if start is not None:
|
||||
elapsed = (now - start).total_seconds()
|
||||
if elapsed > 0:
|
||||
# timeslot is currently overlapping
|
||||
return (
|
||||
next_slot_end,
|
||||
stop
|
||||
if self._timeslots[next_slot_end][const.ATTR_STOP] is not None
|
||||
else None,
|
||||
)
|
||||
return (None, None)
|
||||
Reference in New Issue
Block a user