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 <joostlek@outlook.com>

* Update homeassistant/components/evohome/entity.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* 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 <joostlek@outlook.com>

* add test of coordinator

* bump client to 1.0.2

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
David Bonnes 2025-02-08 14:45:48 +00:00 committed by GitHub
parent 21dd6fa53d
commit a542a2e021
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1065 additions and 1089 deletions

View File

@ -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(
{

View File

@ -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()

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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"]
}

View File

@ -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)

View File

@ -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())

2
requirements_all.txt generated
View File

@ -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

View File

@ -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

View File

@ -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)}"

View File

@ -3,6 +3,7 @@
"locationInfo": {
"locationId": "111111",
"name": "My Home",
"useDaylightSaveSwitching": true,
"timeZone": {
"timeZoneId": "GMTStandardTime",
"displayName": "(UTC+00:00) Dublin, Edinburgh, Lisbon, London",

View File

@ -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",

View File

@ -2,120 +2,120 @@
# name: test_ctl_set_hvac_mode[default]
list([
tuple(
'HeatingOff',
<SystemMode.HEATING_OFF: 'HeatingOff'>,
),
tuple(
'Auto',
<SystemMode.AUTO: 'Auto'>,
),
])
# ---
# name: test_ctl_set_hvac_mode[h032585]
list([
tuple(
'Off',
<SystemMode.OFF: 'Off'>,
),
tuple(
'Heat',
<SystemMode.HEAT: 'Heat'>,
),
])
# ---
# name: test_ctl_set_hvac_mode[h099625]
list([
tuple(
'HeatingOff',
<SystemMode.HEATING_OFF: 'HeatingOff'>,
),
tuple(
'Auto',
<SystemMode.AUTO: 'Auto'>,
),
])
# ---
# name: test_ctl_set_hvac_mode[minimal]
list([
tuple(
'HeatingOff',
<SystemMode.HEATING_OFF: 'HeatingOff'>,
),
tuple(
'Auto',
<SystemMode.AUTO: 'Auto'>,
),
])
# ---
# name: test_ctl_set_hvac_mode[sys_004]
list([
tuple(
'HeatingOff',
<SystemMode.HEATING_OFF: 'HeatingOff'>,
),
tuple(
'Auto',
<SystemMode.AUTO: 'Auto'>,
),
])
# ---
# name: test_ctl_turn_off[default]
list([
tuple(
'HeatingOff',
<SystemMode.HEATING_OFF: 'HeatingOff'>,
),
])
# ---
# name: test_ctl_turn_off[h032585]
list([
tuple(
'Off',
<SystemMode.OFF: 'Off'>,
),
])
# ---
# name: test_ctl_turn_off[h099625]
list([
tuple(
'HeatingOff',
<SystemMode.HEATING_OFF: 'HeatingOff'>,
),
])
# ---
# name: test_ctl_turn_off[minimal]
list([
tuple(
'HeatingOff',
<SystemMode.HEATING_OFF: 'HeatingOff'>,
),
])
# ---
# name: test_ctl_turn_off[sys_004]
list([
tuple(
'HeatingOff',
<SystemMode.HEATING_OFF: 'HeatingOff'>,
),
])
# ---
# name: test_ctl_turn_on[default]
list([
tuple(
'Auto',
<SystemMode.AUTO: 'Auto'>,
),
])
# ---
# name: test_ctl_turn_on[h032585]
list([
tuple(
'Heat',
<SystemMode.HEAT: 'Heat'>,
),
])
# ---
# name: test_ctl_turn_on[h099625]
list([
tuple(
'Auto',
<SystemMode.AUTO: 'Auto'>,
),
])
# ---
# name: test_ctl_turn_on[minimal]
list([
tuple(
'Auto',
<SystemMode.AUTO: 'Auto'>,
),
])
# ---
# name: test_ctl_turn_on[sys_004]
list([
tuple(
'Auto',
<SystemMode.AUTO: '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),
}),
])
# ---

View File

@ -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({

View File

@ -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()

View File

@ -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

View File

@ -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)

View File

@ -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"]

View File

@ -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)