appdaemon icon indicating copy to clipboard operation
appdaemon copied to clipboard

`run_at` running immediately if `start` in past

Open dekiesel opened this issue 1 month ago • 4 comments

What happened?

According to the documentation

If the time specified is in the past, the callback will occur the next day at the specified time.

A run with a time that lies in the past should be scheduled for the next day, but that doesn't seem to be the case.

Issue

It is 13:46 now. When I schedule this run (one minute into the future) everything works as expected: self.run_at(self.run_actions_cb, "13:47:00") But when I schedule it one minute "too late"/one minute in the past it fires immediately self.run_at(self.run_actions_cb, "13:45:00")

Am I misunderstanding the doc or should the second run be scheduled for tomorrow's 13:45?

Version

4.5.12

Installation type

Docker container

Relevant log output


Relevant code in the app or config file that caused the issue


Anything else?

No response

dekiesel avatar Nov 27 '25 12:11 dekiesel

Slight correction, the same happens when using datetime.time

    def initialize(self):
            run_at_time_object = datetime.time.fromisoformat(run_at_time)
            self.run_at(self.run_actions, run_at_time_object)

This yields the same outcome: run_at triggers immediately when the time is in the past.

Minimal non-working example:

import hassapi as hass
from datetime import time

class TimeTriggeredActions(hass.Hass):

    def initialize(self):
        run_at_time_object = time.fromisoformat("01:00:00")
        self.log(f"{run_at_time_object=}")
        self.run_at(self.run_actions, run_at_time_object)
        self.log("initialized")

    def run_actions(self, **kwargs):
        self.log("Called run_at")


Log output:

2025-11-27 13:20:05.779307 INFO timetrigeredactionstestapp: run_at_time_object=datetime.time(1, 0)
2025-11-27 13:20:05.783002 INFO timetrigeredactionstestapp: Called run_at
2025-11-27 13:20:05.783445 INFO timetrigeredactionstestapp: initialized

dekiesel avatar Nov 27 '25 12:11 dekiesel

Am I really the only one with this issue? Is there a workaround? Or am I just using it incorrectly?

dekiesel avatar Dec 01 '25 18:12 dekiesel

You are not the only one. I don't use run_at in any of my apps, but I just tested it with a run_daily callback (replacing run_daily with run_at). With run_daily and times in the past, the app does not run until the next future callback time. However, with run_at, it runs immediately.

I am also running AD v4.5.12 (via the Home Assistant add-on v0.17.12), but since I've never used run_at before, I can't say if behaved differently in earlier versions of AD in my environment.

Edit: My simple callback that reproduced your issue: self.run_at(self.warn_of_upcoming_freezing_weather, "08:00:00")

hugh-martin avatar Dec 01 '25 18:12 hugh-martin

As a workaround I've created a file in helpers called time_functions.py with the following content:

from datetime import time
from datetime import datetime, date


def seconds_to(run_at_time: time | str) -> float:
    """Seconds from now() until run_at_time. Negative if run_at_time is in the past."""
    if isinstance(run_at_time, str):
        nr_delimiters = run_at_time.count(":")
        match nr_delimiters:
            case 1:
                format = "%H:%M"
            case 2:
                format = "%H:%M:%S"
            case _:
                raise ValueError(
                    f"run_at_time should contain one or two colons. Contains {nr_delimiters}"
                )
        run_at_time = datetime.strptime(run_at_time, format).time()

    now = datetime.now()
    diff = datetime.combine(date.today(), run_at_time) - now
    return diff.total_seconds()


def time_in_window(run_at_time: time | str, window_size_seconds: int) -> bool:
    """Checks if run_at_time is happening or has happened within the last `window_size_seconds` seconds.

    Example 1:
    run_at_time: 00:59:55
    now: 01:00:00
    if window_size_seconds <=5 this function will return True, otherwise false

    Example 2:
    run_at_time: 00:01:05
    now: 01:00:00
    if window_size_seconds <=5 this function will return True, otherwise false
    """
    abs_seconds = abs(seconds_to(run_at_time))
    return abs_seconds < window_size_seconds

I can then use it like this to abort the callback:

from datetime import time

import hassapi as hass
from helpers.time_functions import time_in_window


class TimeTriggeredActions(hass.Hass):

    def initialize(self):
        self.run_at_time_object = time.fromisoformat("14:52:00")
        self.log(f"{self.run_at_time_object=}")
        self.run_at(self.run_actions, self.run_at_time_object)
        self.log("initialized")

    def run_actions(self, **kwargs):

        self.log("Before check")
        if not time_in_window(self.run_at_time_object,3):
            return
        self.log("Called run_at")

"If the callback didn't happen within 3 seconds of the requested time then abort the callback".

dekiesel avatar Dec 08 '25 15:12 dekiesel