From a542a2e0212cdb2ff22983afd34b8ae1f8e2b61c Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sat, 8 Feb 2025 14:45:48 +0000 Subject: [PATCH] Refactor evohome for major bump of client to 1.0.2 (#135436) * working test_init * update fixtures to be compliant with new schema * test_storage is now working * all tests passing * bump client to 1.0.1b0 * test commit (working tests) * use only id (not e.g. zoneId), use StrEnums * mypy, lint * remove deprecated module * remove waffle * improve typing of asserts * broker is now coordinator * WIP - test failing * rename class * remove unneeded async_dispatcher_send() * restore missing code * harden test * bugfix failing test * don't capture blind except * shrink log messages * doctweak * rationalize asserts * remove unneeded listerner * refactor setup * bump client to 1.0.2b0 * bump client to 1.0.2b1 * refactor extended state attrs * pass UpdateFailed to _async_refresh() * Update homeassistant/components/evohome/entity.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/evohome/entity.py Co-authored-by: Joost Lekkerkerker * not even lint * undo not even lint * remove unused logger * restore old namespace for e_s_a * minimize diff * doctweak * remove unused method * lint * DUC now working * restore old camelCase keynames * tweak * small tweak to _handle_coordinator_update() * Update homeassistant/components/evohome/coordinator.py Co-authored-by: Joost Lekkerkerker * add test of coordinator * bump client to 1.0.2 --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/evohome/__init__.py | 232 ++++----------- homeassistant/components/evohome/climate.py | 272 +++++++++--------- homeassistant/components/evohome/const.py | 23 -- .../components/evohome/coordinator.py | 250 +++++++++------- homeassistant/components/evohome/entity.py | 203 ++++++------- homeassistant/components/evohome/helpers.py | 110 ------- .../components/evohome/manifest.json | 4 +- homeassistant/components/evohome/storage.py | 118 ++++++++ .../components/evohome/water_heater.py | 91 +++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/evohome/conftest.py | 125 ++++---- .../fixtures/h032585/user_locations.json | 1 + .../fixtures/h099625/user_locations.json | 1 + .../evohome/snapshots/test_climate.ambr | 258 +++++++++-------- .../evohome/snapshots/test_water_heater.ambr | 32 +-- tests/components/evohome/test_climate.py | 84 +++--- tests/components/evohome/test_coordinator.py | 55 ++++ tests/components/evohome/test_init.py | 203 ++++++++----- tests/components/evohome/test_storage.py | 58 ++-- tests/components/evohome/test_water_heater.py | 30 +- 21 files changed, 1065 insertions(+), 1089 deletions(-) delete mode 100644 homeassistant/components/evohome/helpers.py create mode 100644 homeassistant/components/evohome/storage.py create mode 100644 tests/components/evohome/test_coordinator.py diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 97f7c2db54d..e322e266b8a 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -2,22 +2,24 @@ Such systems provide heating/cooling and DHW and include Evohome, Round Thermostat, and others. + +Note that the API used by this integration's client does not support cooling. """ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import logging -from typing import Any, Final +from typing import Final -import evohomeasync as ev1 -from evohomeasync.schema import SZ_SESSION_ID -import evohomeasync2 as evo -from evohomeasync2.schema.const import ( - SZ_AUTO_WITH_RESET, - SZ_CAN_BE_TEMPORARY, - SZ_SYSTEM_MODE, - SZ_TIMING_MODE, +import evohomeasync as ec1 +import evohomeasync2 as ec2 +from evohomeasync2.const import SZ_CAN_BE_TEMPORARY, SZ_SYSTEM_MODE, SZ_TIMING_MODE +from evohomeasync2.schemas.const import ( + S2_DURATION as SZ_DURATION, + S2_PERIOD as SZ_PERIOD, + SystemMode as EvoSystemMode, ) import voluptuous as vol @@ -34,14 +36,10 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.service import verify_domain_control -from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from homeassistant.util import dt as dt_util +from homeassistant.util.hass_dict import HassKey from .const import ( - ACCESS_TOKEN, - ACCESS_TOKEN_EXPIRES, ATTR_DURATION_DAYS, ATTR_DURATION_HOURS, ATTR_DURATION_UNTIL, @@ -49,16 +47,12 @@ from .const import ( ATTR_ZONE_TEMP, CONF_LOCATION_IDX, DOMAIN, - REFRESH_TOKEN, SCAN_INTERVAL_DEFAULT, SCAN_INTERVAL_MINIMUM, - STORAGE_KEY, - STORAGE_VER, - USER_DATA, EvoService, ) -from .coordinator import EvoBroker -from .helpers import dt_aware_to_naive, dt_local_to_aware, handle_evo_exception +from .coordinator import EvoDataUpdateCoordinator +from .storage import TokenManager _LOGGER = logging.getLogger(__name__) @@ -96,177 +90,69 @@ SET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema( } ) +EVOHOME_KEY: HassKey[EvoData] = HassKey(DOMAIN) -class EvoSession: - """Class for evohome client instantiation & authentication.""" - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the evohome broker and its data structure.""" +@dataclass +class EvoData: + """Dataclass for storing evohome data.""" - self.hass = hass - - self._session = async_get_clientsession(hass) - self._store = Store[dict[str, Any]](hass, STORAGE_VER, STORAGE_KEY) - - # the main client, which uses the newer API - self.client_v2: evo.EvohomeClient | None = None - self._tokens: dict[str, Any] = {} - - # the older client can be used to obtain high-precision temps (only) - self.client_v1: ev1.EvohomeClient | None = None - self.session_id: str | None = None - - async def authenticate(self, username: str, password: str) -> None: - """Check the user credentials against the web API. - - Will raise evo.AuthenticationFailed if the credentials are invalid. - """ - - if ( - self.client_v2 is None - or username != self.client_v2.username - or password != self.client_v2.password - ): - await self._load_auth_tokens(username) - - client_v2 = evo.EvohomeClient( - username, - password, - **self._tokens, - session=self._session, - ) - - else: # force a re-authentication - client_v2 = self.client_v2 - client_v2._user_account = None # noqa: SLF001 - - await client_v2.login() - self.client_v2 = client_v2 # only set attr if authentication succeeded - - await self.save_auth_tokens() - - self.client_v1 = ev1.EvohomeClient( - username, - password, - session_id=self.session_id, - session=self._session, - ) - - async def _load_auth_tokens(self, username: str) -> None: - """Load access tokens and session_id from the store and validate them. - - Sets self._tokens and self._session_id to the latest values. - """ - - app_storage: dict[str, Any] = dict(await self._store.async_load() or {}) - - if app_storage.pop(CONF_USERNAME, None) != username: - # any tokens won't be valid, and store might be corrupt - await self._store.async_save({}) - - self.session_id = None - self._tokens = {} - - return - - # evohomeasync2 requires naive/local datetimes as strings - if app_storage.get(ACCESS_TOKEN_EXPIRES) is not None and ( - expires := dt_util.parse_datetime(app_storage[ACCESS_TOKEN_EXPIRES]) - ): - app_storage[ACCESS_TOKEN_EXPIRES] = dt_aware_to_naive(expires) - - user_data: dict[str, str] = app_storage.pop(USER_DATA, {}) or {} - - self.session_id = user_data.get(SZ_SESSION_ID) - self._tokens = app_storage - - async def save_auth_tokens(self) -> None: - """Save access tokens and session_id to the store. - - Sets self._tokens and self._session_id to the latest values. - """ - - if self.client_v2 is None: - await self._store.async_save({}) - return - - # evohomeasync2 uses naive/local datetimes - access_token_expires = dt_local_to_aware( - self.client_v2.access_token_expires # type: ignore[arg-type] - ) - - self._tokens = { - CONF_USERNAME: self.client_v2.username, - REFRESH_TOKEN: self.client_v2.refresh_token, - ACCESS_TOKEN: self.client_v2.access_token, - ACCESS_TOKEN_EXPIRES: access_token_expires.isoformat(), - } - - self.session_id = self.client_v1.broker.session_id if self.client_v1 else None - - app_storage = self._tokens - if self.client_v1: - app_storage[USER_DATA] = {SZ_SESSION_ID: self.session_id} - - await self._store.async_save(app_storage) + coordinator: EvoDataUpdateCoordinator + loc_idx: int + tcs: ec2.ControlSystem async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Evohome integration.""" - sess = EvoSession(hass) - - try: - await sess.authenticate( - config[DOMAIN][CONF_USERNAME], - config[DOMAIN][CONF_PASSWORD], - ) - - except (evo.AuthenticationFailed, evo.RequestFailed) as err: - handle_evo_exception(err) - return False - - finally: - config[DOMAIN][CONF_PASSWORD] = "REDACTED" - - broker = EvoBroker(sess) - - if not broker.validate_location( - config[DOMAIN][CONF_LOCATION_IDX], - ): - return False - - coordinator = DataUpdateCoordinator( + token_manager = TokenManager( + hass, + config[DOMAIN][CONF_USERNAME], + config[DOMAIN][CONF_PASSWORD], + async_get_clientsession(hass), + ) + coordinator = EvoDataUpdateCoordinator( hass, _LOGGER, - config_entry=None, + ec2.EvohomeClient(token_manager), name=f"{DOMAIN}_coordinator", update_interval=config[DOMAIN][CONF_SCAN_INTERVAL], - update_method=broker.async_update, + location_idx=config[DOMAIN][CONF_LOCATION_IDX], + client_v1=ec1.EvohomeClient(token_manager), ) + await coordinator.async_register_shutdown() + await coordinator.async_first_refresh() - hass.data[DOMAIN] = {"broker": broker, "coordinator": coordinator} + if not coordinator.last_update_success: + _LOGGER.error(f"Failed to fetch initial data: {coordinator.last_exception}") # noqa: G004 + return False - # without a listener, _schedule_refresh() won't be invoked by _async_refresh() - coordinator.async_add_listener(lambda: None) - await coordinator.async_refresh() # get initial state + assert coordinator.tcs is not None # mypy + + hass.data[EVOHOME_KEY] = EvoData( + coordinator=coordinator, + loc_idx=coordinator.loc_idx, + tcs=coordinator.tcs, + ) hass.async_create_task( async_load_platform(hass, Platform.CLIMATE, DOMAIN, {}, config) ) - if broker.tcs.hotwater: + if coordinator.tcs.hotwater: hass.async_create_task( async_load_platform(hass, Platform.WATER_HEATER, DOMAIN, {}, config) ) - setup_service_functions(hass, broker) + setup_service_functions(hass, coordinator) return True @callback -def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None: +def setup_service_functions( + hass: HomeAssistant, coordinator: EvoDataUpdateCoordinator +) -> None: """Set up the service handlers for the system/zone operating modes. Not all Honeywell TCC-compatible systems support all operating modes. In addition, @@ -279,13 +165,15 @@ def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None: @verify_domain_control(hass, DOMAIN) async def force_refresh(call: ServiceCall) -> None: """Obtain the latest state data via the vendor's RESTful API.""" - await broker.async_update() + await coordinator.async_refresh() @verify_domain_control(hass, DOMAIN) async def set_system_mode(call: ServiceCall) -> None: """Set the system mode.""" + assert coordinator.tcs is not None # mypy + payload = { - "unique_id": broker.tcs.systemId, + "unique_id": coordinator.tcs.id, "service": call.service, "data": call.data, } @@ -313,17 +201,23 @@ def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None: async_dispatcher_send(hass, DOMAIN, payload) + assert coordinator.tcs is not None # mypy + hass.services.async_register(DOMAIN, EvoService.REFRESH_SYSTEM, force_refresh) # Enumerate which operating modes are supported by this system - modes = broker.tcs.allowedSystemModes + modes = list(coordinator.tcs.allowed_system_modes) # Not all systems support "AutoWithReset": register this handler only if required - if [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_SYSTEM_MODE] == SZ_AUTO_WITH_RESET]: + if any( + m[SZ_SYSTEM_MODE] + for m in modes + if m[SZ_SYSTEM_MODE] == EvoSystemMode.AUTO_WITH_RESET + ): hass.services.async_register(DOMAIN, EvoService.RESET_SYSTEM, set_system_mode) system_mode_schemas = [] - modes = [m for m in modes if m[SZ_SYSTEM_MODE] != SZ_AUTO_WITH_RESET] + modes = [m for m in modes if m[SZ_SYSTEM_MODE] != EvoSystemMode.AUTO_WITH_RESET] # Permanent-only modes will use this schema perm_modes = [m[SZ_SYSTEM_MODE] for m in modes if not m[SZ_CAN_BE_TEMPORARY]] @@ -334,7 +228,7 @@ def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None: modes = [m for m in modes if m[SZ_CAN_BE_TEMPORARY]] # These modes are set for a number of hours (or indefinitely): use this schema - temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == "Duration"] + temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == SZ_DURATION] if temp_modes: # any of: "AutoWithEco", permanent or for 0-24 hours schema = vol.Schema( { @@ -348,7 +242,7 @@ def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None: system_mode_schemas.append(schema) # These modes are set for a number of days (or indefinitely): use this schema - temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == "Period"] + temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == SZ_PERIOD] if temp_modes: # any of: "Away", "Custom", "DayOff", permanent or for 1-99 days schema = vol.Schema( { diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 64e7367bc32..8a455b300f8 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -4,20 +4,20 @@ from __future__ import annotations from datetime import datetime, timedelta import logging -from typing import TYPE_CHECKING, Any +from typing import Any import evohomeasync2 as evo -from evohomeasync2.schema.const import ( - SZ_ACTIVE_FAULTS, +from evohomeasync2.const import ( SZ_SETPOINT_STATUS, - SZ_SYSTEM_ID, SZ_SYSTEM_MODE, SZ_SYSTEM_MODE_STATUS, SZ_TEMPERATURE_STATUS, - SZ_UNTIL, - SZ_ZONE_ID, - ZoneModelType, - ZoneType, +) +from evohomeasync2.schemas.const import ( + SystemMode as EvoSystemMode, + ZoneMode as EvoZoneMode, + ZoneModelType as EvoZoneModelType, + ZoneType as EvoZoneType, ) from homeassistant.components.climate import ( @@ -30,67 +30,46 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.const import PRECISION_TENTHS, UnitOfTemperature -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util +from . import EVOHOME_KEY from .const import ( ATTR_DURATION_DAYS, ATTR_DURATION_HOURS, ATTR_DURATION_UNTIL, ATTR_SYSTEM_MODE, ATTR_ZONE_TEMP, - DOMAIN, - EVO_AUTO, - EVO_AUTOECO, - EVO_AWAY, - EVO_CUSTOM, - EVO_DAYOFF, - EVO_FOLLOW, - EVO_HEATOFF, - EVO_PERMOVER, - EVO_RESET, - EVO_TEMPOVER, EvoService, ) -from .entity import EvoChild, EvoDevice - -if TYPE_CHECKING: - from . import EvoBroker - +from .coordinator import EvoDataUpdateCoordinator +from .entity import EvoChild, EvoEntity _LOGGER = logging.getLogger(__name__) -PRESET_RESET = "Reset" # reset all child zones to EVO_FOLLOW +PRESET_RESET = "Reset" # reset all child zones to EvoZoneMode.FOLLOW_SCHEDULE PRESET_CUSTOM = "Custom" TCS_PRESET_TO_HA = { - EVO_AWAY: PRESET_AWAY, - EVO_CUSTOM: PRESET_CUSTOM, - EVO_AUTOECO: PRESET_ECO, - EVO_DAYOFF: PRESET_HOME, - EVO_RESET: PRESET_RESET, -} # EVO_AUTO: None, + EvoSystemMode.AWAY: PRESET_AWAY, + EvoSystemMode.CUSTOM: PRESET_CUSTOM, + EvoSystemMode.AUTO_WITH_ECO: PRESET_ECO, + EvoSystemMode.DAY_OFF: PRESET_HOME, + EvoSystemMode.AUTO_WITH_RESET: PRESET_RESET, +} # EvoSystemMode.AUTO: None, HA_PRESET_TO_TCS = {v: k for k, v in TCS_PRESET_TO_HA.items()} EVO_PRESET_TO_HA = { - EVO_FOLLOW: PRESET_NONE, - EVO_TEMPOVER: "temporary", - EVO_PERMOVER: "permanent", + EvoZoneMode.FOLLOW_SCHEDULE: PRESET_NONE, + EvoZoneMode.TEMPORARY_OVERRIDE: "temporary", + EvoZoneMode.PERMANENT_OVERRIDE: "permanent", } HA_PRESET_TO_EVO = {v: k for k, v in EVO_PRESET_TO_HA.items()} -STATE_ATTRS_TCS = [SZ_SYSTEM_ID, SZ_ACTIVE_FAULTS, SZ_SYSTEM_MODE_STATUS] -STATE_ATTRS_ZONES = [ - SZ_ZONE_ID, - SZ_ACTIVE_FAULTS, - SZ_SETPOINT_STATUS, - SZ_TEMPERATURE_STATUS, -] - async def async_setup_platform( hass: HomeAssistant, @@ -102,32 +81,34 @@ async def async_setup_platform( if discovery_info is None: return - broker: EvoBroker = hass.data[DOMAIN]["broker"] + coordinator = hass.data[EVOHOME_KEY].coordinator + loc_idx = hass.data[EVOHOME_KEY].loc_idx + tcs = hass.data[EVOHOME_KEY].tcs _LOGGER.debug( "Found the Location/Controller (%s), id=%s, name=%s (location_idx=%s)", - broker.tcs.modelType, - broker.tcs.systemId, - broker.loc.name, - broker.loc_idx, + tcs.model, + tcs.id, + tcs.location.name, + loc_idx, ) - entities: list[EvoClimateEntity] = [EvoController(broker, broker.tcs)] + entities: list[EvoController | EvoZone] = [EvoController(coordinator, tcs)] - for zone in broker.tcs.zones.values(): + for zone in tcs.zones: if ( - zone.modelType == ZoneModelType.HEATING_ZONE - or zone.zoneType == ZoneType.THERMOSTAT + zone.model == EvoZoneModelType.HEATING_ZONE + or zone.type == EvoZoneType.THERMOSTAT ): _LOGGER.debug( "Adding: %s (%s), id=%s, name=%s", - zone.zoneType, - zone.modelType, - zone.zoneId, + zone.type, + zone.model, + zone.id, zone.name, ) - new_entity = EvoZone(broker, zone) + new_entity = EvoZone(coordinator, zone) entities.append(new_entity) else: @@ -136,16 +117,19 @@ async def async_setup_platform( "Ignoring: %s (%s), id=%s, name=%s: unknown/invalid zone type, " "report as an issue if you feel this zone type should be supported" ), - zone.zoneType, - zone.modelType, - zone.zoneId, + zone.type, + zone.model, + zone.id, zone.name, ) - async_add_entities(entities, update_before_add=True) + async_add_entities(entities) + + for entity in entities: + await entity.update_attrs() -class EvoClimateEntity(EvoDevice, ClimateEntity): +class EvoClimateEntity(EvoEntity, ClimateEntity): """Base for any evohome-compatible climate entity (controller, zone).""" _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] @@ -157,25 +141,29 @@ class EvoZone(EvoChild, EvoClimateEntity): _attr_preset_modes = list(HA_PRESET_TO_EVO) - _evo_device: evo.Zone # mypy hint + _evo_device: evo.Zone + _evo_id_attr = "zone_id" + _evo_state_attr_names = (SZ_SETPOINT_STATUS, SZ_TEMPERATURE_STATUS) - def __init__(self, evo_broker: EvoBroker, evo_device: evo.Zone) -> None: + def __init__( + self, coordinator: EvoDataUpdateCoordinator, evo_device: evo.Zone + ) -> None: """Initialize an evohome-compatible heating zone.""" - super().__init__(evo_broker, evo_device) - self._evo_id = evo_device.zoneId + super().__init__(coordinator, evo_device) + self._evo_id = evo_device.id - if evo_device.modelType.startswith("VisionProWifi"): + if evo_device.model.startswith("VisionProWifi"): # this system does not have a distinct ID for the zone - self._attr_unique_id = f"{evo_device.zoneId}z" + self._attr_unique_id = f"{evo_device.id}z" else: - self._attr_unique_id = evo_device.zoneId + self._attr_unique_id = evo_device.id - if evo_broker.client_v1: + if coordinator.client_v1: self._attr_precision = PRECISION_TENTHS else: - self._attr_precision = self._evo_device.setpointCapabilities[ - "valueResolution" + self._attr_precision = self._evo_device.setpoint_capabilities[ + "value_resolution" ] self._attr_supported_features = ( @@ -188,7 +176,7 @@ class EvoZone(EvoChild, EvoClimateEntity): async def async_zone_svc_request(self, service: str, data: dict[str, Any]) -> None: """Process a service request (setpoint override) for a zone.""" if service == EvoService.RESET_ZONE_OVERRIDE: - await self._evo_broker.call_client_api(self._evo_device.reset_mode()) + await self.coordinator.call_client_api(self._evo_device.reset()) return # otherwise it is EvoService.SET_ZONE_OVERRIDE @@ -198,14 +186,14 @@ class EvoZone(EvoChild, EvoClimateEntity): duration: timedelta = data[ATTR_DURATION_UNTIL] if duration.total_seconds() == 0: await self._update_schedule() - until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) + until = self.setpoints.get("next_sp_from") else: until = dt_util.now() + data[ATTR_DURATION_UNTIL] else: until = None # indefinitely until = dt_util.as_utc(until) if until else None - await self._evo_broker.call_client_api( + await self.coordinator.call_client_api( self._evo_device.set_temperature(temperature, until=until) ) @@ -217,7 +205,7 @@ class EvoZone(EvoChild, EvoClimateEntity): @property def hvac_mode(self) -> HVACMode | None: """Return the current operating mode of a Zone.""" - if self._evo_tcs.system_mode in (EVO_AWAY, EVO_HEATOFF): + if self._evo_tcs.mode in (EvoSystemMode.AWAY, EvoSystemMode.HEATING_OFF): return HVACMode.AUTO if self.target_temperature is None: return None @@ -233,10 +221,8 @@ class EvoZone(EvoChild, EvoClimateEntity): @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" - if self._evo_tcs.system_mode in (EVO_AWAY, EVO_HEATOFF): - return TCS_PRESET_TO_HA.get(self._evo_tcs.system_mode) - if self._evo_device.mode is None: - return None + if self._evo_tcs.mode in (EvoSystemMode.AWAY, EvoSystemMode.HEATING_OFF): + return TCS_PRESET_TO_HA.get(self._evo_tcs.mode) return EVO_PRESET_TO_HA.get(self._evo_device.mode) @property @@ -245,8 +231,6 @@ class EvoZone(EvoChild, EvoClimateEntity): The default is 5, but is user-configurable within 5-21 (in Celsius). """ - if self._evo_device.min_heat_setpoint is None: - return 5 return self._evo_device.min_heat_setpoint @property @@ -255,33 +239,27 @@ class EvoZone(EvoChild, EvoClimateEntity): The default is 35, but is user-configurable within 21-35 (in Celsius). """ - if self._evo_device.max_heat_setpoint is None: - return 35 return self._evo_device.max_heat_setpoint async def async_set_temperature(self, **kwargs: Any) -> None: """Set a new target temperature.""" - assert self._evo_device.setpointStatus is not None # mypy check - temperature = kwargs["temperature"] if (until := kwargs.get("until")) is None: - if self._evo_device.mode == EVO_FOLLOW: + if self._evo_device.mode == EvoZoneMode.TEMPORARY_OVERRIDE: + until = self._evo_device.until + if self._evo_device.mode == EvoZoneMode.FOLLOW_SCHEDULE: await self._update_schedule() - until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) - elif self._evo_device.mode == EVO_TEMPOVER: - until = dt_util.parse_datetime( - self._evo_device.setpointStatus[SZ_UNTIL] - ) + until = self.setpoints.get("next_sp_from") until = dt_util.as_utc(until) if until else None - await self._evo_broker.call_client_api( + await self.coordinator.call_client_api( self._evo_device.set_temperature(temperature, until=until) ) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set a Zone to one of its native EVO_* operating modes. + """Set a Zone to one of its native operating modes. Zones inherit their _effective_ operating mode from their Controller. @@ -298,41 +276,34 @@ class EvoZone(EvoChild, EvoClimateEntity): and 'Away', Zones to (by default) 12C. """ if hvac_mode == HVACMode.OFF: - await self._evo_broker.call_client_api( + await self.coordinator.call_client_api( self._evo_device.set_temperature(self.min_temp, until=None) ) else: # HVACMode.HEAT - await self._evo_broker.call_client_api(self._evo_device.reset_mode()) + await self.coordinator.call_client_api(self._evo_device.reset()) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode; if None, then revert to following the schedule.""" - evo_preset_mode = HA_PRESET_TO_EVO.get(preset_mode, EVO_FOLLOW) + evo_preset_mode = HA_PRESET_TO_EVO.get(preset_mode, EvoZoneMode.FOLLOW_SCHEDULE) - if evo_preset_mode == EVO_FOLLOW: - await self._evo_broker.call_client_api(self._evo_device.reset_mode()) + if evo_preset_mode == EvoZoneMode.FOLLOW_SCHEDULE: + await self.coordinator.call_client_api(self._evo_device.reset()) return - if evo_preset_mode == EVO_TEMPOVER: + if evo_preset_mode == EvoZoneMode.TEMPORARY_OVERRIDE: await self._update_schedule() - until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) - else: # EVO_PERMOVER + until = self.setpoints.get("next_sp_from") + else: # EvoZoneMode.PERMANENT_OVERRIDE until = None temperature = self._evo_device.target_heat_temperature assert temperature is not None # mypy check until = dt_util.as_utc(until) if until else None - await self._evo_broker.call_client_api( + await self.coordinator.call_client_api( self._evo_device.set_temperature(temperature, until=until) ) - async def async_update(self) -> None: - """Get the latest state data for a Zone.""" - await super().async_update() - - for attr in STATE_ATTRS_ZONES: - self._device_state_attrs[attr] = getattr(self._evo_device, attr) - class EvoController(EvoClimateEntity): """Base for any evohome-compatible controller. @@ -347,18 +318,22 @@ class EvoController(EvoClimateEntity): _attr_icon = "mdi:thermostat" _attr_precision = PRECISION_TENTHS - _evo_device: evo.ControlSystem # mypy hint + _evo_device: evo.ControlSystem + _evo_id_attr = "system_id" + _evo_state_attr_names = (SZ_SYSTEM_MODE_STATUS,) - def __init__(self, evo_broker: EvoBroker, evo_device: evo.ControlSystem) -> None: + def __init__( + self, coordinator: EvoDataUpdateCoordinator, evo_device: evo.ControlSystem + ) -> None: """Initialize an evohome-compatible controller.""" - super().__init__(evo_broker, evo_device) - self._evo_id = evo_device.systemId + super().__init__(coordinator, evo_device) + self._evo_id = evo_device.id - self._attr_unique_id = evo_device.systemId + self._attr_unique_id = evo_device.id self._attr_name = evo_device.location.name - self._evo_modes = [m[SZ_SYSTEM_MODE] for m in evo_device.allowedSystemModes] + self._evo_modes = [m[SZ_SYSTEM_MODE] for m in evo_device.allowed_system_modes] self._attr_preset_modes = [ TCS_PRESET_TO_HA[m] for m in self._evo_modes if m in list(TCS_PRESET_TO_HA) ] @@ -376,7 +351,7 @@ class EvoController(EvoClimateEntity): if service == EvoService.SET_SYSTEM_MODE: mode = data[ATTR_SYSTEM_MODE] else: # otherwise it is EvoService.RESET_SYSTEM - mode = EVO_RESET + mode = EvoSystemMode.AUTO_WITH_RESET if ATTR_DURATION_DAYS in data: until = dt_util.start_of_local_day() @@ -390,18 +365,24 @@ class EvoController(EvoClimateEntity): await self._set_tcs_mode(mode, until=until) - async def _set_tcs_mode(self, mode: str, until: datetime | None = None) -> None: - """Set a Controller to any of its native EVO_* operating modes.""" + async def _set_tcs_mode( + self, mode: EvoSystemMode, until: datetime | None = None + ) -> None: + """Set a Controller to any of its native operating modes.""" until = dt_util.as_utc(until) if until else None - await self._evo_broker.call_client_api( - self._evo_device.set_mode(mode, until=until) # type: ignore[arg-type] + await self.coordinator.call_client_api( + self._evo_device.set_mode(mode, until=until) ) @property def hvac_mode(self) -> HVACMode: """Return the current operating mode of a Controller.""" - evo_mode = self._evo_device.system_mode - return HVACMode.OFF if evo_mode in (EVO_HEATOFF, "Off") else HVACMode.HEAT + evo_mode = self._evo_device.mode + return ( + HVACMode.OFF + if evo_mode in (EvoSystemMode.HEATING_OFF, EvoSystemMode.OFF) + else HVACMode.HEAT + ) @property def current_temperature(self) -> float | None: @@ -410,18 +391,14 @@ class EvoController(EvoClimateEntity): Controllers do not have a current temp, but one is expected by HA. """ temps = [ - z.temperature - for z in self._evo_device.zones.values() - if z.temperature is not None + z.temperature for z in self._evo_device.zones if z.temperature is not None ] return round(sum(temps) / len(temps), 1) if temps else None @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" - if not self._evo_device.system_mode: - return None - return TCS_PRESET_TO_HA.get(self._evo_device.system_mode) + return TCS_PRESET_TO_HA.get(self._evo_device.mode) async def async_set_temperature(self, **kwargs: Any) -> None: """Raise exception as Controllers don't have a target temperature.""" @@ -429,25 +406,40 @@ class EvoController(EvoClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set an operating mode for a Controller.""" + + evo_mode: EvoSystemMode + if hvac_mode == HVACMode.HEAT: - evo_mode = EVO_AUTO if EVO_AUTO in self._evo_modes else "Heat" + evo_mode = ( + EvoSystemMode.AUTO + if EvoSystemMode.AUTO in self._evo_modes + else EvoSystemMode.HEAT + ) elif hvac_mode == HVACMode.OFF: - evo_mode = EVO_HEATOFF if EVO_HEATOFF in self._evo_modes else "Off" + evo_mode = ( + EvoSystemMode.HEATING_OFF + if EvoSystemMode.HEATING_OFF in self._evo_modes + else EvoSystemMode.OFF + ) else: raise HomeAssistantError(f"Invalid hvac_mode: {hvac_mode}") await self._set_tcs_mode(evo_mode) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode; if None, then revert to 'Auto' mode.""" - await self._set_tcs_mode(HA_PRESET_TO_TCS.get(preset_mode, EVO_AUTO)) + await self._set_tcs_mode(HA_PRESET_TO_TCS.get(preset_mode, EvoSystemMode.AUTO)) - async def async_update(self) -> None: - """Get the latest state data for a Controller.""" - self._device_state_attrs = {} + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" - attrs = self._device_state_attrs - for attr in STATE_ATTRS_TCS: - if attr == SZ_ACTIVE_FAULTS: - attrs["activeSystemFaults"] = getattr(self._evo_device, attr) - else: - attrs[attr] = getattr(self._evo_device, attr) + self._device_state_attrs = { + "activeSystemFaults": self._evo_device.active_faults + + self._evo_device.gateway.active_faults + } + + super()._handle_coordinator_update() + + async def update_attrs(self) -> None: + """Update the entity's extra state attrs.""" + self._handle_coordinator_update() diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py index 3ebe6954fea..12642addfa4 100644 --- a/homeassistant/components/evohome/const.py +++ b/homeassistant/components/evohome/const.py @@ -11,31 +11,8 @@ DOMAIN: Final = "evohome" STORAGE_VER: Final = 1 STORAGE_KEY: Final = DOMAIN -# The Parent's (i.e. TCS, Controller) operating mode is one of: -EVO_RESET: Final = "AutoWithReset" -EVO_AUTO: Final = "Auto" -EVO_AUTOECO: Final = "AutoWithEco" -EVO_AWAY: Final = "Away" -EVO_DAYOFF: Final = "DayOff" -EVO_CUSTOM: Final = "Custom" -EVO_HEATOFF: Final = "HeatingOff" - -# The Children's (i.e. Dhw, Zone) operating mode is one of: -EVO_FOLLOW: Final = "FollowSchedule" # the operating mode is 'inherited' from the TCS -EVO_TEMPOVER: Final = "TemporaryOverride" -EVO_PERMOVER: Final = "PermanentOverride" - -# These two are used only to help prevent E501 (line too long) violations -GWS: Final = "gateways" -TCS: Final = "temperatureControlSystems" - -UTC_OFFSET: Final = "currentOffsetMinutes" - CONF_LOCATION_IDX: Final = "location_idx" -ACCESS_TOKEN: Final = "access_token" -ACCESS_TOKEN_EXPIRES: Final = "access_token_expires" -REFRESH_TOKEN: Final = "refresh_token" USER_DATA: Final = "user_data" SCAN_INTERVAL_DEFAULT: Final = timedelta(seconds=300) diff --git a/homeassistant/components/evohome/coordinator.py b/homeassistant/components/evohome/coordinator.py index 943bd6605b4..7b197f1b643 100644 --- a/homeassistant/components/evohome/coordinator.py +++ b/homeassistant/components/evohome/coordinator.py @@ -4,109 +4,143 @@ from __future__ import annotations from collections.abc import Awaitable from datetime import timedelta +from http import HTTPStatus import logging -from typing import TYPE_CHECKING, Any +from typing import Any -import evohomeasync as ev1 -from evohomeasync.schema import SZ_ID, SZ_TEMP -import evohomeasync2 as evo -from evohomeasync2.schema.const import ( +import evohomeasync as ec1 +import evohomeasync2 as ec2 +from evohomeasync2.const import ( SZ_GATEWAY_ID, SZ_GATEWAY_INFO, + SZ_GATEWAYS, SZ_LOCATION_ID, SZ_LOCATION_INFO, + SZ_TEMPERATURE_CONTROL_SYSTEMS, SZ_TIME_ZONE, + SZ_USE_DAYLIGHT_SAVE_SWITCHING, ) +from evohomeasync2.schemas.typedefs import EvoLocStatusResponseT -from homeassistant.helpers.dispatcher import async_dispatcher_send - -from .const import CONF_LOCATION_IDX, DOMAIN, GWS, TCS, UTC_OFFSET -from .helpers import handle_evo_exception - -if TYPE_CHECKING: - from . import EvoSession - -_LOGGER = logging.getLogger(__name__.rpartition(".")[0]) +from homeassistant.const import CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -class EvoBroker: - """Broker for evohome client broker.""" +class EvoDataUpdateCoordinator(DataUpdateCoordinator): + """Coordinator for evohome integration/client.""" - loc_idx: int - loc: evo.Location - loc_utc_offset: timedelta - tcs: evo.ControlSystem + # These will not be None after _async_setup()) + loc: ec2.Location + tcs: ec2.ControlSystem - def __init__(self, sess: EvoSession) -> None: - """Initialize the evohome broker and its data structure.""" + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + client_v2: ec2.EvohomeClient, + *, + name: str, + update_interval: timedelta, + location_idx: int, + client_v1: ec1.EvohomeClient | None = None, + ) -> None: + """Class to manage fetching data.""" - self._sess = sess - self.hass = sess.hass + super().__init__( + hass, + logger, + config_entry=None, + name=name, + update_interval=update_interval, + ) - assert sess.client_v2 is not None # mypy + self.client = client_v2 + self.client_v1 = client_v1 - self.client = sess.client_v2 - self.client_v1 = sess.client_v1 + self.loc_idx = location_idx + self.data: EvoLocStatusResponseT = None # type: ignore[assignment] self.temps: dict[str, float | None] = {} - def validate_location(self, loc_idx: int) -> bool: - """Get the default TCS of the specified location.""" + self._first_refresh_done = False # get schedules only after first refresh - self.loc_idx = loc_idx + # our version of async_config_entry_first_refresh()... + async def async_first_refresh(self) -> None: + """Refresh data for the first time when integration is setup. - assert self.client.installation_info is not None # mypy + This integration does not have config flow, so it is inappropriate to + invoke `async_config_entry_first_refresh()`. + """ + + # can't replicate `if not await self.__wrap_async_setup():` (is mangled), so... + if not await self._DataUpdateCoordinator__wrap_async_setup(): # type: ignore[attr-defined] + return + + await self._async_refresh( + log_failures=False, raise_on_auth_failed=True, raise_on_entry_error=True + ) + + async def _async_setup(self) -> None: + """Set up the coordinator. + + Fetch the user information, and the configuration of their locations. + """ try: - loc_config = self.client.installation_info[loc_idx] - except IndexError: - _LOGGER.error( - ( - "Config error: '%s' = %s, but the valid range is 0-%s. " - "Unable to continue. Fix any configuration errors and restart HA" - ), - CONF_LOCATION_IDX, - loc_idx, - len(self.client.installation_info) - 1, - ) - return False + await self.client.update(dont_update_status=True) # only config for now + except ec2.EvohomeError as err: + raise UpdateFailed(err) from err - self.loc = self.client.locations[loc_idx] - self.loc_utc_offset = timedelta(minutes=self.loc.timeZone[UTC_OFFSET]) - self.tcs = self.loc._gateways[0]._control_systems[0] # noqa: SLF001 + try: + self.loc = self.client.locations[self.loc_idx] + except IndexError as err: + raise UpdateFailed( + f""" + Config error: 'location_idx' = {self.loc_idx}, + but the valid range is 0-{len(self.client.locations) - 1}. + Unable to continue. Fix any configuration errors and restart HA + """ + ) from err - if _LOGGER.isEnabledFor(logging.DEBUG): + self.tcs = self.loc.gateways[0].systems[0] + + if self.logger.isEnabledFor(logging.DEBUG): loc_info = { - SZ_LOCATION_ID: loc_config[SZ_LOCATION_INFO][SZ_LOCATION_ID], - SZ_TIME_ZONE: loc_config[SZ_LOCATION_INFO][SZ_TIME_ZONE], + SZ_LOCATION_ID: self.loc.id, + SZ_TIME_ZONE: self.loc.config[SZ_TIME_ZONE], + SZ_USE_DAYLIGHT_SAVE_SWITCHING: self.loc.config[ + SZ_USE_DAYLIGHT_SAVE_SWITCHING + ], } gwy_info = { - SZ_GATEWAY_ID: loc_config[GWS][0][SZ_GATEWAY_INFO][SZ_GATEWAY_ID], - TCS: loc_config[GWS][0][TCS], + SZ_GATEWAY_ID: self.loc.gateways[0].id, + SZ_TEMPERATURE_CONTROL_SYSTEMS: [ + self.loc.gateways[0].systems[0].config + ], } config = { SZ_LOCATION_INFO: loc_info, - GWS: [{SZ_GATEWAY_INFO: gwy_info}], + SZ_GATEWAYS: [{SZ_GATEWAY_INFO: gwy_info}], } - _LOGGER.debug("Config = %s", config) - - return True + self.logger.debug("Config = %s", config) async def call_client_api( self, client_api: Awaitable[dict[str, Any] | None], - update_state: bool = True, + request_refresh: bool = True, ) -> dict[str, Any] | None: - """Call a client API and update the broker state if required.""" + """Call a client API and update the Coordinator state if required.""" try: result = await client_api - except evo.RequestFailed as err: - handle_evo_exception(err) + + except ec2.ApiRequestFailedError as err: + self.logger.error(err) return None - if update_state: # wait a moment for system to quiesce before updating state - await self.hass.data[DOMAIN]["coordinator"].async_request_refresh() + if request_refresh: # wait a moment for system to quiesce before updating state + await self.async_request_refresh() # hass.async_create_task() won't help return result @@ -115,80 +149,82 @@ class EvoBroker: assert self.client_v1 is not None # mypy check - old_session_id = self._sess.session_id - try: - temps = await self.client_v1.get_temperatures() + await self.client_v1.update() - except ev1.InvalidSchema as err: - _LOGGER.warning( + except ec1.BadUserCredentialsError as err: + self.logger.warning( ( "Unable to obtain high-precision temperatures. " - "It appears the JSON schema is not as expected, " - "so the high-precision feature will be disabled until next restart." - "Message is: %s" + "The feature will be disabled until next restart: %r" ), err, ) self.client_v1 = None - except ev1.RequestFailed as err: - _LOGGER.warning( + except ec1.EvohomeError as err: + self.logger.warning( ( "Unable to obtain the latest high-precision temperatures. " - "Check your network and the vendor's service status page. " - "Proceeding without high-precision temperatures for now. " - "Message is: %s" + "They will be ignored this refresh cycle: %r" ), err, ) self.temps = {} # high-precision temps now considered stale - except Exception: - self.temps = {} # high-precision temps now considered stale - raise - else: - if str(self.client_v1.location_id) != self.loc.locationId: - _LOGGER.warning( - "The v2 API's configured location doesn't match " - "the v1 API's default location (there is more than one location), " - "so the high-precision feature will be disabled until next restart" - ) - self.client_v1 = None - else: - self.temps = {str(i[SZ_ID]): i[SZ_TEMP] for i in temps} + self.temps = await self.client_v1.location_by_id[ + self.loc.id + ].get_temperatures(dont_update_status=True) - finally: - if self.client_v1 and self.client_v1.broker.session_id != old_session_id: - await self._sess.save_auth_tokens() - - _LOGGER.debug("Temperatures = %s", self.temps) + self.logger.debug("Status (high-res temps) = %s", self.temps) async def _update_v2_api_state(self, *args: Any) -> None: """Get the latest modes, temperatures, setpoints of a Location.""" - access_token = self.client.access_token # maybe receive a new token? - try: - status = await self.loc.refresh_status() - except evo.RequestFailed as err: - handle_evo_exception(err) - else: - async_dispatcher_send(self.hass, DOMAIN) - _LOGGER.debug("Status = %s", status) - finally: - if access_token != self.client.access_token: - await self._sess.save_auth_tokens() + status = await self.loc.update() - async def async_update(self, *args: Any) -> None: - """Get the latest state data of an entire Honeywell TCC Location. + except ec2.ApiRequestFailedError as err: + if err.status != HTTPStatus.TOO_MANY_REQUESTS: + raise UpdateFailed(err) from err + + raise UpdateFailed( + f""" + The vendor's API rate limit has been exceeded. + Consider increasing the {CONF_SCAN_INTERVAL} + """ + ) from err + + except ec2.EvohomeError as err: + raise UpdateFailed(err) from err + + self.logger.debug("Status = %s", status) + + async def _update_v2_schedules(self) -> None: + for zone in self.tcs.zones: + await zone.get_schedule() + + if dhw := self.tcs.hotwater: + await dhw.get_schedule() + + async def _async_update_data(self) -> EvoLocStatusResponseT: # type: ignore[override] + """Fetch the latest state of an entire TCC Location. This includes state data for a Controller and all its child devices, such as the operating mode of the Controller and the current temp of its children (e.g. Zones, DHW controller). """ - await self._update_v2_api_state() + await self._update_v2_api_state() # may raise UpdateFailed if self.client_v1: - await self._update_v1_api_temps() + await self._update_v1_api_temps() # will never raise UpdateFailed + + # to speed up HA startup, don't update entity schedules during initial + # async_first_refresh(), only during subsequent async_refresh()... + if self._first_refresh_done: + await self._update_v2_schedules() + else: + self._first_refresh_done = True + + return self.loc.status diff --git a/homeassistant/components/evohome/entity.py b/homeassistant/components/evohome/entity.py index a42d8ef7582..11215dd47b6 100644 --- a/homeassistant/components/evohome/entity.py +++ b/homeassistant/components/evohome/entity.py @@ -1,55 +1,49 @@ """Base for evohome entity.""" -from datetime import datetime, timedelta, timezone +from collections.abc import Mapping +from datetime import UTC, datetime import logging from typing import Any import evohomeasync2 as evo -from evohomeasync2.schema.const import ( - SZ_HEAT_SETPOINT, - SZ_SETPOINT_STATUS, - SZ_STATE_STATUS, - SZ_SYSTEM_MODE_STATUS, - SZ_TIME_UNTIL, - SZ_UNTIL, -) +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity -from homeassistant.util import dt as dt_util +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import EvoBroker, EvoService -from .const import DOMAIN -from .helpers import convert_dict, convert_until +from .const import DOMAIN, EvoService +from .coordinator import EvoDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -class EvoDevice(Entity): +class EvoEntity(CoordinatorEntity[EvoDataUpdateCoordinator]): """Base for any evohome-compatible entity (controller, DHW, zone). This includes the controller, (1 to 12) heating zones and (optionally) a DHW controller. """ - _attr_should_poll = False + _evo_device: evo.ControlSystem | evo.HotWater | evo.Zone + _evo_id_attr: str + _evo_state_attr_names: tuple[str, ...] def __init__( self, - evo_broker: EvoBroker, + coordinator: EvoDataUpdateCoordinator, evo_device: evo.ControlSystem | evo.HotWater | evo.Zone, ) -> None: """Initialize an evohome-compatible entity (TCS, DHW, zone).""" + super().__init__(coordinator, context=evo_device.id) self._evo_device = evo_device - self._evo_broker = evo_broker self._device_state_attrs: dict[str, Any] = {} - async def async_refresh(self, payload: dict | None = None) -> None: + async def process_signal(self, payload: dict | None = None) -> None: """Process any signals.""" + if payload is None: - self.async_schedule_update_ha_state(force_refresh=True) - return + raise NotImplementedError if payload["unique_id"] != self._attr_unique_id: return if payload["service"] in ( @@ -69,40 +63,46 @@ class EvoDevice(Entity): raise NotImplementedError @property - def extra_state_attributes(self) -> dict[str, Any]: + def extra_state_attributes(self) -> Mapping[str, Any]: """Return the evohome-specific state attributes.""" - status = self._device_state_attrs - if SZ_SYSTEM_MODE_STATUS in status: - convert_until(status[SZ_SYSTEM_MODE_STATUS], SZ_TIME_UNTIL) - if SZ_SETPOINT_STATUS in status: - convert_until(status[SZ_SETPOINT_STATUS], SZ_UNTIL) - if SZ_STATE_STATUS in status: - convert_until(status[SZ_STATE_STATUS], SZ_UNTIL) - - return {"status": convert_dict(status)} + return {"status": self._device_state_attrs} async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" - async_dispatcher_connect(self.hass, DOMAIN, self.async_refresh) + await super().async_added_to_hass() + + async_dispatcher_connect(self.hass, DOMAIN, self.process_signal) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + self._device_state_attrs[self._evo_id_attr] = self._evo_device.id + + for attr in self._evo_state_attr_names: + self._device_state_attrs[attr] = getattr(self._evo_device, attr) + + super()._handle_coordinator_update() -class EvoChild(EvoDevice): +class EvoChild(EvoEntity): """Base for any evohome-compatible child entity (DHW, zone). This includes (1 to 12) heating zones and (optionally) a DHW controller. """ - _evo_id: str # mypy hint + _evo_device: evo.HotWater | evo.Zone + _evo_id: str def __init__( - self, evo_broker: EvoBroker, evo_device: evo.HotWater | evo.Zone + self, coordinator: EvoDataUpdateCoordinator, evo_device: evo.HotWater | evo.Zone ) -> None: """Initialize an evohome-compatible child entity (DHW, zone).""" - super().__init__(evo_broker, evo_device) + super().__init__(coordinator, evo_device) self._evo_tcs = evo_device.tcs - self._schedule: dict[str, Any] = {} + self._schedule: dict[str, Any] | None = None self._setpoints: dict[str, Any] = {} @property @@ -111,101 +111,78 @@ class EvoChild(EvoDevice): assert isinstance(self._evo_device, evo.HotWater | evo.Zone) # mypy check - if (temp := self._evo_broker.temps.get(self._evo_id)) is not None: + if (temp := self.coordinator.temps.get(self._evo_id)) is not None: # use high-precision temps if available return temp return self._evo_device.temperature @property - def setpoints(self) -> dict[str, Any]: + def setpoints(self) -> Mapping[str, Any]: """Return the current/next setpoints from the schedule. Only Zones & DHW controllers (but not the TCS) can have schedules. """ - def _dt_evo_to_aware(dt_naive: datetime, utc_offset: timedelta) -> datetime: - dt_aware = dt_naive.replace(tzinfo=dt_util.UTC) - utc_offset - return dt_util.as_local(dt_aware) + this_sp_dtm, this_sp_val = self._evo_device.this_switchpoint + next_sp_dtm, next_sp_val = self._evo_device.next_switchpoint - if not (schedule := self._schedule.get("DailySchedules")): - return {} # no scheduled setpoints when {'DailySchedules': []} + key = "temp" if isinstance(self._evo_device, evo.Zone) else "state" - # get dt in the same TZ as the TCS location, so we can compare schedule times - day_time = dt_util.now().astimezone(timezone(self._evo_broker.loc_utc_offset)) - day_of_week = day_time.weekday() # for evohome, 0 is Monday - time_of_day = day_time.strftime("%H:%M:%S") - - try: - # Iterate today's switchpoints until past the current time of day... - day = schedule[day_of_week] - sp_idx = -1 # last switchpoint of the day before - for i, tmp in enumerate(day["Switchpoints"]): - if time_of_day > tmp["TimeOfDay"]: - sp_idx = i # current setpoint - else: - break - - # Did this setpoint start yesterday? Does the next setpoint start tomorrow? - this_sp_day = -1 if sp_idx == -1 else 0 - next_sp_day = 1 if sp_idx + 1 == len(day["Switchpoints"]) else 0 - - for key, offset, idx in ( - ("this", this_sp_day, sp_idx), - ("next", next_sp_day, (sp_idx + 1) * (1 - next_sp_day)), - ): - sp_date = (day_time + timedelta(days=offset)).strftime("%Y-%m-%d") - day = schedule[(day_of_week + offset) % 7] - switchpoint = day["Switchpoints"][idx] - - switchpoint_time_of_day = dt_util.parse_datetime( - f"{sp_date}T{switchpoint['TimeOfDay']}" - ) - assert switchpoint_time_of_day is not None # mypy check - dt_aware = _dt_evo_to_aware( - switchpoint_time_of_day, self._evo_broker.loc_utc_offset - ) - - self._setpoints[f"{key}_sp_from"] = dt_aware.isoformat() - try: - self._setpoints[f"{key}_sp_temp"] = switchpoint[SZ_HEAT_SETPOINT] - except KeyError: - self._setpoints[f"{key}_sp_state"] = switchpoint["DhwState"] - - except IndexError: - self._setpoints = {} - _LOGGER.warning( - "Failed to get setpoints, report as an issue if this error persists", - exc_info=True, - ) + self._setpoints = { + "this_sp_from": this_sp_dtm, + f"this_sp_{key}": this_sp_val, + "next_sp_from": next_sp_dtm, + f"next_sp_{key}": next_sp_val, + } return self._setpoints - async def _update_schedule(self) -> None: + async def _update_schedule(self, force_refresh: bool = False) -> None: """Get the latest schedule, if any.""" - assert isinstance(self._evo_device, evo.HotWater | evo.Zone) # mypy check + async def get_schedule() -> None: + try: + schedule = await self.coordinator.call_client_api( + self._evo_device.get_schedule(), # type: ignore[arg-type] + request_refresh=False, + ) + except evo.InvalidScheduleError as err: + _LOGGER.warning( + "%s: Unable to retrieve a valid schedule: %s", + self._evo_device, + err, + ) + self._schedule = {} + return + else: + self._schedule = schedule or {} # mypy hint - try: - schedule = await self._evo_broker.call_client_api( - self._evo_device.get_schedule(), update_state=False + _LOGGER.debug("Schedule['%s'] = %s", self.name, schedule) + + if ( + force_refresh + or self._schedule is None + or ( + (until := self._setpoints.get("next_sp_from")) is not None + and until < datetime.now(UTC) ) - except evo.InvalidSchedule as err: - _LOGGER.warning( - "%s: Unable to retrieve a valid schedule: %s", - self._evo_device, - err, - ) - self._schedule = {} - else: - self._schedule = schedule or {} + ): # must use self._setpoints, not self.setpoints + await get_schedule() - _LOGGER.debug("Schedule['%s'] = %s", self.name, self._schedule) + _ = self.setpoints # update the setpoints attr - async def async_update(self) -> None: - """Get the latest state data.""" - next_sp_from = self._setpoints.get("next_sp_from", "2000-01-01T00:00:00+00:00") - next_sp_from_dt = dt_util.parse_datetime(next_sp_from) - if next_sp_from_dt is None or dt_util.now() >= next_sp_from_dt: - await self._update_schedule() # no schedule, or it's out-of-date + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" - self._device_state_attrs = {"setpoints": self.setpoints} + self._device_state_attrs = { + "activeFaults": self._evo_device.active_faults, + "setpoints": self._setpoints, + } + + super()._handle_coordinator_update() + + async def update_attrs(self) -> None: + """Update the entity's extra state attrs.""" + await self._update_schedule() + self._handle_coordinator_update() diff --git a/homeassistant/components/evohome/helpers.py b/homeassistant/components/evohome/helpers.py deleted file mode 100644 index 0e2de36eb47..00000000000 --- a/homeassistant/components/evohome/helpers.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Support for (EMEA/EU-based) Honeywell TCC systems.""" - -from __future__ import annotations - -from datetime import datetime, timedelta -from http import HTTPStatus -import logging -import re -from typing import Any - -import evohomeasync2 as evo - -from homeassistant.const import CONF_SCAN_INTERVAL -from homeassistant.util import dt as dt_util - -_LOGGER = logging.getLogger(__name__) - - -def dt_local_to_aware(dt_naive: datetime) -> datetime: - """Convert a local/naive datetime to TZ-aware.""" - dt_aware = dt_util.now() + (dt_naive - datetime.now()) - if dt_aware.microsecond >= 500000: - dt_aware += timedelta(seconds=1) - return dt_aware.replace(microsecond=0) - - -def dt_aware_to_naive(dt_aware: datetime) -> datetime: - """Convert a TZ-aware datetime to naive/local.""" - dt_naive = datetime.now() + (dt_aware - dt_util.now()) - if dt_naive.microsecond >= 500000: - dt_naive += timedelta(seconds=1) - return dt_naive.replace(microsecond=0) - - -def convert_until(status_dict: dict, until_key: str) -> None: - """Reformat a dt str from "%Y-%m-%dT%H:%M:%SZ" as local/aware/isoformat.""" - if until_key in status_dict and ( # only present for certain modes - dt_utc_naive := dt_util.parse_datetime(status_dict[until_key]) - ): - status_dict[until_key] = dt_util.as_local(dt_utc_naive).isoformat() - - -def convert_dict(dictionary: dict[str, Any]) -> dict[str, Any]: - """Recursively convert a dict's keys to snake_case.""" - - def convert_key(key: str) -> str: - """Convert a string to snake_case.""" - string = re.sub(r"[\-\.\s]", "_", str(key)) - return ( - (string[0]).lower() - + re.sub( - r"[A-Z]", - lambda matched: f"_{matched.group(0).lower()}", # type:ignore[str-bytes-safe] - string[1:], - ) - ) - - return { - (convert_key(k) if isinstance(k, str) else k): ( - convert_dict(v) if isinstance(v, dict) else v - ) - for k, v in dictionary.items() - } - - -def handle_evo_exception(err: evo.RequestFailed) -> None: - """Return False if the exception can't be ignored.""" - - try: - raise err - - except evo.AuthenticationFailed: - _LOGGER.error( - ( - "Failed to authenticate with the vendor's server. Check your username" - " and password. NB: Some special password characters that work" - " correctly via the website will not work via the web API. Message" - " is: %s" - ), - err, - ) - - except evo.RequestFailed: - if err.status is None: - _LOGGER.warning( - ( - "Unable to connect with the vendor's server. " - "Check your network and the vendor's service status page. " - "Message is: %s" - ), - err, - ) - - elif err.status == HTTPStatus.SERVICE_UNAVAILABLE: - _LOGGER.warning( - "The vendor says their server is currently unavailable. " - "Check the vendor's service status page" - ) - - elif err.status == HTTPStatus.TOO_MANY_REQUESTS: - _LOGGER.warning( - ( - "The vendor's API rate limit has been exceeded. " - "If this message persists, consider increasing the %s" - ), - CONF_SCAN_INTERVAL, - ) - - else: - raise # we don't expect/handle any other Exceptions diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 22edadad7f4..823ad7be5df 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -4,7 +4,7 @@ "codeowners": ["@zxdavb"], "documentation": "https://www.home-assistant.io/integrations/evohome", "iot_class": "cloud_polling", - "loggers": ["evohomeasync", "evohomeasync2"], + "loggers": ["evohome", "evohomeasync", "evohomeasync2"], "quality_scale": "legacy", - "requirements": ["evohome-async==0.4.21"] + "requirements": ["evohome-async==1.0.2"] } diff --git a/homeassistant/components/evohome/storage.py b/homeassistant/components/evohome/storage.py new file mode 100644 index 00000000000..b078c33b305 --- /dev/null +++ b/homeassistant/components/evohome/storage.py @@ -0,0 +1,118 @@ +"""Support for (EMEA/EU-based) Honeywell TCC systems.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Any, NotRequired, TypedDict + +from evohomeasync.auth import ( + SZ_SESSION_ID, + SZ_SESSION_ID_EXPIRES, + AbstractSessionManager, +) +from evohomeasync2.auth import AbstractTokenManager + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store + +from .const import STORAGE_KEY, STORAGE_VER + + +class _SessionIdEntryT(TypedDict): + session_id: str + session_id_expires: NotRequired[str] # dt.isoformat() # TZ-aware + + +class _TokenStoreT(TypedDict): + username: str + refresh_token: str + access_token: str + access_token_expires: str # dt.isoformat() # TZ-aware + session_id: NotRequired[str] + session_id_expires: NotRequired[str] # dt.isoformat() # TZ-aware + + +class TokenManager(AbstractTokenManager, AbstractSessionManager): + """A token manager that uses a cache file to store the tokens.""" + + def __init__( + self, + hass: HomeAssistant, + *args: Any, + **kwargs: Any, + ) -> None: + """Initialise the token manager.""" + super().__init__(*args, **kwargs) + + self._store = Store(hass, STORAGE_VER, STORAGE_KEY) # type: ignore[var-annotated] + self._store_initialized = False # True once cache loaded first time + + async def get_access_token(self) -> str: + """Return a valid access token. + + If the cached entry is not valid, will fetch a new access token. + """ + + if not self._store_initialized: + await self._load_cache_from_store() + + return await super().get_access_token() + + async def get_session_id(self) -> str: + """Return a valid session id. + + If the cached entry is not valid, will fetch a new session id. + """ + + if not self._store_initialized: + await self._load_cache_from_store() + + return await super().get_session_id() + + async def _load_cache_from_store(self) -> None: + """Load the user entry from the cache. + + Assumes single reader/writer. Reads only once, at initialization. + """ + + cache: _TokenStoreT = await self._store.async_load() or {} # type: ignore[assignment] + self._store_initialized = True + + if not cache or cache["username"] != self._client_id: + return + + if SZ_SESSION_ID in cache: + self._import_session_id(cache) # type: ignore[arg-type] + self._import_access_token(cache) + + def _import_session_id(self, session: _SessionIdEntryT) -> None: # type: ignore[override] + """Extract the session id from a (serialized) dictionary.""" + # base class method overridden because session_id_expired is NotRequired here + + self._session_id = session[SZ_SESSION_ID] + + session_id_expires = session.get(SZ_SESSION_ID_EXPIRES) + if session_id_expires is None: + self._session_id_expires = datetime.now(tz=UTC) + timedelta(minutes=15) + else: + self._session_id_expires = datetime.fromisoformat(session_id_expires) + + async def save_access_token(self) -> None: # an abstractmethod + """Save the access token (and expiry dtm, refresh token) to the cache.""" + await self.save_cache_to_store() + + async def save_session_id(self) -> None: # an abstractmethod + """Save the session id (and expiry dtm) to the cache.""" + await self.save_cache_to_store() + + async def save_cache_to_store(self) -> None: + """Save the access token (and session id, if any) to the cache. + + Assumes a single reader/writer. Writes whenever new data has been fetched. + """ + + cache = {"username": self._client_id} | self._export_access_token() + if self._session_id: + cache |= self._export_session_id() + + await self._store.async_save(cache) diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index 2c3cf9de12d..7ea0fb3a2d9 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -3,17 +3,11 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any +from typing import Any import evohomeasync2 as evo -from evohomeasync2.schema.const import ( - SZ_ACTIVE_FAULTS, - SZ_DHW_ID, - SZ_OFF, - SZ_ON, - SZ_STATE_STATUS, - SZ_TEMPERATURE_STATUS, -) +from evohomeasync2.const import SZ_STATE_STATUS, SZ_TEMPERATURE_STATUS +from evohomeasync2.schemas.const import DhwState as EvoDhwState, ZoneMode as EvoZoneMode from homeassistant.components.water_heater import ( WaterHeaterEntity, @@ -31,22 +25,17 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from .const import DOMAIN, EVO_FOLLOW, EVO_PERMOVER +from . import EVOHOME_KEY +from .coordinator import EvoDataUpdateCoordinator from .entity import EvoChild -if TYPE_CHECKING: - from . import EvoBroker - - _LOGGER = logging.getLogger(__name__) STATE_AUTO = "auto" -HA_STATE_TO_EVO = {STATE_AUTO: "", STATE_ON: SZ_ON, STATE_OFF: SZ_OFF} +HA_STATE_TO_EVO = {STATE_AUTO: "", STATE_ON: EvoDhwState.ON, STATE_OFF: EvoDhwState.OFF} EVO_STATE_TO_HA = {v: k for k, v in HA_STATE_TO_EVO.items() if k != ""} -STATE_ATTRS_DHW = [SZ_DHW_ID, SZ_ACTIVE_FAULTS, SZ_STATE_STATUS, SZ_TEMPERATURE_STATUS] - async def async_setup_platform( hass: HomeAssistant, @@ -58,19 +47,22 @@ async def async_setup_platform( if discovery_info is None: return - broker: EvoBroker = hass.data[DOMAIN]["broker"] + coordinator = hass.data[EVOHOME_KEY].coordinator + tcs = hass.data[EVOHOME_KEY].tcs - assert broker.tcs.hotwater is not None # mypy check + assert tcs.hotwater is not None # mypy check _LOGGER.debug( "Adding: DhwController (%s), id=%s", - broker.tcs.hotwater.TYPE, - broker.tcs.hotwater.dhwId, + tcs.hotwater.type, + tcs.hotwater.id, ) - new_entity = EvoDHW(broker, broker.tcs.hotwater) + entity = EvoDHW(coordinator, tcs.hotwater) - async_add_entities([new_entity], update_before_add=True) + async_add_entities([entity]) + + await entity.update_attrs() class EvoDHW(EvoChild, WaterHeaterEntity): @@ -81,19 +73,23 @@ class EvoDHW(EvoChild, WaterHeaterEntity): _attr_operation_list = list(HA_STATE_TO_EVO) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _evo_device: evo.HotWater # mypy hint + _evo_device: evo.HotWater + _evo_id_attr = "dhw_id" + _evo_state_attr_names = (SZ_STATE_STATUS, SZ_TEMPERATURE_STATUS) - def __init__(self, evo_broker: EvoBroker, evo_device: evo.HotWater) -> None: + def __init__( + self, coordinator: EvoDataUpdateCoordinator, evo_device: evo.HotWater + ) -> None: """Initialize an evohome-compatible DHW controller.""" - super().__init__(evo_broker, evo_device) - self._evo_id = evo_device.dhwId + super().__init__(coordinator, evo_device) + self._evo_id = evo_device.id - self._attr_unique_id = evo_device.dhwId + self._attr_unique_id = evo_device.id self._attr_name = evo_device.name # is static self._attr_precision = ( - PRECISION_TENTHS if evo_broker.client_v1 else PRECISION_WHOLE + PRECISION_TENTHS if coordinator.client_v1 else PRECISION_WHOLE ) self._attr_supported_features = ( WaterHeaterEntityFeature.AWAY_MODE | WaterHeaterEntityFeature.OPERATION_MODE @@ -102,19 +98,15 @@ class EvoDHW(EvoChild, WaterHeaterEntity): @property def current_operation(self) -> str | None: """Return the current operating mode (Auto, On, or Off).""" - if self._evo_device.mode == EVO_FOLLOW: + if self._evo_device.mode == EvoZoneMode.FOLLOW_SCHEDULE: return STATE_AUTO - if (device_state := self._evo_device.state) is None: - return None - return EVO_STATE_TO_HA[device_state] + return EVO_STATE_TO_HA[self._evo_device.state] @property def is_away_mode_on(self) -> bool | None: """Return True if away mode is on.""" - if self._evo_device.state is None: - return None is_off = EVO_STATE_TO_HA[self._evo_device.state] == STATE_OFF - is_permanent = self._evo_device.mode == EVO_PERMOVER + is_permanent = self._evo_device.mode == EvoZoneMode.PERMANENT_OVERRIDE return is_off and is_permanent async def async_set_operation_mode(self, operation_mode: str) -> None: @@ -123,40 +115,31 @@ class EvoDHW(EvoChild, WaterHeaterEntity): Except for Auto, the mode is only until the next SetPoint. """ if operation_mode == STATE_AUTO: - await self._evo_broker.call_client_api(self._evo_device.reset_mode()) + await self.coordinator.call_client_api(self._evo_device.reset()) else: await self._update_schedule() - until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) + until = self.setpoints.get("next_sp_from") until = dt_util.as_utc(until) if until else None if operation_mode == STATE_ON: - await self._evo_broker.call_client_api( - self._evo_device.set_on(until=until) - ) + await self.coordinator.call_client_api(self._evo_device.on(until=until)) else: # STATE_OFF - await self._evo_broker.call_client_api( - self._evo_device.set_off(until=until) + await self.coordinator.call_client_api( + self._evo_device.off(until=until) ) async def async_turn_away_mode_on(self) -> None: """Turn away mode on.""" - await self._evo_broker.call_client_api(self._evo_device.set_off()) + await self.coordinator.call_client_api(self._evo_device.off()) async def async_turn_away_mode_off(self) -> None: """Turn away mode off.""" - await self._evo_broker.call_client_api(self._evo_device.reset_mode()) + await self.coordinator.call_client_api(self._evo_device.reset()) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on.""" - await self._evo_broker.call_client_api(self._evo_device.set_on()) + await self.coordinator.call_client_api(self._evo_device.on()) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off.""" - await self._evo_broker.call_client_api(self._evo_device.set_off()) - - async def async_update(self) -> None: - """Get the latest state data for a DHW controller.""" - await super().async_update() - - for attr in STATE_ATTRS_DHW: - self._device_state_attrs[attr] = getattr(self._evo_device, attr) + await self.coordinator.call_client_api(self._evo_device.off()) diff --git a/requirements_all.txt b/requirements_all.txt index 0fdf048bc63..13abe012fcc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -893,7 +893,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==0.4.21 +evohome-async==1.0.2 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 053acf5bd86..580efd88992 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -759,7 +759,7 @@ eternalegypt==0.0.16 eufylife-ble-client==0.1.8 # homeassistant.components.evohome -evohome-async==0.4.21 +evohome-async==1.0.2 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 diff --git a/tests/components/evohome/conftest.py b/tests/components/evohome/conftest.py index 6daab3f32bb..5f60bc418e3 100644 --- a/tests/components/evohome/conftest.py +++ b/tests/components/evohome/conftest.py @@ -3,26 +3,26 @@ from __future__ import annotations from collections.abc import AsyncGenerator, Callable -from datetime import datetime, timedelta, timezone +from datetime import timedelta, timezone from http import HTTPMethod from typing import Any from unittest.mock import MagicMock, patch -from aiohttp import ClientSession from evohomeasync2 import EvohomeClient -from evohomeasync2.broker import Broker -from evohomeasync2.controlsystem import ControlSystem +from evohomeasync2.auth import AbstractTokenManager, Auth +from evohomeasync2.control_system import ControlSystem from evohomeasync2.zone import Zone +from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.evohome import CONF_PASSWORD, CONF_USERNAME, DOMAIN -from homeassistant.const import Platform +from homeassistant.components.evohome.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util, slugify from homeassistant.util.json import JsonArrayType, JsonObjectType -from .const import ACCESS_TOKEN, REFRESH_TOKEN, USERNAME +from .const import ACCESS_TOKEN, REFRESH_TOKEN, SESSION_ID, USERNAME from tests.common import load_json_array_fixture, load_json_object_fixture @@ -64,44 +64,69 @@ def zone_schedule_fixture(install: str) -> JsonObjectType: return load_json_object_fixture("default/schedule_zone.json", DOMAIN) -def mock_get_factory(install: str) -> Callable: +def mock_post_request(install: str) -> Callable: + """Obtain an access token via a POST to the vendor's web API.""" + + async def post_request( + self: AbstractTokenManager, url: str, /, **kwargs: Any + ) -> JsonArrayType | JsonObjectType: + """Obtain an access token via a POST to the vendor's web API.""" + + if "Token" in url: + return { + "access_token": f"new_{ACCESS_TOKEN}", + "token_type": "bearer", + "expires_in": 1800, + "refresh_token": f"new_{REFRESH_TOKEN}", + # "scope": "EMEA-V1-Basic EMEA-V1-Anonymous", # optional + } + + if "session" in url: + return {"sessionId": f"new_{SESSION_ID}"} + + pytest.fail(f"Unexpected request: {HTTPMethod.POST} {url}") + + return post_request + + +def mock_make_request(install: str) -> Callable: """Return a get method for a specified installation.""" - async def mock_get( - self: Broker, url: str, **kwargs: Any + async def make_request( + self: Auth, method: HTTPMethod, 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 method != HTTPMethod.GET: + pytest.fail(f"Unmocked method: {method} {url}") - 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) + await self._headers() # assume a valid GET, and return the JSON for that web API - if url == "userAccount": # userAccount + if url == "accountInfo": # /v0/accountInfo + return {} # will throw a KeyError -> BadApiResponseError + + if url.startswith("locations/"): # /v0/locations?userId={id}&allData=True + return [] # user has no locations + + if url == "userAccount": # /v2/userAccount return user_account_config_fixture(install) - if url.startswith("location"): - if "installationInfo" in url: # location/installationInfo?userId={id} + if url.startswith("location/"): + if "installationInfo" in url: # /v2/location/installationInfo?userId={id} return user_locations_config_fixture(install) - if "location" in url: # location/{id}/status + if "status" in url: # /v2/location/{id}/status return location_status_fixture(install) elif "schedule" in url: - if url.startswith("domesticHotWater"): # domesticHotWater/{id}/schedule + if url.startswith("domesticHotWater"): # /v2/domesticHotWater/{id}/schedule return dhw_schedule_fixture(install) - if url.startswith("temperatureZone"): # temperatureZone/{id}/schedule + if url.startswith("temperatureZone"): # /v2/temperatureZone/{id}/schedule return zone_schedule_fixture(install) pytest.fail(f"Unexpected request: {HTTPMethod.GET} {url}") - return mock_get + return make_request @pytest.fixture @@ -137,9 +162,13 @@ async def setup_evohome( dt_util.set_default_time_zone(timezone(timedelta(minutes=utc_offset))) with ( - patch("homeassistant.components.evohome.evo.EvohomeClient") as mock_client, - patch("homeassistant.components.evohome.ev1.EvohomeClient", return_value=None), - patch("evohomeasync2.broker.Broker.get", mock_get_factory(install)), + # patch("homeassistant.components.evohome.ec1.EvohomeClient", return_value=None), + patch("homeassistant.components.evohome.ec2.EvohomeClient") as mock_client, + patch( + "evohomeasync2.auth.CredentialsManagerBase._post_request", + mock_post_request(install), + ), + patch("evohome.auth.AbstractAuth._make_request", mock_make_request(install)), ): evo: EvohomeClient | None = None @@ -155,12 +184,11 @@ async def setup_evohome( mock_client.assert_called_once() - assert mock_client.call_args.args[0] == config[CONF_USERNAME] - assert mock_client.call_args.args[1] == config[CONF_PASSWORD] + assert isinstance(evo, EvohomeClient) + assert evo._token_manager.client_id == config[CONF_USERNAME] + assert evo._token_manager._secret == config[CONF_PASSWORD] - assert isinstance(mock_client.call_args.kwargs["session"], ClientSession) - - assert evo and evo.account_info is not None + assert evo.user_account mock_client.return_value = evo yield mock_client @@ -170,39 +198,32 @@ async def setup_evohome( async def evohome( hass: HomeAssistant, config: dict[str, str], + freezer: FrozenDateTimeFactory, install: str, ) -> AsyncGenerator[MagicMock]: """Return the mocked evohome client for this install fixture.""" + freezer.move_to("2024-07-10T12:00:00Z") # so schedules are as expected + async for mock_client in setup_evohome(hass, config, install=install): yield mock_client @pytest.fixture -async def ctl_id( - hass: HomeAssistant, - config: dict[str, str], - install: MagicMock, -) -> AsyncGenerator[str]: +def ctl_id(evohome: MagicMock) -> str: """Return the entity_id of the evohome integration's controller.""" - async for mock_client in setup_evohome(hass, config, install=install): - evo: EvohomeClient = mock_client.return_value - ctl: ControlSystem = evo._get_single_tcs() + evo: EvohomeClient = evohome.return_value + ctl: ControlSystem = evo.tcs - yield f"{Platform.CLIMATE}.{slugify(ctl.location.name)}" + return f"{Platform.CLIMATE}.{slugify(ctl.location.name)}" @pytest.fixture -async def zone_id( - hass: HomeAssistant, - config: dict[str, str], - install: MagicMock, -) -> AsyncGenerator[str]: +def zone_id(evohome: MagicMock) -> str: """Return the entity_id of the evohome integration's first zone.""" - async for mock_client in setup_evohome(hass, config, install=install): - evo: EvohomeClient = mock_client.return_value - zone: Zone = list(evo._get_single_tcs().zones.values())[0] + evo: EvohomeClient = evohome.return_value + zone: Zone = evo.tcs.zones[0] - yield f"{Platform.CLIMATE}.{slugify(zone.name)}" + return f"{Platform.CLIMATE}.{slugify(zone.name)}" diff --git a/tests/components/evohome/fixtures/h032585/user_locations.json b/tests/components/evohome/fixtures/h032585/user_locations.json index b4ea2e5c420..c291d591c99 100644 --- a/tests/components/evohome/fixtures/h032585/user_locations.json +++ b/tests/components/evohome/fixtures/h032585/user_locations.json @@ -3,6 +3,7 @@ "locationInfo": { "locationId": "111111", "name": "My Home", + "useDaylightSaveSwitching": true, "timeZone": { "timeZoneId": "GMTStandardTime", "displayName": "(UTC+00:00) Dublin, Edinburgh, Lisbon, London", diff --git a/tests/components/evohome/fixtures/h099625/user_locations.json b/tests/components/evohome/fixtures/h099625/user_locations.json index cc32caccc73..31cac00ae9e 100644 --- a/tests/components/evohome/fixtures/h099625/user_locations.json +++ b/tests/components/evohome/fixtures/h099625/user_locations.json @@ -3,6 +3,7 @@ "locationInfo": { "locationId": "111111", "name": "My Home", + "useDaylightSaveSwitching": true, "timeZone": { "timeZoneId": "FLEStandardTime", "displayName": "(UTC+02:00) Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius", diff --git a/tests/components/evohome/snapshots/test_climate.ambr b/tests/components/evohome/snapshots/test_climate.ambr index ce7fcf2744e..23a15e3f64f 100644 --- a/tests/components/evohome/snapshots/test_climate.ambr +++ b/tests/components/evohome/snapshots/test_climate.ambr @@ -2,120 +2,120 @@ # name: test_ctl_set_hvac_mode[default] list([ tuple( - 'HeatingOff', + , ), tuple( - 'Auto', + , ), ]) # --- # name: test_ctl_set_hvac_mode[h032585] list([ tuple( - 'Off', + , ), tuple( - 'Heat', + , ), ]) # --- # name: test_ctl_set_hvac_mode[h099625] list([ tuple( - 'HeatingOff', + , ), tuple( - 'Auto', + , ), ]) # --- # name: test_ctl_set_hvac_mode[minimal] list([ tuple( - 'HeatingOff', + , ), tuple( - 'Auto', + , ), ]) # --- # name: test_ctl_set_hvac_mode[sys_004] list([ tuple( - 'HeatingOff', + , ), tuple( - 'Auto', + , ), ]) # --- # name: test_ctl_turn_off[default] list([ tuple( - 'HeatingOff', + , ), ]) # --- # name: test_ctl_turn_off[h032585] list([ tuple( - 'Off', + , ), ]) # --- # name: test_ctl_turn_off[h099625] list([ tuple( - 'HeatingOff', + , ), ]) # --- # name: test_ctl_turn_off[minimal] list([ tuple( - 'HeatingOff', + , ), ]) # --- # name: test_ctl_turn_off[sys_004] list([ tuple( - 'HeatingOff', + , ), ]) # --- # name: test_ctl_turn_on[default] list([ tuple( - 'Auto', + , ), ]) # --- # name: test_ctl_turn_on[h032585] list([ tuple( - 'Heat', + , ), ]) # --- # name: test_ctl_turn_on[h099625] list([ tuple( - 'Auto', + , ), ]) # --- # name: test_ctl_turn_on[minimal] list([ tuple( - 'Auto', + , ), ]) # --- # name: test_ctl_turn_on[sys_004] list([ tuple( - 'Auto', + , ), ]) # --- @@ -137,16 +137,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 16.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -184,16 +184,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -230,21 +230,21 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ + 'activeFaults': tuple( dict({ - 'faultType': 'TempZoneActuatorLowBattery', - 'since': '2022-03-02T04:50:20', + 'fault_type': 'TempZoneActuatorLowBattery', + 'since': '2022-03-02T04:50:20+00:00', }), - ]), + ), 'setpoint_status': dict({ 'setpoint_mode': 'TemporaryOverride', 'target_heat_temperature': 21.0, - 'until': '2022-03-07T20:00:00+01:00', + 'until': '2022-03-07T19:00:00+00:00', }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -282,16 +282,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -329,16 +329,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -376,16 +376,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 16.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -423,20 +423,20 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ + 'activeFaults': tuple( dict({ - 'faultType': 'TempZoneActuatorCommunicationLost', - 'since': '2022-03-02T15:56:01', + 'fault_type': 'TempZoneActuatorCommunicationLost', + 'since': '2022-03-02T15:56:01+00:00', }), - ]), + ), 'setpoint_status': dict({ 'setpoint_mode': 'PermanentOverride', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -477,8 +477,8 @@ 'Custom', ]), 'status': dict({ - 'active_system_faults': list([ - ]), + 'activeSystemFaults': tuple( + ), 'system_id': '3432522', 'system_mode_status': dict({ 'is_permanent': True, @@ -513,16 +513,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 16.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -560,16 +560,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -606,17 +606,17 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'TemporaryOverride', 'target_heat_temperature': 21.0, - 'until': '2022-03-07T20:00:00+01:00', + 'until': '2022-03-07T19:00:00+00:00', }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -654,16 +654,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -701,16 +701,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -748,16 +748,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 16.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -795,16 +795,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'PermanentOverride', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -845,8 +845,8 @@ 'Custom', ]), 'status': dict({ - 'active_system_faults': list([ - ]), + 'activeSystemFaults': tuple( + ), 'system_id': '3432522', 'system_mode_status': dict({ 'is_permanent': True, @@ -881,16 +881,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'PermanentOverride', 'target_heat_temperature': 14.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -923,8 +923,8 @@ 'max_temp': 35, 'min_temp': 7, 'status': dict({ - 'active_system_faults': list([ - ]), + 'activeSystemFaults': tuple( + ), 'system_id': '416856', 'system_mode_status': dict({ 'is_permanent': True, @@ -959,16 +959,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 21.5, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -1006,8 +1006,8 @@ 'away', ]), 'status': dict({ - 'active_system_faults': list([ - ]), + 'activeSystemFaults': tuple( + ), 'system_id': '8557535', 'system_mode_status': dict({ 'is_permanent': True, @@ -1042,16 +1042,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 21.5, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+03:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/Kiev')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+03:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Kiev')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -1089,16 +1089,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 21.5, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+03:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/Kiev')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+03:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Kiev')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -1136,16 +1136,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -1186,8 +1186,8 @@ 'Custom', ]), 'status': dict({ - 'active_system_faults': list([ - ]), + 'activeSystemFaults': tuple( + ), 'system_id': '3432522', 'system_mode_status': dict({ 'is_permanent': True, @@ -1222,8 +1222,12 @@ 'away', ]), 'status': dict({ - 'active_system_faults': list([ - ]), + 'activeSystemFaults': tuple( + dict({ + 'fault_type': 'GatewayCommunicationLost', + 'since': '2023-05-04T18:47:36.772704+02:00', + }), + ), 'system_id': '4187769', 'system_mode_status': dict({ 'is_permanent': True, @@ -1258,16 +1262,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'PermanentOverride', 'target_heat_temperature': 15.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+02:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/Berlin')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+02:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Berlin')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -1331,7 +1335,7 @@ 17.0, ), dict({ - 'until': datetime.datetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), }), ]) # --- @@ -1344,7 +1348,7 @@ 21.5, ), dict({ - 'until': datetime.datetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), }), ]) # --- @@ -1357,7 +1361,7 @@ 21.5, ), dict({ - 'until': datetime.datetime(2024, 7, 10, 19, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 19, 10, tzinfo=datetime.timezone.utc), }), ]) # --- @@ -1370,7 +1374,7 @@ 17.0, ), dict({ - 'until': datetime.datetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), }), ]) # --- @@ -1383,35 +1387,35 @@ 15.0, ), dict({ - 'until': datetime.datetime(2024, 7, 10, 20, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 20, 10, tzinfo=datetime.timezone.utc), }), ]) # --- # name: test_zone_set_temperature[default] list([ dict({ - 'until': datetime.datetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), }), ]) # --- # name: test_zone_set_temperature[h032585] list([ dict({ - 'until': datetime.datetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), }), ]) # --- # name: test_zone_set_temperature[h099625] list([ dict({ - 'until': datetime.datetime(2024, 7, 10, 19, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 19, 10, tzinfo=datetime.timezone.utc), }), ]) # --- # name: test_zone_set_temperature[minimal] list([ dict({ - 'until': datetime.datetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), }), ]) # --- diff --git a/tests/components/evohome/snapshots/test_water_heater.ambr b/tests/components/evohome/snapshots/test_water_heater.ambr index 4cdeb28f445..771e2c20cba 100644 --- a/tests/components/evohome/snapshots/test_water_heater.ambr +++ b/tests/components/evohome/snapshots/test_water_heater.ambr @@ -2,10 +2,10 @@ # name: test_set_operation_mode[default] list([ dict({ - 'until': datetime.datetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc), }), dict({ - 'until': datetime.datetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc), }), ]) # --- @@ -13,11 +13,11 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'away_mode': 'on', - 'current_temperature': 23, + 'current_temperature': 23.0, 'friendly_name': 'Domestic Hot Water', 'icon': 'mdi:thermometer-lines', - 'max_temp': 60, - 'min_temp': 43, + 'max_temp': 60.0, + 'min_temp': 43.3, 'operation_list': list([ 'auto', 'on', @@ -25,13 +25,13 @@ ]), 'operation_mode': 'off', 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'dhw_id': '3933910', 'setpoints': dict({ - 'next_sp_from': '2024-07-10T13:00:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 13, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_state': 'Off', - 'this_sp_from': '2024-07-10T12:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_state': 'On', }), 'state_status': dict({ @@ -60,11 +60,11 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'away_mode': 'on', - 'current_temperature': 23, + 'current_temperature': 23.0, 'friendly_name': 'Domestic Hot Water', 'icon': 'mdi:thermometer-lines', - 'max_temp': 60, - 'min_temp': 43, + 'max_temp': 60.0, + 'min_temp': 43.3, 'operation_list': list([ 'auto', 'on', @@ -72,13 +72,13 @@ ]), 'operation_mode': 'off', 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'dhw_id': '3933910', 'setpoints': dict({ - 'next_sp_from': '2024-07-10T13:00:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 13, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_state': 'Off', - 'this_sp_from': '2024-07-10T12:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_state': 'On', }), 'state_status': dict({ diff --git a/tests/components/evohome/test_climate.py b/tests/components/evohome/test_climate.py index 325dd914bc0..b1b930c6382 100644 --- a/tests/components/evohome/test_climate.py +++ b/tests/components/evohome/test_climate.py @@ -65,7 +65,7 @@ async def test_ctl_set_hvac_mode( results = [] # SERVICE_SET_HVAC_MODE: HVACMode.OFF - with patch("evohomeasync2.controlsystem.ControlSystem.set_mode") as mock_fcn: + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: await hass.services.async_call( Platform.CLIMATE, SERVICE_SET_HVAC_MODE, @@ -76,14 +76,15 @@ async def test_ctl_set_hvac_mode( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args != () # 'HeatingOff' or 'Off' - assert mock_fcn.await_args.kwargs == {"until": None} + try: + mock_fcn.assert_awaited_once_with("HeatingOff", until=None) + except AssertionError: + mock_fcn.assert_awaited_once_with("Off", until=None) - results.append(mock_fcn.await_args.args) + results.append(mock_fcn.await_args.args) # type: ignore[union-attr] # SERVICE_SET_HVAC_MODE: HVACMode.HEAT - with patch("evohomeasync2.controlsystem.ControlSystem.set_mode") as mock_fcn: + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: await hass.services.async_call( Platform.CLIMATE, SERVICE_SET_HVAC_MODE, @@ -94,11 +95,12 @@ async def test_ctl_set_hvac_mode( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args != () # 'Auto' or 'Heat' - assert mock_fcn.await_args.kwargs == {"until": None} + try: + mock_fcn.assert_awaited_once_with("Auto", until=None) + except AssertionError: + mock_fcn.assert_awaited_once_with("Heat", until=None) - results.append(mock_fcn.await_args.args) + results.append(mock_fcn.await_args.args) # type: ignore[union-attr] assert results == snapshot @@ -134,7 +136,7 @@ async def test_ctl_turn_off( results = [] # SERVICE_TURN_OFF - with patch("evohomeasync2.controlsystem.ControlSystem.set_mode") as mock_fcn: + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: await hass.services.async_call( Platform.CLIMATE, SERVICE_TURN_OFF, @@ -144,11 +146,12 @@ async def test_ctl_turn_off( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args != () # 'HeatingOff' or 'Off' - assert mock_fcn.await_args.kwargs == {"until": None} + try: + mock_fcn.assert_awaited_once_with("HeatingOff", until=None) + except AssertionError: + mock_fcn.assert_awaited_once_with("Off", until=None) - results.append(mock_fcn.await_args.args) + results.append(mock_fcn.await_args.args) # type: ignore[union-attr] assert results == snapshot @@ -164,7 +167,7 @@ async def test_ctl_turn_on( results = [] # SERVICE_TURN_ON - with patch("evohomeasync2.controlsystem.ControlSystem.set_mode") as mock_fcn: + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: await hass.services.async_call( Platform.CLIMATE, SERVICE_TURN_ON, @@ -174,11 +177,12 @@ async def test_ctl_turn_on( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args != () # 'Auto' or 'Heat' - assert mock_fcn.await_args.kwargs == {"until": None} + try: + mock_fcn.assert_awaited_once_with("Auto", until=None) + except AssertionError: + mock_fcn.assert_awaited_once_with("Heat", until=None) - results.append(mock_fcn.await_args.args) + results.append(mock_fcn.await_args.args) # type: ignore[union-attr] assert results == snapshot @@ -194,7 +198,7 @@ async def test_zone_set_hvac_mode( results = [] # SERVICE_SET_HVAC_MODE: HVACMode.HEAT - with patch("evohomeasync2.zone.Zone.reset_mode") as mock_fcn: + with patch("evohomeasync2.zone.Zone.reset") as mock_fcn: await hass.services.async_call( Platform.CLIMATE, SERVICE_SET_HVAC_MODE, @@ -205,9 +209,7 @@ async def test_zone_set_hvac_mode( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == () - assert mock_fcn.await_args.kwargs == {} + mock_fcn.assert_awaited_once_with() # SERVICE_SET_HVAC_MODE: HVACMode.OFF with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn: @@ -221,7 +223,9 @@ async def test_zone_set_hvac_mode( blocking=True, ) - assert mock_fcn.await_count == 1 + mock_fcn.assert_awaited_once() + + assert mock_fcn.await_args is not None # mypy hint assert mock_fcn.await_args.args != () # minimum target temp assert mock_fcn.await_args.kwargs == {"until": None} @@ -243,7 +247,7 @@ async def test_zone_set_preset_mode( results = [] # SERVICE_SET_PRESET_MODE: none - with patch("evohomeasync2.zone.Zone.reset_mode") as mock_fcn: + with patch("evohomeasync2.zone.Zone.reset") as mock_fcn: await hass.services.async_call( Platform.CLIMATE, SERVICE_SET_PRESET_MODE, @@ -254,9 +258,7 @@ async def test_zone_set_preset_mode( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == () - assert mock_fcn.await_args.kwargs == {} + mock_fcn.assert_awaited_once_with() # SERVICE_SET_PRESET_MODE: permanent with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn: @@ -270,7 +272,9 @@ async def test_zone_set_preset_mode( blocking=True, ) - assert mock_fcn.await_count == 1 + mock_fcn.assert_awaited_once() + + assert mock_fcn.await_args is not None # mypy hint assert mock_fcn.await_args.args != () # current target temp assert mock_fcn.await_args.kwargs == {"until": None} @@ -288,7 +292,9 @@ async def test_zone_set_preset_mode( blocking=True, ) - assert mock_fcn.await_count == 1 + mock_fcn.assert_awaited_once() + + assert mock_fcn.await_args is not None # mypy hint assert mock_fcn.await_args.args != () # current target temp assert mock_fcn.await_args.kwargs != {} # next setpoint dtm @@ -302,12 +308,10 @@ async def test_zone_set_preset_mode( async def test_zone_set_temperature( hass: HomeAssistant, zone_id: str, - freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test SERVICE_SET_TEMPERATURE of an evohome heating zone.""" - freezer.move_to("2024-07-10T12:00:00Z") results = [] # SERVICE_SET_TEMPERATURE: temperature @@ -322,7 +326,9 @@ async def test_zone_set_temperature( blocking=True, ) - assert mock_fcn.await_count == 1 + mock_fcn.assert_awaited_once() + + assert mock_fcn.await_args is not None # mypy hint assert mock_fcn.await_args.args == (19.1,) assert mock_fcn.await_args.kwargs != {} # next setpoint dtm @@ -352,7 +358,9 @@ async def test_zone_turn_off( blocking=True, ) - assert mock_fcn.await_count == 1 + mock_fcn.assert_awaited_once() + + assert mock_fcn.await_args is not None # mypy hint assert mock_fcn.await_args.args != () # minimum target temp assert mock_fcn.await_args.kwargs == {"until": None} @@ -369,7 +377,7 @@ async def test_zone_turn_on( """Test SERVICE_TURN_ON of an evohome heating zone.""" # SERVICE_TURN_ON - with patch("evohomeasync2.zone.Zone.reset_mode") as mock_fcn: + with patch("evohomeasync2.zone.Zone.reset") as mock_fcn: await hass.services.async_call( Platform.CLIMATE, SERVICE_TURN_ON, @@ -379,6 +387,4 @@ async def test_zone_turn_on( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == () - assert mock_fcn.await_args.kwargs == {} + mock_fcn.assert_awaited_once_with() diff --git a/tests/components/evohome/test_coordinator.py b/tests/components/evohome/test_coordinator.py new file mode 100644 index 00000000000..7fb325d55b9 --- /dev/null +++ b/tests/components/evohome/test_coordinator.py @@ -0,0 +1,55 @@ +"""The tests for the evohome coordinator.""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import patch + +from evohomeasync2 import EvohomeClient +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.evohome import EvoData +from homeassistant.components.evohome.const import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import UpdateFailed + +from tests.common import async_fire_time_changed + + +@pytest.mark.parametrize("install", ["minimal"]) +async def test_setup_platform( + hass: HomeAssistant, + config: dict[str, str], + evohome: EvohomeClient, + freezer: FrozenDateTimeFactory, +) -> None: + """Test entities and their states after setup of evohome.""" + + evo_data: EvoData = hass.data.get(DOMAIN) # type: ignore[assignment] + update_interval: timedelta = evo_data.coordinator.update_interval # type: ignore[assignment] + + # confirm initial state after coordinator.async_first_refresh()... + state = hass.states.get("climate.my_home") + assert state is not None and state.state != STATE_UNAVAILABLE + + with patch( + "homeassistant.components.evohome.coordinator.EvoDataUpdateCoordinator._async_update_data", + side_effect=UpdateFailed, + ): + freezer.tick(update_interval) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # confirm appropriate response to loss of state... + state = hass.states.get("climate.my_home") + assert state is not None and state.state == STATE_UNAVAILABLE + + freezer.tick(update_interval) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # if coordinator is working, the state will be restored + state = hass.states.get("climate.my_home") + assert state is not None and state.state != STATE_UNAVAILABLE diff --git a/tests/components/evohome/test_init.py b/tests/components/evohome/test_init.py index 9b5fe6ad62d..d327bdf14b4 100644 --- a/tests/components/evohome/test_init.py +++ b/tests/components/evohome/test_init.py @@ -4,80 +4,130 @@ from __future__ import annotations from http import HTTPStatus import logging -from unittest.mock import patch +from unittest.mock import Mock, patch +import aiohttp from evohomeasync2 import EvohomeClient, exceptions as exc -from evohomeasync2.broker import _ERR_MSG_LOOKUP_AUTH, _ERR_MSG_LOOKUP_BASE import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.evohome import DOMAIN, EvoService +from homeassistant.components.evohome.const import DOMAIN, EvoService from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from .conftest import mock_post_request from .const import TEST_INSTALLS -SETUP_FAILED_ANTICIPATED = ( +_MSG_429 = ( + "You have exceeded the server's API rate limit. Wait a while " + "and try again (consider reducing your polling interval)." +) +_MSG_OTH = ( + "Unable to contact the vendor's server. Check your network " + "and review the vendor's status page, https://status.resideo.com." +) +_MSG_USR = ( + "Failed to authenticate. Check the username/password. Note that some " + "special characters accepted via the vendor's website are not valid here." +) + +LOG_HINT_429_CREDS = ("evohome.credentials", logging.ERROR, _MSG_429) +LOG_HINT_OTH_CREDS = ("evohome.credentials", logging.ERROR, _MSG_OTH) +LOG_HINT_USR_CREDS = ("evohome.credentials", logging.ERROR, _MSG_USR) + +LOG_HINT_429_AUTH = ("evohome.auth", logging.ERROR, _MSG_429) +LOG_HINT_OTH_AUTH = ("evohome.auth", logging.ERROR, _MSG_OTH) +LOG_HINT_USR_AUTH = ("evohome.auth", logging.ERROR, _MSG_USR) + +LOG_FAIL_CONNECTION = ( + "homeassistant.components.evohome", + logging.ERROR, + "Failed to fetch initial data: Authenticator response is invalid: Connection error", +) +LOG_FAIL_CREDENTIALS = ( + "homeassistant.components.evohome", + logging.ERROR, + "Failed to fetch initial data: " + "Authenticator response is invalid: {'error': 'invalid_grant'}", +) +LOG_FAIL_GATEWAY = ( + "homeassistant.components.evohome", + logging.ERROR, + "Failed to fetch initial data: " + "Authenticator response is invalid: 502 Bad Gateway, response=None", +) +LOG_FAIL_TOO_MANY = ( + "homeassistant.components.evohome", + logging.ERROR, + "Failed to fetch initial data: " + "Authenticator response is invalid: 429 Too Many Requests, response=None", +) + +LOG_FGET_CONNECTION = ( + "homeassistant.components.evohome", + logging.ERROR, + "Failed to fetch initial data: " + "GET https://tccna.resideo.com/WebAPI/emea/api/v1/userAccount: " + "Connection error", +) +LOG_FGET_GATEWAY = ( + "homeassistant.components.evohome", + logging.ERROR, + "Failed to fetch initial data: " + "GET https://tccna.resideo.com/WebAPI/emea/api/v1/userAccount: " + "502 Bad Gateway, response=None", +) +LOG_FGET_TOO_MANY = ( + "homeassistant.components.evohome", + logging.ERROR, + "Failed to fetch initial data: " + "GET https://tccna.resideo.com/WebAPI/emea/api/v1/userAccount: " + "429 Too Many Requests, response=None", +) + + +LOG_SETUP_FAILED = ( "homeassistant.setup", logging.ERROR, "Setup failed for 'evohome': Integration failed to initialize.", ) -SETUP_FAILED_UNEXPECTED = ( - "homeassistant.setup", - logging.ERROR, - "Error during setup of component evohome: ", + +EXC_BAD_CONNECTION = aiohttp.ClientConnectionError( + "Connection error", ) -AUTHENTICATION_FAILED = ( - "homeassistant.components.evohome.helpers", - logging.ERROR, - "Failed to authenticate with the vendor's server. Check your username" - " and password. NB: Some special password characters that work" - " correctly via the website will not work via the web API. Message" - " is: ", +EXC_BAD_CREDENTIALS = exc.AuthenticationFailedError( + "Authenticator response is invalid: {'error': 'invalid_grant'}", + status=HTTPStatus.BAD_REQUEST, ) -REQUEST_FAILED_NONE = ( - "homeassistant.components.evohome.helpers", - logging.WARNING, - "Unable to connect with the vendor's server. " - "Check your network and the vendor's service status page. " - "Message is: ", +EXC_TOO_MANY_REQUESTS = aiohttp.ClientResponseError( + Mock(), + (), + status=HTTPStatus.TOO_MANY_REQUESTS, + message=HTTPStatus.TOO_MANY_REQUESTS.phrase, ) -REQUEST_FAILED_503 = ( - "homeassistant.components.evohome.helpers", - logging.WARNING, - "The vendor says their server is currently unavailable. " - "Check the vendor's service status page", -) -REQUEST_FAILED_429 = ( - "homeassistant.components.evohome.helpers", - logging.WARNING, - "The vendor's API rate limit has been exceeded. " - "If this message persists, consider increasing the scan_interval", +EXC_BAD_GATEWAY = aiohttp.ClientResponseError( + Mock(), (), status=HTTPStatus.BAD_GATEWAY, message=HTTPStatus.BAD_GATEWAY.phrase ) -REQUEST_FAILED_LOOKUP = { - None: [ - REQUEST_FAILED_NONE, - SETUP_FAILED_ANTICIPATED, - ], - HTTPStatus.SERVICE_UNAVAILABLE: [ - REQUEST_FAILED_503, - SETUP_FAILED_ANTICIPATED, - ], - HTTPStatus.TOO_MANY_REQUESTS: [ - REQUEST_FAILED_429, - SETUP_FAILED_ANTICIPATED, - ], +AUTHENTICATION_TESTS: dict[Exception, list] = { + EXC_BAD_CONNECTION: [LOG_HINT_OTH_CREDS, LOG_FAIL_CONNECTION, LOG_SETUP_FAILED], + EXC_BAD_CREDENTIALS: [LOG_HINT_USR_CREDS, LOG_FAIL_CREDENTIALS, LOG_SETUP_FAILED], + EXC_BAD_GATEWAY: [LOG_HINT_OTH_CREDS, LOG_FAIL_GATEWAY, LOG_SETUP_FAILED], + EXC_TOO_MANY_REQUESTS: [LOG_HINT_429_CREDS, LOG_FAIL_TOO_MANY, LOG_SETUP_FAILED], +} + +CLIENT_REQUEST_TESTS: dict[Exception, list] = { + EXC_BAD_CONNECTION: [LOG_HINT_OTH_AUTH, LOG_FGET_CONNECTION, LOG_SETUP_FAILED], + EXC_BAD_GATEWAY: [LOG_HINT_OTH_AUTH, LOG_FGET_GATEWAY, LOG_SETUP_FAILED], + EXC_TOO_MANY_REQUESTS: [LOG_HINT_429_AUTH, LOG_FGET_TOO_MANY, LOG_SETUP_FAILED], } -@pytest.mark.parametrize( - "status", [*sorted([*_ERR_MSG_LOOKUP_AUTH, HTTPStatus.BAD_GATEWAY]), None] -) +@pytest.mark.parametrize("exception", AUTHENTICATION_TESTS) async def test_authentication_failure_v2( hass: HomeAssistant, config: dict[str, str], - status: HTTPStatus, + exception: Exception, caplog: pytest.LogCaptureFixture, ) -> None: """Test failure to setup an evohome-compatible system. @@ -85,27 +135,24 @@ async def test_authentication_failure_v2( In this instance, the failure occurs in the v2 API. """ - with patch("evohomeasync2.broker.Broker.get") as mock_fcn: - mock_fcn.side_effect = exc.AuthenticationFailed("", status=status) - - with caplog.at_level(logging.WARNING): - result = await async_setup_component(hass, DOMAIN, {DOMAIN: config}) + with ( + patch( + "evohome.credentials.CredentialsManagerBase._request", side_effect=exception + ), + caplog.at_level(logging.WARNING), + ): + result = await async_setup_component(hass, DOMAIN, {DOMAIN: config}) assert result is False - assert caplog.record_tuples == [ - AUTHENTICATION_FAILED, - SETUP_FAILED_ANTICIPATED, - ] + assert caplog.record_tuples == AUTHENTICATION_TESTS[exception] -@pytest.mark.parametrize( - "status", [*sorted([*_ERR_MSG_LOOKUP_BASE, HTTPStatus.BAD_GATEWAY]), None] -) +@pytest.mark.parametrize("exception", CLIENT_REQUEST_TESTS) async def test_client_request_failure_v2( hass: HomeAssistant, config: dict[str, str], - status: HTTPStatus, + exception: Exception, caplog: pytest.LogCaptureFixture, ) -> None: """Test failure to setup an evohome-compatible system. @@ -113,17 +160,19 @@ async def test_client_request_failure_v2( In this instance, the failure occurs in the v2 API. """ - with patch("evohomeasync2.broker.Broker.get") as mock_fcn: - mock_fcn.side_effect = exc.RequestFailed("", status=status) - - with caplog.at_level(logging.WARNING): - result = await async_setup_component(hass, DOMAIN, {DOMAIN: config}) + with ( + patch( + "evohomeasync2.auth.CredentialsManagerBase._post_request", + mock_post_request("default"), + ), + patch("evohome.auth.AbstractAuth._request", side_effect=exception), + caplog.at_level(logging.WARNING), + ): + result = await async_setup_component(hass, DOMAIN, {DOMAIN: config}) assert result is False - assert caplog.record_tuples == REQUEST_FAILED_LOOKUP.get( - status, [SETUP_FAILED_UNEXPECTED] - ) + assert caplog.record_tuples == CLIENT_REQUEST_TESTS[exception] @pytest.mark.parametrize("install", [*TEST_INSTALLS, "botched"]) @@ -148,7 +197,7 @@ async def test_service_refresh_system( """Test EvoService.REFRESH_SYSTEM of an evohome system.""" # EvoService.REFRESH_SYSTEM - with patch("evohomeasync2.location.Location.refresh_status") as mock_fcn: + with patch("evohomeasync2.location.Location.update") as mock_fcn: await hass.services.async_call( DOMAIN, EvoService.REFRESH_SYSTEM, @@ -156,9 +205,7 @@ async def test_service_refresh_system( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == () - assert mock_fcn.await_args.kwargs == {} + mock_fcn.assert_awaited_once_with() @pytest.mark.parametrize("install", ["default"]) @@ -169,7 +216,7 @@ async def test_service_reset_system( """Test EvoService.RESET_SYSTEM of an evohome system.""" # EvoService.RESET_SYSTEM (if SZ_AUTO_WITH_RESET in modes) - with patch("evohomeasync2.controlsystem.ControlSystem.set_mode") as mock_fcn: + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: await hass.services.async_call( DOMAIN, EvoService.RESET_SYSTEM, @@ -177,6 +224,4 @@ async def test_service_reset_system( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == ("AutoWithReset",) - assert mock_fcn.await_args.kwargs == {"until": None} + mock_fcn.assert_awaited_once_with("AutoWithReset", until=None) diff --git a/tests/components/evohome/test_storage.py b/tests/components/evohome/test_storage.py index b3597352487..4528f1c8590 100644 --- a/tests/components/evohome/test_storage.py +++ b/tests/components/evohome/test_storage.py @@ -7,13 +7,7 @@ from typing import Any, Final, NotRequired, TypedDict import pytest -from homeassistant.components.evohome import ( - CONF_USERNAME, - DOMAIN, - STORAGE_KEY, - STORAGE_VER, - dt_aware_to_naive, -) +from homeassistant.components.evohome.const import DOMAIN, STORAGE_KEY, STORAGE_VER from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -22,7 +16,8 @@ from .const import ACCESS_TOKEN, REFRESH_TOKEN, SESSION_ID, USERNAME class _SessionDataT(TypedDict): - sessionId: str + session_id: str + session_id_expires: NotRequired[str] # 2024-07-27T23:57:30+01:00 class _TokenStoreT(TypedDict): @@ -65,7 +60,7 @@ _TEST_STORAGE_BASE: Final[_TokenStoreT] = { TEST_STORAGE_DATA: Final[dict[str, _TokenStoreT]] = { "sans_session_id": _TEST_STORAGE_BASE, "null_session_id": _TEST_STORAGE_BASE | {SZ_USER_DATA: None}, # type: ignore[dict-item] - "with_session_id": _TEST_STORAGE_BASE | {SZ_USER_DATA: {"sessionId": SESSION_ID}}, + "with_session_id": _TEST_STORAGE_BASE | {SZ_USER_DATA: {"session_id": SESSION_ID}}, } TEST_STORAGE_NULL: Final[dict[str, _EmptyStoreT | None]] = { @@ -89,15 +84,12 @@ async def test_auth_tokens_null( idx: str, install: str, ) -> None: - """Test loading/saving authentication tokens when no cached tokens in the store.""" + """Test credentials manager when cache is empty.""" hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_STORAGE_NULL[idx]} - async for mock_client in setup_evohome(hass, config, install=install): - # 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 + async for _ in setup_evohome(hass, config, install=install): + pass # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] @@ -120,17 +112,12 @@ async def test_auth_tokens_same( idx: str, install: str, ) -> None: - """Test loading/saving authentication tokens when matching username.""" + """Test credentials manager when cache contains valid data for this user.""" hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_STORAGE_DATA[idx]} - async for mock_client in setup_evohome(hass, config, install=install): - # 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) + async for _ in setup_evohome(hass, config, install=install): + pass # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] @@ -150,7 +137,7 @@ async def test_auth_tokens_past( idx: str, install: str, ) -> None: - """Test loading/saving authentication tokens with matching username, but expired.""" + """Test credentials manager when cache contains expired data for this user.""" dt_dtm, dt_str = dt_pair(dt_util.now() - timedelta(hours=1)) @@ -160,19 +147,14 @@ async def test_auth_tokens_past( hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": test_data} - async for mock_client in setup_evohome(hass, config, install=install): - # 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) + async for _ in setup_evohome(hass, config, install=install): + pass # 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_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) @@ -189,17 +171,13 @@ async def test_auth_tokens_diff( idx: str, install: str, ) -> None: - """Test loading/saving authentication tokens when unmatched username.""" + """Test credentials manager when cache contains data for a different user.""" hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_STORAGE_DATA[idx]} + config["username"] = USERNAME_DIFF - async for mock_client in setup_evohome( - hass, config | {CONF_USERNAME: USERNAME_DIFF}, install=install - ): - # 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 + async for _ in setup_evohome(hass, config, install=install): + pass # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] diff --git a/tests/components/evohome/test_water_heater.py b/tests/components/evohome/test_water_heater.py index 8acfd469b59..a201ff63d1e 100644 --- a/tests/components/evohome/test_water_heater.py +++ b/tests/components/evohome/test_water_heater.py @@ -67,7 +67,7 @@ async def test_set_operation_mode( results = [] # SERVICE_SET_OPERATION_MODE: auto - with patch("evohomeasync2.hotwater.HotWater.reset_mode") as mock_fcn: + with patch("evohomeasync2.hotwater.HotWater.reset") as mock_fcn: await hass.services.async_call( Platform.WATER_HEATER, SERVICE_SET_OPERATION_MODE, @@ -78,12 +78,10 @@ async def test_set_operation_mode( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == () - assert mock_fcn.await_args.kwargs == {} + mock_fcn.assert_awaited_once_with() # SERVICE_SET_OPERATION_MODE: off (until next scheduled setpoint) - with patch("evohomeasync2.hotwater.HotWater.set_off") as mock_fcn: + with patch("evohomeasync2.hotwater.HotWater.off") as mock_fcn: await hass.services.async_call( Platform.WATER_HEATER, SERVICE_SET_OPERATION_MODE, @@ -94,14 +92,16 @@ async def test_set_operation_mode( blocking=True, ) - assert mock_fcn.await_count == 1 + mock_fcn.assert_awaited_once() + + assert mock_fcn.await_args is not None # mypy hint assert mock_fcn.await_args.args == () assert mock_fcn.await_args.kwargs != {} results.append(mock_fcn.await_args.kwargs) # SERVICE_SET_OPERATION_MODE: on (until next scheduled setpoint) - with patch("evohomeasync2.hotwater.HotWater.set_on") as mock_fcn: + with patch("evohomeasync2.hotwater.HotWater.on") as mock_fcn: await hass.services.async_call( Platform.WATER_HEATER, SERVICE_SET_OPERATION_MODE, @@ -112,7 +112,9 @@ async def test_set_operation_mode( blocking=True, ) - assert mock_fcn.await_count == 1 + mock_fcn.assert_awaited_once() + + assert mock_fcn.await_args is not None # mypy hint assert mock_fcn.await_args.args == () assert mock_fcn.await_args.kwargs != {} @@ -126,7 +128,7 @@ async def test_set_away_mode(hass: HomeAssistant, evohome: EvohomeClient) -> Non """Test SERVICE_SET_AWAY_MODE of an evohome DHW zone.""" # set_away_mode: off - with patch("evohomeasync2.hotwater.HotWater.reset_mode") as mock_fcn: + with patch("evohomeasync2.hotwater.HotWater.reset") as mock_fcn: await hass.services.async_call( Platform.WATER_HEATER, SERVICE_SET_AWAY_MODE, @@ -137,12 +139,10 @@ async def test_set_away_mode(hass: HomeAssistant, evohome: EvohomeClient) -> Non blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == () - assert mock_fcn.await_args.kwargs == {} + mock_fcn.assert_awaited_once_with() # set_away_mode: on - with patch("evohomeasync2.hotwater.HotWater.set_off") as mock_fcn: + with patch("evohomeasync2.hotwater.HotWater.off") as mock_fcn: await hass.services.async_call( Platform.WATER_HEATER, SERVICE_SET_AWAY_MODE, @@ -153,9 +153,7 @@ async def test_set_away_mode(hass: HomeAssistant, evohome: EvohomeClient) -> Non blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == () - assert mock_fcn.await_args.kwargs == {} + mock_fcn.assert_awaited_once_with() @pytest.mark.parametrize("install", TEST_INSTALLS_WITH_DHW)