From 5c80d5a620905ed529de19a9c101d18601fdc589 Mon Sep 17 00:00:00 2001 From: Alexey Masolov Date: Mon, 25 May 2026 01:52:21 +1000 Subject: [PATCH] Apply debounce globally to all requests Move the existing debounce mechanism into _request() so it applies uniformly to all HTTP requests (reads and writes). Previously the debounce only gated read requests that followed a write command. Now every request waits at least _debounce_delay seconds since the previous request completed, giving the device's embedded web server time to recover between operations and preventing socket exhaustion under sustained polling. Changes: - _wait_for_debounce() now tracks _last_request_time (all requests) instead of only command times - Debounce is enforced inside _request(), removing per-method calls - _send_command() no longer separately tracks command time - _last_request_time is updated on errors so the delay is respected when the device is struggling --- src/hdfury/api.py | 22 +++++++++++++++------- tests/test_api.py | 25 +++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/hdfury/api.py b/src/hdfury/api.py index c91ce81..9bc1d21 100644 --- a/src/hdfury/api.py +++ b/src/hdfury/api.py @@ -19,7 +19,7 @@ def __init__(self, host: str, session: aiohttp.ClientSession | None = None) -> N """HDFury API Client.""" self.host: str = host self._session: aiohttp.ClientSession = session or aiohttp.ClientSession() - self._last_command_time: float = 0 + self._last_request_time: float = 0 self._debounce_delay: int = 2 # seconds @staticmethod @@ -36,8 +36,13 @@ def _normalize_state(state: str, output: Literal["text", "number"] = "text") -> raise HDFuryParseError(f"Invalid state: {state}") async def _wait_for_debounce(self) -> None: - """Wait until at least `_debounce_delay` seconds have passed since the last command.""" - elapsed = time.time() - self._last_command_time + """Wait until at least `_debounce_delay` seconds have passed since the last request. + + HDFury devices run an embedded web server with limited resources. + Spacing out requests prevents socket exhaustion and ensures the + device has time to process before the next request arrives. + """ + elapsed = time.time() - self._last_request_time if elapsed < self._debounce_delay: wait_time = self._debounce_delay - elapsed await asyncio.sleep(wait_time) @@ -46,8 +51,12 @@ async def _request(self, endpoint: str) -> str: """Handle a request to the HDFury device.""" url = f"http://{self.host}{endpoint}" + await self._wait_for_debounce() + try: async with self._session.get(url, timeout=ClientTimeout(total=10)) as response: + self._last_request_time = time.time() + if response.status != 200: raise HDFuryConnectionError( f"Unexpected response from: {url} (Status: {response.status})" @@ -55,10 +64,13 @@ async def _request(self, endpoint: str) -> str: return await response.text() except TimeoutError as err: + self._last_request_time = time.time() raise HDFuryConnectionError(f"Timeout while fetching: {url}") from err except (ClientError, ClientResponseError) as err: + self._last_request_time = time.time() raise HDFuryConnectionError(f"Request failed ({url}): {err}") from err except Exception as err: + self._last_request_time = time.time() raise HDFuryConnectionError(f"Unexpected error ({url}): {err}") from err async def _request_json(self, path: str) -> dict[str, str]: @@ -71,19 +83,16 @@ async def _request_json(self, path: str) -> dict[str, str]: async def get_board(self) -> dict[str, str]: """Fetch board info.""" - await self._wait_for_debounce() response = await self._request_json("/ssi/brdinfo.ssi") return response async def get_info(self) -> dict[str, str]: """Fetch device info.""" - await self._wait_for_debounce() response = await self._request_json("/ssi/infopage.ssi") return response async def get_config(self) -> dict[str, str]: """Fetch device configuration.""" - await self._wait_for_debounce() config_response = await self._request_json("/ssi/confpage.ssi") try: @@ -98,7 +107,6 @@ async def get_config(self) -> dict[str, str]: async def _send_command(self, command: str, option: str = "") -> None: """Send a command to the device.""" await self._request(f"/cmd?{command}={option}") - self._last_command_time = time.time() async def issue_reboot(self) -> None: """Send reboot command to the device.""" diff --git a/tests/test_api.py b/tests/test_api.py index 07dcaf0..2dc94eb 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -433,10 +433,31 @@ async def test_set_reboot_timer(client: HDFuryAPI, endpoint: str, method: str, v @pytest.mark.asyncio async def test_wait_for_debounce_sleeps_when_called_too_fast(client: HDFuryAPI): - """Verify that _wait_for_debounce sleeps if commands are called too quickly.""" + """Verify that _wait_for_debounce sleeps if requests are made too quickly.""" client._debounce_delay = 2 - client._last_command_time = time.time() + client._last_request_time = time.time() with patch("asyncio.sleep", new=AsyncMock()) as sleep_mock: await client._wait_for_debounce() sleep_mock.assert_called_once() + +@pytest.mark.asyncio +async def test_wait_for_debounce_no_sleep_when_enough_time_elapsed(client: HDFuryAPI): + """Verify that no sleep occurs when enough time has passed since last request.""" + client._debounce_delay = 2 + client._last_request_time = time.time() - 3.0 + + with patch("asyncio.sleep", new=AsyncMock()) as sleep_mock: + await client._wait_for_debounce() + sleep_mock.assert_not_called() + +@pytest.mark.asyncio +async def test_request_updates_last_request_time(client: HDFuryAPI): + """Verify that _request updates _last_request_time after completing.""" + before = time.time() + + with aioresponses() as mock: + mock.get("http://192.168.1.123/test", body="ok") + await client._request("/test") + + assert client._last_request_time >= before