diff --git a/appdaemon/adapi.py b/appdaemon/adapi.py index 4c27f0c40..aba26b6f2 100644 --- a/appdaemon/adapi.py +++ b/appdaemon/adapi.py @@ -24,6 +24,7 @@ from appdaemon.models.config.app import AppConfig from appdaemon.parse import resolve_time_str from appdaemon.state import StateCallbackType +from .types import TimeDeltaLike T = TypeVar("T") @@ -1413,9 +1414,9 @@ async def listen_state( namespace: str | None = None, new: str | Callable[[Any], bool] | None = None, old: str | Callable[[Any], bool] | None = None, - duration: str | int | float | timedelta | None = None, + duration: TimeDeltaLike | None = None, attribute: str | None = None, - timeout: str | int | float | timedelta | None = None, + timeout: TimeDeltaLike | None = None, immediate: bool = False, oneshot: bool = False, pin: bool | None = None, @@ -1432,9 +1433,9 @@ async def listen_state( namespace: str | None = None, new: str | Callable[[Any], bool] | None = None, old: str | Callable[[Any], bool] | None = None, - duration: str | int | float | timedelta | None = None, + duration: TimeDeltaLike | None = None, attribute: str | None = None, - timeout: str | int | float | timedelta | None = None, + timeout: TimeDeltaLike | None = None, immediate: bool = False, oneshot: bool = False, pin: bool | None = None, @@ -1450,9 +1451,9 @@ async def listen_state( namespace: str | None = None, new: str | Callable[[Any], bool] | None = None, old: str | Callable[[Any], bool] | None = None, - duration: str | int | float | timedelta | None = None, + duration: TimeDeltaLike | None = None, attribute: str | None = None, - timeout: str | int | float | timedelta | None = None, + timeout: TimeDeltaLike | None = None, immediate: bool = False, oneshot: bool = False, pin: bool | None = None, @@ -1486,7 +1487,7 @@ async def listen_state( careful when comparing them. The ``self.get_state()`` method is useful for checking the data type of the desired attribute. If ``old`` is a callable (lambda, function, etc), then it will be called with the old state, and the callback will only be invoked if the callable returns ``True``. - duration (str | int | float | timedelta, optional): If supplied, the callback will not be invoked unless the + duration (TimeDeltaLike, optional): If supplied, the callback will not be invoked unless the desired state is maintained for that amount of time. This requires that a specific attribute is specified (or the default of ``state`` is used), and should be used in conjunction with either or both of the ``new`` and ``old`` parameters. When the callback is called, it is supplied with the values of @@ -1500,7 +1501,7 @@ async def listen_state( the default behavior is to use the value of ``state``. Using the value ``all`` will cause the callback to get triggered for any change in state, and the new/old values used for the callback will be the entire state dict rather than the individual value of an attribute. - timeout (str | int | float | timedelta, optional): If given, the callback will be automatically removed + timeout (TimeDeltaLike, optional): If given, the callback will be automatically removed after that amount of time. If activity for the listened state has occurred that would trigger a duration timer, the duration timer will still be fired even though the callback has been removed. immediate (bool, optional): If given, it enables the countdown for a delay parameter to start at the time. @@ -2102,7 +2103,7 @@ async def listen_event( event: str | None = None, *, namespace: str | None = None, - timeout: str | int | float | timedelta | None = None, + timeout: TimeDeltaLike | None = None, oneshot: bool = False, pin: bool | None = None, pin_thread: int | None = None, @@ -2117,7 +2118,7 @@ async def listen_event( event: list[str], *, namespace: str | None = None, - timeout: str | int | float | timedelta | None = None, + timeout: TimeDeltaLike | None = None, oneshot: bool = False, pin: bool | None = None, pin_thread: int | None = None, @@ -2131,7 +2132,7 @@ async def listen_event( event: str | Iterable[str] | None = None, *, # Arguments after this are keyword only namespace: str | Literal["global"] | None = None, - timeout: str | int | float | timedelta | None = None, + timeout: TimeDeltaLike | None = None, oneshot: bool = False, pin: bool | None = None, pin_thread: int | None = None, @@ -2294,7 +2295,7 @@ async def fire_event( self, event: str, namespace: str | None = None, - timeout: str | int | float | timedelta | None = -1, # Used by utils.sync_decorator + timeout: TimeDeltaLike | None = -1, # Used by utils.sync_decorator **kwargs, ) -> None: """Fires an event on the AppDaemon bus, for apps and plugins. @@ -2747,7 +2748,7 @@ async def reset_timer(self, handle: str) -> bool: return await self.AD.sched.reset_timer(self.name, handle) @utils.sync_decorator - async def info_timer(self, handle: str) -> tuple[dt.datetime, int, dict] | None: + async def info_timer(self, handle: str) -> tuple[dt.datetime, float, dict] | None: """Get information about a previously created timer. Args: @@ -2757,7 +2758,7 @@ async def info_timer(self, handle: str) -> tuple[dt.datetime, int, dict] | None: A tuple with the following values or ``None`` if handle is invalid or timer no longer exists. - `time` - datetime object representing the next time the callback will be fired - - `interval` - repeat interval if applicable, `0` otherwise. + - `interval` - repeat interval in seconds if applicable, `0` otherwise. - `kwargs` - the values supplied when the callback was initially created. Examples: @@ -2765,16 +2766,19 @@ async def info_timer(self, handle: str) -> tuple[dt.datetime, int, dict] | None: >>> time, interval, kwargs = info """ - return await self.AD.sched.info_timer(handle, self.name) + if (result := await self.AD.sched.info_timer(handle, self.name)) is not None: + time, interval, kwargs = result + return time, interval.total_seconds(), kwargs + return None @utils.sync_decorator async def run_in( self, callback: Callable, - delay: str | int | float | timedelta, + delay: TimeDeltaLike, *args, - random_start: int | None = None, - random_end: int | None = None, + random_start: TimeDeltaLike | None = None, + random_end: TimeDeltaLike | None = None, pin: bool | None = None, pin_thread: int | None = None, **kwargs, @@ -2826,8 +2830,8 @@ async def run_in( name=self.name, aware_dt=exec_time, callback=sched_func, - random_start=random_start, - random_end=random_end, + random_start=utils.parse_timedelta_or_none(random_start), + random_end=utils.parse_timedelta_or_none(random_end), pin=pin, pin_thread=pin_thread, ) @@ -2838,8 +2842,8 @@ async def run_once( callback: Callable, start: str | dt.time | dt.datetime | None = None, *args, - random_start: int | None = None, - random_end: int | None = None, + random_start: TimeDeltaLike | None = None, + random_end: TimeDeltaLike | None = None, pin: bool | None = None, pin_thread: int | None = None, **kwargs, @@ -2908,8 +2912,8 @@ async def run_at( callback: Callable, start: str | dt.time | dt.datetime, *args, - random_start: int | None = None, - random_end: int | None = None, + random_start: TimeDeltaLike | None = None, + random_end: TimeDeltaLike | None = None, pin: bool | None = None, pin_thread: int | None = None, **kwargs, @@ -2962,6 +2966,9 @@ async def run_at( """ start = "now" if start is None else start + random_start_td = utils.parse_timedelta_or_none(random_start) + random_end_td = utils.parse_timedelta_or_none(random_end) + match start: case str() as start_str if start.startswith("sun"): if start.startswith("sunrise"): @@ -2982,14 +2989,14 @@ async def run_at( self.AD.sched.insert_schedule, name=self.name, aware_dt=start, - interval=timedelta(days=1).total_seconds() + interval=timedelta(days=1) ) # fmt: skip func = functools.partial( func, callback=functools.partial(callback, *args, **kwargs), - random_start=random_start, - random_end=random_end, + random_start=random_start_td, + random_end=random_end_td, pin=pin, pin_thread=pin_thread, ) @@ -3001,8 +3008,8 @@ async def run_daily( callback: Callable, start: str | dt.time | dt.datetime | None = None, *args, - random_start: int | None = None, - random_end: int | None = None, + random_start: TimeDeltaLike | None = None, + random_end: TimeDeltaLike | None = None, pin: bool | None = None, pin_thread: int | None = None, **kwargs, @@ -3094,8 +3101,8 @@ async def run_hourly( callback: Callable, start: str | dt.time | dt.datetime | None = None, *args, - random_start: int | None = None, - random_end: int | None = None, + random_start: TimeDeltaLike | None = None, + random_end: TimeDeltaLike | None = None, pin: bool | None = None, pin_thread: int | None = None, **kwargs, @@ -3155,8 +3162,8 @@ async def run_minutely( callback: Callable, start: str | dt.time | dt.datetime | None = None, *args, - random_start: int | None = None, - random_end: int | None = None, + random_start: TimeDeltaLike | None = None, + random_end: TimeDeltaLike | None = None, pin: bool | None = None, pin_thread: int | None = None, **kwargs, @@ -3216,10 +3223,10 @@ async def run_every( self, callback: Callable, start: str | dt.time | dt.datetime | None = None, - interval: str | int | float | timedelta = 0, + interval: TimeDeltaLike = 0, *args, - random_start: int | None = None, - random_end: int | None = None, + random_start: TimeDeltaLike | None = None, + random_end: TimeDeltaLike | None = None, pin: bool | None = None, pin_thread: int | None = None, **kwargs, @@ -3317,9 +3324,9 @@ def timed_callback(self, **kwargs): ... # example callback aware_dt=next_period, callback=functools.partial(callback, *args, **kwargs), repeat=True, - interval=interval.total_seconds(), - random_start=random_start, - random_end=random_end, + interval=interval, + random_start=utils.parse_timedelta_or_none(random_start), + random_end=utils.parse_timedelta_or_none(random_end), pin=pin, pin_thread=pin_thread, ) @@ -3330,9 +3337,9 @@ async def run_at_sunset( callback: Callable, *args, repeat: bool = True, - offset: str | int | float | timedelta | None = None, - random_start: int | None = None, - random_end: int | None = None, + offset: TimeDeltaLike | None = None, + random_start: TimeDeltaLike | None = None, + random_end: TimeDeltaLike | None = None, pin: bool | None = None, pin_thread: int | None = None, **kwargs, @@ -3383,20 +3390,20 @@ async def run_at_sunset( """ now = await self.AD.sched.get_now() sunset = await self.AD.sched.todays_sunset() - td = utils.parse_timedelta(offset) - if sunset + td < now: + offset_td = utils.parse_timedelta(offset) + if sunset + offset_td < now: sunset = await self.AD.sched.next_sunset() - self.logger.debug(f"Registering run_at_sunset at {sunset + td} with {args}, {kwargs}") + self.logger.debug(f"Registering run_at_sunset at {sunset + offset_td} with {args}, {kwargs}") return await self.AD.sched.insert_schedule( name=self.name, aware_dt=sunset, callback=functools.partial(callback, *args, **kwargs), repeat=repeat, type_="next_setting", - offset=offset, - random_start=random_start, - random_end=random_end, + offset=offset_td, + random_start=utils.parse_timedelta_or_none(random_start), + random_end=utils.parse_timedelta_or_none(random_end), pin=pin, pin_thread=pin_thread, ) @@ -3407,9 +3414,9 @@ async def run_at_sunrise( callback: Callable, *args, repeat: bool = True, - offset: str | int | float | timedelta | None = None, - random_start: int | None = None, - random_end: int | None = None, + offset: TimeDeltaLike | None = None, + random_start: TimeDeltaLike | None = None, + random_end: TimeDeltaLike | None = None, pin: bool | None = None, pin_thread: int | None = None, **kwargs, @@ -3460,19 +3467,19 @@ async def run_at_sunrise( """ now = await self.AD.sched.get_now() sunrise = await self.AD.sched.todays_sunrise() - td = utils.parse_timedelta(offset) - if sunrise + td < now: + offset_td = utils.parse_timedelta(offset) + if sunrise + offset_td < now: sunrise = await self.AD.sched.next_sunrise() - self.logger.debug(f"Registering run_at_sunrise at {sunrise + td} with {args}, {kwargs}") + self.logger.debug(f"Registering run_at_sunrise at {sunrise + offset_td} with {args}, {kwargs}") return await self.AD.sched.insert_schedule( name=self.name, aware_dt=sunrise, callback=functools.partial(callback, *args, **kwargs), repeat=repeat, type_="next_rising", - offset=offset, - random_start=random_start, - random_end=random_end, + offset=offset_td, + random_start=utils.parse_timedelta_or_none(random_start), + random_end=utils.parse_timedelta_or_none(random_end), pin=pin, pin_thread=pin_thread, ) @@ -3484,7 +3491,7 @@ async def run_at_sunrise( def dash_navigate( self, target: str, - timeout: str | int | float | timedelta | None = -1, # Used by utils.sync_decorator + timeout: TimeDeltaLike | None = -1, # Used by utils.sync_decorator ret: str | None = None, sticky: int = 0, deviceid: str | None = None, diff --git a/appdaemon/entity.py b/appdaemon/entity.py index b2f7a5175..a9a68d81f 100644 --- a/appdaemon/entity.py +++ b/appdaemon/entity.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, overload from appdaemon import utils +from .types import TimeDeltaLike from .exceptions import TimeOutException from .state import StateCallbackType @@ -192,9 +193,9 @@ async def listen_state( callback: StateCallbackType, new: str | Callable[[Any], bool] | None = None, old: str | Callable[[Any], bool] | None = None, - duration: str | int | float | timedelta | None = None, + duration: TimeDeltaLike | None = None, attribute: str | None = None, - timeout: str | int | float | timedelta | None = None, + timeout: TimeDeltaLike | None = None, immediate: bool = False, oneshot: bool = False, pin: bool | None = None, @@ -223,7 +224,7 @@ async def listen_state(self, callback: StateCallbackType, **kwargs: Any) -> str: careful when comparing them. The ``self.get_state()`` method is useful for checking the data type of the desired attribute. If ``old`` is a callable (lambda, function, etc), then it will be called with the old state, and the callback will only be invoked if the callable returns ``True``. - duration (str | int | float | timedelta, optional): If supplied, the callback will not be invoked unless the + duration (TimeDeltaLike, optional): If supplied, the callback will not be invoked unless the desired state is maintained for that amount of time. This requires that a specific attribute is specified (or the default of ``state`` is used), and should be used in conjunction with either or both of the ``new`` and ``old`` parameters. When the callback is called, it is supplied with the values of @@ -237,7 +238,7 @@ async def listen_state(self, callback: StateCallbackType, **kwargs: Any) -> str: the default behavior is to use the value of ``state``. Using the value ``all`` will cause the callback to get triggered for any change in state, and the new/old values used for the callback will be the entire state dict rather than the individual value of an attribute. - timeout (str | int | float | timedelta, optional): If given, the callback will be automatically removed + timeout (TimeDeltaLike, optional): If given, the callback will be automatically removed after that amount of time. If activity for the listened state has occurred that would trigger a duration timer, the duration timer will still be fired even though the callback has been removed. immediate (bool, optional): If given, it enables the countdown for a delay parameter to start at the time. diff --git a/appdaemon/exceptions.py b/appdaemon/exceptions.py index 9db3fcc2b..9933e5c93 100644 --- a/appdaemon/exceptions.py +++ b/appdaemon/exceptions.py @@ -13,6 +13,7 @@ from collections.abc import Iterable from contextlib import contextmanager from dataclasses import dataclass +from datetime import timedelta from logging import Logger from pathlib import Path from typing import TYPE_CHECKING, Any, Type @@ -586,6 +587,27 @@ class BadSchedulerCallback(AppDaemonException): pass +@dataclass +class OffsetExceedsIntervalError(AppDaemonException): + """Raised when a scheduler offset (including random range) exceeds the event interval.""" + + offset: timedelta + interval: timedelta + event_type: str + random_start: timedelta | None = None + random_end: timedelta | None = None + + def __str__(self): + parts = [f"offset={self.offset}"] + if self.random_start is not None or self.random_end is not None: + parts.append(f"random_start={self.random_start}") + parts.append(f"random_end={self.random_end}") + return ( + f"Offset exceeds event interval for {self.event_type} schedule: " + f"{', '.join(parts)}, but interval is {self.interval}" + ) + + @dataclass class BadSequenceStepDefinition(AppDaemonException): step: Any diff --git a/appdaemon/parse.py b/appdaemon/parse.py index 536abea57..d323e3a56 100644 --- a/appdaemon/parse.py +++ b/appdaemon/parse.py @@ -10,6 +10,8 @@ import pytz from astral.location import Location +from .types import TimeDeltaLike + def normalize_tz(tz: tzinfo) -> tzinfo: """Convert pytz timezone to ZoneInfo for clean stdlib-compatible handling. @@ -230,7 +232,7 @@ def resolve_time(self, now: datetime) -> datetime: DATACLASSES: list[type[ParsedTimeString]] = [Now, SunEvent, ElevationEvent] -def parse_timedelta(input_: str | int | float | timedelta | None, total: timedelta | None = None) -> timedelta: +def parse_timedelta(input_: TimeDeltaLike | None, total: timedelta | None = None) -> timedelta: """Convert a variety of inputs, including strings in various formats, to a timedelta object.""" total = timedelta() if total is None else total @@ -346,7 +348,7 @@ def parse_datetime( now: datetime | str, location: Location | None = None, today: bool | None = None, - offset: str | int | float | timedelta | None = None, + offset: TimeDeltaLike | None = None, days_offset: int = 0, aware: bool = True, ) -> datetime: diff --git a/appdaemon/plugins/hass/hassplugin.py b/appdaemon/plugins/hass/hassplugin.py index c2790af1c..1f6f89d1c 100644 --- a/appdaemon/plugins/hass/hassplugin.py +++ b/appdaemon/plugins/hass/hassplugin.py @@ -9,7 +9,7 @@ from collections.abc import AsyncGenerator, Callable, Coroutine, Iterable from copy import deepcopy from dataclasses import dataclass, field -from datetime import datetime, timedelta +from datetime import datetime from time import perf_counter from typing import Any, Literal, Optional @@ -21,6 +21,7 @@ from appdaemon.appdaemon import AppDaemon from appdaemon.models.config.plugin import HASSConfig, StartupConditions from appdaemon.plugin_management import PluginBase +from appdaemon.types import TimeDeltaLike from .exceptions import HAEventsSubError, HassConnectionError from .utils import ServiceCallStatus, hass_check @@ -352,7 +353,7 @@ async def receive_event(self, event: dict[str, Any]) -> None: @utils.warning_decorator(error_text="Unexpected error during websocket send") async def websocket_send_json( self, - timeout: str | int | float | timedelta | None = None, + timeout: TimeDeltaLike | None = None, *, # Arguments after this are keyword-only silent: bool = False, **request: Any, @@ -363,7 +364,7 @@ async def websocket_send_json( The `id` parameter is handled automatically and is used to match the response to the request. Args: - timeout (str | int | float | timedelta, optional): Length of time to wait for a response from Home + timeout (TimeDeltaLike, optional): Length of time to wait for a response from Home Assistant with a matching `id`. Defaults to the value of the `ws_timeout` setting in the plugin config. silent (bool, optional): If set to `True`, the method will not log the request or response. Defaults to `False`. @@ -440,7 +441,7 @@ async def http_method( self, method: Literal["get", "post", "delete"], endpoint: str, - timeout: str | int | float | timedelta | None = 10, + timeout: TimeDeltaLike | None = 10, **kwargs: Any, ) -> str | dict[str, Any] | list[Any] | aiohttp.ClientResponseError | None: """Wrapper for making HTTP requests to Home Assistant's @@ -449,7 +450,7 @@ async def http_method( Args: method (Literal['get', 'post', 'delete']): HTTP method to use. endpoint (str): Home Assistant REST endpoint to use. For example '/api/states' - timeout (float, optional): Timeout for the method in seconds. Defaults to 10s. + timeout (TimeDeltaLike, optional): Timeout for the method in seconds. Defaults to 10s. **kwargs (optional): Zero or more keyword arguments. These get used as the data for the method, as appropriate. """ @@ -819,7 +820,7 @@ async def fire_plugin_event( self, event: str, namespace: str, - timeout: str | int | float | timedelta | None = None, + timeout: TimeDeltaLike | None = None, **kwargs: Any, ) -> dict[str, Any] | None: # fmt: skip # if we get a request for not our namespace something has gone very wrong @@ -903,7 +904,7 @@ async def safe_set_state(self: "HassPlugin"): async def get_plugin_state( self, entity_id: str, - timeout: str | int | float | timedelta | None = 5, + timeout: TimeDeltaLike | None = 5, ) -> dict | None: resp = await self.http_method("get", f"/api/states/{entity_id}", timeout) match resp: @@ -918,7 +919,7 @@ async def get_plugin_state( async def check_for_entity( self, entity_id: str, - timeout: str | int | float | timedelta | None = 5, + timeout: TimeDeltaLike | None = 5, *, # Arguments after this are keyword-only local: bool = False, ) -> dict | Literal[False]: diff --git a/appdaemon/scheduler.py b/appdaemon/scheduler.py index e593645e8..5d1f0b0db 100644 --- a/appdaemon/scheduler.py +++ b/appdaemon/scheduler.py @@ -16,6 +16,7 @@ from astral.location import Location from . import parse, utils +from .types import TimeDeltaLike if TYPE_CHECKING: from .adbase import ADBase @@ -152,10 +153,10 @@ async def insert_schedule( callback: Callable | None, repeat: bool = False, type_: str | None = None, - interval: str | int | float | timedelta = 0, - offset: str | int | float | timedelta | None = None, - random_start: int | None = None, - random_end: int | None = None, + interval: timedelta = timedelta(), + offset: timedelta = timedelta(), + random_start: timedelta | None = None, + random_end: timedelta | None = None, pin: bool | None = None, pin_thread: int | None = None, **kwargs, @@ -186,25 +187,37 @@ async def insert_schedule( handle = uuid.uuid4().hex # Resolve the first run - offset = utils.parse_timedelta(offset) + + # Validate offset doesn't exceed the interval for repeating schedules + if repeat: + match type_: + case "next_rising": + utils.validate_offset_within_interval( + offset, utils.SUN_EVENT_INTERVAL, "sunrise", random_start, random_end + ) + case "next_setting": + utils.validate_offset_within_interval( + offset, utils.SUN_EVENT_INTERVAL, "sunset", random_start, random_end + ) + case _ if interval.total_seconds() > 0: + utils.validate_offset_within_interval( + offset, interval, "interval", random_start, random_end + ) + c_offset = utils.resolve_offset(offset=offset, random_start=random_start, random_end=random_end) timestamp = basetime + c_offset - # Preserve randomization kwargs because this is where they're looked for later - if random_start is not None: - kwargs["random_start"] = random_start - if random_end is not None: - kwargs["random_end"] = random_end - self.schedule[name][handle] = { "name": name, "id": self.AD.app_management.objects[name].id, "callback": callback, "timestamp": timestamp, - "interval": utils.parse_timedelta(interval).total_seconds(), # guarantees that interval is a float + "interval": interval, "basetime": basetime, "repeat": repeat, - "offset": offset.total_seconds(), + "offset": offset, + "random_start": random_start, + "random_end": random_end, "type": type_, "pin_app": pin_app, "pin_thread": pin_thread, @@ -261,10 +274,13 @@ async def cancel_timer(self, name: str, handle: str, silent: bool) -> bool: async def restart_timer(self, uuid_: str, args: dict[str, Any]) -> dict: """Used to restart a timer. This directly modifies the internal schedule dict.""" match args: - case {"type": "next_rising" | "next_setting", "offset": offset}: - # If the offset is negative, the next sunrise/sunset will still be today, so get tomorrow's by setting - # the days_offset to 1. - days_offset = 1 if offset < 0 else 0 + case {"type": "next_rising" | "next_setting", "timestamp": timestamp, "basetime": basetime}: + # Determine if we need to skip ahead a day based on the effective offset + # (including any random component) that was actually used for this firing. + # If negative, the callback fired before the sun event, so next_*() returns + # that same event and we need to skip ahead. + effective_offset = timestamp - basetime + days_offset = 1 if effective_offset < timedelta() else 0 match args: case {"type": "next_rising"}: args["basetime"] = await self.next_sunrise(days_offset) @@ -272,7 +288,7 @@ async def restart_timer(self, uuid_: str, args: dict[str, Any]) -> dict: args["basetime"] = await self.next_sunset(days_offset) case {"interval": interval}: # Just increment the basetime with the repeat interval - args["basetime"] += utils.parse_timedelta(interval) + args["basetime"] += interval case _: raise ValueError("Malformed scheduler args, expected 'type' or 'interval' key") @@ -341,13 +357,13 @@ def _log_exec_start(self, args: dict[str, Any]) -> None: "callback": callback, "timestamp": datetime() as timestamp, "basetime": datetime() as basetime, - "interval": (int() | float()) as interval, + "interval": timedelta() as interval, }: callback_name = utils.unwrapped(callback).__name__ logger.debug(f"callback name={callback_name}") logger.debug(f" basetime={basetime.astimezone(self.AD.tz).isoformat()}") logger.debug(f" timestamp={timestamp.astimezone(self.AD.tz).isoformat()}") - logger.debug(f" interval={utils.parse_timedelta(interval)}") + logger.debug(f" interval={interval}") pass case _: logger.debug(" Executing: %s", args) @@ -465,20 +481,22 @@ def init_sun(self): async def get_next_period( self, - interval: int | float | timedelta, + interval: TimeDeltaLike, start: time | datetime | str | None = None, - buffer: str | float | int | timedelta = 0.01, ) -> datetime: interval = utils.parse_timedelta(interval) start = "now" if start is None else start - aware_start = await self.parse_datetime(start, aware=True) + + # Get "now" once and use it consistently to avoid timing races + now = await self.get_now() + aware_start = await self.parse_datetime(start, aware=True, now=now) assert isinstance(aware_start, datetime) and aware_start.tzinfo is not None - buffer = utils.parse_timedelta(buffer) - while True: - if aware_start >= (await self.get_now() - buffer): - return aware_start - else: - aware_start += interval + + # Skip forward to the next period if start is in the past + while aware_start < now: + aware_start += interval + + return aware_start async def terminate_app(self, name: str): if app_sched := self.schedule.pop(name, False): @@ -717,7 +735,7 @@ async def sun_up(self) -> bool: async def sun_down(self) -> bool: return await self.now_is_between(start_time="sunset", end_time="sunrise") - async def info_timer(self, handle, name) -> tuple[datetime, float, dict] | None: + async def info_timer(self, handle, name) -> tuple[datetime, timedelta, dict] | None: if self.timer_running(name, handle): callback = self.schedule[name][handle] return ( @@ -861,7 +879,9 @@ async def parse_datetime( input_: str | time | datetime, aware: bool = False, today: bool | None = None, - days_offset: int = 0 + days_offset: int = 0, + *, + now: datetime | None = None, ) -> datetime: # fmt: skip """Parse a variety of inputs into a datetime object. @@ -876,9 +896,12 @@ async def parse_datetime( of the next one. days_offset (int, optional): Number of days to offset from the current date for sunrise/sunset parsing. If this is negative, this will unset the `today` argument, which allows the result to be in the past. + now (datetime, optional): The current time to use as reference. If not provided, will call get_now(). """ # Need to force timezone during time-travel mode - now = (await self.get_now()).astimezone(self.AD.tz) + if now is None: + now = await self.get_now() + now = now.astimezone(self.AD.tz) return parse.parse_datetime( input_=input_, now=now, diff --git a/appdaemon/state.py b/appdaemon/state.py index e17a62ffa..240d9cce6 100644 --- a/appdaemon/state.py +++ b/appdaemon/state.py @@ -5,7 +5,6 @@ import uuid from collections.abc import Awaitable, Callable, Mapping from copy import copy, deepcopy -from datetime import timedelta from enum import Enum from logging import Logger from pathlib import Path @@ -13,6 +12,7 @@ from . import exceptions as ade from . import utils +from .types import TimeDeltaLike from .utils import ADWritebackType if TYPE_CHECKING: @@ -249,7 +249,7 @@ async def add_state_callback( namespace: str, entity: str | None, cb: StateCallbackType, - timeout: str | int | float | timedelta | None = None, + timeout: TimeDeltaLike | None = None, oneshot: bool = False, immediate: bool = False, pin: bool | None = None, @@ -934,7 +934,7 @@ def close_namespaces(self) -> None: self.logger.error("Unexpected error saving namespace: %s", ns) self.logger.error(traceback.format_exc()) - async def periodic_save(self, interval: str | int | float | timedelta) -> None: + async def periodic_save(self, interval: TimeDeltaLike) -> None: """Periodically save all namespaces that are persistent with writeback_type 'hybrid'""" interval = utils.parse_timedelta(interval).total_seconds() while not self.AD.stopping: diff --git a/appdaemon/types.py b/appdaemon/types.py new file mode 100644 index 000000000..920800f84 --- /dev/null +++ b/appdaemon/types.py @@ -0,0 +1,6 @@ +"""Type aliases and protocols used throughout AppDaemon.""" + +from datetime import timedelta + +# Type alias for values that can be parsed to timedelta +TimeDeltaLike = str | int | float | timedelta diff --git a/appdaemon/utils.py b/appdaemon/utils.py index 7d495dc4b..b912c3bd1 100644 --- a/appdaemon/utils.py +++ b/appdaemon/utils.py @@ -41,6 +41,7 @@ from . import exceptions as ade from .parse import parse_timedelta +from .types import TimeDeltaLike logger = logging.getLogger("AppDaemon._utility") file_log = logger.getChild("file") @@ -284,26 +285,86 @@ def check_state(logger, new_state, callback_state, name) -> bool: R = TypeVar("R") +def parse_timedelta_or_none(input_: str | int | float | timedelta | None) -> timedelta | None: + """Parse to timedelta, but return None if input is None.""" + return parse_timedelta(input_) if input_ is not None else None + + def resolve_offset( - offset: str | int | float | timedelta | None, - random_start: int | float | None = None, - random_end: int | float | None = None, + offset: timedelta, + random_start: timedelta | None = None, + random_end: timedelta | None = None, ) -> timedelta: - """Resolves a given offset with some randomization into a timedelta object.""" - offset = parse_timedelta(offset) + """Resolves a given offset with some randomization into a timedelta object. + + Args: + offset: Base offset as a timedelta + random_start: Start of random range as a timedelta (can be negative) + random_end: End of random range as a timedelta + + Returns: + The offset plus a random value in [random_start, random_end] + """ if random_start is not None or random_end is not None: - random_start = random_start if random_start is not None else 0 - random_end = random_end if random_end is not None else 0 + r_start = random_start if random_start is not None else timedelta() + r_end = random_end if random_end is not None else timedelta() - span = random_end - random_start - assert span >= 0, "Random end must be greater than or equal to random start" + span = r_end - r_start + assert span >= timedelta(), "Random end must be greater than or equal to random start" - random_secs = (span * random.random()) + random_start - random_offset = parse_timedelta(random_secs) + random_offset = span * random.random() + r_start offset += random_offset return offset +# Maximum allowed offset for sun events (sunrise/sunset repeat daily) +SUN_EVENT_INTERVAL = timedelta(days=1) + + +def validate_offset_within_interval( + offset: timedelta, + interval: timedelta, + event_type: str, + random_start: timedelta | None = None, + random_end: timedelta | None = None, +) -> None: + """Validate that the offset (including random range) doesn't exceed the event interval. + + For repeating schedules, an offset that exceeds the interval between events would cause + confusing behavior where the callback fires at unpredictable times relative to the intended + base time. + + Args: + offset: The base offset as a timedelta + interval: The interval between events as a timedelta + event_type: Human-readable description of the event type (e.g., "sunrise", "daily") + random_start: Optional random range start as a timedelta + random_end: Optional random range end as a timedelta + + Raises: + OffsetExceedsIntervalError: If the maximum possible offset exceeds the interval + """ + if interval <= timedelta(): + return # Non-repeating event or invalid interval, skip validation + + r_start = random_start if random_start is not None else timedelta() + r_end = random_end if random_end is not None else timedelta() + + # Calculate the extreme possible offsets + min_offset = offset + r_start + max_offset = offset + r_end + + # Check if any possible offset would exceed the interval + if abs(min_offset) >= interval or abs(max_offset) >= interval: + raise ade.OffsetExceedsIntervalError( + offset=offset, + interval=interval, + event_type=event_type, + random_start=random_start, + random_end=random_end, + ) + + def sync_decorator(coro_func: Callable[P, Awaitable[R]]) -> Callable[P, R]: """Wrap a coroutine function to ensure it gets run in the main thread. @@ -318,7 +379,7 @@ def sync_decorator(coro_func: Callable[P, Awaitable[R]]) -> Callable[P, R]: """ @wraps(coro_func) - def wrapper(self, *args, timeout: str | int | float | timedelta | None = None, **kwargs) -> R: + def wrapper(self, *args, timeout: TimeDeltaLike | None = None, **kwargs) -> R: ad: "AppDaemon" = self.AD # Checks to see if it's being called from the main thread, which has the event loop in it @@ -374,11 +435,11 @@ def profiled_fn(*args, **kwargs): return profiled_fn -def format_seconds(secs: str | int | float | timedelta) -> str: +def format_seconds(secs: TimeDeltaLike) -> str: return str(parse_timedelta(secs)) -def format_timedelta(td: str | int | float | timedelta | None) -> str: +def format_timedelta(td: TimeDeltaLike | None) -> str: """Format a timedelta object into a human-readable string. There are different brackets for lengths of time that will format the strings differently. @@ -637,7 +698,7 @@ async def run_in_executor(self: Subsystem, fn: Callable[..., R], *args, **kwargs return await future -def run_coroutine_threadsafe(self: "ADBase", coro: Coroutine[Any, Any, R], timeout: str | int | float | timedelta | None = None) -> R: +def run_coroutine_threadsafe(self: "ADBase", coro: Coroutine[Any, Any, R], timeout: TimeDeltaLike | None = None) -> R: """Run an instantiated coroutine (async) from sync code. This wraps the native python function ``asyncio.run_coroutine_threadsafe`` with logic to add a timeout. See diff --git a/tests/functional/test_run_every.py b/tests/functional/test_run_every.py index a4829d20a..9e0cf1261 100644 --- a/tests/functional/test_run_every.py +++ b/tests/functional/test_run_every.py @@ -6,6 +6,7 @@ from itertools import product import pytest +from appdaemon.types import TimeDeltaLike from appdaemon.utils import parse_timedelta from tests.conftest import AsyncTempTest @@ -23,7 +24,7 @@ @pytest.mark.parametrize(("start", "interval"), product(STARTS, INTERVALS)) async def test_run_every( run_app_for_time: AsyncTempTest, - interval: str | int | float | timedelta, + interval: TimeDeltaLike, start: str, n: int = 2, ) -> None: diff --git a/tests/unit/datetime/test_datetime_misc.py b/tests/unit/datetime/test_datetime_misc.py index fadb21321..73083516a 100644 --- a/tests/unit/datetime/test_datetime_misc.py +++ b/tests/unit/datetime/test_datetime_misc.py @@ -11,7 +11,14 @@ def test_resolve_offset() -> None: - offsets = sorted(utils.resolve_offset(10, random_start=-5, random_end=5) for _ in range(100)) + offsets = sorted( + utils.resolve_offset( + timedelta(seconds=10), + random_start=timedelta(seconds=-5), + random_end=timedelta(seconds=5) + ) + for _ in range(100) + ) assert len(set(offsets)) >= 90, "Offsets should be sufficiently random" assert offsets[0] > timedelta(seconds=5) assert offsets[-1] < timedelta(seconds=15) diff --git a/tests/unit/datetime/test_parse_datetime.py b/tests/unit/datetime/test_parse_datetime.py index 637705449..e4ae0962e 100644 --- a/tests/unit/datetime/test_parse_datetime.py +++ b/tests/unit/datetime/test_parse_datetime.py @@ -6,7 +6,9 @@ import appdaemon.parse import pytest import pytz +from appdaemon.exceptions import OffsetExceedsIntervalError from appdaemon.parse import resolve_time_str +from appdaemon.utils import SUN_EVENT_INTERVAL, validate_offset_within_interval from astral import SunDirection from astral.location import Location from pytz import BaseTzInfo @@ -372,3 +374,57 @@ def test_run_at_time_in_past(default_now: datetime, default_date: date, tomorrow result_none = parser(past_time, today=None) assert result_none.date() == default_date assert result_none.time() == past_time + + +class TestOffsetValidation: + """Tests for validate_offset_within_interval""" + + def test_valid_offset_within_interval(self) -> None: + """Offset smaller than interval should pass""" + # 1 hour offset with 24 hour interval - should not raise + offset = timedelta(hours=1) + validate_offset_within_interval(offset, timedelta(days=1), "daily") + + def test_valid_offset_with_random_within_interval(self) -> None: + """Offset + random range smaller than interval should pass""" + # 1 hour offset with random range of -30min to +30min + # Max possible offset = 1h + 30m = 1.5h, which is < 1 day + offset = timedelta(hours=1) + validate_offset_within_interval( + offset, SUN_EVENT_INTERVAL, "sunset", + random_start=timedelta(minutes=-30), random_end=timedelta(minutes=30) + ) + + def test_offset_exceeds_interval_raises(self) -> None: + """Offset larger than interval should raise""" + # 25 hour offset with 1 day sun event interval - should raise + offset = timedelta(hours=25) + with pytest.raises(OffsetExceedsIntervalError) as exc_info: + validate_offset_within_interval(offset, SUN_EVENT_INTERVAL, "sunrise") + + assert exc_info.value.offset == timedelta(hours=25) + assert exc_info.value.interval == SUN_EVENT_INTERVAL + assert exc_info.value.event_type == "sunrise" + + def test_random_end_exceeds_interval_raises(self) -> None: + """Random end that would push total offset past interval should raise""" + # 23 hour offset + random range up to 2 hours = 25 hours max, exceeds 1 day + offset = timedelta(hours=23) + with pytest.raises(OffsetExceedsIntervalError): + validate_offset_within_interval( + offset, SUN_EVENT_INTERVAL, "sunset", + random_start=timedelta(), random_end=timedelta(hours=2) + ) + + def test_negative_offset_exceeds_interval_raises(self) -> None: + """Negative offset larger than interval should raise""" + # -25 hour offset with 24 hour daily interval - should raise + offset = timedelta(hours=-25) + with pytest.raises(OffsetExceedsIntervalError): + validate_offset_within_interval(offset, timedelta(days=1), "daily") + + def test_zero_interval_skips_validation(self) -> None: + """Zero interval (non-repeating) should skip validation""" + # Even a huge offset should pass with zero interval + offset = timedelta(days=365) + validate_offset_within_interval(offset, timedelta(), "one-time")