From c472e72a63abf6d4fbb00d04334c189cca1cee6f Mon Sep 17 00:00:00 2001 From: flupkede Date: Fri, 28 Aug 2026 19:39:55 +0000 Subject: [PATCH 1/4] fix(api): honour 429 retry-after instead of failing immediately The API returns the block duration in the response body but the client discarded it and raised straight away, so the next poll often landed inside the same window. Parse the value, wait it out, and share the blocked-until deadline across all callers. Closes: AGENTS_ariston-429-holiday.md --- ariston/ariston_api.py | 68 +++++++++++++- pyproject.toml | 2 +- tests/test_rate_limit.py | 197 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 264 insertions(+), 3 deletions(-) create mode 100644 tests/test_rate_limit.py diff --git a/ariston/ariston_api.py b/ariston/ariston_api.py index 465e078..4ffa26e 100644 --- a/ariston/ariston_api.py +++ b/ariston/ariston_api.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import re import time from typing import Any, Optional @@ -39,6 +40,16 @@ _LOGGER = logging.getLogger(__name__) +_RATE_LIMIT_RE = re.compile(rb"blocked for (\d+) seconds", re.IGNORECASE) + + +def _parse_block_seconds(content: bytes) -> Optional[int]: + """Extract the block duration in seconds from a 429 response body.""" + match = _RATE_LIMIT_RE.search(content) + if match is None: + return None + return int(match.group(1)) + class ConnectionException(Exception): """When can not connect to Ariston cloud""" @@ -47,6 +58,10 @@ class ConnectionException(Exception): class AristonAPI: """Ariston API class""" + # Shared across all instances: one 429 silences every caller (all three + # HA coordinators) until the block window has passed. + _blocked_until: float = 0.0 + def __init__( self, username: str, @@ -470,6 +485,11 @@ def __request( is_retry: bool = False, ) -> Optional[dict[str, Any]]: """Request with requests""" + if not is_retry and time.time() < AristonAPI._blocked_until: + raise ConnectionException( + 429, + "Rate limited; skipping request until the block window ends", + ) headers: dict[str, Any] = { "User-Agent": self.__user_agent, "ar.authToken": self.__token, @@ -495,8 +515,27 @@ def __request( case 404: return None case 429: - content = response.content.decode() - raise ConnectionException(response.status_code, content) + content = response.content + wait_seconds = _parse_block_seconds(content) + if wait_seconds is not None: + wait_seconds += 1 + _LOGGER.warning( + "Rate limited (429) for %s seconds; waiting it out", + wait_seconds, + ) + else: + wait_seconds = 5 + _LOGGER.warning( + "Rate limited (429) without parsable block " + "duration; falling back to 5s wait" + ) + AristonAPI._blocked_until = time.time() + wait_seconds + if not is_retry: + time.sleep(wait_seconds) + return self.__request(method, path, params, body, True) + raise ConnectionException( + response.status_code, content.decode() + ) case _: if not is_retry: time.sleep(5) @@ -932,6 +971,11 @@ async def __async_request( is_retry: bool = False, ) -> Optional[dict[str, Any]]: """Async request with aiohttp""" + if not is_retry and time.time() < AristonAPI._blocked_until: + raise ConnectionException( + 429, + "Rate limited; skipping request until the block window ends", + ) headers: dict[str, Any] = { "User-Agent": self.__user_agent, "ar.authToken": self.__token, @@ -965,6 +1009,26 @@ async def __async_request( return None case 429: content = await response.content.read() + wait_seconds = _parse_block_seconds(content) + if wait_seconds is not None: + wait_seconds += 1 + _LOGGER.warning( + "Rate limited (429) for %s seconds; " + "waiting it out", + wait_seconds, + ) + else: + wait_seconds = 5 + _LOGGER.warning( + "Rate limited (429) without parsable block " + "duration; falling back to 5s wait" + ) + AristonAPI._blocked_until = time.time() + wait_seconds + if not is_retry: + await asyncio.sleep(wait_seconds) + return await self.__async_request( + method, path, params, body, True + ) raise ConnectionException(response.status, content) case _: if not is_retry: diff --git a/pyproject.toml b/pyproject.toml index d8ebd4f..986f1fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ ] requires-python = ">=3.9" license = { file = "LICENSE" } -version = "0.19.9" +version = "0.19.9+patch1" dynamic = ['description'] [project.urls] diff --git a/tests/test_rate_limit.py b/tests/test_rate_limit.py new file mode 100644 index 0000000..71bebf2 --- /dev/null +++ b/tests/test_rate_limit.py @@ -0,0 +1,197 @@ +"""Unit tests for the 429 rate-limit handling in ariston.ariston_api.""" + +import asyncio +import time +from typing import Any, Optional +from unittest.mock import MagicMock, patch + +import pytest + +from ariston.ariston_api import ( + AristonAPI, + ConnectionException, + _parse_block_seconds, +) + + +@pytest.fixture(autouse=True) +def _reset_blocked_until(): + """Isolate the class-level block window between tests.""" + AristonAPI._blocked_until = 0.0 + yield + AristonAPI._blocked_until = 0.0 + + +def _api() -> AristonAPI: + return AristonAPI("user", "pass") + + +# --------------------------------------------------------------------------- +# parse helper +# --------------------------------------------------------------------------- + + +def test_parse_block_seconds(): + assert _parse_block_seconds(b"Requests are blocked for 66 seconds") == 66 + assert _parse_block_seconds(b"Requests are blocked for 5 seconds") == 5 + assert _parse_block_seconds(b"Too many requests") is None + + +# --------------------------------------------------------------------------- +# sync path (requests) +# --------------------------------------------------------------------------- + + +def _sync_response(status: int, content: bytes = b"", json_data: Any = None): + response = MagicMock() + response.ok = status < 400 + response.status_code = status + response.content = content + if json_data is not None: + response.json.return_value = json_data + return response + + +def test_429_parses_wait_seconds(): + responses = [ + _sync_response(429, b"Requests are blocked for 66 seconds"), + _sync_response(200, b"{}", {"ok": True}), + ] + http = MagicMock(side_effect=responses) + sleeps: list[int] = [] + with patch("ariston.ariston_api.requests.request", http), patch( + "ariston.ariston_api.time.sleep", side_effect=sleeps.append + ): + result = _api()._get("https://example/api") + + assert result == {"ok": True} + assert http.call_count == 2 # exactly one retry + assert sleeps == [67] # parsed 66 + 1s margin + assert AristonAPI._blocked_until > 0 # block window was set + + +def test_429_without_parsable_body(): + responses = [ + _sync_response(429, b"Too many requests"), + _sync_response(200, b"{}", {"ok": True}), + ] + http = MagicMock(side_effect=responses) + sleeps: list[int] = [] + with patch("ariston.ariston_api.requests.request", http), patch( + "ariston.ariston_api.time.sleep", side_effect=sleeps.append + ): + result = _api()._get("https://example/api") + + assert result == {"ok": True} + assert sleeps == [5] # documented fallback, same as generic error case + assert http.call_count == 2 + + +def test_429_twice_raises(): + body = b"Requests are blocked for 10 seconds" + http = MagicMock( + side_effect=[_sync_response(429, body), _sync_response(429, body)] + ) + with patch("ariston.ariston_api.requests.request", http), patch( + "ariston.ariston_api.time.sleep" + ): + with pytest.raises(ConnectionException): + _api()._get("https://example/api") + + assert http.call_count == 2 # one retry, then raise — no infinite loop + + +def test_blocked_until_short_circuits(): + AristonAPI._blocked_until = time.time() + 30 + http = MagicMock() + with patch("ariston.ariston_api.requests.request", http): + with pytest.raises(ConnectionException): + _api()._get("https://example/api") + + http.assert_not_called() # no network call inside the block window + + +# --------------------------------------------------------------------------- +# async path (aiohttp) +# --------------------------------------------------------------------------- + + +class _FakeAiohttpContent: + def __init__(self, payload: bytes) -> None: + self._payload = payload + + async def read(self) -> bytes: + return self._payload + + +class _FakeAiohttpResponse: + def __init__( + self, status: int, payload: bytes = b"", json_data: Any = None + ) -> None: + self.status = status + self.ok = status < 400 + self.content = _FakeAiohttpContent(payload) + self.content_length = len(payload) + self._json_data = json_data + + async def json(self) -> Any: + return self._json_data + + +def _fake_session_cls(responses: list, calls: list): + class _Session: + def __init__(self, *args: Any, **kwargs: Any) -> None: + pass + + async def __aenter__(self) -> "_Session": + return self + + async def __aexit__(self, *exc: Any) -> bool: + return False + + async def request(self, *args: Any, **kwargs: Any): + calls.append(1) + return responses.pop(0) + + return _Session + + +def test_async_429_parses_and_retries(): + responses = [ + _FakeAiohttpResponse(429, b"Requests are blocked for 66 seconds"), + _FakeAiohttpResponse(200, b"{}", {"ok": True}), + ] + calls: list[int] = [] + sleeps: list[Optional[int]] = [] + + async def run() -> Any: + with patch( + "ariston.ariston_api.aiohttp.ClientSession", + _fake_session_cls(responses, calls), + ), patch( + "ariston.ariston_api.asyncio.sleep", side_effect=sleeps.append + ): + return await _api()._async_get("https://example/api") + + result = asyncio.run(run()) + + assert result == {"ok": True} + assert len(calls) == 2 # exactly one retry + assert sleeps == [67] # parsed 66 + 1s margin + + +def test_async_blocked_until_short_circuits(): + AristonAPI._blocked_until = time.time() + 30 + calls: list[int] = [] + + async def run() -> None: + with patch( + "ariston.ariston_api.aiohttp.ClientSession", + _fake_session_cls([], calls), + ): + with pytest.raises(ConnectionException): + await _api()._async_get("https://example/api") + + asyncio.run(run()) + + assert calls == [] # no network call inside the block window From 430c56983e41f0f243a69f67c97e2341f485bb57 Mon Sep 17 00:00:00 2001 From: flupkede Date: Fri, 28 Aug 2026 22:20:13 +0000 Subject: [PATCH 2/4] feat(velis): add Velis holiday support The app exposes holiday mode for Velis water heaters but the library had no method for it. POST velis/slpPlantData/{gw}/holiday with {"new": date} to enable (ISO midnight format) and {"new": null} to cancel, mirroring the existing Galevo holiday API. Date format centralized in a shared constant. Proven against the live API: POST returns {"success":true} and the field holidayUntil appears in velis/slpPlantData afterwards. Closes: AGENTS_ariston-429-holiday.md --- ariston/ariston_api.py | 26 +++++++++++ ariston/const.py | 1 + ariston/galevo_device.py | 5 ++- ariston/velis_device.py | 21 ++++++++- pyproject.toml | 2 +- tests/test_velis_holiday.py | 89 +++++++++++++++++++++++++++++++++++++ 6 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 tests/test_velis_holiday.py diff --git a/ariston/ariston_api.py b/ariston/ariston_api.py index 4ffa26e..fda8258 100644 --- a/ariston/ariston_api.py +++ b/ariston/ariston_api.py @@ -931,6 +931,32 @@ async def async_set_velis_plant_setting( {setting: {"new": value, "old": old_value}}, ) + def set_velis_holiday( + self, + gw_id: str, + holiday_end_date: Optional[str], + ) -> None: + """Set Velis holiday""" + self._post( + f"{self.__api_url}{ARISTON_VELIS}/{PlantData.Slp.value}/{gw_id}/holiday", + { + "new": holiday_end_date, + }, + ) + + async def async_set_velis_holiday( + self, + gw_id: str, + holiday_end_date: Optional[str], + ) -> None: + """Async set Velis holiday""" + await self._async_post( + f"{self.__api_url}{ARISTON_VELIS}/{PlantData.Slp.value}/{gw_id}/holiday", + { + "new": holiday_end_date, + }, + ) + async def async_get_thermostat_time_progs( self, gw_id: str, zone: int, umsys: str ) -> Optional[dict[str, Any]]: diff --git a/ariston/const.py b/ariston/const.py index 13f9b09..0c14f9e 100644 --- a/ariston/const.py +++ b/ariston/const.py @@ -7,6 +7,7 @@ ARISTON_LOGIN: Final[str] = "accounts/login" ARISTON_REMOTE: Final[str] = "remote" ARISTON_VELIS: Final[str] = "velis" +HOLIDAY_DATE_FORMAT: Final[str] = "%Y-%m-%dT00:00:00" ARISTON_PLANTS: Final[str] = "plants" ARISTON_LITE: Final[str] = "lite" ARISTON_DATA_ITEMS: Final[str] = "dataItems" diff --git a/ariston/galevo_device.py b/ariston/galevo_device.py index 59c489b..bf8682f 100644 --- a/ariston/galevo_device.py +++ b/ariston/galevo_device.py @@ -18,6 +18,7 @@ DeviceProperties, GasEnergyUnit, GasType, + HOLIDAY_DATE_FORMAT, PlantMode, PropertyType, ThermostatProperties, @@ -979,7 +980,9 @@ async def async_set_item_by_id( @staticmethod def _create_holiday_end_date(holiday_end: Optional[date]): return ( - None if holiday_end is None else holiday_end.strftime("%Y-%m-%dT00:00:00") + None + if holiday_end is None + else holiday_end.strftime(HOLIDAY_DATE_FORMAT) ) def _set_holiday(self, holiday_end_date: Optional[str]): diff --git a/ariston/velis_device.py b/ariston/velis_device.py index 46393f9..de8d4e4 100644 --- a/ariston/velis_device.py +++ b/ariston/velis_device.py @@ -3,10 +3,11 @@ import logging from abc import ABC, abstractmethod +from datetime import date from typing import Any, Optional from .ariston_api import AristonAPI -from .const import VelisDeviceProperties +from .const import HOLIDAY_DATE_FORMAT, VelisDeviceProperties from .velis_base_device import AristonVelisBaseDevice from .device import AristonDevice @@ -88,6 +89,24 @@ async def async_set_max_setpoint_temp(self, max_setpoint_temp: float): ) self.plant_settings[self.max_setpoint_temp] = max_setpoint_temp + @staticmethod + def _create_holiday_end_date(holiday_end: Optional[date]) -> Optional[str]: + return ( + None + if holiday_end is None + else holiday_end.strftime(HOLIDAY_DATE_FORMAT) + ) + + def set_velis_holiday(self, holiday_end: Optional[date]) -> None: + """Set Velis holiday on device""" + self.api.set_velis_holiday(self.gw, self._create_holiday_end_date(holiday_end)) + + async def async_set_velis_holiday(self, holiday_end: Optional[date]) -> None: + """Async set Velis holiday on device""" + await self.api.async_set_velis_holiday( + self.gw, self._create_holiday_end_date(holiday_end) + ) + @property @abstractmethod def water_heater_maximum_setpoint_temperature_minimum(self) -> Optional[float]: diff --git a/pyproject.toml b/pyproject.toml index 986f1fc..de81683 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ ] requires-python = ">=3.9" license = { file = "LICENSE" } -version = "0.19.9+patch1" +version = "0.19.9+patch2" dynamic = ['description'] [project.urls] diff --git a/tests/test_velis_holiday.py b/tests/test_velis_holiday.py new file mode 100644 index 0000000..940fbaa --- /dev/null +++ b/tests/test_velis_holiday.py @@ -0,0 +1,89 @@ +"""Unit tests for the Velis holiday write (endpoint proven by live write-test).""" + +import asyncio +from datetime import date +from typing import Any + +from ariston.ariston_api import AristonAPI +from ariston.const import ARISTON_API_URL, ARISTON_VELIS +from ariston.velis_device import AristonVelisDevice + +GW = "3C8A1F2EDE84" +EXPECTED_URL = ( + f"{ARISTON_API_URL}{ARISTON_VELIS}/slpPlantData/{GW}/holiday" +) + + +def _fake_session_cls(posts: list): + class _Session: + def __init__(self, *args: Any, **kwargs: Any) -> None: + pass + + async def __aenter__(self) -> "_Session": + return self + + async def __aexit__(self, *exc: Any) -> bool: + return False + + async def request(self, method: str, path: str, params=None, json=None, + headers=None): + if method == "POST": + posts.append({"path": path, "json": json}) + class _Resp: + status = 200 + ok = True + content_length = 0 + return _Resp() + + return _Session + + +def _device() -> AristonVelisDevice: + """Instantiate without the abstract machinery; only api+gw are used.""" + cls = AristonVelisDevice + original = cls.__abstractmethods__ + cls.__abstractmethods__ = frozenset() + try: + device = cls.__new__(cls) + finally: + cls.__abstractmethods__ = original + device.api = AristonAPI("user", "pass") + device.gw = GW + return device + + +def test_holiday_end_date_format(): + assert ( + AristonVelisDevice._create_holiday_end_date(date(2026, 9, 15)) + == "2026-09-15T00:00:00" + ) + assert AristonVelisDevice._create_holiday_end_date(None) is None + + +def test_async_set_velis_holiday_on_posts_date(): + posts: list = [] + device = _device() + with patch_session(_fake_session_cls(posts)): + asyncio.run(device.async_set_velis_holiday(date(2026, 9, 15))) + + assert len(posts) == 1 + assert posts[0]["path"] == EXPECTED_URL + assert posts[0]["json"] == {"new": "2026-09-15T00:00:00"} + + +def test_async_set_velis_holiday_off_posts_null(): + posts: list = [] + device = _device() + with patch_session(_fake_session_cls(posts)): + asyncio.run(device.async_set_velis_holiday(None)) + + assert len(posts) == 1 + assert posts[0]["path"] == EXPECTED_URL + assert posts[0]["json"] == {"new": None} + + +def patch_session(fake_cls): + """Patch aiohttp.ClientSession inside ariston_api for one test.""" + from unittest.mock import patch + + return patch("ariston.ariston_api.aiohttp.ClientSession", fake_cls) From 5dc22129477c847409c6d88471a0e00f5d93940e Mon Sep 17 00:00:00 2001 From: flupkede Date: Sat, 29 Aug 2026 09:32:42 +0000 Subject: [PATCH 3/4] chore: drop local version suffix, fix Sonar test smells The +patchN version suffix is only needed for our local wheel deployment; release versioning belongs to the maintainer, so the PR diff no longer touches pyproject.toml (this also clears the Sonar S8565 lock-file finding from new code). Tests now use the monkeypatch fixture for the shared _blocked_until state and keep a single invocation inside pytest.raises blocks (S8997, S5778). --- pyproject.toml | 2 +- tests/test_rate_limit.py | 17 ++++++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index de81683..d8ebd4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ ] requires-python = ">=3.9" license = { file = "LICENSE" } -version = "0.19.9+patch2" +version = "0.19.9" dynamic = ['description'] [project.urls] diff --git a/tests/test_rate_limit.py b/tests/test_rate_limit.py index 71bebf2..89134cf 100644 --- a/tests/test_rate_limit.py +++ b/tests/test_rate_limit.py @@ -92,21 +92,23 @@ def test_429_twice_raises(): http = MagicMock( side_effect=[_sync_response(429, body), _sync_response(429, body)] ) + api = _api() with patch("ariston.ariston_api.requests.request", http), patch( "ariston.ariston_api.time.sleep" ): with pytest.raises(ConnectionException): - _api()._get("https://example/api") + api._get("https://example/api") assert http.call_count == 2 # one retry, then raise — no infinite loop -def test_blocked_until_short_circuits(): - AristonAPI._blocked_until = time.time() + 30 +def test_blocked_until_short_circuits(monkeypatch): + monkeypatch.setattr(AristonAPI, "_blocked_until", time.time() + 30) http = MagicMock() + api = _api() with patch("ariston.ariston_api.requests.request", http): with pytest.raises(ConnectionException): - _api()._get("https://example/api") + api._get("https://example/api") http.assert_not_called() # no network call inside the block window @@ -180,9 +182,10 @@ async def run() -> Any: assert sleeps == [67] # parsed 66 + 1s margin -def test_async_blocked_until_short_circuits(): - AristonAPI._blocked_until = time.time() + 30 +def test_async_blocked_until_short_circuits(monkeypatch): + monkeypatch.setattr(AristonAPI, "_blocked_until", time.time() + 30) calls: list[int] = [] + api = _api() async def run() -> None: with patch( @@ -190,7 +193,7 @@ async def run() -> None: _fake_session_cls([], calls), ): with pytest.raises(ConnectionException): - await _api()._async_get("https://example/api") + await api._async_get("https://example/api") asyncio.run(run()) From 13aec65049f570a2959b547c61e153e70367f2a5 Mon Sep 17 00:00:00 2001 From: flupkede Date: Sat, 29 Aug 2026 09:54:51 +0000 Subject: [PATCH 4/4] refactor: literal syntax + extracted rate-limit handlers - Replace dict()/list() constructor calls with {}/[] literals across ariston_api, galevo_device, velis_device (Sonar S7498) - Extract the 429 branch from __request/__async_request into __handle_rate_limit/__async_handle_rate_limit helpers plus a module-level _rate_limit_wait_seconds() (Sonar S3776) No behavior change; full suite 10/10 green (ariston-test:py313). --- ariston/ariston_api.py | 144 +++++++++++++++++++++------------------ ariston/galevo_device.py | 6 +- ariston/velis_device.py | 2 +- 3 files changed, 82 insertions(+), 70 deletions(-) diff --git a/ariston/ariston_api.py b/ariston/ariston_api.py index fda8258..2584edb 100644 --- a/ariston/ariston_api.py +++ b/ariston/ariston_api.py @@ -51,6 +51,19 @@ def _parse_block_seconds(content: bytes) -> Optional[int]: return int(match.group(1)) +def _rate_limit_wait_seconds(content: bytes) -> int: + """Effective wait for a 429: parsed duration + 1s margin, else 5s.""" + parsed = _parse_block_seconds(content) + if parsed is None: + _LOGGER.warning( + "Rate limited (429) without parsable block duration; " + "falling back to 5s wait" + ) + return 5 + _LOGGER.warning("Rate limited (429) for %s seconds; waiting it out", parsed + 1) + return parsed + 1 + + class ConnectionException(Exception): """When can not connect to Ariston cloud""" @@ -100,14 +113,14 @@ def get_detailed_devices(self) -> list[Any]: devices = self._get(f"{self.__api_url}{ARISTON_REMOTE}/{ARISTON_PLANTS}") if devices is not None: return list(devices) - return list() + return [] def get_detailed_velis_devices(self) -> list[Any]: """Get detailed cloud devices""" devices = self._get(f"{self.__api_url}{ARISTON_VELIS}/{ARISTON_PLANTS}") if devices is not None: return list(devices) - return list() + return [] def get_devices(self) -> list[Any]: """Get cloud devices""" @@ -116,7 +129,7 @@ def get_devices(self) -> list[Any]: ) if devices is not None: return list(devices) - return list() + return [] def get_features_for_device(self, gw_id: str) -> dict[str, Any]: """Get features for the device""" @@ -125,7 +138,7 @@ def get_features_for_device(self, gw_id: str) -> dict[str, Any]: ) if features is not None: return features - return dict() + return {} def get_energy_account(self, gw_id: str) -> dict[str, Any]: """Get energy account for the device""" @@ -134,7 +147,7 @@ def get_energy_account(self, gw_id: str) -> dict[str, Any]: ) if energy_account is not None: return energy_account - return dict() + return {} def get_consumptions_sequences(self, gw_id: str, usages: str) -> list[Any]: """Get consumption sequences for the device""" @@ -143,7 +156,7 @@ def get_consumptions_sequences(self, gw_id: str, usages: str) -> list[Any]: ) if consumptions_sequences is not None: return list(consumptions_sequences) - return list() + return [] def get_consumptions_settings(self, gw_id: str) -> dict[str, Any]: """Get consumption settings""" @@ -153,7 +166,7 @@ def get_consumptions_settings(self, gw_id: str) -> dict[str, Any]: ) if consumptions_settings is not None: return consumptions_settings - return dict() + return {} def set_consumptions_settings( self, @@ -189,7 +202,7 @@ def get_properties( ) if properties is not None: return properties - return dict() + return {} def get_bsb_plant_data(self, gw_id: str) -> dict[str, Any]: """Get BSB plant data.""" @@ -198,14 +211,14 @@ def get_bsb_plant_data(self, gw_id: str) -> dict[str, Any]: ) if data is not None: return data - return dict() + return {} def get_velis_plant_data(self, plant_data: PlantData, gw_id: str) -> dict[str, Any]: """Get Velis properties""" data = self._get(f"{self.__api_url}{ARISTON_VELIS}/{plant_data.value}/{gw_id}") if data is not None: return data - return dict() + return {} def get_velis_plant_settings( self, plant_data: PlantData, gw_id: str @@ -216,7 +229,7 @@ def get_velis_plant_settings( ) if settings is not None: return settings - return dict() + return {} def get_menu_items(self, gw_id: str) -> list[dict[str, Any]]: """Get menu items""" @@ -225,7 +238,7 @@ def get_menu_items(self, gw_id: str) -> list[dict[str, Any]]: ) if items is not None: return items - return list() + return [] def set_property( self, @@ -452,7 +465,7 @@ def get_thermostat_time_progs( ) if thermostat_time_progs is not None: return thermostat_time_progs - return dict() + return {} def set_holiday( self, @@ -515,26 +528,8 @@ def __request( case 404: return None case 429: - content = response.content - wait_seconds = _parse_block_seconds(content) - if wait_seconds is not None: - wait_seconds += 1 - _LOGGER.warning( - "Rate limited (429) for %s seconds; waiting it out", - wait_seconds, - ) - else: - wait_seconds = 5 - _LOGGER.warning( - "Rate limited (429) without parsable block " - "duration; falling back to 5s wait" - ) - AristonAPI._blocked_until = time.time() + wait_seconds - if not is_retry: - time.sleep(wait_seconds) - return self.__request(method, path, params, body, True) - raise ConnectionException( - response.status_code, content.decode() + return self.__handle_rate_limit( + response, is_retry, method, path, params, body ) case _: if not is_retry: @@ -553,6 +548,24 @@ def _post(self, path: str, body: Any) -> Any: """POST request""" return self.__request("POST", path, None, body) + def __handle_rate_limit( + self, + response, + is_retry: bool, + method: str, + path: str, + params: Optional[dict[str, Any]], + body: Any, + ) -> Optional[dict[str, Any]]: + """Sleep out the 429 block window, retry once, share the deadline.""" + content = response.content + wait_seconds = _rate_limit_wait_seconds(content) + AristonAPI._blocked_until = time.time() + wait_seconds + if not is_retry: + time.sleep(wait_seconds) + return self.__request(method, path, params, body, True) + raise ConnectionException(response.status_code, content.decode()) + def _get(self, path: str, params: Optional[dict[str, Any]] = None) -> Any: """GET request""" return self.__request("GET", path, params, None) @@ -583,7 +596,7 @@ async def async_get_detailed_devices(self) -> list[Any]: ) if detailed_devices is not None: return list(detailed_devices) - return list() + return [] async def async_get_detailed_velis_devices(self) -> list[Any]: """Async get detailed cloud devices""" @@ -592,7 +605,7 @@ async def async_get_detailed_velis_devices(self) -> list[Any]: ) if detailed_velis_devices is not None: return list(detailed_velis_devices) - return list() + return [] async def async_get_devices(self) -> list[Any]: """Async get cloud devices""" @@ -601,7 +614,7 @@ async def async_get_devices(self) -> list[Any]: ) if devices is not None: return list(devices) - return list() + return [] async def async_get_features_for_device( self, gw_id: str @@ -618,7 +631,7 @@ async def async_get_energy_account(self, gw_id: str) -> dict[str, Any]: ) if energy_account is not None: return energy_account - return dict() + return {} async def async_get_consumptions_sequences( self, gw_id: str, usages: str @@ -629,7 +642,7 @@ async def async_get_consumptions_sequences( ) if consumptions_sequences is not None: return list(consumptions_sequences) - return list() + return [] async def async_get_consumptions_settings(self, gw_id: str) -> dict[str, Any]: """Async get consumption settings""" @@ -639,7 +652,7 @@ async def async_get_consumptions_settings(self, gw_id: str) -> dict[str, Any]: ) if consumptions_settings is not None: return consumptions_settings - return dict() + return {} async def async_set_consumptions_settings( self, @@ -667,7 +680,7 @@ async def async_get_properties( ) if properties is not None: return properties - return dict() + return {} async def async_get_bsb_plant_data(self, gw_id: str) -> dict[str, Any]: """Get BSB plant data.""" @@ -676,7 +689,7 @@ async def async_get_bsb_plant_data(self, gw_id: str) -> dict[str, Any]: ) if data is not None: return data - return dict() + return {} async def async_get_velis_plant_data( self, plant_data: PlantData, gw_id: str @@ -687,7 +700,7 @@ async def async_get_velis_plant_data( ) if med_plant_data is not None: return med_plant_data - return dict() + return {} async def async_get_velis_plant_settings( self, plant_data: PlantData, gw_id: str @@ -698,7 +711,7 @@ async def async_get_velis_plant_settings( ) if med_plant_settings is not None: return med_plant_settings - return dict() + return {} async def async_get_menu_items(self, gw_id: str) -> list[dict[str, Any]]: """Async get menu items""" @@ -707,7 +720,7 @@ async def async_get_menu_items(self, gw_id: str) -> list[dict[str, Any]]: ) if items is not None: return items - return list() + return [] async def async_set_property( self, @@ -1034,28 +1047,9 @@ async def __async_request( case 404: return None case 429: - content = await response.content.read() - wait_seconds = _parse_block_seconds(content) - if wait_seconds is not None: - wait_seconds += 1 - _LOGGER.warning( - "Rate limited (429) for %s seconds; " - "waiting it out", - wait_seconds, - ) - else: - wait_seconds = 5 - _LOGGER.warning( - "Rate limited (429) without parsable block " - "duration; falling back to 5s wait" - ) - AristonAPI._blocked_until = time.time() + wait_seconds - if not is_retry: - await asyncio.sleep(wait_seconds) - return await self.__async_request( - method, path, params, body, True - ) - raise ConnectionException(response.status, content) + return await self.__async_handle_rate_limit( + response, is_retry, method, path, params, body + ) case _: if not is_retry: await asyncio.sleep(5) @@ -1075,6 +1069,24 @@ async def _async_post(self, path: str, body: Any) -> Any: """Async POST request""" return await self.__async_request("POST", path, None, body) + async def __async_handle_rate_limit( + self, + response, + is_retry: bool, + method: str, + path: str, + params: Optional[dict[str, Any]], + body: Any, + ) -> Optional[dict[str, Any]]: + """Sleep out the 429 block window, retry once, share the deadline.""" + content = await response.content.read() + wait_seconds = _rate_limit_wait_seconds(content) + AristonAPI._blocked_until = time.time() + wait_seconds + if not is_retry: + await asyncio.sleep(wait_seconds) + return await self.__async_request(method, path, params, body, True) + raise ConnectionException(response.status, content) + async def _async_get( self, path: str, params: Optional[dict[str, Any]] = None ) -> Any: diff --git a/ariston/galevo_device.py b/ariston/galevo_device.py index bf8682f..dfc3f3e 100644 --- a/ariston/galevo_device.py +++ b/ariston/galevo_device.py @@ -44,9 +44,9 @@ def __init__( super().__init__(api, attributes) self.umsys = "si" if is_metric else "us" self.language_tag = language_tag - self.consumptions_settings: dict[str, Any] = dict() - self.energy_account: dict[str, Any] = dict() - self.menu_items: list[dict[str, Any]] = list() + self.consumptions_settings: dict[str, Any] = {} + self.energy_account: dict[str, Any] = {} + self.menu_items: list[dict[str, Any]] = [] @property def consumption_type(self) -> str: diff --git a/ariston/velis_device.py b/ariston/velis_device.py index de8d4e4..4e954df 100644 --- a/ariston/velis_device.py +++ b/ariston/velis_device.py @@ -23,7 +23,7 @@ def __init__( attributes: dict[str, Any], ) -> None: super().__init__(api, attributes) - self.plant_settings: dict[str, Any] = dict() + self.plant_settings: dict[str, Any] = {} @property @abstractmethod