Add async_schedule_call to the Debouncer (#111051)

This commit is contained in:
J. Nick Koston 2024-02-21 00:09:45 -06:00 committed by GitHub
parent aaa071e810
commit 490c03d248
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 183 additions and 8 deletions

View file

@ -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,

View file

@ -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}",

View file

@ -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 = []