From 490c03d248872c989612bcc0761359cda1bf0c7f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 21 Feb 2024 00:09:45 -0600 Subject: [PATCH] Add async_schedule_call to the Debouncer (#111051) --- homeassistant/config_entries.py | 2 +- homeassistant/helpers/debounce.py | 32 ++++-- tests/helpers/test_debounce.py | 157 +++++++++++++++++++++++++++++- 3 files changed, 183 insertions(+), 8 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index fc37b23d497..471a7efa0a3 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -969,7 +969,7 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): self._hass_config = hass_config self._pending_import_flows: dict[str, dict[str, asyncio.Future[None]]] = {} self._initialize_futures: dict[str, list[asyncio.Future[None]]] = {} - self._discovery_debouncer = Debouncer( + self._discovery_debouncer = Debouncer[None]( hass, _LOGGER, cooldown=DISCOVERY_COOLDOWN, diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py index 54b90077cdc..d25026edcd1 100644 --- a/homeassistant/helpers/debounce.py +++ b/homeassistant/helpers/debounce.py @@ -61,26 +61,44 @@ class Debouncer(Generic[_R_co]): f"debouncer cooldown={self.cooldown}, immediate={self.immediate}", ) - async def async_call(self) -> None: - """Call the function.""" + @callback + def async_schedule_call(self) -> None: + """Schedule a call to the function.""" + if self._async_schedule_or_call_now(): + self._execute_at_end_of_timer = True + self._on_debounce() + + def _async_schedule_or_call_now(self) -> bool: + """Check if a call should be scheduled. + + Returns True if the function should be called immediately. + + Returns False if there is nothing to do. + """ if self._shutdown_requested: self.logger.debug("Debouncer call ignored as shutdown has been requested.") - return - assert self._job is not None + return False if self._timer_task: if not self._execute_at_end_of_timer: self._execute_at_end_of_timer = True - return + return False # Locked means a call is in progress. Any call is good, so abort. if self._execute_lock.locked(): - return + return False if not self.immediate: self._execute_at_end_of_timer = True self._schedule_timer() + return False + + return True + + async def async_call(self) -> None: + """Call the function.""" + if not self._async_schedule_or_call_now(): return async with self._execute_lock: @@ -88,6 +106,7 @@ class Debouncer(Generic[_R_co]): if self._timer_task: return + assert self._job is not None try: if task := self.hass.async_run_hass_job(self._job): await task @@ -137,6 +156,7 @@ class Debouncer(Generic[_R_co]): """Create job task, but only if pending.""" self._timer_task = None if self._execute_at_end_of_timer: + self._execute_at_end_of_timer = False self.hass.async_create_task( self._handle_timer_finish(), f"debouncer {self._job} finish cooldown={self.cooldown}, immediate={self.immediate}", diff --git a/tests/helpers/test_debounce.py b/tests/helpers/test_debounce.py index f31453cfe96..a1073ed39bf 100644 --- a/tests/helpers/test_debounce.py +++ b/tests/helpers/test_debounce.py @@ -2,7 +2,7 @@ import asyncio from datetime import timedelta import logging -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock import pytest @@ -69,6 +69,106 @@ async def test_immediate_works(hass: HomeAssistant) -> None: assert debouncer._job.target == debouncer.function +async def test_immediate_works_with_schedule_call(hass: HomeAssistant) -> None: + """Test immediate works with scheduled calls.""" + calls = [] + debouncer = debounce.Debouncer( + hass, + _LOGGER, + cooldown=0.01, + immediate=True, + function=AsyncMock(side_effect=lambda: calls.append(None)), + ) + + # Call when nothing happening + debouncer.async_schedule_call() + await hass.async_block_till_done() + assert len(calls) == 1 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + + # Call when cooldown active setting execute at end to True + debouncer.async_schedule_call() + await hass.async_block_till_done() + assert len(calls) == 1 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is True + assert debouncer._job.target == debouncer.function + + # Canceling debounce in cooldown + debouncer.async_cancel() + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + + before_job = debouncer._job + + # Call and let timer run out + debouncer.async_schedule_call() + await hass.async_block_till_done() + assert len(calls) == 2 + async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() + assert len(calls) == 2 + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + assert debouncer._job == before_job + + # Test calling doesn't execute/cooldown if currently executing. + await debouncer._execute_lock.acquire() + debouncer.async_schedule_call() + await hass.async_block_till_done() + assert len(calls) == 2 + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + debouncer._execute_lock.release() + assert debouncer._job.target == debouncer.function + + +async def test_immediate_works_with_callback_function(hass: HomeAssistant) -> None: + """Test immediate works with callback function.""" + calls = [] + debouncer = debounce.Debouncer( + hass, + _LOGGER, + cooldown=0.01, + immediate=True, + function=callback(Mock(side_effect=lambda: calls.append(None))), + ) + + # Call when nothing happening + await debouncer.async_call() + assert len(calls) == 1 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + + debouncer.async_cancel() + + +async def test_immediate_works_with_executor_function(hass: HomeAssistant) -> None: + """Test immediate works with executor function.""" + calls = [] + debouncer = debounce.Debouncer( + hass, + _LOGGER, + cooldown=0.01, + immediate=True, + function=Mock(side_effect=lambda: calls.append(None)), + ) + + # Call when nothing happening + await debouncer.async_call() + assert len(calls) == 1 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + + debouncer.async_cancel() + + async def test_immediate_works_with_passed_callback_function_raises( hass: HomeAssistant, ) -> None: @@ -247,6 +347,61 @@ async def test_not_immediate_works(hass: HomeAssistant) -> None: assert debouncer._job.target == debouncer.function +async def test_not_immediate_works_schedule_call(hass: HomeAssistant) -> None: + """Test immediate works with schedule call.""" + calls = [] + debouncer = debounce.Debouncer( + hass, + _LOGGER, + cooldown=0.01, + immediate=False, + function=AsyncMock(side_effect=lambda: calls.append(None)), + ) + + # Call when nothing happening + debouncer.async_schedule_call() + await hass.async_block_till_done() + assert len(calls) == 0 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is True + + # Call while still on cooldown + debouncer.async_schedule_call() + await hass.async_block_till_done() + assert len(calls) == 0 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is True + + # Canceling while on cooldown + debouncer.async_cancel() + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + + # Call and let timer run out + debouncer.async_schedule_call() + await hass.async_block_till_done() + assert len(calls) == 0 + async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() + assert len(calls) == 1 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + + # Reset debouncer + debouncer.async_cancel() + + # Test calling doesn't schedule if currently executing. + await debouncer._execute_lock.acquire() + debouncer.async_schedule_call() + await hass.async_block_till_done() + assert len(calls) == 1 + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + debouncer._execute_lock.release() + assert debouncer._job.target == debouncer.function + + async def test_immediate_works_with_function_swapped(hass: HomeAssistant) -> None: """Test immediate works and we can change out the function.""" calls = []