Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions src/hdfury/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -46,19 +51,26 @@ 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})"
)

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]:
Expand All @@ -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:
Expand All @@ -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."""
Expand Down
25 changes: 23 additions & 2 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading