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."""