diff --git a/custom_components/pyscript/decorators/timing.py b/custom_components/pyscript/decorators/timing.py index d431adc..42b8fb9 100644 --- a/custom_components/pyscript/decorators/timing.py +++ b/custom_components/pyscript/decorators/timing.py @@ -30,18 +30,52 @@ 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: + # 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 %s", self, data) + 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 +87,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..0541041 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -940,16 +940,25 @@ first time (so there is no prior value). .. code:: python - @time_active(time_spec, ..., hold_off=None) - -``@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(time_spec, ..., hold_off=None, hold_off_send_last=False) + +``@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. +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: @@ -984,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 f87fbe1..fd468a7 100644 --- a/tests/test_function.py +++ b/tests/test_function.py @@ -789,6 +789,97 @@ 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.1, 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_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."""