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
158 changes: 130 additions & 28 deletions ariston/ariston_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import logging
import re
import time
from typing import Any, Optional

Expand Down Expand Up @@ -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"""
Expand All @@ -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,
Expand Down Expand Up @@ -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"""
Expand All @@ -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"""
Expand All @@ -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"""
Expand All @@ -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"""
Expand All @@ -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"""
Expand All @@ -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,
Expand Down Expand Up @@ -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."""
Expand All @@ -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
Expand All @@ -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"""
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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"""
Expand All @@ -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"""
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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"""
Expand All @@ -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,
Expand Down Expand Up @@ -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."""
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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"""
Expand All @@ -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,
Expand Down Expand Up @@ -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]]:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down
Loading