Skip to content

Commit 0c2e8be

Browse files
committed
add hold_off_send_last to time_active
1 parent b9b7eb9 commit 0c2e8be

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
@@ -950,6 +950,10 @@ range specified, the trigger is ignored and the trigger function is not called.
950950
the last successful one. Think of this as making the trigger inactive for that number of seconds
951951
immediately following each successful trigger. This can be used for rate-limiting trigger events or
952952
debouncing a noisy sensor.
953+
If ``hold_off_send_last`` is true, triggers that arrive during the ``hold_off`` window are still
954+
suppressed, but if at least one trigger was suppressed, the function runs after the window ends
955+
using the data from the most recent suppressed trigger. Earlier suppressed triggers are discarded.
956+
If no triggers arrive during the window, no extra run is scheduled.
953957

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

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)