Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion custom_components/pyscript/decorators/timing.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,52 @@ class TimeActiveDecorator(TriggerHandlerDecorator, AutoKwargsDecorator):

name = "time_active"
args_schema = vol.Schema(vol.All([vol.Coerce(str)], vol.Length(min=0)))
kwargs_schema = vol.Schema({vol.Optional("hold_off", default=0.0): vol.Any(None, cv.positive_float)})
kwargs_schema = vol.Schema(
{
vol.Optional("hold_off", default=0.0): vol.Any(None, cv.positive_float),
vol.Optional("hold_off_send_last", default=False): cv.boolean,
}
)

hold_off: float | None
hold_off_send_last: bool

last_trig_time: float = 0.0
_hold_off_task: asyncio.Task | None = None
_pending_data: DispatchData | None = None

async def _dispatch_after_hold_off(self) -> None:
"""Dispatch the latest suppressed payload after the current hold-off window."""
while self._pending_data is not None:
delay = self.last_trig_time + self.hold_off - time.monotonic()
if delay > 0.0:
# A trigger may pass and clear _pending_data while we sleep;
# re-check the loop condition after every await.
await asyncio.sleep(delay)
continue

data = self._pending_data
_LOGGER.debug("%s hold_off_send_last dispatching %s", self, data)
await self.dm.dispatch(data)
if self._pending_data is data:
self._pending_data = None

async def handle_dispatch(self, data: DispatchData) -> bool:
"""Handle dispatch."""
if self.last_trig_time > 0.0 and self.hold_off is not None and self.hold_off > 0.0:
if time.monotonic() - self.last_trig_time < self.hold_off:
if self.hold_off_send_last:
self._pending_data = data
if self._hold_off_task is None or self._hold_off_task.done():
self._hold_off_task = self.dm.hass.async_create_background_task(
self._dispatch_after_hold_off(), f"{self} hold_off_send_last"
)
return False

if data is self._pending_data:
self.last_trig_time = time.monotonic()
return True

if len(self.args) > 0:
if "trigger_time" in data.func_args and isinstance(data.func_args["trigger_time"], dt.datetime):
now = data.func_args["trigger_time"]
Expand All @@ -53,12 +87,23 @@ async def handle_dispatch(self, data: DispatchData) -> bool:
_LOGGER.debug("time_active now %s, %s", now, self)
if await trigger.TrigTime.timer_active_check(time_spec, now, self.dm.startup_time):
self.last_trig_time = time.monotonic()
if data is not self._pending_data:
self._pending_data = None
return True
return False

self.last_trig_time = time.monotonic()
if data is not self._pending_data:
self._pending_data = None
return True

async def stop(self) -> None:
"""Stop pending hold-off dispatch."""
await super().stop()
self._pending_data = None
if self._hold_off_task is not None:
self._hold_off_task.cancel()


class TimeTriggerDecorator(TriggerDecorator):
"""Implementation for @time_trigger."""
Expand Down
5 changes: 4 additions & 1 deletion custom_components/pyscript/stubs/pyscript_builtins.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,15 @@ def event_trigger(
...


def time_active(*time_spec: str, hold_off: int | float | None = None) -> Callable[..., Any]:
def time_active(
*time_spec: str, hold_off: int | float | None = None, hold_off_send_last: bool = False
) -> Callable[..., Any]:
"""Restrict trigger execution to specific time windows.

Args:
time_spec: ``range()`` or ``cron()`` expressions (optionally prefixed with ``not``) checked on each trigger.
hold_off: Seconds to suppress further triggers after a successful run.
hold_off_send_last: Run once with the latest suppressed trigger data when ``hold_off`` ends.

"""
...
Expand Down
35 changes: 25 additions & 10 deletions docs/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -940,16 +940,25 @@ first time (so there is no prior value).

.. code:: python

@time_active(time_spec, ..., hold_off=None)

``@time_active`` takes zero or more strings that specify time-based ranges. Only a single
``@time_active`` decorator can be used per function. When any trigger occurs (whether time, state
or event), each time range specification is checked. If the current time doesn't fall within any
range specified, the trigger is ignored and the trigger function is not called. The optional numeric
``hold_off`` setting in seconds will ignore any triggers that are within that amount of time from
the last successful one. Think of this as making the trigger inactive for that number of seconds
immediately following each successful trigger. This can be used for rate-limiting trigger events or
debouncing a noisy sensor.
@time_active(time_spec, ..., hold_off=None, hold_off_send_last=False)

``@time_active`` takes zero or more strings that specify time-based ranges. Multiple
``@time_active`` decorators can be used per function; a trigger must satisfy all of them. When any
trigger occurs (whether time, state or event), each time range specification is checked. If the
current time doesn't fall within any range specified, the trigger is ignored and the trigger
function is not called. The optional numeric ``hold_off`` setting in seconds will ignore any
triggers that are within that amount of time from the last successful one. Think of this as making
the trigger inactive for that number of seconds immediately following each successful trigger.
This can be used for rate-limiting trigger events or debouncing a noisy sensor.
If ``hold_off_send_last`` is true, triggers that arrive during the ``hold_off`` window are still
suppressed, but if at least one trigger was suppressed, the function runs after the window ends
using the data from the most recent suppressed trigger. Earlier suppressed triggers are discarded.
If no triggers arrive during the window, no extra run is scheduled. The deferred run bypasses the
``time_spec`` ranges of its own decorator: it always happens, even if the window ends outside
every range, and the data it delivers may come from a trigger that itself arrived outside the
ranges. This way the function always receives the latest trigger data. To keep a time range in
force for the deferred run too, either check the current time inside the function, or put the
range in a separate ``@time_active`` decorator, whose checks apply to the deferred run as well.

Each string specification ``time_spec`` can take two forms:

Expand Down Expand Up @@ -984,6 +993,12 @@ matches any of the positive arguments, and none of the negative arguments.
log.info(f"got motion. turning on the lights")
light.turn_on(entity_id="light.hallway")

@state_trigger("sensor.temperature")
@time_active(hold_off=60, hold_off_send_last=True) # at most once a minute, latest value wins
@time_active("range(8:00, 22:00)") # only during the day, including the deferred run
def show_temperature(value):
log.info(f"temperature is now {value}")

Other Function Decorators
-------------------------

Expand Down
91 changes: 91 additions & 0 deletions tests/test_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,97 @@ def func2(var_name=None, value=None):
assert literal_eval(await wait_until_done(notify_q)) == ["watch_none", "pyscript.var2", "2"]


@pytest.mark.asyncio
async def test_time_active_hold_off_send_last(hass):
"""Test hold_off_send_last runs with the latest suppressed trigger data."""
notify_q = asyncio.Queue(0)

await setup_script(
hass,
notify_q,
None,
[dt(2020, 7, 1, 10, 59, 59, 999998)],
"""
seq_num = 0

@state_trigger("True", watch=["pyscript.var1"])
@time_active(hold_off=0.1, hold_off_send_last=True)
def func1(var_name=None, value=None):
global seq_num

seq_num += 1
pyscript.done = ["hold_off_send_last", seq_num, var_name, value]
""",
)

hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()

hass.states.async_set("pyscript.var1", 2)
assert literal_eval(await wait_until_done(notify_q)) == [
"hold_off_send_last",
1,
"pyscript.var1",
"2",
]

hass.states.async_set("pyscript.var1", 3)
hass.states.async_set("pyscript.var1", 4)
assert literal_eval(await wait_until_done(notify_q)) == [
"hold_off_send_last",
2,
"pyscript.var1",
"4",
]


@pytest.mark.asyncio
async def test_time_active_hold_off_send_last_outside_range(hass):
"""Test the deferred hold_off_send_last run fires even outside the time_active range."""
notify_q = asyncio.Queue(0)

await setup_script(
hass,
notify_q,
None,
# consumed in order: startup_time, first range check; 12:00 for anything after
[dt(2020, 7, 1, 10, 30, 0, 0), dt(2020, 7, 1, 10, 30, 0, 0), dt(2020, 7, 1, 12, 0, 0, 0)],
"""
seq_num = 0

@state_trigger("True", watch=["pyscript.var1"])
@time_active("range(10:00, 11:00)", hold_off=0.1, hold_off_send_last=True)
def func1(var_name=None, value=None):
global seq_num

seq_num += 1
pyscript.done = ["outside_range", seq_num, var_name, value]
""",
)

hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()

# first trigger checks the range against dt_now() == 10:30 and fires
hass.states.async_set("pyscript.var1", 2)
assert literal_eval(await wait_until_done(notify_q)) == [
"outside_range",
1,
"pyscript.var1",
"2",
]

# suppressed during hold_off; dt_now() now reads 12:00, outside the range,
# but the deferred run is not range-checked and still fires with the latest data
hass.states.async_set("pyscript.var1", 3)
assert literal_eval(await wait_until_done(notify_q)) == [
"outside_range",
2,
"pyscript.var1",
"3",
]


@pytest.mark.asyncio
async def test_state_trigger_time(hass, caplog):
"""Test state trigger."""
Expand Down
Loading