From 0c2e8be3682cb3cb00c1050cc742b58db628ee89 Mon Sep 17 00:00:00 2001 From: Dmitrii Amelin Date: Tue, 12 May 2026 18:46:03 +0200 Subject: [PATCH 1/5] add hold_off_send_last to time_active --- .../pyscript/decorators/timing.py | 44 ++++++++++++++++++- .../pyscript/stubs/pyscript_builtins.py | 5 ++- docs/reference.rst | 6 ++- tests/test_function.py | 44 +++++++++++++++++++ 4 files changed, 96 insertions(+), 3 deletions(-) diff --git a/custom_components/pyscript/decorators/timing.py b/custom_components/pyscript/decorators/timing.py index d431adc..4b39a60 100644 --- a/custom_components/pyscript/decorators/timing.py +++ b/custom_components/pyscript/decorators/timing.py @@ -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"] @@ -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.""" diff --git a/custom_components/pyscript/stubs/pyscript_builtins.py b/custom_components/pyscript/stubs/pyscript_builtins.py index c4b1736..3190f12 100644 --- a/custom_components/pyscript/stubs/pyscript_builtins.py +++ b/custom_components/pyscript/stubs/pyscript_builtins.py @@ -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. """ ... diff --git a/docs/reference.rst b/docs/reference.rst index fc8992c..33e65e3 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -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 @@ -950,6 +950,10 @@ range specified, the trigger is ignored and the trigger function is not called. 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. Each string specification ``time_spec`` can take two forms: diff --git a/tests/test_function.py b/tests/test_function.py index f87fbe1..0a1e64b 100644 --- a/tests/test_function.py +++ b/tests/test_function.py @@ -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.""" From 39adfe0b37865f7c3cde98e3db12f11ef8a13855 Mon Sep 17 00:00:00 2001 From: Dmitrii Amelin Date: Thu, 16 Jul 2026 13:53:16 +0200 Subject: [PATCH 2/5] fix race between hold_off_send_last task and passing trigger --- custom_components/pyscript/decorators/timing.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/custom_components/pyscript/decorators/timing.py b/custom_components/pyscript/decorators/timing.py index 4b39a60..42b8fb9 100644 --- a/custom_components/pyscript/decorators/timing.py +++ b/custom_components/pyscript/decorators/timing.py @@ -49,10 +49,13 @@ async def _dispatch_after_hold_off(self) -> None: 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 after delay %s", self, delay) + _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 From b884f4c5aeb13da44909d3a91405e4130e1f2fa0 Mon Sep 17 00:00:00 2001 From: Dmitrii Amelin Date: Thu, 16 Jul 2026 13:53:57 +0200 Subject: [PATCH 3/5] test: increase hold_off to reduce flakiness under load --- tests/test_function.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_function.py b/tests/test_function.py index 0a1e64b..9dddd04 100644 --- a/tests/test_function.py +++ b/tests/test_function.py @@ -803,7 +803,7 @@ async def test_time_active_hold_off_send_last(hass): seq_num = 0 @state_trigger("True", watch=["pyscript.var1"]) -@time_active(hold_off=0.05, hold_off_send_last=True) +@time_active(hold_off=0.1, hold_off_send_last=True) def func1(var_name=None, value=None): global seq_num From 20c32661869454324b95ba76b9df3497fbfb4309 Mon Sep 17 00:00:00 2001 From: Dmitrii Amelin Date: Thu, 16 Jul 2026 15:12:51 +0200 Subject: [PATCH 4/5] docs: multiple @time_active decorators are allowed --- docs/reference.rst | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/reference.rst b/docs/reference.rst index 33e65e3..ca0c2fa 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -942,14 +942,14 @@ first time (so there is no prior value). @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 -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`` 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. From 70dd2202167309442a87d9ea95dce3b30cbb4631 Mon Sep 17 00:00:00 2001 From: Dmitrii Amelin Date: Thu, 16 Jul 2026 16:14:32 +0200 Subject: [PATCH 5/5] document hold_off_send_last time range behavior --- docs/reference.rst | 13 +++++++++++- tests/test_function.py | 47 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/docs/reference.rst b/docs/reference.rst index ca0c2fa..0541041 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -953,7 +953,12 @@ 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. +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: @@ -988,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 ------------------------- diff --git a/tests/test_function.py b/tests/test_function.py index 9dddd04..fd468a7 100644 --- a/tests/test_function.py +++ b/tests/test_function.py @@ -833,6 +833,53 @@ def func1(var_name=None, value=None): ] +@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."""