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..163b2e6 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 @@ -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 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."""