diff --git a/CODEOWNERS b/CODEOWNERS index b53e0a929bb52e..c9b3a53184fd51 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -433,6 +433,7 @@ build.json @home-assistant/supervisor /homeassistant/components/evil_genius_labs/ @balloob /tests/components/evil_genius_labs/ @balloob /homeassistant/components/evohome/ @zxdavb +/tests/components/evohome/ @zxdavb /homeassistant/components/ezviz/ @RenierM26 @baqs /tests/components/ezviz/ @RenierM26 @baqs /homeassistant/components/faa_delays/ @ntilley905 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c7934e7fc84d96..e55efb78abbf44 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -717,6 +717,9 @@ eternalegypt==0.0.16 # homeassistant.components.eufylife_ble eufylife-ble-client==0.1.8 +# homeassistant.components.evohome +evohome-async==0.4.20 + # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 diff --git a/tests/components/evohome/__init__.py b/tests/components/evohome/__init__.py new file mode 100644 index 00000000000000..588e0f61746611 --- /dev/null +++ b/tests/components/evohome/__init__.py @@ -0,0 +1 @@ +"""The tests for the evohome integration.""" diff --git a/tests/components/evohome/conftest.py b/tests/components/evohome/conftest.py new file mode 100644 index 00000000000000..260330896b79db --- /dev/null +++ b/tests/components/evohome/conftest.py @@ -0,0 +1,111 @@ +"""Fixtures and helpers for the evohome tests.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any, Final +from unittest.mock import MagicMock, patch + +from aiohttp import ClientSession +from evohomeasync2 import EvohomeClient +from evohomeasync2.broker import Broker +import pytest + +from homeassistant.components.evohome import CONF_PASSWORD, CONF_USERNAME, DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util.json import JsonArrayType, JsonObjectType + +from .const import ACCESS_TOKEN, REFRESH_TOKEN + +from tests.common import load_json_array_fixture, load_json_object_fixture + +TEST_CONFIG: Final = { + CONF_USERNAME: "username", + CONF_PASSWORD: "password", +} + + +def user_account_config_fixture() -> JsonObjectType: + """Load JSON for the config of a user's account.""" + return load_json_object_fixture("user_account.json", DOMAIN) + + +def user_locations_config_fixture() -> JsonArrayType: + """Load JSON for the config of a user's installation (a list of locations).""" + return load_json_array_fixture("user_locations.json", DOMAIN) + + +def location_status_fixture(loc_id: str) -> JsonObjectType: + """Load JSON for the status of a specific location.""" + return load_json_object_fixture(f"status_{loc_id}.json", DOMAIN) + + +def dhw_schedule_fixture() -> JsonObjectType: + """Load JSON for the schedule of a domesticHotWater zone.""" + return load_json_object_fixture("schedule_dhw.json", DOMAIN) + + +def zone_schedule_fixture() -> JsonObjectType: + """Load JSON for the schedule of a temperatureZone zone.""" + return load_json_object_fixture("schedule_zone.json", DOMAIN) + + +async def mock_get( + self: Broker, url: str, **kwargs: Any +) -> JsonArrayType | JsonObjectType: + """Return the JSON for a HTTP get of a given URL.""" + + # a proxy for the behaviour of the real web API + if self.refresh_token is None: + self.refresh_token = f"new_{REFRESH_TOKEN}" + + if self.access_token_expires is None or self.access_token_expires < datetime.now(): + self.access_token = f"new_{ACCESS_TOKEN}" + self.access_token_expires = datetime.now() + timedelta(minutes=30) + + # assume a valid GET, and return the JSON for that web API + if url == "userAccount": # userAccount + return user_account_config_fixture() + + if url.startswith("location"): + if "installationInfo" in url: # location/installationInfo?userId={id} + return user_locations_config_fixture() + if "location" in url: # location/{id}/status + return location_status_fixture("2738909") + + elif "schedule" in url: + if url.startswith("domesticHotWater"): # domesticHotWater/{id}/schedule + return dhw_schedule_fixture() + if url.startswith("temperatureZone"): # temperatureZone/{id}/schedule + return zone_schedule_fixture() + + pytest.xfail(f"Unexpected URL: {url}") + + +@patch("evohomeasync2.broker.Broker.get", mock_get) +async def setup_evohome(hass: HomeAssistant, test_config: dict[str, str]) -> MagicMock: + """Set up the evohome integration and return its client. + + The class is mocked here to check the client was instantiated with the correct args. + """ + + with ( + patch("homeassistant.components.evohome.evo.EvohomeClient") as mock_client, + patch("homeassistant.components.evohome.ev1.EvohomeClient", return_value=None), + ): + mock_client.side_effect = EvohomeClient + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: test_config}) + await hass.async_block_till_done() + + mock_client.assert_called_once() + + assert mock_client.call_args.args[0] == test_config[CONF_USERNAME] + assert mock_client.call_args.args[1] == test_config[CONF_PASSWORD] + + assert isinstance(mock_client.call_args.kwargs["session"], ClientSession) + + assert mock_client.account_info is not None + + return mock_client diff --git a/tests/components/evohome/const.py b/tests/components/evohome/const.py new file mode 100644 index 00000000000000..0b298db533ac69 --- /dev/null +++ b/tests/components/evohome/const.py @@ -0,0 +1,10 @@ +"""Constants for the evohome tests.""" + +from __future__ import annotations + +from typing import Final + +ACCESS_TOKEN: Final = "at_1dc7z657UKzbhKA..." +REFRESH_TOKEN: Final = "rf_jg68ZCKYdxEI3fF..." +SESSION_ID: Final = "F7181186..." +USERNAME: Final = "test_user@gmail.com" diff --git a/tests/components/evohome/fixtures/schedule_dhw.json b/tests/components/evohome/fixtures/schedule_dhw.json new file mode 100644 index 00000000000000..da9a225fb8226e --- /dev/null +++ b/tests/components/evohome/fixtures/schedule_dhw.json @@ -0,0 +1,81 @@ +{ + "dailySchedules": [ + { + "dayOfWeek": "Monday", + "switchpoints": [ + { "dhwState": "On", "timeOfDay": "06:30:00" }, + { "dhwState": "Off", "timeOfDay": "08:30:00" }, + { "dhwState": "On", "timeOfDay": "12:00:00" }, + { "dhwState": "Off", "timeOfDay": "13:00:00" }, + { "dhwState": "On", "timeOfDay": "16:30:00" }, + { "dhwState": "Off", "timeOfDay": "22:30:00" } + ] + }, + { + "dayOfWeek": "Tuesday", + "switchpoints": [ + { "dhwState": "On", "timeOfDay": "06:30:00" }, + { "dhwState": "Off", "timeOfDay": "08:30:00" }, + { "dhwState": "On", "timeOfDay": "12:00:00" }, + { "dhwState": "Off", "timeOfDay": "13:00:00" }, + { "dhwState": "On", "timeOfDay": "16:30:00" }, + { "dhwState": "Off", "timeOfDay": "22:30:00" } + ] + }, + { + "dayOfWeek": "Wednesday", + "switchpoints": [ + { "dhwState": "On", "timeOfDay": "06:30:00" }, + { "dhwState": "Off", "timeOfDay": "08:30:00" }, + { "dhwState": "On", "timeOfDay": "12:00:00" }, + { "dhwState": "Off", "timeOfDay": "13:00:00" }, + { "dhwState": "On", "timeOfDay": "16:30:00" }, + { "dhwState": "Off", "timeOfDay": "22:30:00" } + ] + }, + { + "dayOfWeek": "Thursday", + "switchpoints": [ + { "dhwState": "On", "timeOfDay": "06:30:00" }, + { "dhwState": "Off", "timeOfDay": "08:30:00" }, + { "dhwState": "On", "timeOfDay": "12:00:00" }, + { "dhwState": "Off", "timeOfDay": "13:00:00" }, + { "dhwState": "On", "timeOfDay": "16:30:00" }, + { "dhwState": "Off", "timeOfDay": "22:30:00" } + ] + }, + { + "dayOfWeek": "Friday", + "switchpoints": [ + { "dhwState": "On", "timeOfDay": "06:30:00" }, + { "dhwState": "Off", "timeOfDay": "08:30:00" }, + { "dhwState": "On", "timeOfDay": "12:00:00" }, + { "dhwState": "Off", "timeOfDay": "13:00:00" }, + { "dhwState": "On", "timeOfDay": "16:30:00" }, + { "dhwState": "Off", "timeOfDay": "22:30:00" } + ] + }, + { + "dayOfWeek": "Saturday", + "switchpoints": [ + { "dhwState": "On", "timeOfDay": "06:30:00" }, + { "dhwState": "Off", "timeOfDay": "09:30:00" }, + { "dhwState": "On", "timeOfDay": "12:00:00" }, + { "dhwState": "Off", "timeOfDay": "13:00:00" }, + { "dhwState": "On", "timeOfDay": "16:30:00" }, + { "dhwState": "Off", "timeOfDay": "23:00:00" } + ] + }, + { + "dayOfWeek": "Sunday", + "switchpoints": [ + { "dhwState": "On", "timeOfDay": "06:30:00" }, + { "dhwState": "Off", "timeOfDay": "09:30:00" }, + { "dhwState": "On", "timeOfDay": "12:00:00" }, + { "dhwState": "Off", "timeOfDay": "13:00:00" }, + { "dhwState": "On", "timeOfDay": "16:30:00" }, + { "dhwState": "Off", "timeOfDay": "23:00:00" } + ] + } + ] +} diff --git a/tests/components/evohome/fixtures/schedule_zone.json b/tests/components/evohome/fixtures/schedule_zone.json new file mode 100644 index 00000000000000..5030d92ff3ddad --- /dev/null +++ b/tests/components/evohome/fixtures/schedule_zone.json @@ -0,0 +1,67 @@ +{ + "dailySchedules": [ + { + "dayOfWeek": "Monday", + "switchpoints": [ + { "heatSetpoint": 18.1, "timeOfDay": "07:00:00" }, + { "heatSetpoint": 16.0, "timeOfDay": "08:00:00" }, + { "heatSetpoint": 18.6, "timeOfDay": "22:10:00" }, + { "heatSetpoint": 15.9, "timeOfDay": "23:00:00" } + ] + }, + { + "dayOfWeek": "Tuesday", + "switchpoints": [ + { "heatSetpoint": 18.1, "timeOfDay": "07:00:00" }, + { "heatSetpoint": 16.0, "timeOfDay": "08:00:00" }, + { "heatSetpoint": 18.6, "timeOfDay": "22:10:00" }, + { "heatSetpoint": 15.9, "timeOfDay": "23:00:00" } + ] + }, + { + "dayOfWeek": "Wednesday", + "switchpoints": [ + { "heatSetpoint": 18.1, "timeOfDay": "07:00:00" }, + { "heatSetpoint": 16.0, "timeOfDay": "08:00:00" }, + { "heatSetpoint": 18.6, "timeOfDay": "22:10:00" }, + { "heatSetpoint": 15.9, "timeOfDay": "23:00:00" } + ] + }, + { + "dayOfWeek": "Thursday", + "switchpoints": [ + { "heatSetpoint": 18.1, "timeOfDay": "07:00:00" }, + { "heatSetpoint": 16.0, "timeOfDay": "08:00:00" }, + { "heatSetpoint": 18.6, "timeOfDay": "22:10:00" }, + { "heatSetpoint": 15.9, "timeOfDay": "23:00:00" } + ] + }, + { + "dayOfWeek": "Friday", + "switchpoints": [ + { "heatSetpoint": 18.1, "timeOfDay": "07:00:00" }, + { "heatSetpoint": 16.0, "timeOfDay": "08:00:00" }, + { "heatSetpoint": 18.6, "timeOfDay": "22:10:00" }, + { "heatSetpoint": 15.9, "timeOfDay": "23:00:00" } + ] + }, + { + "dayOfWeek": "Saturday", + "switchpoints": [ + { "heatSetpoint": 18.5, "timeOfDay": "07:00:00" }, + { "heatSetpoint": 16.0, "timeOfDay": "08:30:00" }, + { "heatSetpoint": 18.6, "timeOfDay": "22:10:00" }, + { "heatSetpoint": 15.9, "timeOfDay": "23:00:00" } + ] + }, + { + "dayOfWeek": "Sunday", + "switchpoints": [ + { "heatSetpoint": 18.5, "timeOfDay": "07:00:00" }, + { "heatSetpoint": 16.0, "timeOfDay": "08:30:00" }, + { "heatSetpoint": 18.6, "timeOfDay": "22:10:00" }, + { "heatSetpoint": 15.9, "timeOfDay": "23:00:00" } + ] + } + ] +} diff --git a/tests/components/evohome/fixtures/status_2738909.json b/tests/components/evohome/fixtures/status_2738909.json new file mode 100644 index 00000000000000..6d555ba4e3e49c --- /dev/null +++ b/tests/components/evohome/fixtures/status_2738909.json @@ -0,0 +1,125 @@ +{ + "locationId": "2738909", + "gateways": [ + { + "gatewayId": "2499896", + "temperatureControlSystems": [ + { + "systemId": "3432522", + "zones": [ + { + "zoneId": "3432521", + "name": "Dead Zone", + "temperatureStatus": { "isAvailable": false }, + "setpointStatus": { + "targetHeatTemperature": 17.0, + "setpointMode": "FollowSchedule" + }, + "activeFaults": [] + }, + { + "zoneId": "3432576", + "name": "Main Room", + "temperatureStatus": { "temperature": 19.0, "isAvailable": true }, + "setpointStatus": { + "targetHeatTemperature": 17.0, + "setpointMode": "PermanentOverride" + }, + "activeFaults": [ + { + "faultType": "TempZoneActuatorCommunicationLost", + "since": "2022-03-02T15:56:01" + } + ] + }, + { + "zoneId": "3432577", + "name": "Front Room", + "temperatureStatus": { "temperature": 19.0, "isAvailable": true }, + "setpointStatus": { + "targetHeatTemperature": 21.0, + "setpointMode": "TemporaryOverride", + "until": "2022-03-07T19:00:00Z" + }, + "activeFaults": [ + { + "faultType": "TempZoneActuatorLowBattery", + "since": "2022-03-02T04:50:20" + } + ] + }, + { + "zoneId": "3432578", + "temperatureStatus": { "temperature": 20.0, "isAvailable": true }, + "activeFaults": [], + "setpointStatus": { + "targetHeatTemperature": 17.0, + "setpointMode": "FollowSchedule" + }, + "name": "Kitchen" + }, + { + "zoneId": "3432579", + "temperatureStatus": { "temperature": 20.0, "isAvailable": true }, + "activeFaults": [], + "setpointStatus": { + "targetHeatTemperature": 16.0, + "setpointMode": "FollowSchedule" + }, + "name": "Bathroom Dn" + }, + { + "zoneId": "3432580", + "temperatureStatus": { "temperature": 21.0, "isAvailable": true }, + "activeFaults": [], + "setpointStatus": { + "targetHeatTemperature": 16.0, + "setpointMode": "FollowSchedule" + }, + "name": "Main Bedroom" + }, + { + "zoneId": "3449703", + "temperatureStatus": { "temperature": 19.5, "isAvailable": true }, + "activeFaults": [], + "setpointStatus": { + "targetHeatTemperature": 17.0, + "setpointMode": "FollowSchedule" + }, + "name": "Kids Room" + }, + { + "zoneId": "3449740", + "temperatureStatus": { "temperature": 21.5, "isAvailable": true }, + "activeFaults": [], + "setpointStatus": { + "targetHeatTemperature": 16.5, + "setpointMode": "FollowSchedule" + }, + "name": "" + }, + { + "zoneId": "3450733", + "temperatureStatus": { "temperature": 19.5, "isAvailable": true }, + "activeFaults": [], + "setpointStatus": { + "targetHeatTemperature": 14.0, + "setpointMode": "PermanentOverride" + }, + "name": "Spare Room" + } + ], + "dhw": { + "dhwId": "3933910", + "temperatureStatus": { "temperature": 23.0, "isAvailable": true }, + "stateStatus": { "state": "Off", "mode": "PermanentOverride" }, + "activeFaults": [] + }, + "activeFaults": [], + "systemModeStatus": { "mode": "AutoWithEco", "isPermanent": true } + } + ], + "activeFaults": [] + } + ] +} diff --git a/tests/components/evohome/fixtures/user_account.json b/tests/components/evohome/fixtures/user_account.json new file mode 100644 index 00000000000000..99a96a7961e314 --- /dev/null +++ b/tests/components/evohome/fixtures/user_account.json @@ -0,0 +1,11 @@ +{ + "userId": "2263181", + "username": "user_2263181@gmail.com", + "firstname": "John", + "lastname": "Smith", + "streetAddress": "1 Main Street", + "city": "London", + "postcode": "E1 1AA", + "country": "UnitedKingdom", + "language": "enGB" +} diff --git a/tests/components/evohome/fixtures/user_locations.json b/tests/components/evohome/fixtures/user_locations.json new file mode 100644 index 00000000000000..cf59aa9ae8a827 --- /dev/null +++ b/tests/components/evohome/fixtures/user_locations.json @@ -0,0 +1,346 @@ +[ + { + "locationInfo": { + "locationId": "2738909", + "name": "My Home", + "streetAddress": "1 Main Street", + "city": "London", + "country": "UnitedKingdom", + "postcode": "E1 1AA", + "locationType": "Residential", + "useDaylightSaveSwitching": true, + "timeZone": { + "timeZoneId": "GMTStandardTime", + "displayName": "(UTC+00:00) Dublin, Edinburgh, Lisbon, London", + "offsetMinutes": 0, + "currentOffsetMinutes": 60, + "supportsDaylightSaving": true + }, + "locationOwner": { + "userId": "2263181", + "username": "user_2263181@gmail.com", + "firstname": "John", + "lastname": "Smith" + } + }, + "gateways": [ + { + "gatewayInfo": { + "gatewayId": "2499896", + "mac": "00D02DEE0000", + "crc": "1234", + "isWiFi": false + }, + "temperatureControlSystems": [ + { + "systemId": "3432522", + "modelType": "EvoTouch", + "zones": [ + { + "zoneId": "3432521", + "modelType": "HeatingZone", + "setpointCapabilities": { + "maxHeatSetpoint": 35.0, + "minHeatSetpoint": 5.0, + "valueResolution": 0.5, + "canControlHeat": true, + "canControlCool": false, + "allowedSetpointModes": [ + "PermanentOverride", + "FollowSchedule", + "TemporaryOverride" + ], + "maxDuration": "1.00:00:00", + "timingResolution": "00:10:00" + }, + "scheduleCapabilities": { + "maxSwitchpointsPerDay": 6, + "minSwitchpointsPerDay": 1, + "timingResolution": "00:10:00", + "setpointValueResolution": 0.5 + }, + "name": "Dead Zone", + "zoneType": "RadiatorZone" + }, + { + "zoneId": "3432576", + "modelType": "HeatingZone", + "setpointCapabilities": { + "maxHeatSetpoint": 35.0, + "minHeatSetpoint": 5.0, + "valueResolution": 0.5, + "canControlHeat": true, + "canControlCool": false, + "allowedSetpointModes": [ + "PermanentOverride", + "FollowSchedule", + "TemporaryOverride" + ], + "maxDuration": "1.00:00:00", + "timingResolution": "00:10:00" + }, + "scheduleCapabilities": { + "maxSwitchpointsPerDay": 6, + "minSwitchpointsPerDay": 1, + "timingResolution": "00:10:00", + "setpointValueResolution": 0.5 + }, + "name": "Main Room", + "zoneType": "RadiatorZone" + }, + { + "zoneId": "3432577", + "modelType": "HeatingZone", + "setpointCapabilities": { + "maxHeatSetpoint": 35.0, + "minHeatSetpoint": 5.0, + "valueResolution": 0.5, + "canControlHeat": true, + "canControlCool": false, + "allowedSetpointModes": [ + "PermanentOverride", + "FollowSchedule", + "TemporaryOverride" + ], + "maxDuration": "1.00:00:00", + "timingResolution": "00:10:00" + }, + "scheduleCapabilities": { + "maxSwitchpointsPerDay": 6, + "minSwitchpointsPerDay": 1, + "timingResolution": "00:10:00", + "setpointValueResolution": 0.5 + }, + "name": "Front Room", + "zoneType": "RadiatorZone" + }, + { + "zoneId": "3432578", + "modelType": "HeatingZone", + "setpointCapabilities": { + "maxHeatSetpoint": 35.0, + "minHeatSetpoint": 5.0, + "valueResolution": 0.5, + "canControlHeat": true, + "canControlCool": false, + "allowedSetpointModes": [ + "PermanentOverride", + "FollowSchedule", + "TemporaryOverride" + ], + "maxDuration": "1.00:00:00", + "timingResolution": "00:10:00" + }, + "scheduleCapabilities": { + "maxSwitchpointsPerDay": 6, + "minSwitchpointsPerDay": 1, + "timingResolution": "00:10:00", + "setpointValueResolution": 0.5 + }, + "name": "Kitchen", + "zoneType": "RadiatorZone" + }, + { + "zoneId": "3432579", + "modelType": "HeatingZone", + "setpointCapabilities": { + "maxHeatSetpoint": 35.0, + "minHeatSetpoint": 5.0, + "valueResolution": 0.5, + "canControlHeat": true, + "canControlCool": false, + "allowedSetpointModes": [ + "PermanentOverride", + "FollowSchedule", + "TemporaryOverride" + ], + "maxDuration": "1.00:00:00", + "timingResolution": "00:10:00" + }, + "scheduleCapabilities": { + "maxSwitchpointsPerDay": 6, + "minSwitchpointsPerDay": 1, + "timingResolution": "00:10:00", + "setpointValueResolution": 0.5 + }, + "name": "Bathroom Dn", + "zoneType": "RadiatorZone" + }, + { + "zoneId": "3432580", + "modelType": "HeatingZone", + "setpointCapabilities": { + "maxHeatSetpoint": 35.0, + "minHeatSetpoint": 5.0, + "valueResolution": 0.5, + "canControlHeat": true, + "canControlCool": false, + "allowedSetpointModes": [ + "PermanentOverride", + "FollowSchedule", + "TemporaryOverride" + ], + "maxDuration": "1.00:00:00", + "timingResolution": "00:10:00" + }, + "scheduleCapabilities": { + "maxSwitchpointsPerDay": 6, + "minSwitchpointsPerDay": 1, + "timingResolution": "00:10:00", + "setpointValueResolution": 0.5 + }, + "name": "Main Bedroom", + "zoneType": "RadiatorZone" + }, + { + "zoneId": "3449703", + "modelType": "HeatingZone", + "setpointCapabilities": { + "maxHeatSetpoint": 35.0, + "minHeatSetpoint": 5.0, + "valueResolution": 0.5, + "canControlHeat": true, + "canControlCool": false, + "allowedSetpointModes": [ + "PermanentOverride", + "FollowSchedule", + "TemporaryOverride" + ], + "maxDuration": "1.00:00:00", + "timingResolution": "00:10:00" + }, + "scheduleCapabilities": { + "maxSwitchpointsPerDay": 6, + "minSwitchpointsPerDay": 1, + "timingResolution": "00:10:00", + "setpointValueResolution": 0.5 + }, + "name": "Kids Room", + "zoneType": "RadiatorZone" + }, + { + "zoneId": "3449740", + "modelType": "Unknown", + "setpointCapabilities": { + "maxHeatSetpoint": 35.0, + "minHeatSetpoint": 5.0, + "valueResolution": 0.5, + "canControlHeat": true, + "canControlCool": false, + "allowedSetpointModes": [ + "PermanentOverride", + "FollowSchedule", + "TemporaryOverride" + ], + "maxDuration": "1.00:00:00", + "timingResolution": "00:10:00" + }, + "scheduleCapabilities": { + "maxSwitchpointsPerDay": 6, + "minSwitchpointsPerDay": 1, + "timingResolution": "00:10:00", + "setpointValueResolution": 0.5 + }, + "name": "", + "zoneType": "Unknown" + }, + { + "zoneId": "3450733", + "modelType": "xx", + "setpointCapabilities": { + "maxHeatSetpoint": 35.0, + "minHeatSetpoint": 5.0, + "valueResolution": 0.5, + "canControlHeat": true, + "canControlCool": false, + "allowedSetpointModes": [ + "PermanentOverride", + "FollowSchedule", + "TemporaryOverride" + ], + "maxDuration": "1.00:00:00", + "timingResolution": "00:10:00" + }, + "scheduleCapabilities": { + "maxSwitchpointsPerDay": 6, + "minSwitchpointsPerDay": 1, + "timingResolution": "00:10:00", + "setpointValueResolution": 0.5 + }, + "name": "Spare Room", + "zoneType": "xx" + } + ], + "dhw": { + "dhwId": "3933910", + "dhwStateCapabilitiesResponse": { + "allowedStates": ["On", "Off"], + "allowedModes": [ + "FollowSchedule", + "PermanentOverride", + "TemporaryOverride" + ], + "maxDuration": "1.00:00:00", + "timingResolution": "00:10:00" + }, + "scheduleCapabilitiesResponse": { + "maxSwitchpointsPerDay": 6, + "minSwitchpointsPerDay": 1, + "timingResolution": "00:10:00" + } + }, + "allowedSystemModes": [ + { + "systemMode": "HeatingOff", + "canBePermanent": true, + "canBeTemporary": false + }, + { + "systemMode": "Auto", + "canBePermanent": true, + "canBeTemporary": false + }, + { + "systemMode": "AutoWithReset", + "canBePermanent": true, + "canBeTemporary": false + }, + { + "systemMode": "AutoWithEco", + "canBePermanent": true, + "canBeTemporary": true, + "maxDuration": "1.00:00:00", + "timingResolution": "01:00:00", + "timingMode": "Duration" + }, + { + "systemMode": "Away", + "canBePermanent": true, + "canBeTemporary": true, + "maxDuration": "99.00:00:00", + "timingResolution": "1.00:00:00", + "timingMode": "Period" + }, + { + "systemMode": "DayOff", + "canBePermanent": true, + "canBeTemporary": true, + "maxDuration": "99.00:00:00", + "timingResolution": "1.00:00:00", + "timingMode": "Period" + }, + { + "systemMode": "Custom", + "canBePermanent": true, + "canBeTemporary": true, + "maxDuration": "99.00:00:00", + "timingResolution": "1.00:00:00", + "timingMode": "Period" + } + ] + } + ] + } + ] + } +] diff --git a/tests/components/evohome/test_storage.py b/tests/components/evohome/test_storage.py new file mode 100644 index 00000000000000..e87b847a9ff501 --- /dev/null +++ b/tests/components/evohome/test_storage.py @@ -0,0 +1,208 @@ +"""The tests for evohome storage load & save.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any, Final, NotRequired, TypedDict + +import pytest + +from homeassistant.components.evohome import ( + CONF_PASSWORD, + CONF_USERNAME, + DOMAIN, + STORAGE_KEY, + STORAGE_VER, + dt_aware_to_naive, +) +from homeassistant.core import HomeAssistant +import homeassistant.util.dt as dt_util + +from .conftest import setup_evohome +from .const import ACCESS_TOKEN, REFRESH_TOKEN, SESSION_ID, USERNAME + + +class _SessionDataT(TypedDict): + sessionId: str + + +class _TokenStoreT(TypedDict): + username: str + refresh_token: str + access_token: str + access_token_expires: str # 2024-07-27T23:57:30+01:00 + user_data: NotRequired[_SessionDataT] + + +class _EmptyStoreT(TypedDict): + pass + + +SZ_USERNAME: Final = "username" +SZ_REFRESH_TOKEN: Final = "refresh_token" +SZ_ACCESS_TOKEN: Final = "access_token" +SZ_ACCESS_TOKEN_EXPIRES: Final = "access_token_expires" +SZ_USER_DATA: Final = "user_data" + + +def dt_pair(dt_dtm: datetime) -> tuple[datetime, str]: + """Return a datetime without milliseconds and its string representation.""" + dt_str = dt_dtm.isoformat(timespec="seconds") # e.g. 2024-07-28T00:57:29+01:00 + return dt_util.parse_datetime(dt_str, raise_on_error=True), dt_str + + +ACCESS_TOKEN_EXP_DTM, ACCESS_TOKEN_EXP_STR = dt_pair(dt_util.now() + timedelta(hours=1)) + +USERNAME_DIFF: Final = f"not_{USERNAME}" +USERNAME_SAME: Final = USERNAME + +TEST_CONFIG: Final = { + CONF_USERNAME: USERNAME_SAME, + CONF_PASSWORD: "password", +} + +TEST_DATA: Final[dict[str, _TokenStoreT]] = { + "sans_session_id": { + SZ_USERNAME: USERNAME_SAME, + SZ_REFRESH_TOKEN: REFRESH_TOKEN, + SZ_ACCESS_TOKEN: ACCESS_TOKEN, + SZ_ACCESS_TOKEN_EXPIRES: ACCESS_TOKEN_EXP_STR, + }, + "with_session_id": { + SZ_USERNAME: USERNAME_SAME, + SZ_REFRESH_TOKEN: REFRESH_TOKEN, + SZ_ACCESS_TOKEN: ACCESS_TOKEN, + SZ_ACCESS_TOKEN_EXPIRES: ACCESS_TOKEN_EXP_STR, + SZ_USER_DATA: {"sessionId": SESSION_ID}, + }, +} + +TEST_DATA_NULL: Final[dict[str, _EmptyStoreT | None]] = { + "store_is_absent": None, + "store_was_reset": {}, +} + +DOMAIN_STORAGE_BASE: Final = { + "version": STORAGE_VER, + "minor_version": 1, + "key": STORAGE_KEY, +} + + +@pytest.mark.parametrize("idx", TEST_DATA_NULL) +async def test_auth_tokens_null( + hass: HomeAssistant, + hass_storage: dict[str, Any], + idx: str, +) -> None: + """Test loading/saving authentication tokens when no cached tokens in the store.""" + + hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_DATA_NULL[idx]} + + mock_client = await setup_evohome(hass, TEST_CONFIG) + + # Confirm client was instantiated without tokens, as cache was empty... + assert SZ_REFRESH_TOKEN not in mock_client.call_args.kwargs + assert SZ_ACCESS_TOKEN not in mock_client.call_args.kwargs + assert SZ_ACCESS_TOKEN_EXPIRES not in mock_client.call_args.kwarg + + # Confirm the expected tokens were cached to storage... + data: _TokenStoreT = hass_storage[DOMAIN]["data"] + + assert data[SZ_USERNAME] == USERNAME_SAME + assert data[SZ_REFRESH_TOKEN] == f"new_{REFRESH_TOKEN}" + assert data[SZ_ACCESS_TOKEN] == f"new_{ACCESS_TOKEN}" + assert ( + dt_util.parse_datetime(data[SZ_ACCESS_TOKEN_EXPIRES], raise_on_error=True) + > dt_util.now() + ) + + +@pytest.mark.parametrize("idx", TEST_DATA) +async def test_auth_tokens_same( + hass: HomeAssistant, hass_storage: dict[str, Any], idx: str +) -> None: + """Test loading/saving authentication tokens when matching username.""" + + hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_DATA[idx]} + + mock_client = await setup_evohome(hass, TEST_CONFIG) + + # Confirm client was instantiated with the cached tokens... + assert mock_client.call_args.kwargs[SZ_REFRESH_TOKEN] == REFRESH_TOKEN + assert mock_client.call_args.kwargs[SZ_ACCESS_TOKEN] == ACCESS_TOKEN + assert mock_client.call_args.kwargs[SZ_ACCESS_TOKEN_EXPIRES] == dt_aware_to_naive( + ACCESS_TOKEN_EXP_DTM + ) + + # Confirm the expected tokens were cached to storage... + data: _TokenStoreT = hass_storage[DOMAIN]["data"] + + assert data[SZ_USERNAME] == USERNAME_SAME + assert data[SZ_REFRESH_TOKEN] == REFRESH_TOKEN + assert data[SZ_ACCESS_TOKEN] == ACCESS_TOKEN + assert dt_util.parse_datetime(data[SZ_ACCESS_TOKEN_EXPIRES]) == ACCESS_TOKEN_EXP_DTM + + +@pytest.mark.parametrize("idx", TEST_DATA) +async def test_auth_tokens_past( + hass: HomeAssistant, hass_storage: dict[str, Any], idx: str +) -> None: + """Test loading/saving authentication tokens with matching username, but expired.""" + + dt_dtm, dt_str = dt_pair(dt_util.now() - timedelta(hours=1)) + + # make this access token have expired in the past... + test_data = TEST_DATA[idx].copy() # shallow copy is OK here + test_data[SZ_ACCESS_TOKEN_EXPIRES] = dt_str + + hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": test_data} + + mock_client = await setup_evohome(hass, TEST_CONFIG) + + # Confirm client was instantiated with the cached tokens... + assert mock_client.call_args.kwargs[SZ_REFRESH_TOKEN] == REFRESH_TOKEN + assert mock_client.call_args.kwargs[SZ_ACCESS_TOKEN] == ACCESS_TOKEN + assert mock_client.call_args.kwargs[SZ_ACCESS_TOKEN_EXPIRES] == dt_aware_to_naive( + dt_dtm + ) + + # Confirm the expected tokens were cached to storage... + data: _TokenStoreT = hass_storage[DOMAIN]["data"] + + assert data[SZ_USERNAME] == USERNAME_SAME + assert data[SZ_REFRESH_TOKEN] == REFRESH_TOKEN + assert data[SZ_ACCESS_TOKEN] == f"new_{ACCESS_TOKEN}" + assert ( + dt_util.parse_datetime(data[SZ_ACCESS_TOKEN_EXPIRES], raise_on_error=True) + > dt_util.now() + ) + + +@pytest.mark.parametrize("idx", TEST_DATA) +async def test_auth_tokens_diff( + hass: HomeAssistant, hass_storage: dict[str, Any], idx: str +) -> None: + """Test loading/saving authentication tokens when unmatched username.""" + + hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_DATA[idx]} + + mock_client = await setup_evohome( + hass, TEST_CONFIG | {CONF_USERNAME: USERNAME_DIFF} + ) + + # Confirm client was instantiated without tokens, as username was different... + assert SZ_REFRESH_TOKEN not in mock_client.call_args.kwargs + assert SZ_ACCESS_TOKEN not in mock_client.call_args.kwargs + assert SZ_ACCESS_TOKEN_EXPIRES not in mock_client.call_args.kwarg + + # Confirm the expected tokens were cached to storage... + data: _TokenStoreT = hass_storage[DOMAIN]["data"] + + assert data[SZ_USERNAME] == USERNAME_DIFF + assert data[SZ_REFRESH_TOKEN] == f"new_{REFRESH_TOKEN}" + assert data[SZ_ACCESS_TOKEN] == f"new_{ACCESS_TOKEN}" + assert ( + dt_util.parse_datetime(data[SZ_ACCESS_TOKEN_EXPIRES], raise_on_error=True) + > dt_util.now() + )