From a181142e3c887d4834351ea5cd51d1c9e8c1eb37 Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Wed, 18 Feb 2026 22:53:42 +0100 Subject: [PATCH 1/4] add detection of tado generation --- src/tadoasync/const.py | 9 ++- src/tadoasync/models.py | 107 +++++++++++++++++++++++++++-- src/tadoasync/tadoasync.py | 19 ++++- tests/__snapshots__/test_tado.ambr | 101 +++++++++++++++++++++++++++ tests/conftest.py | 10 +++ tests/fixtures/home_v3.json | 58 ++++++++++++++++ tests/fixtures/home_x.json | 59 ++++++++++++++++ tests/test_tado.py | 32 +++++++++ 8 files changed, 389 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/home_v3.json create mode 100644 tests/fixtures/home_x.json diff --git a/src/tadoasync/const.py b/src/tadoasync/const.py index 26f5171..1ed8aa0 100644 --- a/src/tadoasync/const.py +++ b/src/tadoasync/const.py @@ -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" @@ -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.""" diff --git a/src/tadoasync/models.py b/src/tadoasync/models.py index e11d880..ded62c3 100644 --- a/src/tadoasync/models.py +++ b/src/tadoasync/models.py @@ -3,11 +3,13 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any +from typing import Any, Optional from mashumaro import field_options from mashumaro.mixins.orjson import DataClassORJSONMixin +from tadoasync.const import TadoLine + @dataclass class GetMe(DataClassORJSONMixin): @@ -18,17 +20,114 @@ 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: Optional[str] = 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: Optional[str] = field(metadata=field_options(alias="dateTimeZone")) + date_created: Optional[str] = field(metadata=field_options(alias="dateCreated")) + temperature_unit: Optional[str] = field(metadata=field_options(alias="temperatureUnit")) + partner: Optional[str] = None + simple_smart_schedule_enabled: Optional[bool] = field( + default=None, metadata=field_options(alias="simpleSmartScheduleEnabled") + ) + away_radius_in_meters: Optional[float] = field( + default=None, metadata=field_options(alias="awayRadiusInMeters") + ) + installation_completed: Optional[bool] = field( + default=None, metadata=field_options(alias="installationCompleted") + ) + incident_detection: Optional[IncidentDetection] = field( + default=None, metadata=field_options(alias="incidentDetection") + ) + generation: Optional[TadoLine] = None + zones_count: Optional[int] = field(metadata=field_options(alias="zonesCount"), default=None) + language: Optional[str] = None + prevent_from_subscribing: Optional[bool] = field( + default=None, metadata=field_options(alias="preventFromSubscribing") + ) + skills: Optional[list[str]] = None + christmas_mode_enabled: Optional[bool] = field( + default=None, metadata=field_options(alias="christmasModeEnabled") + ) + show_auto_assist_reminders: Optional[bool] = field( + default=None, metadata=field_options(alias="showAutoAssistReminders") + ) + contact_details: Optional[ContactDetails] = field( + default=None, metadata=field_options(alias="contactDetails") + ) + geolocation: Optional[Geolocation] = None + consent_grant_skippable: Optional[bool] = field( + default=None, metadata=field_options(alias="consentGrantSkippable") + ) + enabled_features: Optional[list[str]] = field( + default=None, metadata=field_options(alias="enabledFeatures") + ) + is_air_comfort_eligible: Optional[bool] = field( + default=None, metadata=field_options(alias="isAirComfortEligible") + ) + is_energy_iq_eligible: Optional[bool] = field( + default=None, metadata=field_options(alias="isEnergyIqEligible") + ) + is_heat_source_installed: Optional[bool] = field( + default=None, metadata=field_options(alias="isHeatSourceInstalled") + ) + is_heat_pump_installed: Optional[bool] = field( + default=None, metadata=field_options(alias="isHeatPumpInstalled") + ) + + @dataclass class DeviceMetadata(DataClassORJSONMixin): """DeviceMetadata model represents the metadata of a device.""" diff --git a/src/tadoasync/tadoasync.py b/src/tadoasync/tadoasync.py index 864857c..66e7597 100644 --- a/src/tadoasync/tadoasync.py +++ b/src/tadoasync/tadoasync.py @@ -34,6 +34,7 @@ CONST_VERTICAL_SWING_OFF, TADO_HVAC_ACTION_TO_MODES, TADO_MODES_TO_HVAC_ACTION, + TadoLine, TYPE_AIR_CONDITIONING, HttpMethod, ) @@ -54,7 +55,7 @@ TemperatureOffset, Weather, Zone, - ZoneState, + ZoneState, Home, ) CLIENT_ID = "1bb50063-6b0c-4d11-bd99-387f4a91cc46" @@ -63,6 +64,7 @@ API_URL = "my.tado.com/api/v2" TADO_HOST_URL = "my.tado.com" TADO_API_PATH = "/api/v2" +TADO_X_HOST_URL = "hops.tado.com" EIQ_URL = "energy-insights.tado.com/api" EIQ_HOST_URL = "energy-insights.tado.com" EIQ_API_PATH = "/api" @@ -108,6 +110,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 @@ -242,6 +245,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}") @@ -305,6 +312,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: @@ -376,6 +387,12 @@ async def get_me(self) -> GetMe: self._me = GetMe.from_json(response) return self._me + async def get_home(self) -> Home: + """Get the homes.""" + response = await self._request(f"homes/{self._home_id}") + obj = orjson.loads(response) + 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") diff --git a/tests/__snapshots__/test_tado.ambr b/tests/__snapshots__/test_tado.ambr index 5e1ff42..eff2362 100644 --- a/tests/__snapshots__/test_tado.ambr +++ b/tests/__snapshots__/test_tado.ambr @@ -528,6 +528,107 @@ }), ]) # --- +# 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': , + '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', + ]), + 'temperature_unit': 'CELSIUS', + 'zones_count': 0, + }) +# --- +# 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': , + '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', + ]), + 'temperature_unit': 'CELSIUS', + 'zones_count': 7, + }) +# --- # name: test_get_home_state dict({ 'presence': 'HOME', diff --git a/tests/conftest.py b/tests/conftest.py index 602b2c8..6fff98b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -60,6 +60,16 @@ def _tado_oauth(responses: aioresponses) -> None: status=200, body=load_fixture("me.json"), ) + responses.get( + f"{TADO_API_URL}/homes/1", + status=200, + body=load_fixture("home_v3.json"), + ) + responses.get( + f"{TADO_API_URL}/homes/2", + status=200, + body=load_fixture("home_x.json"), + ) @pytest.fixture(name="responses") diff --git a/tests/fixtures/home_v3.json b/tests/fixtures/home_v3.json new file mode 100644 index 0000000..f058c03 --- /dev/null +++ b/tests/fixtures/home_v3.json @@ -0,0 +1,58 @@ +{ + "id": 1, + "name": "Test Home v3", + "dateTimeZone": "Europe/Berlin", + "dateCreated": "2017-01-01T12:00:00Z", + "temperatureUnit": "CELSIUS", + "partner": null, + "simpleSmartScheduleEnabled": true, + "awayRadiusInMeters": 250.00, + "installationCompleted": true, + "incidentDetection": { + "supported": true, + "enabled": true + }, + "generation": "PRE_LINE_X", + "zonesCount": 7, + "language": "de-DE", + "skills": [ + "AUTO_ASSIST", + "PRE_2025_FREE_FEATURES" + ], + "christmasModeEnabled": true, + "showAutoAssistReminders": true, + "contactDetails": { + "name": "Test User", + "email": "test@example.com", + "phone": " +498941209569" + }, + "address": { + "addressLine1": "Sapporobogen 6-8", + "addressLine2": null, + "zipCode": "80637", + "city": "München", + "state": null, + "country": "DEU" + }, + "geolocation": { + "latitude": 48.168453, + "longitude": 11.534673 + }, + "consentGrantSkippable": true, + "enabledFeatures": [ + "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" + ], + "isAirComfortEligible": true, + "isEnergyIqEligible": true, + "isHeatSourceInstalled": false, + "isHeatPumpInstalled": false, + "supportsFlowTemperatureOptimization": false +} diff --git a/tests/fixtures/home_x.json b/tests/fixtures/home_x.json new file mode 100644 index 0000000..cdcc127 --- /dev/null +++ b/tests/fixtures/home_x.json @@ -0,0 +1,59 @@ +{ + "id": 2, + "name": "Test Home X", + "dateTimeZone": "Europe/Berlin", + "dateCreated": "2025-01-01T12:00:00Z", + "temperatureUnit": "CELSIUS", + "partner": "AA_LIFETIME_WEBSHOP", + "simpleSmartScheduleEnabled": true, + "awayRadiusInMeters": 200.00, + "installationCompleted": true, + "incidentDetection": { + "supported": true, + "enabled": true + }, + "generation": "LINE_X", + "zonesCount": 0, + "language": "en-US", + "preventFromSubscribing": true, + "skills": [ + "AUTO_ASSIST", + "PRE_2025_FREE_FEATURES" + ], + "christmasModeEnabled": true, + "showAutoAssistReminders": true, + "contactDetails": { + "name": "Test User", + "email": "test@example.com", + "phone": " +498941209569" + }, + "address": { + "addressLine1": "Sapporobogen 6-8", + "addressLine2": null, + "zipCode": "80637", + "city": "München", + "state": null, + "country": "DEU" + }, + "geolocation": { + "latitude": 48.168453, + "longitude": 11.534673 + }, + "consentGrantSkippable": true, + "enabledFeatures": [ + "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" + ], + "isAirComfortEligible": false, + "isEnergyIqEligible": true, + "isHeatSourceInstalled": false, + "isHeatPumpInstalled": false +} diff --git a/tests/test_tado.py b/tests/test_tado.py index 9b29ea7..cce20aa 100644 --- a/tests/test_tado.py +++ b/tests/test_tado.py @@ -14,6 +14,7 @@ from tadoasync import ( Tado, ) +from tadoasync.const import TadoLine from tadoasync.exceptions import ( TadoAuthenticationError, TadoBadRequestError, @@ -69,6 +70,7 @@ async def test_login_success(responses: aioresponses) -> None: assert tado._token_expiry is not None assert tado._token_expiry > time.time() assert tado._refresh_token == "test_refresh_token" + assert tado._tado_line == TadoLine.PRE_LINE_X async def test_login_success_no_session(responses: aioresponses) -> None: @@ -86,6 +88,7 @@ async def test_login_success_no_session(responses: aioresponses) -> None: assert tado._token_expiry is not None assert tado._token_expiry > time.time() assert tado._refresh_token == "test_refresh_token" + assert tado._tado_line == TadoLine.PRE_LINE_X async def test_activation_timeout(responses: aioresponses) -> None: @@ -396,6 +399,35 @@ async def test_get_weather( assert await python_tado.get_weather() == snapshot +async def test_get_home_pre_line_x( + python_tado: Tado, + responses: aioresponses, + snapshot: SnapshotAssertion, +) -> None: + """Test get home for PRE_LINE_X homes.""" + responses.get( + f"{TADO_API_URL}/homes/1", + status=200, + body=load_fixture("home_v3.json"), + ) + assert await python_tado.get_home() == snapshot + + +async def test_get_home_line_x( + python_tado: Tado, + responses: aioresponses, + snapshot: SnapshotAssertion, +) -> None: + """Test get home for LINE_X homes.""" + python_tado._home_id = 2 + responses.get( + f"{TADO_API_URL}/homes/2", + status=200, + body=load_fixture("home_x.json"), + ) + assert await python_tado.get_home() == snapshot + + async def test_get_home_state( python_tado: Tado, responses: aioresponses, snapshot: SnapshotAssertion ) -> None: From be94e3e3d17b35aba295a4ce25983ff3df840269 Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Wed, 18 Feb 2026 22:59:50 +0100 Subject: [PATCH 2/4] plural was wrong here --- src/tadoasync/tadoasync.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tadoasync/tadoasync.py b/src/tadoasync/tadoasync.py index 66e7597..6a73874 100644 --- a/src/tadoasync/tadoasync.py +++ b/src/tadoasync/tadoasync.py @@ -388,7 +388,7 @@ async def get_me(self) -> GetMe: return self._me async def get_home(self) -> Home: - """Get the homes.""" + """Get the home.""" response = await self._request(f"homes/{self._home_id}") obj = orjson.loads(response) return Home.from_dict(obj) From f1db916264fa0d6531a2e686a9397e62703e0a18 Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Wed, 18 Feb 2026 23:04:40 +0100 Subject: [PATCH 3/4] use Union instead of Optional --- src/tadoasync/models.py | 53 ++++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/src/tadoasync/models.py b/src/tadoasync/models.py index ded62c3..87ae653 100644 --- a/src/tadoasync/models.py +++ b/src/tadoasync/models.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Optional +from typing import Any from mashumaro import field_options from mashumaro.mixins.orjson import DataClassORJSONMixin @@ -58,7 +58,7 @@ class Address(DataClassORJSONMixin): state: str zip_code: str = field(metadata=field_options(alias="zipCode")) country: str - address_line2: Optional[str] = field( + address_line2: str | None = field( default=None, metadata=field_options(alias="addressLine2") ) @@ -75,55 +75,58 @@ class Geolocation(DataClassORJSONMixin): class Home(DataClassORJSONMixin): """MyHome model represents the user's home information with full details.""" - date_time_zone: Optional[str] = field(metadata=field_options(alias="dateTimeZone")) - date_created: Optional[str] = field(metadata=field_options(alias="dateCreated")) - temperature_unit: Optional[str] = field(metadata=field_options(alias="temperatureUnit")) - partner: Optional[str] = None - simple_smart_schedule_enabled: Optional[bool] = field( + 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: Optional[float] = field( + away_radius_in_meters: float | None = field( default=None, metadata=field_options(alias="awayRadiusInMeters") ) - installation_completed: Optional[bool] = field( + installation_completed: bool | None = field( default=None, metadata=field_options(alias="installationCompleted") ) - incident_detection: Optional[IncidentDetection] = field( + incident_detection: IncidentDetection | None = field( default=None, metadata=field_options(alias="incidentDetection") ) - generation: Optional[TadoLine] = None - zones_count: Optional[int] = field(metadata=field_options(alias="zonesCount"), default=None) - language: Optional[str] = None - prevent_from_subscribing: Optional[bool] = field( + 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: Optional[list[str]] = None - christmas_mode_enabled: Optional[bool] = field( + skills: list[str] | None = None + christmas_mode_enabled: bool | None = field( default=None, metadata=field_options(alias="christmasModeEnabled") ) - show_auto_assist_reminders: Optional[bool] = field( + show_auto_assist_reminders: bool | None = field( default=None, metadata=field_options(alias="showAutoAssistReminders") ) - contact_details: Optional[ContactDetails] = field( + contact_details: ContactDetails | None = field( default=None, metadata=field_options(alias="contactDetails") ) - geolocation: Optional[Geolocation] = None - consent_grant_skippable: Optional[bool] = field( + geolocation: Geolocation | None = None + consent_grant_skippable: bool | None = field( default=None, metadata=field_options(alias="consentGrantSkippable") ) - enabled_features: Optional[list[str]] = field( + enabled_features: list[str] | None = field( default=None, metadata=field_options(alias="enabledFeatures") ) - is_air_comfort_eligible: Optional[bool] = field( + is_air_comfort_eligible: bool | None = field( default=None, metadata=field_options(alias="isAirComfortEligible") ) - is_energy_iq_eligible: Optional[bool] = field( + is_energy_iq_eligible: bool | None = field( default=None, metadata=field_options(alias="isEnergyIqEligible") ) - is_heat_source_installed: Optional[bool] = field( + is_heat_source_installed: bool | None = field( default=None, metadata=field_options(alias="isHeatSourceInstalled") ) - is_heat_pump_installed: Optional[bool] = field( + is_heat_pump_installed: bool | None = field( default=None, metadata=field_options(alias="isHeatPumpInstalled") ) From dc72ee50e4f77006d3f4e8969ea6145f1df6ad5c Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Wed, 18 Feb 2026 23:18:58 +0100 Subject: [PATCH 4/4] include additional home data for tado X installations and combine --- src/tadoasync/models.py | 8 +++++- src/tadoasync/tadoasync.py | 41 +++++++++++++++++++++++++++--- tests/__snapshots__/test_tado.ambr | 4 ++- tests/conftest.py | 19 +++++++++----- tests/const.py | 1 + tests/fixtures/home_x_hops.json | 5 ++++ 6 files changed, 65 insertions(+), 13 deletions(-) create mode 100644 tests/fixtures/home_x_hops.json diff --git a/src/tadoasync/models.py b/src/tadoasync/models.py index 87ae653..e1730c5 100644 --- a/src/tadoasync/models.py +++ b/src/tadoasync/models.py @@ -77,7 +77,9 @@ class Home(DataClassORJSONMixin): 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")) + 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") @@ -129,6 +131,10 @@ class Home(DataClassORJSONMixin): 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 diff --git a/src/tadoasync/tadoasync.py b/src/tadoasync/tadoasync.py index 6a73874..216341b 100644 --- a/src/tadoasync/tadoasync.py +++ b/src/tadoasync/tadoasync.py @@ -34,9 +34,9 @@ CONST_VERTICAL_SWING_OFF, TADO_HVAC_ACTION_TO_MODES, TADO_MODES_TO_HVAC_ACTION, - TadoLine, TYPE_AIR_CONDITIONING, HttpMethod, + TadoLine, ) from tadoasync.exceptions import ( TadoAuthenticationError, @@ -50,12 +50,13 @@ Capabilities, Device, GetMe, + Home, HomeState, MobileDevice, TemperatureOffset, Weather, Zone, - ZoneState, Home, + ZoneState, ) CLIENT_ID = "1bb50063-6b0c-4d11-bd99-387f4a91cc46" @@ -64,7 +65,7 @@ API_URL = "my.tado.com/api/v2" TADO_HOST_URL = "my.tado.com" TADO_API_PATH = "/api/v2" -TADO_X_HOST_URL = "hops.tado.com" +TADO_X_URL = "hops.tado.com" EIQ_URL = "energy-insights.tado.com/api" EIQ_HOST_URL = "energy-insights.tado.com" EIQ_API_PATH = "/api" @@ -391,6 +392,31 @@ 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, + ) + return Home.from_dict(obj) async def get_devices(self) -> list[Device]: @@ -573,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: @@ -582,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) @@ -601,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: diff --git a/tests/__snapshots__/test_tado.ambr b/tests/__snapshots__/test_tado.ambr index eff2362..83413c4 100644 --- a/tests/__snapshots__/test_tado.ambr +++ b/tests/__snapshots__/test_tado.ambr @@ -575,8 +575,9 @@ 'AUTO_ASSIST', 'PRE_2025_FREE_FEATURES', ]), + 'supports_flow_temperature_optimization': False, 'temperature_unit': 'CELSIUS', - 'zones_count': 0, + 'zones_count': 5, }) # --- # name: test_get_home_pre_line_x @@ -625,6 +626,7 @@ 'AUTO_ASSIST', 'PRE_2025_FREE_FEATURES', ]), + 'supports_flow_temperature_optimization': False, 'temperature_unit': 'CELSIUS', 'zones_count': 7, }) diff --git a/tests/conftest.py b/tests/conftest.py index 6fff98b..87bcec3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,7 @@ from syrupy import SnapshotAssertion from tests import load_fixture -from .const import TADO_API_URL, TADO_DEVICE_AUTH_URL, TADO_TOKEN_URL +from .const import TADO_API_URL, TADO_DEVICE_AUTH_URL, TADO_TOKEN_URL, TADO_X_URL from .syrupy import TadoSnapshotExtension @@ -61,14 +61,19 @@ def _tado_oauth(responses: aioresponses) -> None: body=load_fixture("me.json"), ) responses.get( - f"{TADO_API_URL}/homes/1", - status=200, - body=load_fixture("home_v3.json"), + f"{TADO_API_URL}/homes/1", + status=200, + body=load_fixture("home_v3.json"), ) responses.get( - f"{TADO_API_URL}/homes/2", - status=200, - body=load_fixture("home_x.json"), + f"{TADO_API_URL}/homes/2", + status=200, + body=load_fixture("home_x.json"), + ) + responses.get( + f"{TADO_X_URL}/homes/2", + status=200, + body=load_fixture("home_x_hops.json"), ) diff --git a/tests/const.py b/tests/const.py index dcd74ce..4164079 100644 --- a/tests/const.py +++ b/tests/const.py @@ -1,6 +1,7 @@ """Constants for tests of Python Tado.""" TADO_API_URL = "https://my.tado.com/api/v2" +TADO_X_URL = "https://hops.tado.com" TADO_TOKEN_URL = "https://login.tado.com/oauth2/token" TADO_DEVICE_AUTH_URL = "https://login.tado.com/oauth2/device_authorize" diff --git a/tests/fixtures/home_x_hops.json b/tests/fixtures/home_x_hops.json new file mode 100644 index 0000000..becc8aa --- /dev/null +++ b/tests/fixtures/home_x_hops.json @@ -0,0 +1,5 @@ +{ + "roomCount": 5, + "isHeatPumpInstalled": false, + "supportsFlowTemperatureOptimization": false +}