diff --git a/ariston/ariston_api.py b/ariston/ariston_api.py index 465e078..2584edb 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,29 @@ _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)) + + +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""" @@ -47,6 +71,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, @@ -85,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""" @@ -101,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""" @@ -110,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""" @@ -119,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""" @@ -128,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""" @@ -138,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, @@ -174,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.""" @@ -183,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 @@ -201,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""" @@ -210,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, @@ -437,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, @@ -470,6 +498,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 +528,9 @@ def __request( case 404: return None case 429: - content = response.content.decode() - raise ConnectionException(response.status_code, content) + return self.__handle_rate_limit( + response, is_retry, method, path, params, body + ) case _: if not is_retry: time.sleep(5) @@ -514,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) @@ -544,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""" @@ -553,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""" @@ -562,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 @@ -579,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 @@ -590,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""" @@ -600,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, @@ -628,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.""" @@ -637,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 @@ -648,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 @@ -659,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""" @@ -668,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, @@ -892,6 +944,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]]: @@ -932,6 +1010,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, @@ -964,8 +1047,9 @@ async def __async_request( case 404: return None case 429: - content = await response.content.read() - 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) @@ -985,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/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..dfc3f3e 100644 --- a/ariston/galevo_device.py +++ b/ariston/galevo_device.py @@ -18,6 +18,7 @@ DeviceProperties, GasEnergyUnit, GasType, + HOLIDAY_DATE_FORMAT, PlantMode, PropertyType, ThermostatProperties, @@ -43,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: @@ -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..4e954df 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 @@ -22,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 @@ -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/tests/test_rate_limit.py b/tests/test_rate_limit.py new file mode 100644 index 0000000..89134cf --- /dev/null +++ b/tests/test_rate_limit.py @@ -0,0 +1,200 @@ +"""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)] + ) + 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") + + assert http.call_count == 2 # one retry, then raise — no infinite loop + + +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") + + 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(monkeypatch): + monkeypatch.setattr(AristonAPI, "_blocked_until", time.time() + 30) + calls: list[int] = [] + api = _api() + + 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 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)