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
2 changes: 2 additions & 0 deletions src/tadoasync/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
51 changes: 51 additions & 0 deletions src/tadoasync/tadoasync.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import asyncio
import enum
import logging
import re
import time
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
Expand Down Expand Up @@ -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:
Expand All @@ -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()
Comment on lines +149 to +166
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not be too nitpicky, but we never exposed these before. I suppose this is from your other work? The refresh auth is something I might have doubts on, since the business logic should be that it checks per request if a refresh is needed.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, you're right, this is from my TadoHijack integration.
I need these for TadoX API support. The new Tado X hardware uses a completely separate API at hops.tado.com that's not covered by tadoasync's existing methods. I have to make direct HTTP requests to that API while reusing tadoasync's session and auth:

In TadoXApi class

await self._tado._refresh_auth()  # Refresh before our external request
headers = {"Authorization": f"Bearer {self._tado._access_token}"}
url = f"https://hops.tado.com/homes/{self._tado._home_id}/rooms"
async with self._tado._ensure_session().request(...) as response:
    return await response.json()

Since we're making requests to an external API (outside of tadoasync's _request() flow), we need manual access to the session, auth token, and refresh logic. Currently we're accessing _refresh_auth(), _access_token, _home_id, and _ensure_session() as private attributes, which is why we'd like to see them public.

The refresh_auth() method is needed because we're bypassing tadoasync's normal request flow entirely - we need to manually refresh before our own external API calls.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, that supports the hybrid solution, right?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, exactly. Once we manage to integrate native Tado X support directly into tadoasync, we can refactor this and maybe make them private again. But for now, while I have to handle the Tado X API externally alongside tadoasync (the hybrid approach), it's much safer to have these exposed publicly rather than relying on private _ members.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, I can accept that change.


@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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -770,6 +806,21 @@ 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."""

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", "")

limit = extract(r"q=(\d+)", policy)
remaining = extract(r"r=(\d+)", rl)

return limit, remaining

async def __aenter__(self) -> Self:
"""Async enter."""
await self.async_init()
Expand Down
Loading