From 2b4e258bda9289e47407dd901c2a402ad1404e90 Mon Sep 17 00:00:00 2001 From: Banter240 <199655869+banter240@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:25:10 +0100 Subject: [PATCH 1/3] feat: add rate limit tracking and public API Essential improvements for production use. Rate Limit Support: - Store response headers from API calls - Parse RateLimit headers per IETF draft spec - rate_limit property returning (limit, remaining) tuple - last_headers property for debugging Public API (enables extensions): - session property (get aiohttp ClientSession) - home_id property (get home ID) - access_token property (get OAuth token) - refresh_auth() public method (refresh token) Model Fixes: - ZoneState null nextTimeBlock handling (API sometimes returns null) Breaking Changes: None (only additions and bug fixes) All changes are backward compatible. --- src/tadoasync/models.py | 2 ++ src/tadoasync/tadoasync.py | 53 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/tadoasync/models.py b/src/tadoasync/models.py index e11d880..c62bc81 100644 --- a/src/tadoasync/models.py +++ b/src/tadoasync/models.py @@ -589,6 +589,8 @@ def __pre_deserialize__(cls, d: dict[str, Any]) -> dict[str, Any]: """Pre deserialize hook.""" if not d["sensorDataPoints"]: d["sensorDataPoints"] = None + if d.get("nextTimeBlock") is None: + d["nextTimeBlock"] = {} return d diff --git a/src/tadoasync/tadoasync.py b/src/tadoasync/tadoasync.py index c1f1ad2..8320dcb 100644 --- a/src/tadoasync/tadoasync.py +++ b/src/tadoasync/tadoasync.py @@ -5,6 +5,7 @@ import asyncio import enum import logging +import re import time from dataclasses import dataclass from datetime import UTC, datetime, timedelta @@ -115,6 +116,10 @@ def __init__( self._device_activation_status = DeviceActivationStatus.NOT_STARTED self._expires_at: datetime | None = None + self._last_headers: dict[str, str] = {} + self._last_limit: int | None = None + self._last_remaining: int | None = None + _LOGGER.setLevel(logging.DEBUG if debug else logging.INFO) async def async_init(self) -> None: @@ -141,6 +146,35 @@ def refresh_token(self) -> str | None: """Return the refresh token.""" return self._refresh_token + @property + def session(self) -> ClientSession: + """Return the aiohttp session.""" + return self._ensure_session() + + @property + def home_id(self) -> int | None: + """Return the home ID.""" + return self._home_id + + @property + def access_token(self) -> str | None: + """Return the OAuth access token.""" + return self._access_token + + async def refresh_auth(self) -> None: + """Refresh the OAuth token.""" + await self._refresh_auth() + + @property + def last_headers(self) -> dict[str, str]: + """Return headers from the last API response.""" + return self._last_headers + + @property + def rate_limit(self) -> tuple[int | None, int | None]: + """Return rate limit (limit, remaining) from the last API response.""" + return (self._last_limit, self._last_remaining) + async def login_device_flow(self) -> DeviceActivationStatus: """Login using device flow.""" if self._device_activation_status != DeviceActivationStatus.NOT_STARTED: @@ -586,6 +620,8 @@ async def _request( request = await session.request( method=method.value, url=str(url), headers=headers, json=data ) + self._last_headers = dict(request.headers) + self._last_limit, self._last_remaining = self._parse_rate_limit() request.raise_for_status() except TimeoutError as err: raise TadoConnectionError( @@ -770,6 +806,23 @@ def _ensure_session(self) -> ClientSession: self._close_session = True return self._session + def _parse_rate_limit(self) -> tuple[int | None, int | None]: + """Parse rate limit from RateLimit headers.""" + limit = None + remaining = None + + if (policy := self._last_headers.get("RateLimit-Policy", "")) and ( + match := re.search(r"quota=(\d+)", policy) + ): + limit = int(match[1]) + + if (rl := self._last_headers.get("RateLimit", "")) and ( + match := re.search(r"remaining=(\d+)", rl) + ): + remaining = int(match[1]) + + return limit, remaining + async def __aenter__(self) -> Self: """Async enter.""" await self.async_init() From 08b8e80b5d22f497f37f7a60d51dde7b7c00bf57 Mon Sep 17 00:00:00 2001 From: Banter240 <199655869+banter240@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:27:55 +0100 Subject: [PATCH 2/3] refactor: simplify rate limit parsing with extract helper Co-authored-by: Erwin Douna --- src/tadoasync/tadoasync.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/tadoasync/tadoasync.py b/src/tadoasync/tadoasync.py index 8320dcb..2adddaf 100644 --- a/src/tadoasync/tadoasync.py +++ b/src/tadoasync/tadoasync.py @@ -808,20 +808,17 @@ def _ensure_session(self) -> ClientSession: def _parse_rate_limit(self) -> tuple[int | None, int | None]: """Parse rate limit from RateLimit headers.""" - limit = None - remaining = None + def extract(pattern: str, value: str) -> int | None: + match = re.search(pattern, value) + return int(match.group(1)) if match else None - if (policy := self._last_headers.get("RateLimit-Policy", "")) and ( - match := re.search(r"quota=(\d+)", policy) - ): - limit = int(match[1]) + policy = self._last_headers.get("RateLimit-Policy", "") + rl = self._last_headers.get("RateLimit", "") - if (rl := self._last_headers.get("RateLimit", "")) and ( - match := re.search(r"remaining=(\d+)", rl) - ): - remaining = int(match[1]) + limit = extract(r"quota=(\d+)", policy) + remaining = extract(r"remaining=(\d+)", rl) - return limit, remaining + return limit, remaining async def __aenter__(self) -> Self: """Async enter.""" From 87132f55b9773283ca9e085006e774bf1f78da0b Mon Sep 17 00:00:00 2001 From: Banter240 <199655869+banter240@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:39:58 +0100 Subject: [PATCH 3/3] fix(rate-limit): update parsing regex for quota and remaining --- src/tadoasync/tadoasync.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/tadoasync/tadoasync.py b/src/tadoasync/tadoasync.py index 2adddaf..8cccb76 100644 --- a/src/tadoasync/tadoasync.py +++ b/src/tadoasync/tadoasync.py @@ -808,17 +808,18 @@ def _ensure_session(self) -> ClientSession: def _parse_rate_limit(self) -> tuple[int | None, int | None]: """Parse rate limit from RateLimit headers.""" - def extract(pattern: str, value: str) -> int | None: - match = re.search(pattern, value) - return int(match.group(1)) if match else None - policy = self._last_headers.get("RateLimit-Policy", "") - rl = self._last_headers.get("RateLimit", "") + def extract(pattern: str, value: str) -> int | None: + match = re.search(pattern, value) + return int(match.group(1)) if match else None - limit = extract(r"quota=(\d+)", policy) - remaining = extract(r"remaining=(\d+)", rl) + policy = self._last_headers.get("RateLimit-Policy", "") + rl = self._last_headers.get("RateLimit", "") - return limit, remaining + limit = extract(r"q=(\d+)", policy) + remaining = extract(r"r=(\d+)", rl) + + return limit, remaining async def __aenter__(self) -> Self: """Async enter."""