Skip to content
Open
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
44 changes: 43 additions & 1 deletion custom_components/pyscript/decorators/timing.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,49 @@ 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:
await asyncio.sleep(delay)

data = self._pending_data
_LOGGER.debug("%s hold_off_send_last dispatching after delay %s", self, delay)
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 +84,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
6 changes: 5 additions & 1 deletion docs/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -940,7 +940,7 @@ first time (so there is no prior value).

.. code:: python

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

``@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
Expand All @@ -951,6 +951,10 @@ the last successful one. Think of this as making the trigger inactive for that n
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, the most recent trigger ignored during ``hold_off`` is run once
when the hold-off period ends. This keeps the rate limit while still applying the latest state or
event data received during the suppressed interval.

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

- ``"range(datetime_start, datetime_end)"`` is satisfied if the current
Expand Down
44 changes: 44 additions & 0 deletions tests/test_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,50 @@ 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.05, 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_state_trigger_time(hass, caplog):
"""Test state trigger."""
Expand Down
Loading