diff --git a/src/tadoasync/api_v3.py b/src/tadoasync/api_v3.py new file mode 100644 index 0000000..e7bb977 --- /dev/null +++ b/src/tadoasync/api_v3.py @@ -0,0 +1,48 @@ +"""Wrapper for Tado v3 API.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import orjson + +from tadoasync.models_v3 import Device, TemperatureOffset, Zone + +if TYPE_CHECKING: + from tadoasync.tadoasync import Tado + + +class ApiV3: + """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( # 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( # 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( # 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") # noqa: SLF001 + 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..8418d7f --- /dev/null +++ b/src/tadoasync/api_x.py @@ -0,0 +1,28 @@ +"""Wrapper for Tado X API.""" + +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: # pylint: disable=too-few-public-methods + """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( # noqa: SLF001 + endpoint=API_URL, + uri=f"homes/{self._base._home_id}/roomsAndDevices", # noqa: SLF001 + ) + 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..9ada897 --- /dev/null +++ b/src/tadoasync/models_unified.py @@ -0,0 +1,61 @@ +"""Abstract models for interaction with the Tado API, regardless of line.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from mashumaro.mixins.orjson import DataClassORJSONMixin + +if TYPE_CHECKING: + from tadoasync import models_v3, models_x + + +@dataclass +class Device(DataClassORJSONMixin): + """Device model.""" + + device_type: str + serial: str + firmware_version: str + + connection_state: bool + + battery_state: str | None + + 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, + 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, + ) 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..6e70e91 --- /dev/null +++ b/src/tadoasync/models_x.py @@ -0,0 +1,76 @@ +"""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 + + +@dataclass +class DeviceManualControlTermination(DataClassORJSONMixin): + """Represents the manual control termination settings of a device.""" + + type: str + 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")) + 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 121be0c..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,6 +18,8 @@ from aiohttp.client import ClientSession from yarl import URL +from tadoasync.api_v3 import ApiV3 +from tadoasync.api_x import ApiX from tadoasync.const import ( CONST_AWAY, CONST_FAN_AUTO, @@ -37,6 +39,7 @@ TADO_MODES_TO_HVAC_ACTION, TYPE_AIR_CONDITIONING, HttpMethod, + TadoLine, ) from tadoasync.exceptions import ( TadoAuthenticationError, @@ -46,7 +49,7 @@ TadoForbiddenError, TadoReadingError, ) -from tadoasync.models import ( +from tadoasync.models_v3 import ( Capabilities, Device, GetMe, @@ -57,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 @@ -109,6 +116,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 @@ -544,6 +555,15 @@ 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]: + """Get devices in a unified format, compatible with both Tado X and v3.""" + 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.""" await self._request( @@ -580,6 +600,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) @@ -758,9 +783,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/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/__snapshots__/test_models_unified.ambr b/tests/__snapshots__/test_models_unified.ambr new file mode 100644 index 0000000..2d48da1 --- /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': -0.3, + }) +# --- +# 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': -1.0, + }) +# --- diff --git a/tests/__snapshots__/test_tado.ambr b/tests/__snapshots__/test_tado.ambr index 5e1ff42..c7df62c 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': -0.3, + }), + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': False, + 'connection_state': True, + 'device_type': 'VA04', + 'firmware_version': '280.1', + 'serial': 'VA0000000001', + 'temperature_offset': -2.2, + }), + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': False, + 'connection_state': True, + 'device_type': 'VA04', + 'firmware_version': '280.1', + 'serial': 'VA0000000002', + 'temperature_offset': -0.5, + }), + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': False, + 'connection_state': True, + 'device_type': 'VA04', + 'firmware_version': '280.1', + 'serial': 'VA0000000003', + 'temperature_offset': -2.0, + }), + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': False, + 'connection_state': True, + 'device_type': 'VA04', + 'firmware_version': '280.1', + 'serial': 'VA0000000004', + 'temperature_offset': -2.0, + }), + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': False, + 'connection_state': True, + 'device_type': 'VA04', + 'firmware_version': '280.1', + 'serial': 'VA0000000005', + 'temperature_offset': -1.0, + }), + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': False, + 'connection_state': False, + 'device_type': 'VA04', + 'firmware_version': '280.1', + 'serial': 'VA0000000006', + 'temperature_offset': -1.0, + }), + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': False, + 'connection_state': True, + 'device_type': 'VA04', + 'firmware_version': '280.1', + 'serial': 'VA0000000007', + 'temperature_offset': -1.0, + }), + dict({ + 'battery_state': 'NORMAL', + 'child_lock_enabled': False, + 'connection_state': True, + 'device_type': 'VA04', + 'firmware_version': '280.1', + 'serial': 'VA0000000008', + 'temperature_offset': -1.0, + }), + 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..a54fe6d --- /dev/null +++ b/tests/test_models_unified.py @@ -0,0 +1,61 @@ +"""Tests for abstract cross-line models.""" + +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 tests import load_fixture + +if TYPE_CHECKING: + from syrupy import SnapshotAssertion + + +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 aa0cce0..34f2a55 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 @@ -14,6 +15,7 @@ from tadoasync import ( Tado, ) +from tadoasync.const import TadoLine from tadoasync.exceptions import ( TadoAuthenticationError, TadoBadRequestError, @@ -26,7 +28,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 AUTH_TOKEN = load_fixture("auth_token.txt") @@ -766,3 +768,70 @@ 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, +) -> 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, +) -> None: + """Test get devices when tado line is not set.""" + python_tado._tado_line = None + + with pytest.raises( + TadoError, match=re.escape("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..fb16e7d --- /dev/null +++ b/tests/test_tado_v3.py @@ -0,0 +1,62 @@ +"""Tests for the Python Tado X models.""" + +from aioresponses import aioresponses +from tadoasync import ( + Tado, +) + +from syrupy import SnapshotAssertion +from tests import load_fixture + +from .const import TADO_API_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..e999709 --- /dev/null +++ b/tests/test_tado_x.py @@ -0,0 +1,23 @@ +"""Tests for the Python Tado X models.""" + +from aioresponses import aioresponses +from tadoasync import ( + Tado, +) + +from syrupy import SnapshotAssertion +from tests import load_fixture + +from .const import TADO_X_API_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 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)