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..e1730c5 100644 --- a/src/tadoasync/models.py +++ b/src/tadoasync/models.py @@ -8,6 +8,8 @@ from mashumaro import field_options from mashumaro.mixins.orjson import DataClassORJSONMixin +from tadoasync.const import TadoLine + @dataclass class GetMe(DataClassORJSONMixin): @@ -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.""" diff --git a/src/tadoasync/tadoasync.py b/src/tadoasync/tadoasync.py index 864857c..216341b 100644 --- a/src/tadoasync/tadoasync.py +++ b/src/tadoasync/tadoasync.py @@ -36,6 +36,7 @@ TADO_MODES_TO_HVAC_ACTION, TYPE_AIR_CONDITIONING, HttpMethod, + TadoLine, ) from tadoasync.exceptions import ( TadoAuthenticationError, @@ -49,6 +50,7 @@ Capabilities, Device, GetMe, + Home, HomeState, MobileDevice, TemperatureOffset, @@ -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" @@ -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 @@ -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}") @@ -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: @@ -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, + ) + + 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") @@ -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: @@ -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) @@ -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: diff --git a/tests/__snapshots__/test_tado.ambr b/tests/__snapshots__/test_tado.ambr index 5e1ff42..83413c4 100644 --- a/tests/__snapshots__/test_tado.ambr +++ b/tests/__snapshots__/test_tado.ambr @@ -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': , + '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': , + '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', diff --git a/tests/conftest.py b/tests/conftest.py index 602b2c8..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 @@ -60,6 +60,21 @@ 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"), + ) + responses.get( + f"{TADO_X_URL}/homes/2", + status=200, + body=load_fixture("home_x_hops.json"), + ) @pytest.fixture(name="responses") 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_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/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 +} 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: