Skip to content

Commit 63dac74

Browse files
committed
add hold_off_send_last to time_active
1 parent b9b7eb9 commit 63dac74

4 files changed

Lines changed: 96 additions & 3 deletions

File tree

custom_components/pyscript/decorators/timing.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,49 @@ class TimeActiveDecorator(TriggerHandlerDecorator, AutoKwargsDecorator):
3030

3131
name = "time_active"
3232
args_schema = vol.Schema(vol.All([vol.Coerce(str)], vol.Length(min=0)))
33-
kwargs_schema = vol.Schema({vol.Optional("hold_off", default=0.0): vol.Any(None, cv.positive_float)})
33+
kwargs_schema = vol.Schema(
34+
{
35+
vol.Optional("hold_off", default=0.0): vol.Any(None, cv.positive_float),
36+
vol.Optional("hold_off_send_last", default=False): cv.boolean,
37+
}
38+
)
3439

3540
hold_off: float | None
41+
hold_off_send_last: bool
3642

3743
last_trig_time: float = 0.0
44+
_hold_off_task: asyncio.Task | None = None
45+
_pending_data: DispatchData | None = None
46+
47+
async def _dispatch_after_hold_off(self) -> None:
48+
"""Dispatch the latest suppressed payload after the current hold-off window."""
49+
while self._pending_data is not None:
50+
delay = self.last_trig_time + self.hold_off - time.monotonic()
51+
if delay > 0.0:
52+
await asyncio.sleep(delay)
53+
54+
data = self._pending_data
55+
_LOGGER.debug("%s hold_off_send_last dispatching after delay %s", self, delay)
56+
await self.dm.dispatch(data)
57+
if self._pending_data is data:
58+
self._pending_data = None
3859

3960
async def handle_dispatch(self, data: DispatchData) -> bool:
4061
"""Handle dispatch."""
4162
if self.last_trig_time > 0.0 and self.hold_off is not None and self.hold_off > 0.0:
4263
if time.monotonic() - self.last_trig_time < self.hold_off:
64+
if self.hold_off_send_last:
65+
self._pending_data = data
66+
if self._hold_off_task is None or self._hold_off_task.done():
67+
self._hold_off_task = self.dm.hass.async_create_background_task(
68+
self._dispatch_after_hold_off(), f"{self} hold_off_send_last"
69+
)
4370
return False
4471

72+
if data is self._pending_data:
73+
self.last_trig_time = time.monotonic()
74+
return True
75+
4576
if len(self.args) > 0:
4677
if "trigger_time" in data.func_args and isinstance(data.func_args["trigger_time"], dt.datetime):
4778
now = data.func_args["trigger_time"]
@@ -53,12 +84,23 @@ async def handle_dispatch(self, data: DispatchData) -> bool:
5384
_LOGGER.debug("time_active now %s, %s", now, self)
5485
if await trigger.TrigTime.timer_active_check(time_spec, now, self.dm.startup_time):
5586
self.last_trig_time = time.monotonic()
87+
if data is not self._pending_data:
88+
self._pending_data = None
5689
return True
5790
return False
5891

5992
self.last_trig_time = time.monotonic()
93+
if data is not self._pending_data:
94+
self._pending_data = None
6095
return True
6196

97+
async def stop(self) -> None:
98+
"""Stop pending hold-off dispatch."""
99+
await super().stop()
100+
self._pending_data = None
101+
if self._hold_off_task is not None:
102+
self._hold_off_task.cancel()
103+
62104

63105
class TimeTriggerDecorator(TriggerDecorator):
64106
"""Implementation for @time_trigger."""

custom_components/pyscript/stubs/pyscript_builtins.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,15 @@ def event_trigger(
9797
...
9898

9999

100-
def time_active(*time_spec: str, hold_off: int | float | None = None) -> Callable[..., Any]:
100+
def time_active(
101+
*time_spec: str, hold_off: int | float | None = None, hold_off_send_last: bool = False
102+
) -> Callable[..., Any]:
101103
"""Restrict trigger execution to specific time windows.
102104
103105
Args:
104106
time_spec: ``range()`` or ``cron()`` expressions (optionally prefixed with ``not``) checked on each trigger.
105107
hold_off: Seconds to suppress further triggers after a successful run.
108+
hold_off_send_last: Run once with the latest suppressed trigger data when ``hold_off`` ends.
106109
107110
"""
108111
...

docs/reference.rst

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -940,7 +940,7 @@ first time (so there is no prior value).
940940

941941
.. code:: python
942942
943-
@time_active(time_spec, ..., hold_off=None)
943+
@time_active(time_spec, ..., hold_off=None, hold_off_send_last=False)
944944
945945
``@time_active`` takes zero or more strings that specify time-based ranges. Only a single
946946
``@time_active`` decorator can be used per function. When any trigger occurs (whether time, state
@@ -951,6 +951,10 @@ the last successful one. Think of this as making the trigger inactive for that n
951951
immediately following each successful trigger. This can be used for rate-limiting trigger events or
952952
debouncing a noisy sensor.
953953

954+
If ``hold_off_send_last`` is true, the most recent trigger ignored during ``hold_off`` is run once
955+
when the hold-off period ends. This keeps the rate limit while still applying the latest state or
956+
event data received during the suppressed interval.
957+
954958
Each string specification ``time_spec`` can take two forms:
955959

956960
- ``"range(datetime_start, datetime_end)"`` is satisfied if the current

tests/test_function.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,50 @@ def func2(var_name=None, value=None):
789789
assert literal_eval(await wait_until_done(notify_q)) == ["watch_none", "pyscript.var2", "2"]
790790

791791

792+
@pytest.mark.asyncio
793+
async def test_time_active_hold_off_send_last(hass):
794+
"""Test hold_off_send_last runs with the latest suppressed trigger data."""
795+
notify_q = asyncio.Queue(0)
796+
797+
await setup_script(
798+
hass,
799+
notify_q,
800+
None,
801+
[dt(2020, 7, 1, 10, 59, 59, 999998)],
802+
"""
803+
seq_num = 0
804+
805+
@state_trigger("True", watch=["pyscript.var1"])
806+
@time_active(hold_off=0.05, hold_off_send_last=True)
807+
def func1(var_name=None, value=None):
808+
global seq_num
809+
810+
seq_num += 1
811+
pyscript.done = ["hold_off_send_last", seq_num, var_name, value]
812+
""",
813+
)
814+
815+
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
816+
await hass.async_block_till_done()
817+
818+
hass.states.async_set("pyscript.var1", 2)
819+
assert literal_eval(await wait_until_done(notify_q)) == [
820+
"hold_off_send_last",
821+
1,
822+
"pyscript.var1",
823+
"2",
824+
]
825+
826+
hass.states.async_set("pyscript.var1", 3)
827+
hass.states.async_set("pyscript.var1", 4)
828+
assert literal_eval(await wait_until_done(notify_q)) == [
829+
"hold_off_send_last",
830+
2,
831+
"pyscript.var1",
832+
"4",
833+
]
834+
835+
792836
@pytest.mark.asyncio
793837
async def test_state_trigger_time(hass, caplog):
794838
"""Test state trigger."""

0 commit comments

Comments
 (0)