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
9 changes: 8 additions & 1 deletion src/tadoasync/const.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Constants for the asynchronous Python API for Tado."""

from enum import Enum
from enum import Enum, StrEnum

# Types
TYPE_AIR_CONDITIONING = "AIR_CONDITIONING"
Expand Down Expand Up @@ -92,6 +92,13 @@
DEVICE_DOMAIN = "devices"


class TadoLine(StrEnum):
"""Supported Tado product lines."""

PRE_LINE_X = "PRE_LINE_X"
LINE_X = "LINE_X"


class HttpMethod(Enum):
"""HTTP methods."""

Expand Down
114 changes: 111 additions & 3 deletions src/tadoasync/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from mashumaro import field_options
from mashumaro.mixins.orjson import DataClassORJSONMixin

from tadoasync.const import TadoLine


@dataclass
class GetMe(DataClassORJSONMixin):
Expand All @@ -18,17 +20,123 @@ class GetMe(DataClassORJSONMixin):
id: str
username: str
locale: str
homes: list[Home]
homes: list[MeHome]


@dataclass
class Home(DataClassORJSONMixin):
"""Home model represents the user's home information."""
class MeHome(DataClassORJSONMixin):
"""MeHome model represents the user's (basic) home information."""

id: int
name: str


@dataclass
class IncidentDetection(DataClassORJSONMixin):
"""IncidentDetection model represents the incident detection settings of a home."""

supported: bool
enabled: bool


@dataclass
class ContactDetails(DataClassORJSONMixin):
"""ContactDetails model represents the contact information of a home."""

name: str
email: str
phone: str


@dataclass
class Address(DataClassORJSONMixin):
"""Address model represents the address information of a home."""

address_line1: str = field(metadata=field_options(alias="addressLine1"))

city: str
state: str
zip_code: str = field(metadata=field_options(alias="zipCode"))
country: str
address_line2: str | None = field(
default=None, metadata=field_options(alias="addressLine2")
)


@dataclass
class Geolocation(DataClassORJSONMixin):
"""Geolocation model represents the geolocation information of a home."""

latitude: float
longitude: float


@dataclass
class Home(DataClassORJSONMixin):
"""MyHome model represents the user's home information with full details."""

date_time_zone: str | None = field(metadata=field_options(alias="dateTimeZone"))
date_created: str | None = field(metadata=field_options(alias="dateCreated"))
temperature_unit: str | None = field(
metadata=field_options(alias="temperatureUnit")
)
partner: str | None = None
simple_smart_schedule_enabled: bool | None = field(
default=None, metadata=field_options(alias="simpleSmartScheduleEnabled")
)
away_radius_in_meters: float | None = field(
default=None, metadata=field_options(alias="awayRadiusInMeters")
)
installation_completed: bool | None = field(
default=None, metadata=field_options(alias="installationCompleted")
)
incident_detection: IncidentDetection | None = field(
default=None, metadata=field_options(alias="incidentDetection")
)
generation: TadoLine | None = None
zones_count: int | None = field(
metadata=field_options(alias="zonesCount"),
default=None,
)
language: str | None = None
prevent_from_subscribing: bool | None = field(
default=None, metadata=field_options(alias="preventFromSubscribing")
)
skills: list[str] | None = None
christmas_mode_enabled: bool | None = field(
default=None, metadata=field_options(alias="christmasModeEnabled")
)
show_auto_assist_reminders: bool | None = field(
default=None, metadata=field_options(alias="showAutoAssistReminders")
)
contact_details: ContactDetails | None = field(
default=None, metadata=field_options(alias="contactDetails")
)
geolocation: Geolocation | None = None
consent_grant_skippable: bool | None = field(
default=None, metadata=field_options(alias="consentGrantSkippable")
)
enabled_features: list[str] | None = field(
default=None, metadata=field_options(alias="enabledFeatures")
)
is_air_comfort_eligible: bool | None = field(
default=None, metadata=field_options(alias="isAirComfortEligible")
)
is_energy_iq_eligible: bool | None = field(
default=None, metadata=field_options(alias="isEnergyIqEligible")
)
is_heat_source_installed: bool | None = field(
default=None, metadata=field_options(alias="isHeatSourceInstalled")
)
is_heat_pump_installed: bool | None = field(
default=None, metadata=field_options(alias="isHeatPumpInstalled")
)
supports_flow_temperature_optimization: bool | None = field(
default=None,
metadata=field_options(alias="supportsFlowTemperatureOptimization"),
)


@dataclass
class DeviceMetadata(DataClassORJSONMixin):
"""DeviceMetadata model represents the metadata of a device."""
Expand Down
52 changes: 51 additions & 1 deletion src/tadoasync/tadoasync.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
TADO_MODES_TO_HVAC_ACTION,
TYPE_AIR_CONDITIONING,
HttpMethod,
TadoLine,
)
from tadoasync.exceptions import (
TadoAuthenticationError,
Expand All @@ -49,6 +50,7 @@
Capabilities,
Device,
GetMe,
Home,
HomeState,
MobileDevice,
TemperatureOffset,
Expand All @@ -63,6 +65,7 @@
API_URL = "my.tado.com/api/v2"
TADO_HOST_URL = "my.tado.com"
TADO_API_PATH = "/api/v2"
TADO_X_URL = "hops.tado.com"
EIQ_URL = "energy-insights.tado.com/api"
EIQ_HOST_URL = "energy-insights.tado.com"
EIQ_API_PATH = "/api"
Expand Down Expand Up @@ -108,6 +111,7 @@ def __init__(
self._home_id: int | None = None
self._me: GetMe | None = None
self._auto_geofencing_supported: bool | None = None
self._tado_line: TadoLine | None = None

self._user_code: str | None = None
self._device_verification_url: str | None = None
Expand Down Expand Up @@ -242,6 +246,10 @@ async def _check_device_activation(self) -> bool:
get_me = await self.get_me()
self._home_id = get_me.homes[0].id

# request home details to determine tado generation (v3 or X)
home = await self.get_home()
self._tado_line = home.generation

return True

raise TadoError(f"Login failed. Reason: {request.reason}")
Expand Down Expand Up @@ -305,6 +313,10 @@ async def login(self) -> None:
get_me = await self.get_me()
self._home_id = get_me.homes[0].id

# request home details to determine tado generation (v3 or X)
home = await self.get_home()
self._tado_line = home.generation

async def check_request_status(
self, response_error: ClientResponseError, *, login: bool = False
) -> None:
Expand Down Expand Up @@ -376,6 +388,37 @@ async def get_me(self) -> GetMe:
self._me = GetMe.from_json(response)
return self._me

async def get_home(self) -> Home:
"""Get the home."""
response = await self._request(f"homes/{self._home_id}")
obj = orjson.loads(response)

# For Tado X, enrich the home payload from hops.tado.com.
# The X payload provides roomCount, which maps to zonesCount in our model.
if obj.get("generation") == TadoLine.LINE_X.value:
try:
x_response = await self._request(
f"homes/{self._home_id}",
endpoint=TADO_X_URL,
)
x_obj = orjson.loads(x_response)

if "roomCount" in x_obj:
obj["zonesCount"] = x_obj["roomCount"]
if "isHeatPumpInstalled" in x_obj:
obj["isHeatPumpInstalled"] = x_obj["isHeatPumpInstalled"]
if "supportsFlowTemperatureOptimization" in x_obj:
obj["supportsFlowTemperatureOptimization"] = x_obj[
"supportsFlowTemperatureOptimization"
]
except TadoError as err:
_LOGGER.debug(
"Failed to enrich Tado X home data from hops endpoint: %s",
err,
)
Comment on lines +398 to +418
Copy link
Collaborator

Choose a reason for hiding this comment

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

on one hand it will create a lot of extra code for every method and for every change in the api. I am naturally more a fan of abstraction here, but adding the complete overhead we added in PyTado seems also not the right way to solve it.

Copy link
Collaborator

Choose a reason for hiding this comment

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

What if we put this "enricher" code into its own class and then call only an "EnricherFactory" within it, which would then call the actual enricher class based on the "generation" and simply modify the current object?

Then the code is separated, we can call the enricher wherever we need it, and we don't clutter up our methods so much!

Could be a main enricher we create on first contact, because this "generation" won't change for the complete api of this home!


return Home.from_dict(obj)

async def get_devices(self) -> list[Device]:
"""Get the devices."""
response = await self._request(f"homes/{self._home_id}/devices")
Expand Down Expand Up @@ -556,6 +599,7 @@ async def _request(
self,
uri: str | None = None,
endpoint: str = API_URL,
params: dict[str, str] | None = None,
data: dict[str, object] | None = None,
method: HttpMethod = HttpMethod.GET,
) -> str:
Expand All @@ -565,6 +609,8 @@ async def _request(
url = URL.build(scheme="https", host=TADO_HOST_URL, path=TADO_API_PATH)
if endpoint == EIQ_HOST_URL:
url = URL.build(scheme="https", host=EIQ_HOST_URL, path=EIQ_API_PATH)
elif endpoint == TADO_X_URL:
url = URL.build(scheme="https", host=TADO_X_URL)

if uri:
url = url.joinpath(uri)
Expand All @@ -584,7 +630,11 @@ async def _request(
async with asyncio.timeout(self._request_timeout):
session = self._ensure_session()
request = await session.request(
method=method.value, url=str(url), headers=headers, json=data
method=method.value,
url=str(url),
params=params,
headers=headers,
json=data,
)
request.raise_for_status()
except asyncio.TimeoutError as err:
Expand Down
103 changes: 103 additions & 0 deletions tests/__snapshots__/test_tado.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,109 @@
}),
])
# ---
# name: test_get_home_line_x
dict({
'away_radius_in_meters': 200.0,
'christmas_mode_enabled': True,
'consent_grant_skippable': True,
'contact_details': dict({
'email': 'test@example.com',
'name': 'Test User',
'phone': ' +498941209569',
}),
'date_created': '2025-01-01T12:00:00Z',
'date_time_zone': 'Europe/Berlin',
'enabled_features': list([
'AA_REVERSE_TRIAL_7D',
'ADAPTIVE_HEATING',
'AI_ASSIST_MESSAGING_ENABLED',
'AI_ASSIST_OVERVIEW',
'AI_PREHEATING_V2',
'CUSTOM_THREAD_NETWORK_FLOW',
'HOLIDAY_MODE',
'ONE_WEBVIEW',
'PREHEATING_GEOFENCING_LINE_X',
'TABBAR_IN_WEBVIEW',
]),
'generation': <TadoLine.LINE_X: 'LINE_X'>,
'geolocation': dict({
'latitude': 48.168453,
'longitude': 11.534673,
}),
'incident_detection': dict({
'enabled': True,
'supported': True,
}),
'installation_completed': True,
'is_air_comfort_eligible': False,
'is_energy_iq_eligible': True,
'is_heat_pump_installed': False,
'is_heat_source_installed': False,
'language': 'en-US',
'partner': 'AA_LIFETIME_WEBSHOP',
'prevent_from_subscribing': True,
'show_auto_assist_reminders': True,
'simple_smart_schedule_enabled': True,
'skills': list([
'AUTO_ASSIST',
'PRE_2025_FREE_FEATURES',
]),
'supports_flow_temperature_optimization': False,
'temperature_unit': 'CELSIUS',
'zones_count': 5,
})
# ---
# name: test_get_home_pre_line_x
dict({
'away_radius_in_meters': 250.0,
'christmas_mode_enabled': True,
'consent_grant_skippable': True,
'contact_details': dict({
'email': 'test@example.com',
'name': 'Test User',
'phone': ' +498941209569',
}),
'date_created': '2017-01-01T12:00:00Z',
'date_time_zone': 'Europe/Berlin',
'enabled_features': list([
'ADAPTIVE_HEATING',
'AI_ASSIST_MESSAGING_ENABLED',
'AI_ASSIST_OVERVIEW',
'AI_PREHEATING_V2',
'ASSIST_BANNER_TEST_HIDE_DISMISSAL',
'CUSTOM_THREAD_NETWORK_FLOW',
'HOLIDAY_MODE',
'ONE_WEBVIEW',
'TABBAR_IN_WEBVIEW',
]),
'generation': <TadoLine.PRE_LINE_X: 'PRE_LINE_X'>,
'geolocation': dict({
'latitude': 48.168453,
'longitude': 11.534673,
}),
'incident_detection': dict({
'enabled': True,
'supported': True,
}),
'installation_completed': True,
'is_air_comfort_eligible': True,
'is_energy_iq_eligible': True,
'is_heat_pump_installed': False,
'is_heat_source_installed': False,
'language': 'de-DE',
'partner': None,
'prevent_from_subscribing': None,
'show_auto_assist_reminders': True,
'simple_smart_schedule_enabled': True,
'skills': list([
'AUTO_ASSIST',
'PRE_2025_FREE_FEATURES',
]),
'supports_flow_temperature_optimization': False,
'temperature_unit': 'CELSIUS',
'zones_count': 7,
})
# ---
# name: test_get_home_state
dict({
'presence': 'HOME',
Expand Down
Loading
Loading