Files
2026-07-08 10:43:39 -04:00

510 lines
19 KiB
Python

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)