From 6b6873e2c2d092a2b34c128ef0733b0e0c25db0f Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Tue, 24 Feb 2026 15:33:46 +0100 Subject: [PATCH 01/10] add unified devices method --- src/tadoasync/api_v3.py | 43 +++ src/tadoasync/api_x.py | 23 ++ src/tadoasync/const.py | 9 + src/tadoasync/models_unified.py | 63 ++++ src/tadoasync/{models.py => models_v3.py} | 2 +- src/tadoasync/models_x.py | 80 +++++ src/tadoasync/tadoasync.py | 54 ++- tests/__snapshots__/test_models.ambr | 45 +++ tests/__snapshots__/test_models_unified.ambr | 45 +++ tests/__snapshots__/test_tado.ambr | 143 ++++++++ tests/__snapshots__/test_tado_v3.ambr | 355 +++++++++++++++++++ tests/__snapshots__/test_tado_x.ambr | 209 +++++++++++ tests/const.py | 1 + tests/fixtures/LINE_X/roomsAndDevices.json | 174 +++++++++ tests/test_models_unified.py | 59 +++ tests/test_tado.py | 69 +++- tests/test_tado_v3.py | 80 +++++ tests/test_tado_x.py | 41 +++ 18 files changed, 1492 insertions(+), 3 deletions(-) create mode 100644 src/tadoasync/api_v3.py create mode 100644 src/tadoasync/api_x.py create mode 100644 src/tadoasync/models_unified.py rename src/tadoasync/{models.py => models_v3.py} (99%) create mode 100644 src/tadoasync/models_x.py create mode 100644 tests/__snapshots__/test_models.ambr create mode 100644 tests/__snapshots__/test_models_unified.ambr create mode 100644 tests/__snapshots__/test_tado_v3.ambr create mode 100644 tests/__snapshots__/test_tado_x.ambr create mode 100644 tests/fixtures/LINE_X/roomsAndDevices.json create mode 100644 tests/test_models_unified.py create mode 100644 tests/test_tado_v3.py create mode 100644 tests/test_tado_x.py diff --git a/src/tadoasync/api_v3.py b/src/tadoasync/api_v3.py new file mode 100644 index 0000000..b709ec0 --- /dev/null +++ b/src/tadoasync/api_v3.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import orjson + +from tadoasync.models_v3 import TemperatureOffset, Zone, Device + +if TYPE_CHECKING: + from tadoasync.tadoasync import Tado + + +class ApiV3: + def __init__(self, base: Tado): + self._base = base + + async def get_zones(self) -> list[Zone]: + """Get zones.""" + response = await self._base._request( + uri=f"homes/{self._base._home_id}/zones", + ) + obj = orjson.loads(response) + return [Zone.from_dict(zone) for zone in obj] + + async def get_devices(self) -> list[Device]: + """Get devices.""" + response = await self._base._request( + uri=f"homes/{self._base._home_id}/devices", + ) + obj = orjson.loads(response) + return [Device.from_dict(device) for device in obj] + + async def get_device(self, serial_no: str) -> Device: + """Get device.""" + response = await self._base._request( + uri=f"homes/{self._base._home_id}/devices/{serial_no}", + ) + return Device.from_json(response) + + async def get_device_temperature_offset(self, serial_no: str) -> TemperatureOffset: + """Get the device temperature offset.""" + response = await self._base._request(f"devices/{serial_no}/temperatureOffset") + return TemperatureOffset.from_json(response) diff --git a/src/tadoasync/api_x.py b/src/tadoasync/api_x.py new file mode 100644 index 0000000..d0a48b3 --- /dev/null +++ b/src/tadoasync/api_x.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from tadoasync.models_x import RoomsAndDevices + +API_URL = "hops.tado.com" + +if TYPE_CHECKING: + from tadoasync.tadoasync import Tado + + +class ApiX: + def __init__(self, base: Tado): + self._base = base + + async def get_rooms_and_devices(self) -> RoomsAndDevices: + """Get rooms and devices.""" + response = await self._base._request( + endpoint=API_URL, + uri=f"homes/{self._base._home_id}/roomsAndDevices", + ) + return RoomsAndDevices.from_json(response) diff --git a/src/tadoasync/const.py b/src/tadoasync/const.py index 26f5171..4ca2e9f 100644 --- a/src/tadoasync/const.py +++ b/src/tadoasync/const.py @@ -83,6 +83,8 @@ CONST_HVAC_COOL: CONST_MODE_COOL, } +INSIDE_TEMPERATURE_MEASUREMENT = "INSIDE_TEMPERATURE_MEASUREMENT" + # These modes will not allow a temp to be set TADO_MODES_WITH_NO_TEMP_SETTING = [CONST_MODE_AUTO, CONST_MODE_DRY, CONST_MODE_FAN] @@ -99,3 +101,10 @@ class HttpMethod(Enum): POST = "POST" PUT = "PUT" DELETE = "DELETE" + + +class TadoLine(Enum): + """Supported Tado product lines.""" + + PRE_LINE_X = "PRE_LINE_X" + LINE_X = "LINE_X" diff --git a/src/tadoasync/models_unified.py b/src/tadoasync/models_unified.py new file mode 100644 index 0000000..b14a3bd --- /dev/null +++ b/src/tadoasync/models_unified.py @@ -0,0 +1,63 @@ +"""Abstract models for interaction with the Tado API, regardless of line.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from mashumaro import field_options +from mashumaro.mixins.orjson import DataClassORJSONMixin + +from tadoasync.const import TadoLine + +import tadoasync.models_v3 as models_v3 +import tadoasync.models_x as models_x + + +@dataclass +class Device(DataClassORJSONMixin): + """Device model.""" + + device_type: str + serial: str + firmware_version: str + + connection_state: bool + + battery_state: str | None # Todo: Enum + + temperature_offset: float | None = None + + child_lock_enabled: bool | None = None + + @classmethod + def from_v3( + cls, + v3_device: models_v3.Device, + offset: models_v3.TemperatureOffset | None = None, + ) -> Device: + """Create a device from the v3 API.""" + return cls( + device_type=v3_device.device_type, + serial=v3_device.serial_no, + firmware_version=v3_device.current_fw_version, + temperature_offset=offset.celsius if offset else None, + connection_state=v3_device.connection_state.value, + battery_state=v3_device.battery_state, + child_lock_enabled=v3_device.child_lock_enabled, + ) + + @classmethod + def from_x( + cls, + x_device: models_x.Device, + ) -> Device: + """Create a device from the X API.""" + return cls( + device_type=x_device.type, + serial=x_device.serial_number, + firmware_version=x_device.firmware_version, + connection_state=x_device.connection.state == "CONNECTED", + battery_state=x_device.battery_state, + child_lock_enabled=x_device.child_lock_enabled, + ) diff --git a/src/tadoasync/models.py b/src/tadoasync/models_v3.py similarity index 99% rename from src/tadoasync/models.py rename to src/tadoasync/models_v3.py index e11d880..6494ad2 100644 --- a/src/tadoasync/models.py +++ b/src/tadoasync/models_v3.py @@ -1,4 +1,4 @@ -"""Models for the Tado API.""" +"""Models for the Tado v3 API.""" from __future__ import annotations diff --git a/src/tadoasync/models_x.py b/src/tadoasync/models_x.py new file mode 100644 index 0000000..431b17d --- /dev/null +++ b/src/tadoasync/models_x.py @@ -0,0 +1,80 @@ +"""Models for the Tado X API.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from mashumaro import field_options +from mashumaro.mixins.orjson import DataClassORJSONMixin + +from tadoasync.const import TadoLine + + +@dataclass +class DeviceManualControlTermination(DataClassORJSONMixin): + """DeviceManualControlTermination model represents the manual control termination settings of a device.""" + + type: str # TODO: Enum + duration_in_seconds: int | None = field( + metadata=field_options(alias="durationInSeconds") + ) + + +@dataclass +class Connection(DataClassORJSONMixin): + """Connection model represents the connection information of a device.""" + + state: str + + +@dataclass +class Device(DataClassORJSONMixin): + """Device model represents the device information.""" + + serial_number: str = field(metadata=field_options(alias="serialNumber")) + type: str + firmware_version: str = field(metadata=field_options(alias="firmwareVersion")) + connection: Connection + battery_state: str | None = field( + default=None, metadata=field_options(alias="batteryState") + ) + temperature_as_measured: float | None = field( + default=None, metadata=field_options(alias="temperatureAsMeasured") + ) + temperature_offset: float | None = field( + default=None, metadata=field_options(alias="temperatureOffset") + ) + mounting_state: str | None = field( + default=None, metadata=field_options(alias="mountingState") + ) + child_lock_enabled: bool | None = field( + default=None, metadata=field_options(alias="childLockEnabled") + ) + + +@dataclass +class Room(DataClassORJSONMixin): + """Room model represents the room information of a home.""" + + room_id: int = field(metadata=field_options(alias="roomId")) + room_name: str = field(metadata=field_options(alias="roomName")) + device_manual_control_termination: DeviceManualControlTermination = field( + metadata=field_options(alias="deviceManualControlTermination") + ) + devices: list[Device] + zone_controller_assignable: bool = field( + metadata=field_options(alias="zoneControllerAssignable") + ) + zone_controllers: list[Any] = field( + metadata=field_options(alias="zoneControllers") + ) # ToDo: Define ZoneController model + room_link_available: bool = field(metadata=field_options(alias="roomLinkAvailable")) + + +@dataclass +class RoomsAndDevices(DataClassORJSONMixin): + """RoomsAndDevices model represents the rooms and devices information of a home.""" + + rooms: list[Room] + other_devices: list[Device] = field(metadata=field_options(alias="otherDevices")) diff --git a/src/tadoasync/tadoasync.py b/src/tadoasync/tadoasync.py index c1f1ad2..ceef7c8 100644 --- a/src/tadoasync/tadoasync.py +++ b/src/tadoasync/tadoasync.py @@ -15,6 +15,9 @@ import orjson from aiohttp import ClientResponseError from aiohttp.client import ClientSession +from tadoasync import models_unified as unified_models +from tadoasync.api_v3 import ApiV3 +from tadoasync.api_x import ApiX from yarl import URL from tadoasync.const import ( @@ -35,7 +38,9 @@ TADO_HVAC_ACTION_TO_MODES, TADO_MODES_TO_HVAC_ACTION, TYPE_AIR_CONDITIONING, + INSIDE_TEMPERATURE_MEASUREMENT, HttpMethod, + TadoLine, ) from tadoasync.exceptions import ( TadoAuthenticationError, @@ -45,7 +50,7 @@ TadoForbiddenError, TadoReadingError, ) -from tadoasync.models import ( +from tadoasync.models_v3 import ( Capabilities, Device, GetMe, @@ -108,6 +113,10 @@ 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.api_x = ApiX(self) + self.api_v3 = ApiV3(self) self._user_code: str | None = None self._device_verification_url: str | None = None @@ -529,6 +538,42 @@ async def get_device_info( response = await self._request(f"devices/{serial_no}/") return Device.from_json(response) + async def get_unified_devices(self) -> list[unified_models.Device]: + if self._tado_line == TadoLine.PRE_LINE_X: + devices = await self.get_devices() + devices_unified = [] + if not devices: + raise TadoError("No devices found for the home") + for device in devices: + offset = None + if ( + INSIDE_TEMPERATURE_MEASUREMENT + in device.characteristics.capabilities + ): + try: + offset = await self.api_v3.get_device_temperature_offset( + device.serial_no, + ) + except TadoError as err: + _LOGGER.warning( + "Failed to get temperature offset for device %s: %s", + device.serial_no, + err, + ) + devices_unified.append(unified_models.Device.from_v3(device, offset)) + return devices_unified + elif self._tado_line == TadoLine.LINE_X: + rooms_and_devices = await self.api_x.get_rooms_and_devices() + devices_unified = [] + for room in rooms_and_devices.rooms: + for device in room.devices: + devices_unified.append(unified_models.Device.from_x(device)) + for device in rooms_and_devices.other_devices: + devices_unified.append(unified_models.Device.from_x(device)) + return devices_unified + else: + raise TadoError("Tado Line not set. Cannot get unified devices.") + async def set_child_lock(self, serial_no: str, *, child_lock: bool) -> None: """Set the child lock.""" await self._request( @@ -565,6 +610,11 @@ 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 != API_URL: + endpoint_url = ( + endpoint if endpoint.startswith("http") else f"https://{endpoint}" + ) + url = URL(endpoint_url) if uri: url = url.joinpath(uri) @@ -594,6 +644,8 @@ async def _request( except ClientResponseError as err: await self.check_request_status(err) + _LOGGER.debug(f"Request to {url} returned headers: {request.headers}") + return await request.text() async def update_zone_data(self, data: ZoneState) -> None: # pylint: disable=too-many-branches diff --git a/tests/__snapshots__/test_models.ambr b/tests/__snapshots__/test_models.ambr new file mode 100644 index 0000000..194b7d3 --- /dev/null +++ b/tests/__snapshots__/test_models.ambr @@ -0,0 +1,45 @@ +# serializer version: 1 +# name: test_device_from_v3_with_offset + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': True, + 'connection_state': True, + 'device_type': 'VA02', + 'firmware_version': '95.1', + 'serial': 'SerialNo2', + 'temperature_offset': 0.0, + }) +# --- +# name: test_device_from_v3_without_offset + dict({ + 'battery_state': None, + 'child_lock_enabled': None, + 'connection_state': True, + 'device_type': 'IB01', + 'firmware_version': '92.1', + 'serial': 'SerialNo1', + 'temperature_offset': None, + }) +# --- +# name: test_device_from_x_connected + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': None, + 'connection_state': True, + 'device_type': 'SU04', + 'firmware_version': '287.1', + 'serial': 'SU0000000000', + 'temperature_offset': None, + }) +# --- +# name: test_device_from_x_disconnected + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': False, + 'connection_state': False, + 'device_type': 'VA04', + 'firmware_version': '280.1', + 'serial': 'VA0000000006', + 'temperature_offset': None, + }) +# --- diff --git a/tests/__snapshots__/test_models_unified.ambr b/tests/__snapshots__/test_models_unified.ambr new file mode 100644 index 0000000..194b7d3 --- /dev/null +++ b/tests/__snapshots__/test_models_unified.ambr @@ -0,0 +1,45 @@ +# serializer version: 1 +# name: test_device_from_v3_with_offset + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': True, + 'connection_state': True, + 'device_type': 'VA02', + 'firmware_version': '95.1', + 'serial': 'SerialNo2', + 'temperature_offset': 0.0, + }) +# --- +# name: test_device_from_v3_without_offset + dict({ + 'battery_state': None, + 'child_lock_enabled': None, + 'connection_state': True, + 'device_type': 'IB01', + 'firmware_version': '92.1', + 'serial': 'SerialNo1', + 'temperature_offset': None, + }) +# --- +# name: test_device_from_x_connected + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': None, + 'connection_state': True, + 'device_type': 'SU04', + 'firmware_version': '287.1', + 'serial': 'SU0000000000', + 'temperature_offset': None, + }) +# --- +# name: test_device_from_x_disconnected + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': False, + 'connection_state': False, + 'device_type': 'VA04', + 'firmware_version': '280.1', + 'serial': 'VA0000000006', + 'temperature_offset': None, + }) +# --- diff --git a/tests/__snapshots__/test_tado.ambr b/tests/__snapshots__/test_tado.ambr index 5e1ff42..824126f 100644 --- a/tests/__snapshots__/test_tado.ambr +++ b/tests/__snapshots__/test_tado.ambr @@ -528,6 +528,149 @@ }), ]) # --- +# name: test_get_devices_unified_v3 + list([ + dict({ + 'battery_state': None, + 'child_lock_enabled': None, + 'connection_state': True, + 'device_type': 'IB01', + 'firmware_version': '92.1', + 'serial': 'SerialNo1', + 'temperature_offset': None, + }), + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': True, + 'connection_state': True, + 'device_type': 'VA02', + 'firmware_version': '95.1', + 'serial': 'SerialNo2', + 'temperature_offset': 0.0, + }), + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': True, + 'connection_state': True, + 'device_type': 'VA02', + 'firmware_version': '95.1', + 'serial': 'SerialNo3', + 'temperature_offset': 0.0, + }), + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': True, + 'connection_state': True, + 'device_type': 'VA02', + 'firmware_version': '95.1', + 'serial': 'SerialNo4', + 'temperature_offset': 0.0, + }), + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': True, + 'connection_state': True, + 'device_type': 'VA02', + 'firmware_version': '95.1', + 'serial': 'SerialNo5', + 'temperature_offset': 0.0, + }), + ]) +# --- +# name: test_get_devices_unified_x + list([ + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': None, + 'connection_state': True, + 'device_type': 'SU04', + 'firmware_version': '287.1', + 'serial': 'SU0000000000', + 'temperature_offset': None, + }), + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': False, + 'connection_state': True, + 'device_type': 'VA04', + 'firmware_version': '280.1', + 'serial': 'VA0000000001', + 'temperature_offset': None, + }), + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': False, + 'connection_state': True, + 'device_type': 'VA04', + 'firmware_version': '280.1', + 'serial': 'VA0000000002', + 'temperature_offset': None, + }), + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': False, + 'connection_state': True, + 'device_type': 'VA04', + 'firmware_version': '280.1', + 'serial': 'VA0000000003', + 'temperature_offset': None, + }), + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': False, + 'connection_state': True, + 'device_type': 'VA04', + 'firmware_version': '280.1', + 'serial': 'VA0000000004', + 'temperature_offset': None, + }), + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': False, + 'connection_state': True, + 'device_type': 'VA04', + 'firmware_version': '280.1', + 'serial': 'VA0000000005', + 'temperature_offset': None, + }), + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': False, + 'connection_state': False, + 'device_type': 'VA04', + 'firmware_version': '280.1', + 'serial': 'VA0000000006', + 'temperature_offset': None, + }), + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': False, + 'connection_state': True, + 'device_type': 'VA04', + 'firmware_version': '280.1', + 'serial': 'VA0000000007', + 'temperature_offset': None, + }), + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': False, + 'connection_state': True, + 'device_type': 'VA04', + 'firmware_version': '280.1', + 'serial': 'VA0000000008', + 'temperature_offset': None, + }), + dict({ + 'battery_state': None, + 'child_lock_enabled': None, + 'connection_state': True, + 'device_type': 'IB02', + 'firmware_version': '279.1', + 'serial': 'IB0000000000', + 'temperature_offset': None, + }), + ]) +# --- # name: test_get_home_state dict({ 'presence': 'HOME', diff --git a/tests/__snapshots__/test_tado_v3.ambr b/tests/__snapshots__/test_tado_v3.ambr new file mode 100644 index 0000000..f41504d --- /dev/null +++ b/tests/__snapshots__/test_tado_v3.ambr @@ -0,0 +1,355 @@ +# serializer version: 1 +# name: test_get_device + dict({ + 'battery_state': None, + 'characteristics': dict({ + 'capabilities': list([ + 'RADIO_ENCRYPTION_KEY_ACCESS', + ]), + }), + 'child_lock_enabled': None, + 'connection_state': dict({ + 'timestamp': '2024-11-18T21:41:49.404Z', + 'value': True, + }), + 'current_fw_version': '92.1', + 'device_type': 'IB01', + 'in_pairing_mode': False, + 'mounting_state': None, + 'mounting_state_with_error': None, + 'orientation': None, + 'serial_no': 'SerialNo1', + 'short_serial_no': 'ShortSerialNo1', + }) +# --- +# name: test_get_device_temperature_offset + dict({ + 'celsius': 0.0, + 'fahrenheit': 0.0, + }) +# --- +# name: test_get_devices + list([ + dict({ + 'battery_state': None, + 'characteristics': dict({ + 'capabilities': list([ + 'RADIO_ENCRYPTION_KEY_ACCESS', + ]), + }), + 'child_lock_enabled': None, + 'connection_state': dict({ + 'timestamp': '2024-02-27T20:13:45.407Z', + 'value': True, + }), + 'current_fw_version': '92.1', + 'device_type': 'IB01', + 'in_pairing_mode': False, + 'mounting_state': None, + 'mounting_state_with_error': None, + 'orientation': None, + 'serial_no': 'SerialNo1', + 'short_serial_no': 'ShortSerialNo1', + }), + dict({ + 'battery_state': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'child_lock_enabled': True, + 'connection_state': dict({ + 'timestamp': '2024-02-27T20:20:20.921Z', + 'value': True, + }), + 'current_fw_version': '95.1', + 'device_type': 'VA02', + 'in_pairing_mode': None, + 'mounting_state': dict({ + 'timestamp': '2024-02-19T17:23:30.537Z', + 'value': 'CALIBRATED', + }), + 'mounting_state_with_error': 'CALIBRATED', + 'orientation': 'HORIZONTAL', + 'serial_no': 'SerialNo2', + 'short_serial_no': 'ShortSerialNo2', + }), + dict({ + 'battery_state': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'child_lock_enabled': True, + 'connection_state': dict({ + 'timestamp': '2024-02-27T20:23:13.342Z', + 'value': True, + }), + 'current_fw_version': '95.1', + 'device_type': 'VA02', + 'in_pairing_mode': None, + 'mounting_state': dict({ + 'timestamp': '2024-02-26T13:33:10.647Z', + 'value': 'CALIBRATED', + }), + 'mounting_state_with_error': 'CALIBRATED', + 'orientation': 'HORIZONTAL', + 'serial_no': 'SerialNo3', + 'short_serial_no': 'ShortSerialNo3', + }), + dict({ + 'battery_state': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'child_lock_enabled': True, + 'connection_state': dict({ + 'timestamp': '2024-02-27T20:23:02.849Z', + 'value': True, + }), + 'current_fw_version': '95.1', + 'device_type': 'VA02', + 'in_pairing_mode': None, + 'mounting_state': dict({ + 'timestamp': '2023-10-09T15:39:08.131Z', + 'value': 'CALIBRATED', + }), + 'mounting_state_with_error': 'CALIBRATED', + 'orientation': 'HORIZONTAL', + 'serial_no': 'SerialNo4', + 'short_serial_no': 'ShortSerialNo4', + }), + dict({ + 'battery_state': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'child_lock_enabled': True, + 'connection_state': dict({ + 'timestamp': '2024-02-27T20:16:13.890Z', + 'value': True, + }), + 'current_fw_version': '95.1', + 'device_type': 'VA02', + 'in_pairing_mode': None, + 'mounting_state': dict({ + 'timestamp': '2024-02-04T10:17:00.266Z', + 'value': 'CALIBRATED', + }), + 'mounting_state_with_error': 'CALIBRATED', + 'orientation': 'HORIZONTAL', + 'serial_no': 'SerialNo5', + 'short_serial_no': 'ShortSerialNo5', + }), + ]) +# --- +# name: test_get_zones + list([ + dict({ + 'date_created': '2023-04-12T12:58:12.737Z', + 'dazzle_enabled': True, + 'dazzle_mode': dict({ + 'enabled': True, + 'supported': True, + }), + 'device_types': list([ + 'VA02', + ]), + 'devices': list([ + dict({ + 'battery_state': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'child_lock_enabled': True, + 'connection_state': dict({ + 'timestamp': '2024-02-27T20:30:13.976Z', + 'value': True, + }), + 'current_fw_version': '95.1', + 'device_type': 'VA02', + 'in_pairing_mode': None, + 'mounting_state': dict({ + 'timestamp': '2024-02-04T10:17:00.266Z', + 'value': 'CALIBRATED', + }), + 'mounting_state_with_error': 'CALIBRATED', + 'orientation': 'HORIZONTAL', + 'serial_no': 'Serial1', + 'short_serial_no': 'ShortSerial1', + }), + ]), + 'id': 2, + 'name': 'Zone1', + 'open_window_detection': dict({ + 'enabled': False, + 'supported': True, + 'timeout_in_seconds': 900, + }), + 'report_available': False, + 'show_schedule_setup': False, + 'supports_dazzle': True, + 'type': 'HEATING', + }), + dict({ + 'date_created': '2023-01-29T16:02:14.530Z', + 'dazzle_enabled': True, + 'dazzle_mode': dict({ + 'enabled': True, + 'supported': True, + }), + 'device_types': list([ + 'VA02', + ]), + 'devices': list([ + dict({ + 'battery_state': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'child_lock_enabled': True, + 'connection_state': dict({ + 'timestamp': '2024-02-27T20:32:05.188Z', + 'value': True, + }), + 'current_fw_version': '95.1', + 'device_type': 'VA02', + 'in_pairing_mode': None, + 'mounting_state': dict({ + 'timestamp': '2023-10-09T15:39:08.131Z', + 'value': 'CALIBRATED', + }), + 'mounting_state_with_error': 'CALIBRATED', + 'orientation': 'HORIZONTAL', + 'serial_no': 'Serial2', + 'short_serial_no': 'ShortSerial2', + }), + ]), + 'id': 1, + 'name': 'Zone2', + 'open_window_detection': dict({ + 'enabled': False, + 'supported': True, + 'timeout_in_seconds': 900, + }), + 'report_available': False, + 'show_schedule_setup': True, + 'supports_dazzle': True, + 'type': 'HEATING', + }), + dict({ + 'date_created': '2023-04-14T07:52:56.352Z', + 'dazzle_enabled': True, + 'dazzle_mode': dict({ + 'enabled': True, + 'supported': True, + }), + 'device_types': list([ + 'VA02', + ]), + 'devices': list([ + dict({ + 'battery_state': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'child_lock_enabled': True, + 'connection_state': dict({ + 'timestamp': '2024-02-27T20:31:11.417Z', + 'value': True, + }), + 'current_fw_version': '95.1', + 'device_type': 'VA02', + 'in_pairing_mode': None, + 'mounting_state': dict({ + 'timestamp': '2024-02-26T13:33:10.647Z', + 'value': 'CALIBRATED', + }), + 'mounting_state_with_error': 'CALIBRATED', + 'orientation': 'HORIZONTAL', + 'serial_no': 'Serial3', + 'short_serial_no': 'ShortSerial3', + }), + ]), + 'id': 3, + 'name': 'Zone3', + 'open_window_detection': dict({ + 'enabled': False, + 'supported': True, + 'timeout_in_seconds': 900, + }), + 'report_available': False, + 'show_schedule_setup': True, + 'supports_dazzle': True, + 'type': 'HEATING', + }), + dict({ + 'date_created': '2023-04-14T07:58:45.196Z', + 'dazzle_enabled': True, + 'dazzle_mode': dict({ + 'enabled': True, + 'supported': True, + }), + 'device_types': list([ + 'VA02', + ]), + 'devices': list([ + dict({ + 'battery_state': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'child_lock_enabled': True, + 'connection_state': dict({ + 'timestamp': '2024-02-27T20:33:21.903Z', + 'value': True, + }), + 'current_fw_version': '95.1', + 'device_type': 'VA02', + 'in_pairing_mode': None, + 'mounting_state': dict({ + 'timestamp': '2024-02-19T17:23:30.537Z', + 'value': 'CALIBRATED', + }), + 'mounting_state_with_error': 'CALIBRATED', + 'orientation': 'HORIZONTAL', + 'serial_no': 'Serial4', + 'short_serial_no': 'ShortSerial4', + }), + ]), + 'id': 4, + 'name': 'Zone4', + 'open_window_detection': dict({ + 'enabled': False, + 'supported': True, + 'timeout_in_seconds': 900, + }), + 'report_available': False, + 'show_schedule_setup': True, + 'supports_dazzle': True, + 'type': 'HEATING', + }), + ]) +# --- diff --git a/tests/__snapshots__/test_tado_x.ambr b/tests/__snapshots__/test_tado_x.ambr new file mode 100644 index 0000000..b787966 --- /dev/null +++ b/tests/__snapshots__/test_tado_x.ambr @@ -0,0 +1,209 @@ +# serializer version: 1 +# name: test_get_rooms_and_devices + dict({ + 'other_devices': list([ + dict({ + 'battery_state': None, + 'child_lock_enabled': None, + 'connection': dict({ + 'state': 'CONNECTED', + }), + 'firmware_version': '279.1', + 'mounting_state': None, + 'serial_number': 'IB0000000000', + 'temperature_as_measured': None, + 'temperature_offset': None, + 'type': 'IB02', + }), + ]), + 'rooms': list([ + dict({ + 'device_manual_control_termination': dict({ + 'duration_in_seconds': None, + 'type': 'NEXT_TIME_BLOCK', + }), + 'devices': list([ + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': None, + 'connection': dict({ + 'state': 'CONNECTED', + }), + 'firmware_version': '287.1', + 'mounting_state': None, + 'serial_number': 'SU0000000000', + 'temperature_as_measured': 19.91, + 'temperature_offset': -0.3, + 'type': 'SU04', + }), + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': False, + 'connection': dict({ + 'state': 'CONNECTED', + }), + 'firmware_version': '280.1', + 'mounting_state': 'CALIBRATED', + 'serial_number': 'VA0000000001', + 'temperature_as_measured': 22.48, + 'temperature_offset': -2.2, + 'type': 'VA04', + }), + ]), + 'room_id': 1, + 'room_link_available': True, + 'room_name': 'Room 1', + 'zone_controller_assignable': False, + 'zone_controllers': list([ + ]), + }), + dict({ + 'device_manual_control_termination': dict({ + 'duration_in_seconds': 3600, + 'type': 'TIMER', + }), + 'devices': list([ + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': False, + 'connection': dict({ + 'state': 'CONNECTED', + }), + 'firmware_version': '280.1', + 'mounting_state': 'CALIBRATED', + 'serial_number': 'VA0000000002', + 'temperature_as_measured': 20.98, + 'temperature_offset': -0.5, + 'type': 'VA04', + }), + ]), + 'room_id': 2, + 'room_link_available': True, + 'room_name': 'Room 2', + 'zone_controller_assignable': False, + 'zone_controllers': list([ + ]), + }), + dict({ + 'device_manual_control_termination': dict({ + 'duration_in_seconds': None, + 'type': 'NEXT_TIME_BLOCK', + }), + 'devices': list([ + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': False, + 'connection': dict({ + 'state': 'CONNECTED', + }), + 'firmware_version': '280.1', + 'mounting_state': 'CALIBRATED', + 'serial_number': 'VA0000000003', + 'temperature_as_measured': 22.9, + 'temperature_offset': -2.0, + 'type': 'VA04', + }), + ]), + 'room_id': 3, + 'room_link_available': True, + 'room_name': 'Room 3', + 'zone_controller_assignable': False, + 'zone_controllers': list([ + ]), + }), + dict({ + 'device_manual_control_termination': dict({ + 'duration_in_seconds': None, + 'type': 'NEXT_TIME_BLOCK', + }), + 'devices': list([ + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': False, + 'connection': dict({ + 'state': 'CONNECTED', + }), + 'firmware_version': '280.1', + 'mounting_state': 'CALIBRATED', + 'serial_number': 'VA0000000004', + 'temperature_as_measured': 22.52, + 'temperature_offset': -2.0, + 'type': 'VA04', + }), + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': False, + 'connection': dict({ + 'state': 'CONNECTED', + }), + 'firmware_version': '280.1', + 'mounting_state': 'CALIBRATED', + 'serial_number': 'VA0000000005', + 'temperature_as_measured': 21.62, + 'temperature_offset': -1.0, + 'type': 'VA04', + }), + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': False, + 'connection': dict({ + 'state': 'DISCONNECTED', + }), + 'firmware_version': '280.1', + 'mounting_state': 'CALIBRATED', + 'serial_number': 'VA0000000006', + 'temperature_as_measured': 20.89, + 'temperature_offset': -1.0, + 'type': 'VA04', + }), + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': False, + 'connection': dict({ + 'state': 'CONNECTED', + }), + 'firmware_version': '280.1', + 'mounting_state': 'CALIBRATED', + 'serial_number': 'VA0000000007', + 'temperature_as_measured': 21.54, + 'temperature_offset': -1.0, + 'type': 'VA04', + }), + ]), + 'room_id': 4, + 'room_link_available': True, + 'room_name': 'Room 4', + 'zone_controller_assignable': False, + 'zone_controllers': list([ + ]), + }), + dict({ + 'device_manual_control_termination': dict({ + 'duration_in_seconds': None, + 'type': 'NEXT_TIME_BLOCK', + }), + 'devices': list([ + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': False, + 'connection': dict({ + 'state': 'CONNECTED', + }), + 'firmware_version': '280.1', + 'mounting_state': 'CALIBRATED', + 'serial_number': 'VA0000000008', + 'temperature_as_measured': 21.22, + 'temperature_offset': -1.0, + 'type': 'VA04', + }), + ]), + 'room_id': 8, + 'room_link_available': True, + 'room_name': 'Room 5', + 'zone_controller_assignable': False, + 'zone_controllers': list([ + ]), + }), + ]), + }) +# --- diff --git a/tests/const.py b/tests/const.py index dcd74ce..3dc043e 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_API_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/LINE_X/roomsAndDevices.json b/tests/fixtures/LINE_X/roomsAndDevices.json new file mode 100644 index 0000000..6ac56ab --- /dev/null +++ b/tests/fixtures/LINE_X/roomsAndDevices.json @@ -0,0 +1,174 @@ +{ + "rooms": [ + { + "roomId": 1, + "roomName": "Room 1", + "deviceManualControlTermination": { + "type": "NEXT_TIME_BLOCK", + "durationInSeconds": null + }, + "devices": [ + { + "serialNumber": "SU0000000000", + "type": "SU04", + "firmwareVersion": "287.1", + "connection": { "state": "CONNECTED" }, + "batteryState": "NORMAL", + "temperatureAsMeasured": 19.91, + "temperatureOffset": -0.3 + }, + { + "serialNumber": "VA0000000001", + "type": "VA04", + "firmwareVersion": "280.1", + "connection": { "state": "CONNECTED" }, + "mountingState": "CALIBRATED", + "batteryState": "NORMAL", + "childLockEnabled": false, + "temperatureAsMeasured": 22.48, + "temperatureOffset": -2.2 + } + ], + "zoneControllerAssignable": false, + "zoneControllers": [], + "roomLinkAvailable": true + }, + { + "roomId": 2, + "roomName": "Room 2", + "deviceManualControlTermination": { + "type": "TIMER", + "durationInSeconds": 3600 + }, + "devices": [ + { + "serialNumber": "VA0000000002", + "type": "VA04", + "firmwareVersion": "280.1", + "connection": { "state": "CONNECTED" }, + "mountingState": "CALIBRATED", + "batteryState": "NORMAL", + "childLockEnabled": false, + "temperatureAsMeasured": 20.98, + "temperatureOffset": -0.5 + } + ], + "zoneControllerAssignable": false, + "zoneControllers": [], + "roomLinkAvailable": true + }, + { + "roomId": 3, + "roomName": "Room 3", + "deviceManualControlTermination": { + "type": "NEXT_TIME_BLOCK", + "durationInSeconds": null + }, + "devices": [ + { + "serialNumber": "VA0000000003", + "type": "VA04", + "firmwareVersion": "280.1", + "connection": { "state": "CONNECTED" }, + "mountingState": "CALIBRATED", + "batteryState": "NORMAL", + "childLockEnabled": false, + "temperatureAsMeasured": 22.9, + "temperatureOffset": -2.0 + } + ], + "zoneControllerAssignable": false, + "zoneControllers": [], + "roomLinkAvailable": true + }, + { + "roomId": 4, + "roomName": "Room 4", + "deviceManualControlTermination": { + "type": "NEXT_TIME_BLOCK", + "durationInSeconds": null + }, + "devices": [ + { + "serialNumber": "VA0000000004", + "type": "VA04", + "firmwareVersion": "280.1", + "connection": { "state": "CONNECTED" }, + "mountingState": "CALIBRATED", + "batteryState": "NORMAL", + "childLockEnabled": false, + "temperatureAsMeasured": 22.52, + "temperatureOffset": -2.0 + }, + { + "serialNumber": "VA0000000005", + "type": "VA04", + "firmwareVersion": "280.1", + "connection": { "state": "CONNECTED" }, + "mountingState": "CALIBRATED", + "batteryState": "NORMAL", + "childLockEnabled": false, + "temperatureAsMeasured": 21.62, + "temperatureOffset": -1.0 + }, + { + "serialNumber": "VA0000000006", + "type": "VA04", + "firmwareVersion": "280.1", + "connection": { "state": "DISCONNECTED" }, + "mountingState": "CALIBRATED", + "batteryState": "NORMAL", + "childLockEnabled": false, + "temperatureAsMeasured": 20.89, + "temperatureOffset": -1.0 + }, + { + "serialNumber": "VA0000000007", + "type": "VA04", + "firmwareVersion": "280.1", + "connection": { "state": "CONNECTED" }, + "mountingState": "CALIBRATED", + "batteryState": "NORMAL", + "childLockEnabled": false, + "temperatureAsMeasured": 21.54, + "temperatureOffset": -1.0 + } + ], + "zoneControllerAssignable": false, + "zoneControllers": [], + "roomLinkAvailable": true + }, + { + "roomId": 8, + "roomName": "Room 5", + "deviceManualControlTermination": { + "type": "NEXT_TIME_BLOCK", + "durationInSeconds": null + }, + "devices": [ + { + "serialNumber": "VA0000000008", + "type": "VA04", + "firmwareVersion": "280.1", + "connection": { "state": "CONNECTED" }, + "mountingState": "CALIBRATED", + "batteryState": "NORMAL", + "childLockEnabled": false, + "temperatureAsMeasured": 21.22, + "temperatureOffset": -1.0 + } + ], + "zoneControllerAssignable": false, + "zoneControllers": [], + "roomLinkAvailable": true + } + ], + "otherDevices": [ + { + "serialNumber": "IB0000000000", + "type": "IB02", + "firmwareVersion": "279.1", + "connection": { "state": "CONNECTED" } + } + ] +} diff --git a/tests/test_models_unified.py b/tests/test_models_unified.py new file mode 100644 index 0000000..877a8f7 --- /dev/null +++ b/tests/test_models_unified.py @@ -0,0 +1,59 @@ +"""Tests for abstract cross-line models.""" + +from __future__ import annotations + +import orjson + +import tadoasync.models_v3 as models_v3 +import tadoasync.models_x as models_x +from syrupy import SnapshotAssertion +from tadoasync.models_unified import Device + +from tests import load_fixture + + +def test_device_from_v3_with_offset(snapshot: SnapshotAssertion) -> None: + """Map a v3 device with a temperature offset to the abstract model.""" + v3_devices = orjson.loads(load_fixture("devices.json")) + v3_device = models_v3.Device.from_dict(v3_devices[1]) + offset = models_v3.TemperatureOffset.from_json( + load_fixture("device_info_attribute.json") + ) + + device = Device.from_v3(v3_device, offset=offset) + + assert device == snapshot + + +def test_device_from_v3_without_offset(snapshot: SnapshotAssertion) -> None: + """Map a v3 device without temperature offset data.""" + v3_devices = orjson.loads(load_fixture("devices.json")) + v3_device = models_v3.Device.from_dict(v3_devices[0]) + + device = Device.from_v3(v3_device) + + assert device == snapshot + + +def test_device_from_x_connected(snapshot: SnapshotAssertion) -> None: + """Map a connected X device to the abstract model.""" + rooms_and_devices = models_x.RoomsAndDevices.from_json( + load_fixture(folder="LINE_X", filename="roomsAndDevices.json") + ) + x_device = rooms_and_devices.rooms[0].devices[0] + + device = Device.from_x(x_device) + + assert device == snapshot + + +def test_device_from_x_disconnected(snapshot: SnapshotAssertion) -> None: + """Map a disconnected X device to the abstract model.""" + rooms_and_devices = models_x.RoomsAndDevices.from_json( + load_fixture(folder="LINE_X", filename="roomsAndDevices.json") + ) + x_device = rooms_and_devices.rooms[3].devices[2] + + device = Device.from_x(x_device) + + assert device == snapshot diff --git a/tests/test_tado.py b/tests/test_tado.py index 5046664..59e2462 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, @@ -26,7 +27,7 @@ from syrupy import SnapshotAssertion from tests import load_fixture -from .const import TADO_API_URL, TADO_EIQ_URL, TADO_TOKEN_URL +from .const import TADO_API_URL, TADO_EIQ_URL, TADO_TOKEN_URL, TADO_X_API_URL async def test_create_session( @@ -736,3 +737,69 @@ async def test_login_device_flow_already_in_progress() -> None: TadoError, match="Device activation already in progress or completed" ): await tado.login_device_flow() + + +async def test_get_devices_unified_x( + python_tado: Tado, responses: aioresponses, snapshot: SnapshotAssertion +) -> None: + """Test get devices.""" + python_tado._tado_line = TadoLine.LINE_X + + responses.get( + f"{TADO_X_API_URL}/homes/1/roomsAndDevices", + status=200, + body=load_fixture(folder="LINE_X", filename="roomsAndDevices.json"), + ) + + assert await python_tado.get_unified_devices() == snapshot + + +async def test_get_devices_unified_v3( + python_tado: Tado, responses: aioresponses, snapshot: SnapshotAssertion +) -> None: + """Test get devices.""" + python_tado._tado_line = TadoLine.PRE_LINE_X + + responses.get( + f"{TADO_API_URL}/homes/1/devices", + status=200, + body=load_fixture(filename="devices.json"), + ) + + for i in range(1, 6): + serial = f"SerialNo{i}" + responses.get( + f"{TADO_API_URL}/devices/{serial}/temperatureOffset", + status=200, + body=load_fixture(filename="device_info_attribute.json"), + ) + + assert await python_tado.get_unified_devices() == snapshot + + +async def test_get_devices_unified_no_devices( + python_tado: Tado, responses: aioresponses, snapshot: SnapshotAssertion +) -> None: + """Test get devices when no devices are returned.""" + python_tado._tado_line = TadoLine.PRE_LINE_X + + responses.get( + f"{TADO_API_URL}/homes/1/devices", + status=200, + body="[]", + ) + + with pytest.raises(TadoError, match="No devices found for the home"): + await python_tado.get_unified_devices() + + +async def test_get_devices_unified_no_tado_line( + python_tado: Tado, responses: aioresponses, snapshot: SnapshotAssertion +) -> None: + """Test get devices when tado line is not set.""" + python_tado._tado_line = None + + with pytest.raises( + TadoError, match="Tado Line not set. Cannot get unified devices." + ): + await python_tado.get_unified_devices() diff --git a/tests/test_tado_v3.py b/tests/test_tado_v3.py new file mode 100644 index 0000000..46dfb5f --- /dev/null +++ b/tests/test_tado_v3.py @@ -0,0 +1,80 @@ +"""Tests for the Python Tado X models.""" + +import asyncio +import os +import time +from datetime import UTC, datetime, timedelta +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import aiohttp +import pytest +from aiohttp import ClientResponse, ClientResponseError, RequestInfo +from aioresponses import CallbackResult, aioresponses +from tadoasync import ( + Tado, +) +from tadoasync.exceptions import ( + TadoAuthenticationError, + TadoBadRequestError, + TadoConnectionError, + TadoError, + TadoReadingError, +) +from tadoasync.tadoasync import DEVICE_AUTH_URL, DeviceActivationStatus + +from syrupy import SnapshotAssertion +from tests import load_fixture + +from .const import TADO_API_URL, TADO_EIQ_URL, TADO_TOKEN_URL + + +async def test_get_zones( + python_tado: Tado, responses: aioresponses, snapshot: SnapshotAssertion +) -> None: + """Test get zones.""" + responses.get( + f"{TADO_API_URL}/homes/1/zones", + status=200, + body=load_fixture(filename="zones.json"), + ) + assert await python_tado.api_v3.get_zones() == snapshot + + +async def test_get_devices( + python_tado: Tado, responses: aioresponses, snapshot: SnapshotAssertion +) -> None: + """Test get devices.""" + responses.get( + f"{TADO_API_URL}/homes/1/devices", + status=200, + body=load_fixture(filename="devices.json"), + ) + assert await python_tado.api_v3.get_devices() == snapshot + + +async def test_get_device( + python_tado: Tado, responses: aioresponses, snapshot: SnapshotAssertion +) -> None: + """Test get device by serial.""" + responses.get( + f"{TADO_API_URL}/homes/1/devices/SerialNo1", + status=200, + body=load_fixture(filename="device_info.json"), + ) + assert await python_tado.api_v3.get_device(serial_no="SerialNo1") == snapshot + + +async def test_get_device_temperature_offset( + python_tado: Tado, responses: aioresponses, snapshot: SnapshotAssertion +) -> None: + """Test get device temperature offset.""" + responses.get( + f"{TADO_API_URL}/devices/SerialNo1/temperatureOffset", + status=200, + body=load_fixture(filename="device_info_attribute.json"), + ) + assert ( + await python_tado.api_v3.get_device_temperature_offset(serial_no="SerialNo1") + == snapshot + ) diff --git a/tests/test_tado_x.py b/tests/test_tado_x.py new file mode 100644 index 0000000..47a8f89 --- /dev/null +++ b/tests/test_tado_x.py @@ -0,0 +1,41 @@ +"""Tests for the Python Tado X models.""" + +import asyncio +import os +import time +from datetime import UTC, datetime, timedelta +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import aiohttp +import pytest +from aiohttp import ClientResponse, ClientResponseError, RequestInfo +from aioresponses import CallbackResult, aioresponses +from tadoasync import ( + Tado, +) +from tadoasync.exceptions import ( + TadoAuthenticationError, + TadoBadRequestError, + TadoConnectionError, + TadoError, + TadoReadingError, +) +from tadoasync.tadoasync import DEVICE_AUTH_URL, DeviceActivationStatus + +from syrupy import SnapshotAssertion +from tests import load_fixture + +from .const import TADO_X_API_URL, TADO_EIQ_URL, TADO_TOKEN_URL + + +async def test_get_rooms_and_devices( + python_tado: Tado, responses: aioresponses, snapshot: SnapshotAssertion +) -> None: + """Test get home.""" + responses.get( + f"{TADO_X_API_URL}/homes/1/roomsAndDevices", + status=200, + body=load_fixture(folder="LINE_X", filename="roomsAndDevices.json"), + ) + assert await python_tado.api_x.get_rooms_and_devices() == snapshot From 4550ab353bfc0d6ba33b8cfa522e9cea4e2abe9c Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Sat, 28 Feb 2026 14:33:19 +0100 Subject: [PATCH 02/10] linter fixes --- src/tadoasync/api_v3.py | 2 +- src/tadoasync/models_unified.py | 11 +++-------- src/tadoasync/models_x.py | 4 +--- src/tadoasync/tadoasync.py | 11 +++++------ tests/test_models_unified.py | 6 ++---- tests/test_tado_v3.py | 22 ++-------------------- tests/test_tado_x.py | 22 ++-------------------- 7 files changed, 16 insertions(+), 62 deletions(-) diff --git a/src/tadoasync/api_v3.py b/src/tadoasync/api_v3.py index b709ec0..7a4ebbd 100644 --- a/src/tadoasync/api_v3.py +++ b/src/tadoasync/api_v3.py @@ -4,7 +4,7 @@ import orjson -from tadoasync.models_v3 import TemperatureOffset, Zone, Device +from tadoasync.models_v3 import Device, TemperatureOffset, Zone if TYPE_CHECKING: from tadoasync.tadoasync import Tado diff --git a/src/tadoasync/models_unified.py b/src/tadoasync/models_unified.py index b14a3bd..bafd255 100644 --- a/src/tadoasync/models_unified.py +++ b/src/tadoasync/models_unified.py @@ -2,16 +2,11 @@ from __future__ import annotations -from dataclasses import dataclass, field -from typing import Any +from dataclasses import dataclass -from mashumaro import field_options from mashumaro.mixins.orjson import DataClassORJSONMixin -from tadoasync.const import TadoLine - -import tadoasync.models_v3 as models_v3 -import tadoasync.models_x as models_x +from tadoasync import models_v3, models_x @dataclass @@ -24,7 +19,7 @@ class Device(DataClassORJSONMixin): connection_state: bool - battery_state: str | None # Todo: Enum + battery_state: str | None # TODO: Enum temperature_offset: float | None = None diff --git a/src/tadoasync/models_x.py b/src/tadoasync/models_x.py index 431b17d..f8b08c9 100644 --- a/src/tadoasync/models_x.py +++ b/src/tadoasync/models_x.py @@ -8,8 +8,6 @@ from mashumaro import field_options from mashumaro.mixins.orjson import DataClassORJSONMixin -from tadoasync.const import TadoLine - @dataclass class DeviceManualControlTermination(DataClassORJSONMixin): @@ -68,7 +66,7 @@ class Room(DataClassORJSONMixin): ) zone_controllers: list[Any] = field( metadata=field_options(alias="zoneControllers") - ) # ToDo: Define ZoneController model + ) # TODO: Define ZoneController model room_link_available: bool = field(metadata=field_options(alias="roomLinkAvailable")) diff --git a/src/tadoasync/tadoasync.py b/src/tadoasync/tadoasync.py index ceef7c8..2615919 100644 --- a/src/tadoasync/tadoasync.py +++ b/src/tadoasync/tadoasync.py @@ -15,11 +15,11 @@ import orjson from aiohttp import ClientResponseError from aiohttp.client import ClientSession +from yarl import URL + from tadoasync import models_unified as unified_models from tadoasync.api_v3 import ApiV3 from tadoasync.api_x import ApiX -from yarl import URL - from tadoasync.const import ( CONST_AWAY, CONST_FAN_AUTO, @@ -35,10 +35,10 @@ CONST_MODE_OFF, CONST_MODE_SMART_SCHEDULE, CONST_VERTICAL_SWING_OFF, + INSIDE_TEMPERATURE_MEASUREMENT, TADO_HVAC_ACTION_TO_MODES, TADO_MODES_TO_HVAC_ACTION, TYPE_AIR_CONDITIONING, - INSIDE_TEMPERATURE_MEASUREMENT, HttpMethod, TadoLine, ) @@ -562,7 +562,7 @@ async def get_unified_devices(self) -> list[unified_models.Device]: ) devices_unified.append(unified_models.Device.from_v3(device, offset)) return devices_unified - elif self._tado_line == TadoLine.LINE_X: + if self._tado_line == TadoLine.LINE_X: rooms_and_devices = await self.api_x.get_rooms_and_devices() devices_unified = [] for room in rooms_and_devices.rooms: @@ -571,8 +571,7 @@ async def get_unified_devices(self) -> list[unified_models.Device]: for device in rooms_and_devices.other_devices: devices_unified.append(unified_models.Device.from_x(device)) return devices_unified - else: - raise TadoError("Tado Line not set. Cannot get unified devices.") + raise TadoError("Tado Line not set. Cannot get unified devices.") async def set_child_lock(self, serial_no: str, *, child_lock: bool) -> None: """Set the child lock.""" diff --git a/tests/test_models_unified.py b/tests/test_models_unified.py index 877a8f7..b82dbf4 100644 --- a/tests/test_models_unified.py +++ b/tests/test_models_unified.py @@ -3,12 +3,10 @@ from __future__ import annotations import orjson - -import tadoasync.models_v3 as models_v3 -import tadoasync.models_x as models_x -from syrupy import SnapshotAssertion +from tadoasync import models_v3, models_x from tadoasync.models_unified import Device +from syrupy import SnapshotAssertion from tests import load_fixture diff --git a/tests/test_tado_v3.py b/tests/test_tado_v3.py index 46dfb5f..fb16e7d 100644 --- a/tests/test_tado_v3.py +++ b/tests/test_tado_v3.py @@ -1,32 +1,14 @@ """Tests for the Python Tado X models.""" -import asyncio -import os -import time -from datetime import UTC, datetime, timedelta -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch - -import aiohttp -import pytest -from aiohttp import ClientResponse, ClientResponseError, RequestInfo -from aioresponses import CallbackResult, aioresponses +from aioresponses import aioresponses from tadoasync import ( Tado, ) -from tadoasync.exceptions import ( - TadoAuthenticationError, - TadoBadRequestError, - TadoConnectionError, - TadoError, - TadoReadingError, -) -from tadoasync.tadoasync import DEVICE_AUTH_URL, DeviceActivationStatus from syrupy import SnapshotAssertion from tests import load_fixture -from .const import TADO_API_URL, TADO_EIQ_URL, TADO_TOKEN_URL +from .const import TADO_API_URL async def test_get_zones( diff --git a/tests/test_tado_x.py b/tests/test_tado_x.py index 47a8f89..e999709 100644 --- a/tests/test_tado_x.py +++ b/tests/test_tado_x.py @@ -1,32 +1,14 @@ """Tests for the Python Tado X models.""" -import asyncio -import os -import time -from datetime import UTC, datetime, timedelta -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch - -import aiohttp -import pytest -from aiohttp import ClientResponse, ClientResponseError, RequestInfo -from aioresponses import CallbackResult, aioresponses +from aioresponses import aioresponses from tadoasync import ( Tado, ) -from tadoasync.exceptions import ( - TadoAuthenticationError, - TadoBadRequestError, - TadoConnectionError, - TadoError, - TadoReadingError, -) -from tadoasync.tadoasync import DEVICE_AUTH_URL, DeviceActivationStatus from syrupy import SnapshotAssertion from tests import load_fixture -from .const import TADO_X_API_URL, TADO_EIQ_URL, TADO_TOKEN_URL +from .const import TADO_X_API_URL async def test_get_rooms_and_devices( From 2ca03fdd05d24bedc56dc95ed55ef69a4a7b71cc Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Sat, 28 Feb 2026 14:37:24 +0100 Subject: [PATCH 03/10] snapshot update, linter --- src/tadoasync/api_v3.py | 21 ++++++++----- src/tadoasync/api_x.py | 11 +++++-- tests/__snapshots__/test_models.ambr | 45 ---------------------------- 3 files changed, 21 insertions(+), 56 deletions(-) delete mode 100644 tests/__snapshots__/test_models.ambr diff --git a/src/tadoasync/api_v3.py b/src/tadoasync/api_v3.py index 7a4ebbd..e7bb977 100644 --- a/src/tadoasync/api_v3.py +++ b/src/tadoasync/api_v3.py @@ -1,3 +1,5 @@ +"""Wrapper for Tado v3 API.""" + from __future__ import annotations from typing import TYPE_CHECKING @@ -11,33 +13,36 @@ class ApiV3: - def __init__(self, base: Tado): + """Wrapper class for the Tado v3 API.""" + + def __init__(self, base: Tado) -> None: + """Initialize the API wrapper.""" self._base = base async def get_zones(self) -> list[Zone]: """Get zones.""" - response = await self._base._request( - uri=f"homes/{self._base._home_id}/zones", + response = await self._base._request( # noqa: SLF001 + uri=f"homes/{self._base._home_id}/zones", # noqa: SLF001 ) obj = orjson.loads(response) return [Zone.from_dict(zone) for zone in obj] async def get_devices(self) -> list[Device]: """Get devices.""" - response = await self._base._request( - uri=f"homes/{self._base._home_id}/devices", + response = await self._base._request( # noqa: SLF001 + uri=f"homes/{self._base._home_id}/devices", # noqa: SLF001 ) obj = orjson.loads(response) return [Device.from_dict(device) for device in obj] async def get_device(self, serial_no: str) -> Device: """Get device.""" - response = await self._base._request( - uri=f"homes/{self._base._home_id}/devices/{serial_no}", + response = await self._base._request( # noqa: SLF001 + uri=f"homes/{self._base._home_id}/devices/{serial_no}", # noqa: SLF001 ) return Device.from_json(response) async def get_device_temperature_offset(self, serial_no: str) -> TemperatureOffset: """Get the device temperature offset.""" - response = await self._base._request(f"devices/{serial_no}/temperatureOffset") + response = await self._base._request(f"devices/{serial_no}/temperatureOffset") # noqa: SLF001 return TemperatureOffset.from_json(response) diff --git a/src/tadoasync/api_x.py b/src/tadoasync/api_x.py index d0a48b3..fd6b2bb 100644 --- a/src/tadoasync/api_x.py +++ b/src/tadoasync/api_x.py @@ -1,3 +1,5 @@ +"""Wrapper for Tado X API.""" + from __future__ import annotations from typing import TYPE_CHECKING @@ -11,13 +13,16 @@ class ApiX: - def __init__(self, base: Tado): + """Wrapper class for the Tado X API.""" + + def __init__(self, base: Tado) -> None: + """Initialize the API wrapper.""" self._base = base async def get_rooms_and_devices(self) -> RoomsAndDevices: """Get rooms and devices.""" - response = await self._base._request( + response = await self._base._request( # noqa: SLF001 endpoint=API_URL, - uri=f"homes/{self._base._home_id}/roomsAndDevices", + uri=f"homes/{self._base._home_id}/roomsAndDevices", # noqa: SLF001 ) return RoomsAndDevices.from_json(response) diff --git a/tests/__snapshots__/test_models.ambr b/tests/__snapshots__/test_models.ambr deleted file mode 100644 index 194b7d3..0000000 --- a/tests/__snapshots__/test_models.ambr +++ /dev/null @@ -1,45 +0,0 @@ -# serializer version: 1 -# name: test_device_from_v3_with_offset - dict({ - 'battery_state': 'NORMAL', - 'child_lock_enabled': True, - 'connection_state': True, - 'device_type': 'VA02', - 'firmware_version': '95.1', - 'serial': 'SerialNo2', - 'temperature_offset': 0.0, - }) -# --- -# name: test_device_from_v3_without_offset - dict({ - 'battery_state': None, - 'child_lock_enabled': None, - 'connection_state': True, - 'device_type': 'IB01', - 'firmware_version': '92.1', - 'serial': 'SerialNo1', - 'temperature_offset': None, - }) -# --- -# name: test_device_from_x_connected - dict({ - 'battery_state': 'NORMAL', - 'child_lock_enabled': None, - 'connection_state': True, - 'device_type': 'SU04', - 'firmware_version': '287.1', - 'serial': 'SU0000000000', - 'temperature_offset': None, - }) -# --- -# name: test_device_from_x_disconnected - dict({ - 'battery_state': 'NORMAL', - 'child_lock_enabled': False, - 'connection_state': False, - 'device_type': 'VA04', - 'firmware_version': '280.1', - 'serial': 'VA0000000006', - 'temperature_offset': None, - }) -# --- From 562e50e58931a9bab70dac7f70b3d3c88dfce65b Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Sat, 28 Feb 2026 14:48:24 +0100 Subject: [PATCH 04/10] linter, cleanup --- src/tadoasync/models_unified.py | 6 ++++-- src/tadoasync/models_x.py | 8 +++----- src/tadoasync/tadoasync.py | 7 ++----- tests/test_models_unified.py | 6 +++++- tests/test_tado.py | 8 +++++--- 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/tadoasync/models_unified.py b/src/tadoasync/models_unified.py index bafd255..5c0c4c2 100644 --- a/src/tadoasync/models_unified.py +++ b/src/tadoasync/models_unified.py @@ -3,10 +3,12 @@ from __future__ import annotations from dataclasses import dataclass +from typing import TYPE_CHECKING from mashumaro.mixins.orjson import DataClassORJSONMixin -from tadoasync import models_v3, models_x +if TYPE_CHECKING: + from tadoasync import models_v3, models_x @dataclass @@ -19,7 +21,7 @@ class Device(DataClassORJSONMixin): connection_state: bool - battery_state: str | None # TODO: Enum + battery_state: str | None temperature_offset: float | None = None diff --git a/src/tadoasync/models_x.py b/src/tadoasync/models_x.py index f8b08c9..6e70e91 100644 --- a/src/tadoasync/models_x.py +++ b/src/tadoasync/models_x.py @@ -11,9 +11,9 @@ @dataclass class DeviceManualControlTermination(DataClassORJSONMixin): - """DeviceManualControlTermination model represents the manual control termination settings of a device.""" + """Represents the manual control termination settings of a device.""" - type: str # TODO: Enum + type: str duration_in_seconds: int | None = field( metadata=field_options(alias="durationInSeconds") ) @@ -64,9 +64,7 @@ class Room(DataClassORJSONMixin): zone_controller_assignable: bool = field( metadata=field_options(alias="zoneControllerAssignable") ) - zone_controllers: list[Any] = field( - metadata=field_options(alias="zoneControllers") - ) # TODO: Define ZoneController model + zone_controllers: list[Any] = field(metadata=field_options(alias="zoneControllers")) room_link_available: bool = field(metadata=field_options(alias="roomLinkAvailable")) diff --git a/src/tadoasync/tadoasync.py b/src/tadoasync/tadoasync.py index 2615919..33cba22 100644 --- a/src/tadoasync/tadoasync.py +++ b/src/tadoasync/tadoasync.py @@ -539,6 +539,7 @@ async def get_device_info( return Device.from_json(response) async def get_unified_devices(self) -> list[unified_models.Device]: + """Get devices in a unified format, compatible with both Tado X and v3.""" if self._tado_line == TadoLine.PRE_LINE_X: devices = await self.get_devices() devices_unified = [] @@ -643,8 +644,6 @@ async def _request( except ClientResponseError as err: await self.check_request_status(err) - _LOGGER.debug(f"Request to {url} returned headers: {request.headers}") - return await request.text() async def update_zone_data(self, data: ZoneState) -> None: # pylint: disable=too-many-branches @@ -794,9 +793,7 @@ async def update_zone_data(self, data: ZoneState) -> None: # pylint: disable=to and data.termination_condition is not None ): data.default_overlay_termination_type = ( - data.termination_condition.type - if data.termination_condition.type - else None + data.termination_condition.type or None ) data.default_overlay_termination_duration = getattr( data.termination_condition, "duration_in_seconds", None diff --git a/tests/test_models_unified.py b/tests/test_models_unified.py index b82dbf4..f240a07 100644 --- a/tests/test_models_unified.py +++ b/tests/test_models_unified.py @@ -2,11 +2,15 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import orjson from tadoasync import models_v3, models_x from tadoasync.models_unified import Device -from syrupy import SnapshotAssertion +if TYPE_CHECKING: + from syrupy import SnapshotAssertion + from tests import load_fixture diff --git a/tests/test_tado.py b/tests/test_tado.py index 59e2462..dfe3f86 100644 --- a/tests/test_tado.py +++ b/tests/test_tado.py @@ -2,6 +2,7 @@ import asyncio import os +import re import time from datetime import UTC, datetime, timedelta from typing import Any @@ -778,7 +779,8 @@ async def test_get_devices_unified_v3( async def test_get_devices_unified_no_devices( - python_tado: Tado, responses: aioresponses, snapshot: SnapshotAssertion + python_tado: Tado, + responses: aioresponses, ) -> None: """Test get devices when no devices are returned.""" python_tado._tado_line = TadoLine.PRE_LINE_X @@ -794,12 +796,12 @@ async def test_get_devices_unified_no_devices( async def test_get_devices_unified_no_tado_line( - python_tado: Tado, responses: aioresponses, snapshot: SnapshotAssertion + python_tado: Tado, ) -> None: """Test get devices when tado line is not set.""" python_tado._tado_line = None with pytest.raises( - TadoError, match="Tado Line not set. Cannot get unified devices." + TadoError, match=re.escape("Tado Line not set. Cannot get unified devices.") ): await python_tado.get_unified_devices() From 5d3c1edc2232fe17a09bdaf5b1a1c02ea70c9ee4 Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Sat, 28 Feb 2026 14:50:41 +0100 Subject: [PATCH 05/10] fix typing --- src/tadoasync/tadoasync.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/tadoasync/tadoasync.py b/src/tadoasync/tadoasync.py index 33cba22..73406c6 100644 --- a/src/tadoasync/tadoasync.py +++ b/src/tadoasync/tadoasync.py @@ -545,32 +545,34 @@ async def get_unified_devices(self) -> list[unified_models.Device]: devices_unified = [] if not devices: raise TadoError("No devices found for the home") - for device in devices: + for v3_device in devices: offset = None if ( INSIDE_TEMPERATURE_MEASUREMENT - in device.characteristics.capabilities + in v3_device.characteristics.capabilities ): try: offset = await self.api_v3.get_device_temperature_offset( - device.serial_no, + v3_device.serial_no, ) except TadoError as err: _LOGGER.warning( "Failed to get temperature offset for device %s: %s", - device.serial_no, + v3_device.serial_no, err, ) - devices_unified.append(unified_models.Device.from_v3(device, offset)) + devices_unified.append( + unified_models.Device.from_v3(v3_device, offset) + ) return devices_unified if self._tado_line == TadoLine.LINE_X: rooms_and_devices = await self.api_x.get_rooms_and_devices() devices_unified = [] for room in rooms_and_devices.rooms: - for device in room.devices: - devices_unified.append(unified_models.Device.from_x(device)) - for device in rooms_and_devices.other_devices: - devices_unified.append(unified_models.Device.from_x(device)) + for x_device in room.devices: + devices_unified.append(unified_models.Device.from_x(x_device)) + for x_device in rooms_and_devices.other_devices: + devices_unified.append(unified_models.Device.from_x(x_device)) return devices_unified raise TadoError("Tado Line not set. Cannot get unified devices.") From b1d36878ff51d86af2456c011c9c85617f3c76d2 Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Sat, 28 Feb 2026 14:54:40 +0100 Subject: [PATCH 06/10] ruff format, pylint --- src/tadoasync/api_x.py | 2 +- src/tadoasync/tadoasync.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/tadoasync/api_x.py b/src/tadoasync/api_x.py index fd6b2bb..8418d7f 100644 --- a/src/tadoasync/api_x.py +++ b/src/tadoasync/api_x.py @@ -12,7 +12,7 @@ from tadoasync.tadoasync import Tado -class ApiX: +class ApiX: # pylint: disable=too-few-public-methods """Wrapper class for the Tado X API.""" def __init__(self, base: Tado) -> None: diff --git a/src/tadoasync/tadoasync.py b/src/tadoasync/tadoasync.py index 73406c6..6c6537b 100644 --- a/src/tadoasync/tadoasync.py +++ b/src/tadoasync/tadoasync.py @@ -561,9 +561,7 @@ async def get_unified_devices(self) -> list[unified_models.Device]: v3_device.serial_no, err, ) - devices_unified.append( - unified_models.Device.from_v3(v3_device, offset) - ) + devices_unified.append(unified_models.Device.from_v3(v3_device, offset)) return devices_unified if self._tado_line == TadoLine.LINE_X: rooms_and_devices = await self.api_x.get_rooms_and_devices() From 2a7eb54dbd025a5c5823fab933984475ef4bd8fe Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Sat, 28 Feb 2026 14:56:16 +0100 Subject: [PATCH 07/10] pylint --- tests/test_models_unified.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_models_unified.py b/tests/test_models_unified.py index f240a07..a54fe6d 100644 --- a/tests/test_models_unified.py +++ b/tests/test_models_unified.py @@ -8,11 +8,11 @@ from tadoasync import models_v3, models_x from tadoasync.models_unified import Device +from tests import load_fixture + if TYPE_CHECKING: from syrupy import SnapshotAssertion -from tests import load_fixture - def test_device_from_v3_with_offset(snapshot: SnapshotAssertion) -> None: """Map a v3 device with a temperature offset to the abstract model.""" From ebbd615d1a099a5a0d128f94dfba2174d93a7084 Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Tue, 3 Mar 2026 21:57:44 +0100 Subject: [PATCH 08/10] add offset for x unified class --- src/tadoasync/models_unified.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tadoasync/models_unified.py b/src/tadoasync/models_unified.py index 5c0c4c2..9ada897 100644 --- a/src/tadoasync/models_unified.py +++ b/src/tadoasync/models_unified.py @@ -54,6 +54,7 @@ def from_x( device_type=x_device.type, serial=x_device.serial_number, firmware_version=x_device.firmware_version, + temperature_offset=x_device.temperature_offset, connection_state=x_device.connection.state == "CONNECTED", battery_state=x_device.battery_state, child_lock_enabled=x_device.child_lock_enabled, From 1d9ea324a902496c89eeddebf25f1e8aab18d070 Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Tue, 3 Mar 2026 21:58:49 +0100 Subject: [PATCH 09/10] update snapshots --- tests/__snapshots__/test_models_unified.ambr | 4 ++-- tests/__snapshots__/test_tado.ambr | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/__snapshots__/test_models_unified.ambr b/tests/__snapshots__/test_models_unified.ambr index 194b7d3..2d48da1 100644 --- a/tests/__snapshots__/test_models_unified.ambr +++ b/tests/__snapshots__/test_models_unified.ambr @@ -29,7 +29,7 @@ 'device_type': 'SU04', 'firmware_version': '287.1', 'serial': 'SU0000000000', - 'temperature_offset': None, + 'temperature_offset': -0.3, }) # --- # name: test_device_from_x_disconnected @@ -40,6 +40,6 @@ 'device_type': 'VA04', 'firmware_version': '280.1', 'serial': 'VA0000000006', - 'temperature_offset': None, + 'temperature_offset': -1.0, }) # --- diff --git a/tests/__snapshots__/test_tado.ambr b/tests/__snapshots__/test_tado.ambr index 824126f..c7df62c 100644 --- a/tests/__snapshots__/test_tado.ambr +++ b/tests/__snapshots__/test_tado.ambr @@ -586,7 +586,7 @@ 'device_type': 'SU04', 'firmware_version': '287.1', 'serial': 'SU0000000000', - 'temperature_offset': None, + 'temperature_offset': -0.3, }), dict({ 'battery_state': 'NORMAL', @@ -595,7 +595,7 @@ 'device_type': 'VA04', 'firmware_version': '280.1', 'serial': 'VA0000000001', - 'temperature_offset': None, + 'temperature_offset': -2.2, }), dict({ 'battery_state': 'NORMAL', @@ -604,7 +604,7 @@ 'device_type': 'VA04', 'firmware_version': '280.1', 'serial': 'VA0000000002', - 'temperature_offset': None, + 'temperature_offset': -0.5, }), dict({ 'battery_state': 'NORMAL', @@ -613,7 +613,7 @@ 'device_type': 'VA04', 'firmware_version': '280.1', 'serial': 'VA0000000003', - 'temperature_offset': None, + 'temperature_offset': -2.0, }), dict({ 'battery_state': 'NORMAL', @@ -622,7 +622,7 @@ 'device_type': 'VA04', 'firmware_version': '280.1', 'serial': 'VA0000000004', - 'temperature_offset': None, + 'temperature_offset': -2.0, }), dict({ 'battery_state': 'NORMAL', @@ -631,7 +631,7 @@ 'device_type': 'VA04', 'firmware_version': '280.1', 'serial': 'VA0000000005', - 'temperature_offset': None, + 'temperature_offset': -1.0, }), dict({ 'battery_state': 'NORMAL', @@ -640,7 +640,7 @@ 'device_type': 'VA04', 'firmware_version': '280.1', 'serial': 'VA0000000006', - 'temperature_offset': None, + 'temperature_offset': -1.0, }), dict({ 'battery_state': 'NORMAL', @@ -649,7 +649,7 @@ 'device_type': 'VA04', 'firmware_version': '280.1', 'serial': 'VA0000000007', - 'temperature_offset': None, + 'temperature_offset': -1.0, }), dict({ 'battery_state': 'NORMAL', @@ -658,7 +658,7 @@ 'device_type': 'VA04', 'firmware_version': '280.1', 'serial': 'VA0000000008', - 'temperature_offset': None, + 'temperature_offset': -1.0, }), dict({ 'battery_state': None, From f19d8446fa46a4d2a05d49e21c92f6bcf556b564 Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Tue, 3 Mar 2026 22:31:37 +0100 Subject: [PATCH 10/10] move to unifier class --- src/tadoasync/tadoasync.py | 47 +++++--------------- src/tadoasync/unifier.py | 89 ++++++++++++++++++++++++++++++++++++++ tests/test_unifier.py | 65 ++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 36 deletions(-) create mode 100644 src/tadoasync/unifier.py create mode 100644 tests/test_unifier.py diff --git a/src/tadoasync/tadoasync.py b/src/tadoasync/tadoasync.py index 4f34871..b12b124 100644 --- a/src/tadoasync/tadoasync.py +++ b/src/tadoasync/tadoasync.py @@ -9,7 +9,7 @@ from dataclasses import dataclass from datetime import UTC, datetime, timedelta from importlib import metadata -from typing import Self +from typing import TYPE_CHECKING, Self from urllib.parse import urlencode import jwt @@ -18,7 +18,6 @@ from aiohttp.client import ClientSession from yarl import URL -from tadoasync import models_unified as unified_models from tadoasync.api_v3 import ApiV3 from tadoasync.api_x import ApiX from tadoasync.const import ( @@ -36,7 +35,6 @@ CONST_MODE_OFF, CONST_MODE_SMART_SCHEDULE, CONST_VERTICAL_SWING_OFF, - INSIDE_TEMPERATURE_MEASUREMENT, TADO_HVAC_ACTION_TO_MODES, TADO_MODES_TO_HVAC_ACTION, TYPE_AIR_CONDITIONING, @@ -62,6 +60,10 @@ Zone, ZoneState, ) +from tadoasync.unifier import get_unifier_from_generation + +if TYPE_CHECKING: + from tadoasync import models_unified as unified_models CLIENT_ID = "1bb50063-6b0c-4d11-bd99-387f4a91cc46" TOKEN_URL = "https://login.tado.com/oauth2/token" # noqa: S105 @@ -555,39 +557,12 @@ async def get_device_info( async def get_unified_devices(self) -> list[unified_models.Device]: """Get devices in a unified format, compatible with both Tado X and v3.""" - if self._tado_line == TadoLine.PRE_LINE_X: - devices = await self.get_devices() - devices_unified = [] - if not devices: - raise TadoError("No devices found for the home") - for v3_device in devices: - offset = None - if ( - INSIDE_TEMPERATURE_MEASUREMENT - in v3_device.characteristics.capabilities - ): - try: - offset = await self.api_v3.get_device_temperature_offset( - v3_device.serial_no, - ) - except TadoError as err: - _LOGGER.warning( - "Failed to get temperature offset for device %s: %s", - v3_device.serial_no, - err, - ) - devices_unified.append(unified_models.Device.from_v3(v3_device, offset)) - return devices_unified - if self._tado_line == TadoLine.LINE_X: - rooms_and_devices = await self.api_x.get_rooms_and_devices() - devices_unified = [] - for room in rooms_and_devices.rooms: - for x_device in room.devices: - devices_unified.append(unified_models.Device.from_x(x_device)) - for x_device in rooms_and_devices.other_devices: - devices_unified.append(unified_models.Device.from_x(x_device)) - return devices_unified - raise TadoError("Tado Line not set. Cannot get unified devices.") + unifier = get_unifier_from_generation( + generation=self._tado_line, + api_x=self.api_x, + api_v3=self.api_v3, + ) + return await unifier.get_devices() async def set_child_lock(self, serial_no: str, *, child_lock: bool) -> None: """Set the child lock.""" diff --git a/src/tadoasync/unifier.py b/src/tadoasync/unifier.py new file mode 100644 index 0000000..ac34871 --- /dev/null +++ b/src/tadoasync/unifier.py @@ -0,0 +1,89 @@ +"""Unifier classes for API generation-specific response handling.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Protocol + +from tadoasync import models_unified as unified_models +from tadoasync.const import INSIDE_TEMPERATURE_MEASUREMENT, TadoLine +from tadoasync.exceptions import TadoError + +if TYPE_CHECKING: + from tadoasync.api_v3 import ApiV3 + from tadoasync.api_x import ApiX + +_LOGGER = logging.getLogger(__name__) + + +class DeviceUnifier(Protocol): # pylint: disable=too-few-public-methods + """Interface class for API generation device unifiers.""" + + async def get_devices(self) -> list[unified_models.Device]: + """Return unified devices for this API generation.""" + + +class UnifierV3: # pylint: disable=too-few-public-methods + """Unifier for the v3 Tado API.""" + + def __init__(self, api_v3: ApiV3) -> None: + """Initialize the v3 unifier.""" + self._api_v3 = api_v3 + + async def get_devices(self) -> list[unified_models.Device]: + """Get devices in unified format from the Tado v3 API.""" + devices = await self._api_v3.get_devices() + if not devices: + raise TadoError("No devices found for the home") + + devices_unified: list[unified_models.Device] = [] + for v3_device in devices: + offset = None + if INSIDE_TEMPERATURE_MEASUREMENT in v3_device.characteristics.capabilities: + try: + offset = await self._api_v3.get_device_temperature_offset( + v3_device.serial_no, + ) + except TadoError as err: + _LOGGER.warning( + "Failed to get temperature offset for device %s: %s", + v3_device.serial_no, + err, + ) + devices_unified.append(unified_models.Device.from_v3(v3_device, offset)) + return devices_unified + + +class UnifierX: # pylint: disable=too-few-public-methods + """Unifier for the Tado X API.""" + + def __init__(self, api_x: ApiX) -> None: + """Initialize the Tado X unifier.""" + self._api_x = api_x + + async def get_devices(self) -> list[unified_models.Device]: + """Get devices in unified format from the Tado X API.""" + rooms_and_devices = await self._api_x.get_rooms_and_devices() + devices_unified = [ + unified_models.Device.from_x(x_device) + for room in rooms_and_devices.rooms + for x_device in room.devices + ] + devices_unified.extend( + [ + unified_models.Device.from_x(x_device) + for x_device in rooms_and_devices.other_devices + ] + ) + return devices_unified + + +def get_unifier_from_generation( + generation: TadoLine | None, api_x: ApiX, api_v3: ApiV3 +) -> DeviceUnifier: + """Return the correct unifier for the selected Tado generation.""" + if generation == TadoLine.PRE_LINE_X: + return UnifierV3(api_v3) + if generation == TadoLine.LINE_X: + return UnifierX(api_x) + raise TadoError("Tado Line not set. Cannot get unified devices.") diff --git a/tests/test_unifier.py b/tests/test_unifier.py new file mode 100644 index 0000000..c3245aa --- /dev/null +++ b/tests/test_unifier.py @@ -0,0 +1,65 @@ +"""Tests for API generation-specific unifiers.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, create_autospec + +import orjson +import pytest +from tadoasync import models_v3 +from tadoasync.api_v3 import ApiV3 +from tadoasync.api_x import ApiX +from tadoasync.const import TadoLine +from tadoasync.exceptions import TadoError +from tadoasync.unifier import UnifierV3, UnifierX, get_unifier_from_generation + +from tests import load_fixture + + +async def test_unifier_v3_offset_error_logs_and_continues( + caplog: pytest.LogCaptureFixture, +) -> None: + """Continue unifier when offset retrieval fails for v3 device.""" + v3_devices = orjson.loads(load_fixture("devices.json")) + v3_device = models_v3.Device.from_dict(v3_devices[1]) + + api_v3 = create_autospec(ApiV3, instance=True) + api_v3.get_devices = AsyncMock(return_value=[v3_device]) + api_v3.get_device_temperature_offset = AsyncMock(side_effect=TadoError("boom")) + + unifier = UnifierV3(api_v3) + + with caplog.at_level("WARNING"): + devices_unified = await unifier.get_devices() + + assert len(devices_unified) == 1 + assert devices_unified[0].serial == v3_device.serial_no + assert devices_unified[0].temperature_offset is None + assert "Failed to get temperature offset for device SerialNo2: boom" in caplog.text + + +def test_get_unifier_from_generation_v3() -> None: + """Build a v3 unifier for PRE_LINE_X generation.""" + api_x = create_autospec(ApiX, instance=True) + api_v3 = create_autospec(ApiV3, instance=True) + unifier = get_unifier_from_generation(TadoLine.PRE_LINE_X, api_x, api_v3) + assert isinstance(unifier, UnifierV3) + + +def test_get_unifier_from_generation_x() -> None: + """Build an X unifier for LINE_X generation.""" + api_x = create_autospec(ApiX, instance=True) + api_v3 = create_autospec(ApiV3, instance=True) + unifier = get_unifier_from_generation(TadoLine.LINE_X, api_x, api_v3) + assert isinstance(unifier, UnifierX) + + +def test_get_unifier_from_generation_unknown_line() -> None: + """Raise when no generation is available.""" + api_x = create_autospec(ApiX, instance=True) + api_v3 = create_autospec(ApiV3, instance=True) + with pytest.raises( + TadoError, + match="Tado Line not set. Cannot get unified devices.", + ): + get_unifier_from_generation(None, api_x, api_v3)