diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py index bbaf6dacfeb..6206081dc8c 100644 --- a/homeassistant/helpers/debounce.py +++ b/homeassistant/helpers/debounce.py @@ -31,6 +31,7 @@ class Debouncer: self.immediate = immediate self._timer_task: Optional[asyncio.TimerHandle] = None self._execute_at_end_of_timer: bool = False + self._execute_lock = asyncio.Lock() async def async_call(self) -> None: """Call the function.""" @@ -42,15 +43,23 @@ class Debouncer: return - if self.immediate: - await self.hass.async_add_job(self.function) # type: ignore - else: - self._execute_at_end_of_timer = True + # Locked means a call is in progress. Any call is good, so abort. + if self._execute_lock.locked(): + return - self._timer_task = self.hass.loop.call_later( - self.cooldown, - lambda: self.hass.async_create_task(self._handle_timer_finish()), - ) + if not self.immediate: + self._execute_at_end_of_timer = True + self._schedule_timer() + return + + async with self._execute_lock: + # Abort if timer got set while we're waiting for the lock. + if self._timer_task: + return + + await self.hass.async_add_job(self.function) # type: ignore + + self._schedule_timer() async def _handle_timer_finish(self) -> None: """Handle a finished timer.""" @@ -63,10 +72,21 @@ class Debouncer: self._execute_at_end_of_timer = False - try: - await self.hass.async_add_job(self.function) # type: ignore - except Exception: # pylint: disable=broad-except - self.logger.exception("Unexpected exception from %s", self.function) + # Locked means a call is in progress. Any call is good, so abort. + if self._execute_lock.locked(): + return + + async with self._execute_lock: + # Abort if timer got set while we're waiting for the lock. + if self._timer_task: + return # type: ignore + + try: + await self.hass.async_add_job(self.function) # type: ignore + except Exception: # pylint: disable=broad-except + self.logger.exception("Unexpected exception from %s", self.function) + + self._schedule_timer() @callback def async_cancel(self) -> None: @@ -76,3 +96,11 @@ class Debouncer: self._timer_task = None self._execute_at_end_of_timer = False + + @callback + def _schedule_timer(self) -> None: + """Schedule a timer.""" + self._timer_task = self.hass.loop.call_later( + self.cooldown, + lambda: self.hass.async_create_task(self._handle_timer_finish()), + ) diff --git a/tests/helpers/test_debounce.py b/tests/helpers/test_debounce.py index 4972fbbc018..d51eb22c90d 100644 --- a/tests/helpers/test_debounce.py +++ b/tests/helpers/test_debounce.py @@ -15,20 +15,24 @@ async def test_immediate_works(hass): function=CoroutineMock(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 + # Call when cooldown active setting execute at end to True await debouncer.async_call() assert len(calls) == 1 assert debouncer._timer_task is not None assert debouncer._execute_at_end_of_timer is True + # Canceling debounce in 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 await debouncer.async_call() assert len(calls) == 2 await debouncer._handle_timer_finish() @@ -36,6 +40,14 @@ async def test_immediate_works(hass): assert debouncer._timer_task is None assert debouncer._execute_at_end_of_timer is False + # Test calling doesn't execute/cooldown if currently executing. + await debouncer._execute_lock.acquire() + await debouncer.async_call() + assert len(calls) == 2 + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + debouncer._execute_lock.release() + async def test_not_immediate_works(hass): """Test immediate works.""" @@ -48,23 +60,38 @@ async def test_not_immediate_works(hass): function=CoroutineMock(side_effect=lambda: calls.append(None)), ) + # Call when nothing happening await debouncer.async_call() 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 await debouncer.async_call() 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 await debouncer.async_call() assert len(calls) == 0 await debouncer._handle_timer_finish() assert len(calls) == 1 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is False + + # Reset debouncer + debouncer.async_cancel() + + # Test calling doesn't schedule if currently executing. + await debouncer._execute_lock.acquire() + await debouncer.async_call() + assert len(calls) == 1 assert debouncer._timer_task is None assert debouncer._execute_at_end_of_timer is False + debouncer._execute_lock.release()