diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 02add8ff0121a3968fe7fe9373e01d8272a46f43..bd1454cf637337e9bf50baae315273a5e0365288 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -10,7 +10,7 @@ import functools as ft import logging from random import randint import time -from typing import Any, Concatenate, ParamSpec, TypedDict, TypeVar +from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypedDict, TypeVar import attr @@ -1389,6 +1389,45 @@ def async_track_point_in_time( track_point_in_time = threaded_listener_factory(async_track_point_in_time) +@dataclass(slots=True) +class _TrackPointUTCTime: + hass: HomeAssistant + job: HassJob[[datetime], Coroutine[Any, Any, None] | None] + utc_point_in_time: datetime + expected_fire_timestamp: float + _cancel_callback: asyncio.TimerHandle | None = None + + def async_attach(self) -> None: + """Initialize track job.""" + loop = self.hass.loop + self._cancel_callback = loop.call_at( + loop.time() + self.expected_fire_timestamp - time.time(), self._run_action + ) + + @callback + def _run_action(self) -> None: + """Call the action.""" + # Depending on the available clock support (including timer hardware + # and the OS kernel) it can happen that we fire a little bit too early + # as measured by utcnow(). That is bad when callbacks have assumptions + # about the current time. Thus, we rearm the timer for the remaining + # time. + if (delta := (self.expected_fire_timestamp - time_tracker_timestamp())) > 0: + _LOGGER.debug("Called %f seconds too early, rearming", delta) + loop = self.hass.loop + self._cancel_callback = loop.call_at(loop.time() + delta, self._run_action) + return + + self.hass.async_run_hass_job(self.job, self.utc_point_in_time) + + @callback + def async_cancel(self) -> None: + """Cancel the call_at.""" + if TYPE_CHECKING: + assert self._cancel_callback is not None + self._cancel_callback.cancel() + + @callback @bind_hass def async_track_point_in_utc_time( @@ -1404,44 +1443,14 @@ def async_track_point_in_utc_time( # Ensure point_in_time is UTC utc_point_in_time = dt_util.as_utc(point_in_time) expected_fire_timestamp = dt_util.utc_to_timestamp(utc_point_in_time) - - # Since this is called once, we accept a HassJob so we can avoid - # having to figure out how to call the action every time its called. - cancel_callback: asyncio.TimerHandle | None = None - loop = hass.loop - - @callback - def run_action(job: HassJob[[datetime], Coroutine[Any, Any, None] | None]) -> None: - """Call the action.""" - nonlocal cancel_callback - # Depending on the available clock support (including timer hardware - # and the OS kernel) it can happen that we fire a little bit too early - # as measured by utcnow(). That is bad when callbacks have assumptions - # about the current time. Thus, we rearm the timer for the remaining - # time. - if (delta := (expected_fire_timestamp - time_tracker_timestamp())) > 0: - _LOGGER.debug("Called %f seconds too early, rearming", delta) - - cancel_callback = loop.call_at(loop.time() + delta, run_action, job) - return - - hass.async_run_hass_job(job, utc_point_in_time) - job = ( action if isinstance(action, HassJob) else HassJob(action, f"track point in utc time {utc_point_in_time}") ) - delta = expected_fire_timestamp - time.time() - cancel_callback = loop.call_at(loop.time() + delta, run_action, job) - - @callback - def unsub_point_in_time_listener() -> None: - """Cancel the call_at.""" - assert cancel_callback is not None - cancel_callback.cancel() - - return unsub_point_in_time_listener + track = _TrackPointUTCTime(hass, job, utc_point_in_time, expected_fire_timestamp) + track.async_attach() + return track.async_cancel track_point_in_utc_time = threaded_listener_factory(async_track_point_in_utc_time) @@ -1500,6 +1509,61 @@ def async_call_later( call_later = threaded_listener_factory(async_call_later) +@dataclass(slots=True) +class _TrackTimeInterval: + """Helper class to help listen to time interval events.""" + + hass: HomeAssistant + seconds: float + job_name: str + action: Callable[[datetime], Coroutine[Any, Any, None] | None] + cancel_on_shutdown: bool | None + _track_job: HassJob[[datetime], Coroutine[Any, Any, None] | None] | None = None + _run_job: HassJob[[datetime], Coroutine[Any, Any, None] | None] | None = None + _cancel_callback: CALLBACK_TYPE | None = None + + def async_attach(self) -> None: + """Initialize track job.""" + hass = self.hass + self._track_job = HassJob( + self._interval_listener, + self.job_name, + job_type=HassJobType.Callback, + cancel_on_shutdown=self.cancel_on_shutdown, + ) + self._run_job = HassJob( + self.action, + f"track time interval {self.seconds}", + cancel_on_shutdown=self.cancel_on_shutdown, + ) + self._cancel_callback = async_call_at( + hass, + self._track_job, + hass.loop.time() + self.seconds, + ) + + @callback + def _interval_listener(self, now: datetime) -> None: + """Handle elapsed intervals.""" + if TYPE_CHECKING: + assert self._run_job is not None + assert self._track_job is not None + hass = self.hass + self._cancel_callback = async_call_at( + hass, + self._track_job, + hass.loop.time() + self.seconds, + ) + hass.async_run_hass_job(self._run_job, now) + + @callback + def async_cancel(self) -> None: + """Cancel the call_at.""" + if TYPE_CHECKING: + assert self._cancel_callback is not None + self._cancel_callback() + + @callback @bind_hass def async_track_time_interval( @@ -1514,41 +1578,13 @@ def async_track_time_interval( The listener is passed the time it fires in UTC time. """ - remove: CALLBACK_TYPE - interval_listener_job: HassJob[[datetime], None] - interval_seconds = interval.total_seconds() - - job = HassJob( - action, f"track time interval {interval}", cancel_on_shutdown=cancel_on_shutdown - ) - - @callback - def interval_listener(now: datetime) -> None: - """Handle elapsed intervals.""" - nonlocal remove - nonlocal interval_listener_job - - remove = async_call_later(hass, interval_seconds, interval_listener_job) - hass.async_run_hass_job(job, now) - + seconds = interval.total_seconds() + job_name = f"track time interval {seconds} {action}" if name: - job_name = f"{name}: track time interval {interval} {action}" - else: - job_name = f"track time interval {interval} {action}" - - interval_listener_job = HassJob( - interval_listener, - job_name, - cancel_on_shutdown=cancel_on_shutdown, - job_type=HassJobType.Callback, - ) - remove = async_call_later(hass, interval_seconds, interval_listener_job) - - def remove_listener() -> None: - """Remove interval listener.""" - remove() - - return remove_listener + job_name = f"{name}: {job_name}" + track = _TrackTimeInterval(hass, seconds, job_name, action, cancel_on_shutdown) + track.async_attach() + return track.async_cancel track_time_interval = threaded_listener_factory(async_track_time_interval)