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 Such systems provide heating/cooling and DHW and include Evohome, Round Thermostat, and
others. others.
Note that the API used by this integration's client does not support cooling.
""" """
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import Any, Final from typing import Final
import evohomeasync as ev1 import evohomeasync as ec1
from evohomeasync.schema import SZ_SESSION_ID import evohomeasync2 as ec2
import evohomeasync2 as evo from evohomeasync2.const import SZ_CAN_BE_TEMPORARY, SZ_SYSTEM_MODE, SZ_TIMING_MODE
from evohomeasync2.schema.const import ( from evohomeasync2.schemas.const import (
SZ_AUTO_WITH_RESET, S2_DURATION as SZ_DURATION,
SZ_CAN_BE_TEMPORARY, S2_PERIOD as SZ_PERIOD,
SZ_SYSTEM_MODE, SystemMode as EvoSystemMode,
SZ_TIMING_MODE,
) )
import voluptuous as vol 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.discovery import async_load_platform
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.service import verify_domain_control from homeassistant.helpers.service import verify_domain_control
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.hass_dict import HassKey
from homeassistant.util import dt as dt_util
from .const import ( from .const import (
ACCESS_TOKEN,
ACCESS_TOKEN_EXPIRES,
ATTR_DURATION_DAYS, ATTR_DURATION_DAYS,
ATTR_DURATION_HOURS, ATTR_DURATION_HOURS,
ATTR_DURATION_UNTIL, ATTR_DURATION_UNTIL,
@ -49,16 +47,12 @@ from .const import (
ATTR_ZONE_TEMP, ATTR_ZONE_TEMP,
CONF_LOCATION_IDX, CONF_LOCATION_IDX,
DOMAIN, DOMAIN,
REFRESH_TOKEN,
SCAN_INTERVAL_DEFAULT, SCAN_INTERVAL_DEFAULT,
SCAN_INTERVAL_MINIMUM, SCAN_INTERVAL_MINIMUM,
STORAGE_KEY,
STORAGE_VER,
USER_DATA,
EvoService, EvoService,
) )
from .coordinator import EvoBroker from .coordinator import EvoDataUpdateCoordinator
from .helpers import dt_aware_to_naive, dt_local_to_aware, handle_evo_exception from .storage import TokenManager
_LOGGER = logging.getLogger(__name__) _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: @dataclass
"""Initialize the evohome broker and its data structure.""" class EvoData:
"""Dataclass for storing evohome data."""
self.hass = hass coordinator: EvoDataUpdateCoordinator
loc_idx: int
self._session = async_get_clientsession(hass) tcs: ec2.ControlSystem
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)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Evohome integration.""" """Set up the Evohome integration."""
sess = EvoSession(hass) token_manager = TokenManager(
hass,
try: config[DOMAIN][CONF_USERNAME],
await sess.authenticate( config[DOMAIN][CONF_PASSWORD],
config[DOMAIN][CONF_USERNAME], async_get_clientsession(hass),
config[DOMAIN][CONF_PASSWORD], )
) coordinator = EvoDataUpdateCoordinator(
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(
hass, hass,
_LOGGER, _LOGGER,
config_entry=None, ec2.EvohomeClient(token_manager),
name=f"{DOMAIN}_coordinator", name=f"{DOMAIN}_coordinator",
update_interval=config[DOMAIN][CONF_SCAN_INTERVAL], 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_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() assert coordinator.tcs is not None # mypy
coordinator.async_add_listener(lambda: None)
await coordinator.async_refresh() # get initial state hass.data[EVOHOME_KEY] = EvoData(
coordinator=coordinator,
loc_idx=coordinator.loc_idx,
tcs=coordinator.tcs,
)
hass.async_create_task( hass.async_create_task(
async_load_platform(hass, Platform.CLIMATE, DOMAIN, {}, config) async_load_platform(hass, Platform.CLIMATE, DOMAIN, {}, config)
) )
if broker.tcs.hotwater: if coordinator.tcs.hotwater:
hass.async_create_task( hass.async_create_task(
async_load_platform(hass, Platform.WATER_HEATER, DOMAIN, {}, config) async_load_platform(hass, Platform.WATER_HEATER, DOMAIN, {}, config)
) )
setup_service_functions(hass, broker) setup_service_functions(hass, coordinator)
return True return True
@callback @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. """Set up the service handlers for the system/zone operating modes.
Not all Honeywell TCC-compatible systems support all operating modes. In addition, 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) @verify_domain_control(hass, DOMAIN)
async def force_refresh(call: ServiceCall) -> None: async def force_refresh(call: ServiceCall) -> None:
"""Obtain the latest state data via the vendor's RESTful API.""" """Obtain the latest state data via the vendor's RESTful API."""
await broker.async_update() await coordinator.async_refresh()
@verify_domain_control(hass, DOMAIN) @verify_domain_control(hass, DOMAIN)
async def set_system_mode(call: ServiceCall) -> None: async def set_system_mode(call: ServiceCall) -> None:
"""Set the system mode.""" """Set the system mode."""
assert coordinator.tcs is not None # mypy
payload = { payload = {
"unique_id": broker.tcs.systemId, "unique_id": coordinator.tcs.id,
"service": call.service, "service": call.service,
"data": call.data, "data": call.data,
} }
@ -313,17 +201,23 @@ def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None:
async_dispatcher_send(hass, DOMAIN, payload) async_dispatcher_send(hass, DOMAIN, payload)
assert coordinator.tcs is not None # mypy
hass.services.async_register(DOMAIN, EvoService.REFRESH_SYSTEM, force_refresh) hass.services.async_register(DOMAIN, EvoService.REFRESH_SYSTEM, force_refresh)
# Enumerate which operating modes are supported by this system # 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 # 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) hass.services.async_register(DOMAIN, EvoService.RESET_SYSTEM, set_system_mode)
system_mode_schemas = [] 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 # Permanent-only modes will use this schema
perm_modes = [m[SZ_SYSTEM_MODE] for m in modes if not m[SZ_CAN_BE_TEMPORARY]] 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]] 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 # 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 if temp_modes: # any of: "AutoWithEco", permanent or for 0-24 hours
schema = vol.Schema( schema = vol.Schema(
{ {
@ -348,7 +242,7 @@ def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None:
system_mode_schemas.append(schema) system_mode_schemas.append(schema)
# These modes are set for a number of days (or indefinitely): use this 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 if temp_modes: # any of: "Away", "Custom", "DayOff", permanent or for 1-99 days
schema = vol.Schema( schema = vol.Schema(
{ {

View File

@ -4,20 +4,20 @@ from __future__ import annotations
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
from typing import TYPE_CHECKING, Any from typing import Any
import evohomeasync2 as evo import evohomeasync2 as evo
from evohomeasync2.schema.const import ( from evohomeasync2.const import (
SZ_ACTIVE_FAULTS,
SZ_SETPOINT_STATUS, SZ_SETPOINT_STATUS,
SZ_SYSTEM_ID,
SZ_SYSTEM_MODE, SZ_SYSTEM_MODE,
SZ_SYSTEM_MODE_STATUS, SZ_SYSTEM_MODE_STATUS,
SZ_TEMPERATURE_STATUS, SZ_TEMPERATURE_STATUS,
SZ_UNTIL, )
SZ_ZONE_ID, from evohomeasync2.schemas.const import (
ZoneModelType, SystemMode as EvoSystemMode,
ZoneType, ZoneMode as EvoZoneMode,
ZoneModelType as EvoZoneModelType,
ZoneType as EvoZoneType,
) )
from homeassistant.components.climate import ( from homeassistant.components.climate import (
@ -30,67 +30,46 @@ from homeassistant.components.climate import (
HVACMode, HVACMode,
) )
from homeassistant.const import PRECISION_TENTHS, UnitOfTemperature 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.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from . import EVOHOME_KEY
from .const import ( from .const import (
ATTR_DURATION_DAYS, ATTR_DURATION_DAYS,
ATTR_DURATION_HOURS, ATTR_DURATION_HOURS,
ATTR_DURATION_UNTIL, ATTR_DURATION_UNTIL,
ATTR_SYSTEM_MODE, ATTR_SYSTEM_MODE,
ATTR_ZONE_TEMP, 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, EvoService,
) )
from .entity import EvoChild, EvoDevice from .coordinator import EvoDataUpdateCoordinator
from .entity import EvoChild, EvoEntity
if TYPE_CHECKING:
from . import EvoBroker
_LOGGER = logging.getLogger(__name__) _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" PRESET_CUSTOM = "Custom"
TCS_PRESET_TO_HA = { TCS_PRESET_TO_HA = {
EVO_AWAY: PRESET_AWAY, EvoSystemMode.AWAY: PRESET_AWAY,
EVO_CUSTOM: PRESET_CUSTOM, EvoSystemMode.CUSTOM: PRESET_CUSTOM,
EVO_AUTOECO: PRESET_ECO, EvoSystemMode.AUTO_WITH_ECO: PRESET_ECO,
EVO_DAYOFF: PRESET_HOME, EvoSystemMode.DAY_OFF: PRESET_HOME,
EVO_RESET: PRESET_RESET, EvoSystemMode.AUTO_WITH_RESET: PRESET_RESET,
} # EVO_AUTO: None, } # EvoSystemMode.AUTO: None,
HA_PRESET_TO_TCS = {v: k for k, v in TCS_PRESET_TO_HA.items()} HA_PRESET_TO_TCS = {v: k for k, v in TCS_PRESET_TO_HA.items()}
EVO_PRESET_TO_HA = { EVO_PRESET_TO_HA = {
EVO_FOLLOW: PRESET_NONE, EvoZoneMode.FOLLOW_SCHEDULE: PRESET_NONE,
EVO_TEMPOVER: "temporary", EvoZoneMode.TEMPORARY_OVERRIDE: "temporary",
EVO_PERMOVER: "permanent", EvoZoneMode.PERMANENT_OVERRIDE: "permanent",
} }
HA_PRESET_TO_EVO = {v: k for k, v in EVO_PRESET_TO_HA.items()} 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( async def async_setup_platform(
hass: HomeAssistant, hass: HomeAssistant,
@ -102,32 +81,34 @@ async def async_setup_platform(
if discovery_info is None: if discovery_info is None:
return 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( _LOGGER.debug(
"Found the Location/Controller (%s), id=%s, name=%s (location_idx=%s)", "Found the Location/Controller (%s), id=%s, name=%s (location_idx=%s)",
broker.tcs.modelType, tcs.model,
broker.tcs.systemId, tcs.id,
broker.loc.name, tcs.location.name,
broker.loc_idx, 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 ( if (
zone.modelType == ZoneModelType.HEATING_ZONE zone.model == EvoZoneModelType.HEATING_ZONE
or zone.zoneType == ZoneType.THERMOSTAT or zone.type == EvoZoneType.THERMOSTAT
): ):
_LOGGER.debug( _LOGGER.debug(
"Adding: %s (%s), id=%s, name=%s", "Adding: %s (%s), id=%s, name=%s",
zone.zoneType, zone.type,
zone.modelType, zone.model,
zone.zoneId, zone.id,
zone.name, zone.name,
) )
new_entity = EvoZone(broker, zone) new_entity = EvoZone(coordinator, zone)
entities.append(new_entity) entities.append(new_entity)
else: else:
@ -136,16 +117,19 @@ async def async_setup_platform(
"Ignoring: %s (%s), id=%s, name=%s: unknown/invalid zone type, " "Ignoring: %s (%s), id=%s, name=%s: unknown/invalid zone type, "
"report as an issue if you feel this zone type should be supported" "report as an issue if you feel this zone type should be supported"
), ),
zone.zoneType, zone.type,
zone.modelType, zone.model,
zone.zoneId, zone.id,
zone.name, 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).""" """Base for any evohome-compatible climate entity (controller, zone)."""
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
@ -157,25 +141,29 @@ class EvoZone(EvoChild, EvoClimateEntity):
_attr_preset_modes = list(HA_PRESET_TO_EVO) _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.""" """Initialize an evohome-compatible heating zone."""
super().__init__(evo_broker, evo_device) super().__init__(coordinator, evo_device)
self._evo_id = evo_device.zoneId 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 # 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: 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 self._attr_precision = PRECISION_TENTHS
else: else:
self._attr_precision = self._evo_device.setpointCapabilities[ self._attr_precision = self._evo_device.setpoint_capabilities[
"valueResolution" "value_resolution"
] ]
self._attr_supported_features = ( 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: async def async_zone_svc_request(self, service: str, data: dict[str, Any]) -> None:
"""Process a service request (setpoint override) for a zone.""" """Process a service request (setpoint override) for a zone."""
if service == EvoService.RESET_ZONE_OVERRIDE: 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 return
# otherwise it is EvoService.SET_ZONE_OVERRIDE # otherwise it is EvoService.SET_ZONE_OVERRIDE
@ -198,14 +186,14 @@ class EvoZone(EvoChild, EvoClimateEntity):
duration: timedelta = data[ATTR_DURATION_UNTIL] duration: timedelta = data[ATTR_DURATION_UNTIL]
if duration.total_seconds() == 0: if duration.total_seconds() == 0:
await self._update_schedule() await self._update_schedule()
until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) until = self.setpoints.get("next_sp_from")
else: else:
until = dt_util.now() + data[ATTR_DURATION_UNTIL] until = dt_util.now() + data[ATTR_DURATION_UNTIL]
else: else:
until = None # indefinitely until = None # indefinitely
until = dt_util.as_utc(until) if until else None 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) self._evo_device.set_temperature(temperature, until=until)
) )
@ -217,7 +205,7 @@ class EvoZone(EvoChild, EvoClimateEntity):
@property @property
def hvac_mode(self) -> HVACMode | None: def hvac_mode(self) -> HVACMode | None:
"""Return the current operating mode of a Zone.""" """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 return HVACMode.AUTO
if self.target_temperature is None: if self.target_temperature is None:
return None return None
@ -233,10 +221,8 @@ class EvoZone(EvoChild, EvoClimateEntity):
@property @property
def preset_mode(self) -> str | None: def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp.""" """Return the current preset mode, e.g., home, away, temp."""
if self._evo_tcs.system_mode in (EVO_AWAY, EVO_HEATOFF): if self._evo_tcs.mode in (EvoSystemMode.AWAY, EvoSystemMode.HEATING_OFF):
return TCS_PRESET_TO_HA.get(self._evo_tcs.system_mode) return TCS_PRESET_TO_HA.get(self._evo_tcs.mode)
if self._evo_device.mode is None:
return None
return EVO_PRESET_TO_HA.get(self._evo_device.mode) return EVO_PRESET_TO_HA.get(self._evo_device.mode)
@property @property
@ -245,8 +231,6 @@ class EvoZone(EvoChild, EvoClimateEntity):
The default is 5, but is user-configurable within 5-21 (in Celsius). 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 return self._evo_device.min_heat_setpoint
@property @property
@ -255,33 +239,27 @@ class EvoZone(EvoChild, EvoClimateEntity):
The default is 35, but is user-configurable within 21-35 (in Celsius). 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 return self._evo_device.max_heat_setpoint
async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set a new target temperature.""" """Set a new target temperature."""
assert self._evo_device.setpointStatus is not None # mypy check
temperature = kwargs["temperature"] temperature = kwargs["temperature"]
if (until := kwargs.get("until")) is None: 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() await self._update_schedule()
until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) until = 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 = dt_util.as_utc(until) if until else None 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) self._evo_device.set_temperature(temperature, until=until)
) )
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: 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. Zones inherit their _effective_ operating mode from their Controller.
@ -298,41 +276,34 @@ class EvoZone(EvoChild, EvoClimateEntity):
and 'Away', Zones to (by default) 12C. and 'Away', Zones to (by default) 12C.
""" """
if hvac_mode == HVACMode.OFF: 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) self._evo_device.set_temperature(self.min_temp, until=None)
) )
else: # HVACMode.HEAT 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: async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode; if None, then revert to following the schedule.""" """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: if evo_preset_mode == EvoZoneMode.FOLLOW_SCHEDULE:
await self._evo_broker.call_client_api(self._evo_device.reset_mode()) await self.coordinator.call_client_api(self._evo_device.reset())
return return
if evo_preset_mode == EVO_TEMPOVER: if evo_preset_mode == EvoZoneMode.TEMPORARY_OVERRIDE:
await self._update_schedule() await self._update_schedule()
until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) until = self.setpoints.get("next_sp_from")
else: # EVO_PERMOVER else: # EvoZoneMode.PERMANENT_OVERRIDE
until = None until = None
temperature = self._evo_device.target_heat_temperature temperature = self._evo_device.target_heat_temperature
assert temperature is not None # mypy check assert temperature is not None # mypy check
until = dt_util.as_utc(until) if until else None 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) 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): class EvoController(EvoClimateEntity):
"""Base for any evohome-compatible controller. """Base for any evohome-compatible controller.
@ -347,18 +318,22 @@ class EvoController(EvoClimateEntity):
_attr_icon = "mdi:thermostat" _attr_icon = "mdi:thermostat"
_attr_precision = PRECISION_TENTHS _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.""" """Initialize an evohome-compatible controller."""
super().__init__(evo_broker, evo_device) super().__init__(coordinator, evo_device)
self._evo_id = evo_device.systemId 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._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 = [ self._attr_preset_modes = [
TCS_PRESET_TO_HA[m] for m in self._evo_modes if m in list(TCS_PRESET_TO_HA) 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: if service == EvoService.SET_SYSTEM_MODE:
mode = data[ATTR_SYSTEM_MODE] mode = data[ATTR_SYSTEM_MODE]
else: # otherwise it is EvoService.RESET_SYSTEM else: # otherwise it is EvoService.RESET_SYSTEM
mode = EVO_RESET mode = EvoSystemMode.AUTO_WITH_RESET
if ATTR_DURATION_DAYS in data: if ATTR_DURATION_DAYS in data:
until = dt_util.start_of_local_day() until = dt_util.start_of_local_day()
@ -390,18 +365,24 @@ class EvoController(EvoClimateEntity):
await self._set_tcs_mode(mode, until=until) await self._set_tcs_mode(mode, until=until)
async def _set_tcs_mode(self, mode: str, until: datetime | None = None) -> None: async def _set_tcs_mode(
"""Set a Controller to any of its native EVO_* operating modes.""" 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 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_mode(mode, until=until) # type: ignore[arg-type] self._evo_device.set_mode(mode, until=until)
) )
@property @property
def hvac_mode(self) -> HVACMode: def hvac_mode(self) -> HVACMode:
"""Return the current operating mode of a Controller.""" """Return the current operating mode of a Controller."""
evo_mode = self._evo_device.system_mode evo_mode = self._evo_device.mode
return HVACMode.OFF if evo_mode in (EVO_HEATOFF, "Off") else HVACMode.HEAT return (
HVACMode.OFF
if evo_mode in (EvoSystemMode.HEATING_OFF, EvoSystemMode.OFF)
else HVACMode.HEAT
)
@property @property
def current_temperature(self) -> float | None: 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. Controllers do not have a current temp, but one is expected by HA.
""" """
temps = [ temps = [
z.temperature z.temperature for z in self._evo_device.zones if z.temperature is not None
for z in self._evo_device.zones.values()
if z.temperature is not None
] ]
return round(sum(temps) / len(temps), 1) if temps else None return round(sum(temps) / len(temps), 1) if temps else None
@property @property
def preset_mode(self) -> str | None: def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp.""" """Return the current preset mode, e.g., home, away, temp."""
if not self._evo_device.system_mode: return TCS_PRESET_TO_HA.get(self._evo_device.mode)
return None
return TCS_PRESET_TO_HA.get(self._evo_device.system_mode)
async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_temperature(self, **kwargs: Any) -> None:
"""Raise exception as Controllers don't have a target temperature.""" """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: async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set an operating mode for a Controller.""" """Set an operating mode for a Controller."""
evo_mode: EvoSystemMode
if hvac_mode == HVACMode.HEAT: 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: 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: else:
raise HomeAssistantError(f"Invalid hvac_mode: {hvac_mode}") raise HomeAssistantError(f"Invalid hvac_mode: {hvac_mode}")
await self._set_tcs_mode(evo_mode) await self._set_tcs_mode(evo_mode)
async def async_set_preset_mode(self, preset_mode: str) -> None: async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode; if None, then revert to 'Auto' mode.""" """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: @callback
"""Get the latest state data for a Controller.""" def _handle_coordinator_update(self) -> None:
self._device_state_attrs = {} """Handle updated data from the coordinator."""
attrs = self._device_state_attrs self._device_state_attrs = {
for attr in STATE_ATTRS_TCS: "activeSystemFaults": self._evo_device.active_faults
if attr == SZ_ACTIVE_FAULTS: + self._evo_device.gateway.active_faults
attrs["activeSystemFaults"] = getattr(self._evo_device, attr) }
else:
attrs[attr] = getattr(self._evo_device, attr) 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_VER: Final = 1
STORAGE_KEY: Final = DOMAIN 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" 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" USER_DATA: Final = "user_data"
SCAN_INTERVAL_DEFAULT: Final = timedelta(seconds=300) SCAN_INTERVAL_DEFAULT: Final = timedelta(seconds=300)

View File

@ -4,109 +4,143 @@ from __future__ import annotations
from collections.abc import Awaitable from collections.abc import Awaitable
from datetime import timedelta from datetime import timedelta
from http import HTTPStatus
import logging import logging
from typing import TYPE_CHECKING, Any from typing import Any
import evohomeasync as ev1 import evohomeasync as ec1
from evohomeasync.schema import SZ_ID, SZ_TEMP import evohomeasync2 as ec2
import evohomeasync2 as evo from evohomeasync2.const import (
from evohomeasync2.schema.const import (
SZ_GATEWAY_ID, SZ_GATEWAY_ID,
SZ_GATEWAY_INFO, SZ_GATEWAY_INFO,
SZ_GATEWAYS,
SZ_LOCATION_ID, SZ_LOCATION_ID,
SZ_LOCATION_INFO, SZ_LOCATION_INFO,
SZ_TEMPERATURE_CONTROL_SYSTEMS,
SZ_TIME_ZONE, SZ_TIME_ZONE,
SZ_USE_DAYLIGHT_SAVE_SWITCHING,
) )
from evohomeasync2.schemas.typedefs import EvoLocStatusResponseT
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.const import CONF_SCAN_INTERVAL
from homeassistant.core import HomeAssistant
from .const import CONF_LOCATION_IDX, DOMAIN, GWS, TCS, UTC_OFFSET from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .helpers import handle_evo_exception
if TYPE_CHECKING:
from . import EvoSession
_LOGGER = logging.getLogger(__name__.rpartition(".")[0])
class EvoBroker: class EvoDataUpdateCoordinator(DataUpdateCoordinator):
"""Broker for evohome client broker.""" """Coordinator for evohome integration/client."""
loc_idx: int # These will not be None after _async_setup())
loc: evo.Location loc: ec2.Location
loc_utc_offset: timedelta tcs: ec2.ControlSystem
tcs: evo.ControlSystem
def __init__(self, sess: EvoSession) -> None: def __init__(
"""Initialize the evohome broker and its data structure.""" 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 super().__init__(
self.hass = sess.hass 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.loc_idx = location_idx
self.client_v1 = sess.client_v1
self.data: EvoLocStatusResponseT = None # type: ignore[assignment]
self.temps: dict[str, float | None] = {} self.temps: dict[str, float | None] = {}
def validate_location(self, loc_idx: int) -> bool: self._first_refresh_done = False # get schedules only after first refresh
"""Get the default TCS of the specified location."""
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: try:
loc_config = self.client.installation_info[loc_idx] await self.client.update(dont_update_status=True) # only config for now
except IndexError: except ec2.EvohomeError as err:
_LOGGER.error( raise UpdateFailed(err) from err
(
"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
self.loc = self.client.locations[loc_idx] try:
self.loc_utc_offset = timedelta(minutes=self.loc.timeZone[UTC_OFFSET]) self.loc = self.client.locations[self.loc_idx]
self.tcs = self.loc._gateways[0]._control_systems[0] # noqa: SLF001 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 = { loc_info = {
SZ_LOCATION_ID: loc_config[SZ_LOCATION_INFO][SZ_LOCATION_ID], SZ_LOCATION_ID: self.loc.id,
SZ_TIME_ZONE: loc_config[SZ_LOCATION_INFO][SZ_TIME_ZONE], SZ_TIME_ZONE: self.loc.config[SZ_TIME_ZONE],
SZ_USE_DAYLIGHT_SAVE_SWITCHING: self.loc.config[
SZ_USE_DAYLIGHT_SAVE_SWITCHING
],
} }
gwy_info = { gwy_info = {
SZ_GATEWAY_ID: loc_config[GWS][0][SZ_GATEWAY_INFO][SZ_GATEWAY_ID], SZ_GATEWAY_ID: self.loc.gateways[0].id,
TCS: loc_config[GWS][0][TCS], SZ_TEMPERATURE_CONTROL_SYSTEMS: [
self.loc.gateways[0].systems[0].config
],
} }
config = { config = {
SZ_LOCATION_INFO: loc_info, SZ_LOCATION_INFO: loc_info,
GWS: [{SZ_GATEWAY_INFO: gwy_info}], SZ_GATEWAYS: [{SZ_GATEWAY_INFO: gwy_info}],
} }
_LOGGER.debug("Config = %s", config) self.logger.debug("Config = %s", config)
return True
async def call_client_api( async def call_client_api(
self, self,
client_api: Awaitable[dict[str, Any] | None], client_api: Awaitable[dict[str, Any] | None],
update_state: bool = True, request_refresh: bool = True,
) -> dict[str, Any] | None: ) -> 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: try:
result = await client_api result = await client_api
except evo.RequestFailed as err:
handle_evo_exception(err) except ec2.ApiRequestFailedError as err:
self.logger.error(err)
return None return None
if update_state: # wait a moment for system to quiesce before updating state if request_refresh: # wait a moment for system to quiesce before updating state
await self.hass.data[DOMAIN]["coordinator"].async_request_refresh() await self.async_request_refresh() # hass.async_create_task() won't help
return result return result
@ -115,80 +149,82 @@ class EvoBroker:
assert self.client_v1 is not None # mypy check assert self.client_v1 is not None # mypy check
old_session_id = self._sess.session_id
try: try:
temps = await self.client_v1.get_temperatures() await self.client_v1.update()
except ev1.InvalidSchema as err: except ec1.BadUserCredentialsError as err:
_LOGGER.warning( self.logger.warning(
( (
"Unable to obtain high-precision temperatures. " "Unable to obtain high-precision temperatures. "
"It appears the JSON schema is not as expected, " "The feature will be disabled until next restart: %r"
"so the high-precision feature will be disabled until next restart."
"Message is: %s"
), ),
err, err,
) )
self.client_v1 = None self.client_v1 = None
except ev1.RequestFailed as err: except ec1.EvohomeError as err:
_LOGGER.warning( self.logger.warning(
( (
"Unable to obtain the latest high-precision temperatures. " "Unable to obtain the latest high-precision temperatures. "
"Check your network and the vendor's service status page. " "They will be ignored this refresh cycle: %r"
"Proceeding without high-precision temperatures for now. "
"Message is: %s"
), ),
err, err,
) )
self.temps = {} # high-precision temps now considered stale self.temps = {} # high-precision temps now considered stale
except Exception:
self.temps = {} # high-precision temps now considered stale
raise
else: else:
if str(self.client_v1.location_id) != self.loc.locationId: self.temps = await self.client_v1.location_by_id[
_LOGGER.warning( self.loc.id
"The v2 API's configured location doesn't match " ].get_temperatures(dont_update_status=True)
"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}
finally: self.logger.debug("Status (high-res temps) = %s", self.temps)
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)
async def _update_v2_api_state(self, *args: Any) -> None: async def _update_v2_api_state(self, *args: Any) -> None:
"""Get the latest modes, temperatures, setpoints of a Location.""" """Get the latest modes, temperatures, setpoints of a Location."""
access_token = self.client.access_token # maybe receive a new token?
try: try:
status = await self.loc.refresh_status() status = await self.loc.update()
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()
async def async_update(self, *args: Any) -> None: except ec2.ApiRequestFailedError as err:
"""Get the latest state data of an entire Honeywell TCC Location. 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 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. operating mode of the Controller and the current temp of its children (e.g.
Zones, DHW controller). Zones, DHW controller).
""" """
await self._update_v2_api_state()
await self._update_v2_api_state() # may raise UpdateFailed
if self.client_v1: 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.""" """Base for evohome entity."""
from datetime import datetime, timedelta, timezone from collections.abc import Mapping
from datetime import UTC, datetime
import logging import logging
from typing import Any from typing import Any
import evohomeasync2 as evo 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.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from . import EvoBroker, EvoService from .const import DOMAIN, EvoService
from .const import DOMAIN from .coordinator import EvoDataUpdateCoordinator
from .helpers import convert_dict, convert_until
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class EvoDevice(Entity): class EvoEntity(CoordinatorEntity[EvoDataUpdateCoordinator]):
"""Base for any evohome-compatible entity (controller, DHW, zone). """Base for any evohome-compatible entity (controller, DHW, zone).
This includes the controller, (1 to 12) heating zones and (optionally) a This includes the controller, (1 to 12) heating zones and (optionally) a
DHW controller. 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__( def __init__(
self, self,
evo_broker: EvoBroker, coordinator: EvoDataUpdateCoordinator,
evo_device: evo.ControlSystem | evo.HotWater | evo.Zone, evo_device: evo.ControlSystem | evo.HotWater | evo.Zone,
) -> None: ) -> None:
"""Initialize an evohome-compatible entity (TCS, DHW, zone).""" """Initialize an evohome-compatible entity (TCS, DHW, zone)."""
super().__init__(coordinator, context=evo_device.id)
self._evo_device = evo_device self._evo_device = evo_device
self._evo_broker = evo_broker
self._device_state_attrs: dict[str, Any] = {} 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.""" """Process any signals."""
if payload is None: if payload is None:
self.async_schedule_update_ha_state(force_refresh=True) raise NotImplementedError
return
if payload["unique_id"] != self._attr_unique_id: if payload["unique_id"] != self._attr_unique_id:
return return
if payload["service"] in ( if payload["service"] in (
@ -69,40 +63,46 @@ class EvoDevice(Entity):
raise NotImplementedError raise NotImplementedError
@property @property
def extra_state_attributes(self) -> dict[str, Any]: def extra_state_attributes(self) -> Mapping[str, Any]:
"""Return the evohome-specific state attributes.""" """Return the evohome-specific state attributes."""
status = self._device_state_attrs return {"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)}
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass.""" """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). """Base for any evohome-compatible child entity (DHW, zone).
This includes (1 to 12) heating zones and (optionally) a DHW controller. 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__( def __init__(
self, evo_broker: EvoBroker, evo_device: evo.HotWater | evo.Zone self, coordinator: EvoDataUpdateCoordinator, evo_device: evo.HotWater | evo.Zone
) -> None: ) -> None:
"""Initialize an evohome-compatible child entity (DHW, zone).""" """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._evo_tcs = evo_device.tcs
self._schedule: dict[str, Any] = {} self._schedule: dict[str, Any] | None = None
self._setpoints: dict[str, Any] = {} self._setpoints: dict[str, Any] = {}
@property @property
@ -111,101 +111,78 @@ class EvoChild(EvoDevice):
assert isinstance(self._evo_device, evo.HotWater | evo.Zone) # mypy check 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 # use high-precision temps if available
return temp return temp
return self._evo_device.temperature return self._evo_device.temperature
@property @property
def setpoints(self) -> dict[str, Any]: def setpoints(self) -> Mapping[str, Any]:
"""Return the current/next setpoints from the schedule. """Return the current/next setpoints from the schedule.
Only Zones & DHW controllers (but not the TCS) can have schedules. Only Zones & DHW controllers (but not the TCS) can have schedules.
""" """
def _dt_evo_to_aware(dt_naive: datetime, utc_offset: timedelta) -> datetime: this_sp_dtm, this_sp_val = self._evo_device.this_switchpoint
dt_aware = dt_naive.replace(tzinfo=dt_util.UTC) - utc_offset next_sp_dtm, next_sp_val = self._evo_device.next_switchpoint
return dt_util.as_local(dt_aware)
if not (schedule := self._schedule.get("DailySchedules")): key = "temp" if isinstance(self._evo_device, evo.Zone) else "state"
return {} # no scheduled setpoints when {'DailySchedules': []}
# get dt in the same TZ as the TCS location, so we can compare schedule times self._setpoints = {
day_time = dt_util.now().astimezone(timezone(self._evo_broker.loc_utc_offset)) "this_sp_from": this_sp_dtm,
day_of_week = day_time.weekday() # for evohome, 0 is Monday f"this_sp_{key}": this_sp_val,
time_of_day = day_time.strftime("%H:%M:%S") "next_sp_from": next_sp_dtm,
f"next_sp_{key}": next_sp_val,
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,
)
return self._setpoints 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.""" """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: _LOGGER.debug("Schedule['%s'] = %s", self.name, schedule)
schedule = await self._evo_broker.call_client_api(
self._evo_device.get_schedule(), update_state=False 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: ): # must use self._setpoints, not self.setpoints
_LOGGER.warning( await get_schedule()
"%s: Unable to retrieve a valid schedule: %s",
self._evo_device,
err,
)
self._schedule = {}
else:
self._schedule = schedule or {}
_LOGGER.debug("Schedule['%s'] = %s", self.name, self._schedule) _ = self.setpoints # update the setpoints attr
async def async_update(self) -> None: @callback
"""Get the latest state data.""" def _handle_coordinator_update(self) -> None:
next_sp_from = self._setpoints.get("next_sp_from", "2000-01-01T00:00:00+00:00") """Handle updated data from the coordinator."""
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
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"], "codeowners": ["@zxdavb"],
"documentation": "https://www.home-assistant.io/integrations/evohome", "documentation": "https://www.home-assistant.io/integrations/evohome",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["evohomeasync", "evohomeasync2"], "loggers": ["evohome", "evohomeasync", "evohomeasync2"],
"quality_scale": "legacy", "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 from __future__ import annotations
import logging import logging
from typing import TYPE_CHECKING, Any from typing import Any
import evohomeasync2 as evo import evohomeasync2 as evo
from evohomeasync2.schema.const import ( from evohomeasync2.const import SZ_STATE_STATUS, SZ_TEMPERATURE_STATUS
SZ_ACTIVE_FAULTS, from evohomeasync2.schemas.const import DhwState as EvoDhwState, ZoneMode as EvoZoneMode
SZ_DHW_ID,
SZ_OFF,
SZ_ON,
SZ_STATE_STATUS,
SZ_TEMPERATURE_STATUS,
)
from homeassistant.components.water_heater import ( from homeassistant.components.water_heater import (
WaterHeaterEntity, WaterHeaterEntity,
@ -31,22 +25,17 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util 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 from .entity import EvoChild
if TYPE_CHECKING:
from . import EvoBroker
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
STATE_AUTO = "auto" 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 != ""} 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( async def async_setup_platform(
hass: HomeAssistant, hass: HomeAssistant,
@ -58,19 +47,22 @@ async def async_setup_platform(
if discovery_info is None: if discovery_info is None:
return 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( _LOGGER.debug(
"Adding: DhwController (%s), id=%s", "Adding: DhwController (%s), id=%s",
broker.tcs.hotwater.TYPE, tcs.hotwater.type,
broker.tcs.hotwater.dhwId, 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): class EvoDHW(EvoChild, WaterHeaterEntity):
@ -81,19 +73,23 @@ class EvoDHW(EvoChild, WaterHeaterEntity):
_attr_operation_list = list(HA_STATE_TO_EVO) _attr_operation_list = list(HA_STATE_TO_EVO)
_attr_temperature_unit = UnitOfTemperature.CELSIUS _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.""" """Initialize an evohome-compatible DHW controller."""
super().__init__(evo_broker, evo_device) super().__init__(coordinator, evo_device)
self._evo_id = evo_device.dhwId 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_name = evo_device.name # is static
self._attr_precision = ( 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 = ( self._attr_supported_features = (
WaterHeaterEntityFeature.AWAY_MODE | WaterHeaterEntityFeature.OPERATION_MODE WaterHeaterEntityFeature.AWAY_MODE | WaterHeaterEntityFeature.OPERATION_MODE
@ -102,19 +98,15 @@ class EvoDHW(EvoChild, WaterHeaterEntity):
@property @property
def current_operation(self) -> str | None: def current_operation(self) -> str | None:
"""Return the current operating mode (Auto, On, or Off).""" """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 return STATE_AUTO
if (device_state := self._evo_device.state) is None: return EVO_STATE_TO_HA[self._evo_device.state]
return None
return EVO_STATE_TO_HA[device_state]
@property @property
def is_away_mode_on(self) -> bool | None: def is_away_mode_on(self) -> bool | None:
"""Return True if away mode is on.""" """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_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 return is_off and is_permanent
async def async_set_operation_mode(self, operation_mode: str) -> None: 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. Except for Auto, the mode is only until the next SetPoint.
""" """
if operation_mode == STATE_AUTO: 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: else:
await self._update_schedule() 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 until = dt_util.as_utc(until) if until else None
if operation_mode == STATE_ON: if operation_mode == STATE_ON:
await self._evo_broker.call_client_api( await self.coordinator.call_client_api(self._evo_device.on(until=until))
self._evo_device.set_on(until=until)
)
else: # STATE_OFF else: # STATE_OFF
await self._evo_broker.call_client_api( await self.coordinator.call_client_api(
self._evo_device.set_off(until=until) self._evo_device.off(until=until)
) )
async def async_turn_away_mode_on(self) -> None: async def async_turn_away_mode_on(self) -> None:
"""Turn away mode on.""" """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: async def async_turn_away_mode_off(self) -> None:
"""Turn away mode off.""" """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: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on.""" """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: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off.""" """Turn off."""
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_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)

2
requirements_all.txt generated
View File

@ -893,7 +893,7 @@ eufylife-ble-client==0.1.8
# evdev==1.6.1 # evdev==1.6.1
# homeassistant.components.evohome # homeassistant.components.evohome
evohome-async==0.4.21 evohome-async==1.0.2
# homeassistant.components.bryant_evolution # homeassistant.components.bryant_evolution
evolutionhttp==0.0.18 evolutionhttp==0.0.18

View File

@ -759,7 +759,7 @@ eternalegypt==0.0.16
eufylife-ble-client==0.1.8 eufylife-ble-client==0.1.8
# homeassistant.components.evohome # homeassistant.components.evohome
evohome-async==0.4.21 evohome-async==1.0.2
# homeassistant.components.bryant_evolution # homeassistant.components.bryant_evolution
evolutionhttp==0.0.18 evolutionhttp==0.0.18

View File

@ -3,26 +3,26 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import AsyncGenerator, Callable from collections.abc import AsyncGenerator, Callable
from datetime import datetime, timedelta, timezone from datetime import timedelta, timezone
from http import HTTPMethod from http import HTTPMethod
from typing import Any from typing import Any
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from aiohttp import ClientSession
from evohomeasync2 import EvohomeClient from evohomeasync2 import EvohomeClient
from evohomeasync2.broker import Broker from evohomeasync2.auth import AbstractTokenManager, Auth
from evohomeasync2.controlsystem import ControlSystem from evohomeasync2.control_system import ControlSystem
from evohomeasync2.zone import Zone from evohomeasync2.zone import Zone
from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from homeassistant.components.evohome import CONF_PASSWORD, CONF_USERNAME, DOMAIN from homeassistant.components.evohome.const import DOMAIN
from homeassistant.const import Platform from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util, slugify from homeassistant.util import dt as dt_util, slugify
from homeassistant.util.json import JsonArrayType, JsonObjectType 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 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) 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.""" """Return a get method for a specified installation."""
async def mock_get( async def make_request(
self: Broker, url: str, **kwargs: Any self: Auth, method: HTTPMethod, url: str, **kwargs: Any
) -> JsonArrayType | JsonObjectType: ) -> JsonArrayType | JsonObjectType:
"""Return the JSON for a HTTP get of a given URL.""" """Return the JSON for a HTTP get of a given URL."""
# a proxy for the behaviour of the real web API if method != HTTPMethod.GET:
if self.refresh_token is None: pytest.fail(f"Unmocked method: {method} {url}")
self.refresh_token = f"new_{REFRESH_TOKEN}"
if ( await self._headers()
self.access_token_expires is None
or self.access_token_expires < datetime.now()
):
self.access_token = f"new_{ACCESS_TOKEN}"
self.access_token_expires = datetime.now() + timedelta(minutes=30)
# assume a valid GET, and return the JSON for that web API # 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) return user_account_config_fixture(install)
if url.startswith("location"): if url.startswith("location/"):
if "installationInfo" in url: # location/installationInfo?userId={id} if "installationInfo" in url: # /v2/location/installationInfo?userId={id}
return user_locations_config_fixture(install) 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) return location_status_fixture(install)
elif "schedule" in url: elif "schedule" in url:
if url.startswith("domesticHotWater"): # domesticHotWater/{id}/schedule if url.startswith("domesticHotWater"): # /v2/domesticHotWater/{id}/schedule
return dhw_schedule_fixture(install) 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) return zone_schedule_fixture(install)
pytest.fail(f"Unexpected request: {HTTPMethod.GET} {url}") pytest.fail(f"Unexpected request: {HTTPMethod.GET} {url}")
return mock_get return make_request
@pytest.fixture @pytest.fixture
@ -137,9 +162,13 @@ async def setup_evohome(
dt_util.set_default_time_zone(timezone(timedelta(minutes=utc_offset))) dt_util.set_default_time_zone(timezone(timedelta(minutes=utc_offset)))
with ( with (
patch("homeassistant.components.evohome.evo.EvohomeClient") as mock_client, # patch("homeassistant.components.evohome.ec1.EvohomeClient", return_value=None),
patch("homeassistant.components.evohome.ev1.EvohomeClient", return_value=None), patch("homeassistant.components.evohome.ec2.EvohomeClient") as mock_client,
patch("evohomeasync2.broker.Broker.get", mock_get_factory(install)), patch(
"evohomeasync2.auth.CredentialsManagerBase._post_request",
mock_post_request(install),
),
patch("evohome.auth.AbstractAuth._make_request", mock_make_request(install)),
): ):
evo: EvohomeClient | None = None evo: EvohomeClient | None = None
@ -155,12 +184,11 @@ async def setup_evohome(
mock_client.assert_called_once() mock_client.assert_called_once()
assert mock_client.call_args.args[0] == config[CONF_USERNAME] assert isinstance(evo, EvohomeClient)
assert mock_client.call_args.args[1] == config[CONF_PASSWORD] 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.user_account
assert evo and evo.account_info is not None
mock_client.return_value = evo mock_client.return_value = evo
yield mock_client yield mock_client
@ -170,39 +198,32 @@ async def setup_evohome(
async def evohome( async def evohome(
hass: HomeAssistant, hass: HomeAssistant,
config: dict[str, str], config: dict[str, str],
freezer: FrozenDateTimeFactory,
install: str, install: str,
) -> AsyncGenerator[MagicMock]: ) -> AsyncGenerator[MagicMock]:
"""Return the mocked evohome client for this install fixture.""" """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): async for mock_client in setup_evohome(hass, config, install=install):
yield mock_client yield mock_client
@pytest.fixture @pytest.fixture
async def ctl_id( def ctl_id(evohome: MagicMock) -> str:
hass: HomeAssistant,
config: dict[str, str],
install: MagicMock,
) -> AsyncGenerator[str]:
"""Return the entity_id of the evohome integration's controller.""" """Return the entity_id of the evohome integration's controller."""
async for mock_client in setup_evohome(hass, config, install=install): evo: EvohomeClient = evohome.return_value
evo: EvohomeClient = mock_client.return_value ctl: ControlSystem = evo.tcs
ctl: ControlSystem = evo._get_single_tcs()
yield f"{Platform.CLIMATE}.{slugify(ctl.location.name)}" return f"{Platform.CLIMATE}.{slugify(ctl.location.name)}"
@pytest.fixture @pytest.fixture
async def zone_id( def zone_id(evohome: MagicMock) -> str:
hass: HomeAssistant,
config: dict[str, str],
install: MagicMock,
) -> AsyncGenerator[str]:
"""Return the entity_id of the evohome integration's first zone.""" """Return the entity_id of the evohome integration's first zone."""
async for mock_client in setup_evohome(hass, config, install=install): evo: EvohomeClient = evohome.return_value
evo: EvohomeClient = mock_client.return_value zone: Zone = evo.tcs.zones[0]
zone: Zone = list(evo._get_single_tcs().zones.values())[0]
yield f"{Platform.CLIMATE}.{slugify(zone.name)}" return f"{Platform.CLIMATE}.{slugify(zone.name)}"

View File

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

View File

@ -3,6 +3,7 @@
"locationInfo": { "locationInfo": {
"locationId": "111111", "locationId": "111111",
"name": "My Home", "name": "My Home",
"useDaylightSaveSwitching": true,
"timeZone": { "timeZone": {
"timeZoneId": "FLEStandardTime", "timeZoneId": "FLEStandardTime",
"displayName": "(UTC+02:00) Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius", "displayName": "(UTC+02:00) Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius",

View File

@ -2,120 +2,120 @@
# name: test_ctl_set_hvac_mode[default] # name: test_ctl_set_hvac_mode[default]
list([ list([
tuple( tuple(
'HeatingOff', <SystemMode.HEATING_OFF: 'HeatingOff'>,
), ),
tuple( tuple(
'Auto', <SystemMode.AUTO: 'Auto'>,
), ),
]) ])
# --- # ---
# name: test_ctl_set_hvac_mode[h032585] # name: test_ctl_set_hvac_mode[h032585]
list([ list([
tuple( tuple(
'Off', <SystemMode.OFF: 'Off'>,
), ),
tuple( tuple(
'Heat', <SystemMode.HEAT: 'Heat'>,
), ),
]) ])
# --- # ---
# name: test_ctl_set_hvac_mode[h099625] # name: test_ctl_set_hvac_mode[h099625]
list([ list([
tuple( tuple(
'HeatingOff', <SystemMode.HEATING_OFF: 'HeatingOff'>,
), ),
tuple( tuple(
'Auto', <SystemMode.AUTO: 'Auto'>,
), ),
]) ])
# --- # ---
# name: test_ctl_set_hvac_mode[minimal] # name: test_ctl_set_hvac_mode[minimal]
list([ list([
tuple( tuple(
'HeatingOff', <SystemMode.HEATING_OFF: 'HeatingOff'>,
), ),
tuple( tuple(
'Auto', <SystemMode.AUTO: 'Auto'>,
), ),
]) ])
# --- # ---
# name: test_ctl_set_hvac_mode[sys_004] # name: test_ctl_set_hvac_mode[sys_004]
list([ list([
tuple( tuple(
'HeatingOff', <SystemMode.HEATING_OFF: 'HeatingOff'>,
), ),
tuple( tuple(
'Auto', <SystemMode.AUTO: 'Auto'>,
), ),
]) ])
# --- # ---
# name: test_ctl_turn_off[default] # name: test_ctl_turn_off[default]
list([ list([
tuple( tuple(
'HeatingOff', <SystemMode.HEATING_OFF: 'HeatingOff'>,
), ),
]) ])
# --- # ---
# name: test_ctl_turn_off[h032585] # name: test_ctl_turn_off[h032585]
list([ list([
tuple( tuple(
'Off', <SystemMode.OFF: 'Off'>,
), ),
]) ])
# --- # ---
# name: test_ctl_turn_off[h099625] # name: test_ctl_turn_off[h099625]
list([ list([
tuple( tuple(
'HeatingOff', <SystemMode.HEATING_OFF: 'HeatingOff'>,
), ),
]) ])
# --- # ---
# name: test_ctl_turn_off[minimal] # name: test_ctl_turn_off[minimal]
list([ list([
tuple( tuple(
'HeatingOff', <SystemMode.HEATING_OFF: 'HeatingOff'>,
), ),
]) ])
# --- # ---
# name: test_ctl_turn_off[sys_004] # name: test_ctl_turn_off[sys_004]
list([ list([
tuple( tuple(
'HeatingOff', <SystemMode.HEATING_OFF: 'HeatingOff'>,
), ),
]) ])
# --- # ---
# name: test_ctl_turn_on[default] # name: test_ctl_turn_on[default]
list([ list([
tuple( tuple(
'Auto', <SystemMode.AUTO: 'Auto'>,
), ),
]) ])
# --- # ---
# name: test_ctl_turn_on[h032585] # name: test_ctl_turn_on[h032585]
list([ list([
tuple( tuple(
'Heat', <SystemMode.HEAT: 'Heat'>,
), ),
]) ])
# --- # ---
# name: test_ctl_turn_on[h099625] # name: test_ctl_turn_on[h099625]
list([ list([
tuple( tuple(
'Auto', <SystemMode.AUTO: 'Auto'>,
), ),
]) ])
# --- # ---
# name: test_ctl_turn_on[minimal] # name: test_ctl_turn_on[minimal]
list([ list([
tuple( tuple(
'Auto', <SystemMode.AUTO: 'Auto'>,
), ),
]) ])
# --- # ---
# name: test_ctl_turn_on[sys_004] # name: test_ctl_turn_on[sys_004]
list([ list([
tuple( tuple(
'Auto', <SystemMode.AUTO: 'Auto'>,
), ),
]) ])
# --- # ---
@ -137,16 +137,16 @@
'permanent', 'permanent',
]), ]),
'status': dict({ 'status': dict({
'active_faults': list([ 'activeFaults': tuple(
]), ),
'setpoint_status': dict({ 'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule', 'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 16.0, 'target_heat_temperature': 16.0,
}), }),
'setpoints': dict({ '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, '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, 'this_sp_temp': 16.0,
}), }),
'temperature_status': dict({ 'temperature_status': dict({
@ -184,16 +184,16 @@
'permanent', 'permanent',
]), ]),
'status': dict({ 'status': dict({
'active_faults': list([ 'activeFaults': tuple(
]), ),
'setpoint_status': dict({ 'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule', 'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 17.0, 'target_heat_temperature': 17.0,
}), }),
'setpoints': dict({ '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, '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, 'this_sp_temp': 16.0,
}), }),
'temperature_status': dict({ 'temperature_status': dict({
@ -230,21 +230,21 @@
'permanent', 'permanent',
]), ]),
'status': dict({ 'status': dict({
'active_faults': list([ 'activeFaults': tuple(
dict({ dict({
'faultType': 'TempZoneActuatorLowBattery', 'fault_type': 'TempZoneActuatorLowBattery',
'since': '2022-03-02T04:50:20', 'since': '2022-03-02T04:50:20+00:00',
}), }),
]), ),
'setpoint_status': dict({ 'setpoint_status': dict({
'setpoint_mode': 'TemporaryOverride', 'setpoint_mode': 'TemporaryOverride',
'target_heat_temperature': 21.0, 'target_heat_temperature': 21.0,
'until': '2022-03-07T20:00:00+01:00', 'until': '2022-03-07T19:00:00+00:00',
}), }),
'setpoints': dict({ '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, '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, 'this_sp_temp': 16.0,
}), }),
'temperature_status': dict({ 'temperature_status': dict({
@ -282,16 +282,16 @@
'permanent', 'permanent',
]), ]),
'status': dict({ 'status': dict({
'active_faults': list([ 'activeFaults': tuple(
]), ),
'setpoint_status': dict({ 'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule', 'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 17.0, 'target_heat_temperature': 17.0,
}), }),
'setpoints': dict({ '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, '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, 'this_sp_temp': 16.0,
}), }),
'temperature_status': dict({ 'temperature_status': dict({
@ -329,16 +329,16 @@
'permanent', 'permanent',
]), ]),
'status': dict({ 'status': dict({
'active_faults': list([ 'activeFaults': tuple(
]), ),
'setpoint_status': dict({ 'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule', 'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 17.0, 'target_heat_temperature': 17.0,
}), }),
'setpoints': dict({ '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, '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, 'this_sp_temp': 16.0,
}), }),
'temperature_status': dict({ 'temperature_status': dict({
@ -376,16 +376,16 @@
'permanent', 'permanent',
]), ]),
'status': dict({ 'status': dict({
'active_faults': list([ 'activeFaults': tuple(
]), ),
'setpoint_status': dict({ 'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule', 'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 16.0, 'target_heat_temperature': 16.0,
}), }),
'setpoints': dict({ '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, '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, 'this_sp_temp': 16.0,
}), }),
'temperature_status': dict({ 'temperature_status': dict({
@ -423,20 +423,20 @@
'permanent', 'permanent',
]), ]),
'status': dict({ 'status': dict({
'active_faults': list([ 'activeFaults': tuple(
dict({ dict({
'faultType': 'TempZoneActuatorCommunicationLost', 'fault_type': 'TempZoneActuatorCommunicationLost',
'since': '2022-03-02T15:56:01', 'since': '2022-03-02T15:56:01+00:00',
}), }),
]), ),
'setpoint_status': dict({ 'setpoint_status': dict({
'setpoint_mode': 'PermanentOverride', 'setpoint_mode': 'PermanentOverride',
'target_heat_temperature': 17.0, 'target_heat_temperature': 17.0,
}), }),
'setpoints': dict({ '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, '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, 'this_sp_temp': 16.0,
}), }),
'temperature_status': dict({ 'temperature_status': dict({
@ -477,8 +477,8 @@
'Custom', 'Custom',
]), ]),
'status': dict({ 'status': dict({
'active_system_faults': list([ 'activeSystemFaults': tuple(
]), ),
'system_id': '3432522', 'system_id': '3432522',
'system_mode_status': dict({ 'system_mode_status': dict({
'is_permanent': True, 'is_permanent': True,
@ -513,16 +513,16 @@
'permanent', 'permanent',
]), ]),
'status': dict({ 'status': dict({
'active_faults': list([ 'activeFaults': tuple(
]), ),
'setpoint_status': dict({ 'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule', 'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 16.0, 'target_heat_temperature': 16.0,
}), }),
'setpoints': dict({ '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, '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, 'this_sp_temp': 16.0,
}), }),
'temperature_status': dict({ 'temperature_status': dict({
@ -560,16 +560,16 @@
'permanent', 'permanent',
]), ]),
'status': dict({ 'status': dict({
'active_faults': list([ 'activeFaults': tuple(
]), ),
'setpoint_status': dict({ 'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule', 'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 17.0, 'target_heat_temperature': 17.0,
}), }),
'setpoints': dict({ '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, '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, 'this_sp_temp': 16.0,
}), }),
'temperature_status': dict({ 'temperature_status': dict({
@ -606,17 +606,17 @@
'permanent', 'permanent',
]), ]),
'status': dict({ 'status': dict({
'active_faults': list([ 'activeFaults': tuple(
]), ),
'setpoint_status': dict({ 'setpoint_status': dict({
'setpoint_mode': 'TemporaryOverride', 'setpoint_mode': 'TemporaryOverride',
'target_heat_temperature': 21.0, 'target_heat_temperature': 21.0,
'until': '2022-03-07T20:00:00+01:00', 'until': '2022-03-07T19:00:00+00:00',
}), }),
'setpoints': dict({ '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, '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, 'this_sp_temp': 16.0,
}), }),
'temperature_status': dict({ 'temperature_status': dict({
@ -654,16 +654,16 @@
'permanent', 'permanent',
]), ]),
'status': dict({ 'status': dict({
'active_faults': list([ 'activeFaults': tuple(
]), ),
'setpoint_status': dict({ 'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule', 'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 17.0, 'target_heat_temperature': 17.0,
}), }),
'setpoints': dict({ '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, '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, 'this_sp_temp': 16.0,
}), }),
'temperature_status': dict({ 'temperature_status': dict({
@ -701,16 +701,16 @@
'permanent', 'permanent',
]), ]),
'status': dict({ 'status': dict({
'active_faults': list([ 'activeFaults': tuple(
]), ),
'setpoint_status': dict({ 'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule', 'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 17.0, 'target_heat_temperature': 17.0,
}), }),
'setpoints': dict({ '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, '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, 'this_sp_temp': 16.0,
}), }),
'temperature_status': dict({ 'temperature_status': dict({
@ -748,16 +748,16 @@
'permanent', 'permanent',
]), ]),
'status': dict({ 'status': dict({
'active_faults': list([ 'activeFaults': tuple(
]), ),
'setpoint_status': dict({ 'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule', 'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 16.0, 'target_heat_temperature': 16.0,
}), }),
'setpoints': dict({ '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, '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, 'this_sp_temp': 16.0,
}), }),
'temperature_status': dict({ 'temperature_status': dict({
@ -795,16 +795,16 @@
'permanent', 'permanent',
]), ]),
'status': dict({ 'status': dict({
'active_faults': list([ 'activeFaults': tuple(
]), ),
'setpoint_status': dict({ 'setpoint_status': dict({
'setpoint_mode': 'PermanentOverride', 'setpoint_mode': 'PermanentOverride',
'target_heat_temperature': 17.0, 'target_heat_temperature': 17.0,
}), }),
'setpoints': dict({ '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, '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, 'this_sp_temp': 16.0,
}), }),
'temperature_status': dict({ 'temperature_status': dict({
@ -845,8 +845,8 @@
'Custom', 'Custom',
]), ]),
'status': dict({ 'status': dict({
'active_system_faults': list([ 'activeSystemFaults': tuple(
]), ),
'system_id': '3432522', 'system_id': '3432522',
'system_mode_status': dict({ 'system_mode_status': dict({
'is_permanent': True, 'is_permanent': True,
@ -881,16 +881,16 @@
'permanent', 'permanent',
]), ]),
'status': dict({ 'status': dict({
'active_faults': list([ 'activeFaults': tuple(
]), ),
'setpoint_status': dict({ 'setpoint_status': dict({
'setpoint_mode': 'PermanentOverride', 'setpoint_mode': 'PermanentOverride',
'target_heat_temperature': 14.0, 'target_heat_temperature': 14.0,
}), }),
'setpoints': dict({ '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, '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, 'this_sp_temp': 16.0,
}), }),
'temperature_status': dict({ 'temperature_status': dict({
@ -923,8 +923,8 @@
'max_temp': 35, 'max_temp': 35,
'min_temp': 7, 'min_temp': 7,
'status': dict({ 'status': dict({
'active_system_faults': list([ 'activeSystemFaults': tuple(
]), ),
'system_id': '416856', 'system_id': '416856',
'system_mode_status': dict({ 'system_mode_status': dict({
'is_permanent': True, 'is_permanent': True,
@ -959,16 +959,16 @@
'permanent', 'permanent',
]), ]),
'status': dict({ 'status': dict({
'active_faults': list([ 'activeFaults': tuple(
]), ),
'setpoint_status': dict({ 'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule', 'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 21.5, 'target_heat_temperature': 21.5,
}), }),
'setpoints': dict({ '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, '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, 'this_sp_temp': 16.0,
}), }),
'temperature_status': dict({ 'temperature_status': dict({
@ -1006,8 +1006,8 @@
'away', 'away',
]), ]),
'status': dict({ 'status': dict({
'active_system_faults': list([ 'activeSystemFaults': tuple(
]), ),
'system_id': '8557535', 'system_id': '8557535',
'system_mode_status': dict({ 'system_mode_status': dict({
'is_permanent': True, 'is_permanent': True,
@ -1042,16 +1042,16 @@
'permanent', 'permanent',
]), ]),
'status': dict({ 'status': dict({
'active_faults': list([ 'activeFaults': tuple(
]), ),
'setpoint_status': dict({ 'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule', 'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 21.5, 'target_heat_temperature': 21.5,
}), }),
'setpoints': dict({ '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, '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, 'this_sp_temp': 16.0,
}), }),
'temperature_status': dict({ 'temperature_status': dict({
@ -1089,16 +1089,16 @@
'permanent', 'permanent',
]), ]),
'status': dict({ 'status': dict({
'active_faults': list([ 'activeFaults': tuple(
]), ),
'setpoint_status': dict({ 'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule', 'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 21.5, 'target_heat_temperature': 21.5,
}), }),
'setpoints': dict({ '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, '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, 'this_sp_temp': 16.0,
}), }),
'temperature_status': dict({ 'temperature_status': dict({
@ -1136,16 +1136,16 @@
'permanent', 'permanent',
]), ]),
'status': dict({ 'status': dict({
'active_faults': list([ 'activeFaults': tuple(
]), ),
'setpoint_status': dict({ 'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule', 'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 17.0, 'target_heat_temperature': 17.0,
}), }),
'setpoints': dict({ '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, '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, 'this_sp_temp': 16.0,
}), }),
'temperature_status': dict({ 'temperature_status': dict({
@ -1186,8 +1186,8 @@
'Custom', 'Custom',
]), ]),
'status': dict({ 'status': dict({
'active_system_faults': list([ 'activeSystemFaults': tuple(
]), ),
'system_id': '3432522', 'system_id': '3432522',
'system_mode_status': dict({ 'system_mode_status': dict({
'is_permanent': True, 'is_permanent': True,
@ -1222,8 +1222,12 @@
'away', 'away',
]), ]),
'status': dict({ '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_id': '4187769',
'system_mode_status': dict({ 'system_mode_status': dict({
'is_permanent': True, 'is_permanent': True,
@ -1258,16 +1262,16 @@
'permanent', 'permanent',
]), ]),
'status': dict({ 'status': dict({
'active_faults': list([ 'activeFaults': tuple(
]), ),
'setpoint_status': dict({ 'setpoint_status': dict({
'setpoint_mode': 'PermanentOverride', 'setpoint_mode': 'PermanentOverride',
'target_heat_temperature': 15.0, 'target_heat_temperature': 15.0,
}), }),
'setpoints': dict({ '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, '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, 'this_sp_temp': 16.0,
}), }),
'temperature_status': dict({ 'temperature_status': dict({
@ -1331,7 +1335,7 @@
17.0, 17.0,
), ),
dict({ 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, 21.5,
), ),
dict({ 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, 21.5,
), ),
dict({ 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, 17.0,
), ),
dict({ 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, 15.0,
), ),
dict({ 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] # name: test_zone_set_temperature[default]
list([ list([
dict({ 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] # name: test_zone_set_temperature[h032585]
list([ list([
dict({ 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] # name: test_zone_set_temperature[h099625]
list([ list([
dict({ 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] # name: test_zone_set_temperature[minimal]
list([ list([
dict({ 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] # name: test_set_operation_mode[default]
list([ list([
dict({ 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({ 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({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
'away_mode': 'on', 'away_mode': 'on',
'current_temperature': 23, 'current_temperature': 23.0,
'friendly_name': 'Domestic Hot Water', 'friendly_name': 'Domestic Hot Water',
'icon': 'mdi:thermometer-lines', 'icon': 'mdi:thermometer-lines',
'max_temp': 60, 'max_temp': 60.0,
'min_temp': 43, 'min_temp': 43.3,
'operation_list': list([ 'operation_list': list([
'auto', 'auto',
'on', 'on',
@ -25,13 +25,13 @@
]), ]),
'operation_mode': 'off', 'operation_mode': 'off',
'status': dict({ 'status': dict({
'active_faults': list([ 'activeFaults': tuple(
]), ),
'dhw_id': '3933910', 'dhw_id': '3933910',
'setpoints': dict({ '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', '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', 'this_sp_state': 'On',
}), }),
'state_status': dict({ 'state_status': dict({
@ -60,11 +60,11 @@
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
'away_mode': 'on', 'away_mode': 'on',
'current_temperature': 23, 'current_temperature': 23.0,
'friendly_name': 'Domestic Hot Water', 'friendly_name': 'Domestic Hot Water',
'icon': 'mdi:thermometer-lines', 'icon': 'mdi:thermometer-lines',
'max_temp': 60, 'max_temp': 60.0,
'min_temp': 43, 'min_temp': 43.3,
'operation_list': list([ 'operation_list': list([
'auto', 'auto',
'on', 'on',
@ -72,13 +72,13 @@
]), ]),
'operation_mode': 'off', 'operation_mode': 'off',
'status': dict({ 'status': dict({
'active_faults': list([ 'activeFaults': tuple(
]), ),
'dhw_id': '3933910', 'dhw_id': '3933910',
'setpoints': dict({ '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', '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', 'this_sp_state': 'On',
}), }),
'state_status': dict({ 'state_status': dict({

View File

@ -65,7 +65,7 @@ async def test_ctl_set_hvac_mode(
results = [] results = []
# SERVICE_SET_HVAC_MODE: HVACMode.OFF # 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( await hass.services.async_call(
Platform.CLIMATE, Platform.CLIMATE,
SERVICE_SET_HVAC_MODE, SERVICE_SET_HVAC_MODE,
@ -76,14 +76,15 @@ async def test_ctl_set_hvac_mode(
blocking=True, blocking=True,
) )
assert mock_fcn.await_count == 1 try:
assert mock_fcn.await_args.args != () # 'HeatingOff' or 'Off' mock_fcn.assert_awaited_once_with("HeatingOff", until=None)
assert mock_fcn.await_args.kwargs == {"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 # 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( await hass.services.async_call(
Platform.CLIMATE, Platform.CLIMATE,
SERVICE_SET_HVAC_MODE, SERVICE_SET_HVAC_MODE,
@ -94,11 +95,12 @@ async def test_ctl_set_hvac_mode(
blocking=True, blocking=True,
) )
assert mock_fcn.await_count == 1 try:
assert mock_fcn.await_args.args != () # 'Auto' or 'Heat' mock_fcn.assert_awaited_once_with("Auto", until=None)
assert mock_fcn.await_args.kwargs == {"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 assert results == snapshot
@ -134,7 +136,7 @@ async def test_ctl_turn_off(
results = [] results = []
# SERVICE_TURN_OFF # 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( await hass.services.async_call(
Platform.CLIMATE, Platform.CLIMATE,
SERVICE_TURN_OFF, SERVICE_TURN_OFF,
@ -144,11 +146,12 @@ async def test_ctl_turn_off(
blocking=True, blocking=True,
) )
assert mock_fcn.await_count == 1 try:
assert mock_fcn.await_args.args != () # 'HeatingOff' or 'Off' mock_fcn.assert_awaited_once_with("HeatingOff", until=None)
assert mock_fcn.await_args.kwargs == {"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 assert results == snapshot
@ -164,7 +167,7 @@ async def test_ctl_turn_on(
results = [] results = []
# SERVICE_TURN_ON # 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( await hass.services.async_call(
Platform.CLIMATE, Platform.CLIMATE,
SERVICE_TURN_ON, SERVICE_TURN_ON,
@ -174,11 +177,12 @@ async def test_ctl_turn_on(
blocking=True, blocking=True,
) )
assert mock_fcn.await_count == 1 try:
assert mock_fcn.await_args.args != () # 'Auto' or 'Heat' mock_fcn.assert_awaited_once_with("Auto", until=None)
assert mock_fcn.await_args.kwargs == {"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 assert results == snapshot
@ -194,7 +198,7 @@ async def test_zone_set_hvac_mode(
results = [] results = []
# SERVICE_SET_HVAC_MODE: HVACMode.HEAT # 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( await hass.services.async_call(
Platform.CLIMATE, Platform.CLIMATE,
SERVICE_SET_HVAC_MODE, SERVICE_SET_HVAC_MODE,
@ -205,9 +209,7 @@ async def test_zone_set_hvac_mode(
blocking=True, blocking=True,
) )
assert mock_fcn.await_count == 1 mock_fcn.assert_awaited_once_with()
assert mock_fcn.await_args.args == ()
assert mock_fcn.await_args.kwargs == {}
# SERVICE_SET_HVAC_MODE: HVACMode.OFF # SERVICE_SET_HVAC_MODE: HVACMode.OFF
with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn: with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn:
@ -221,7 +223,9 @@ async def test_zone_set_hvac_mode(
blocking=True, 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.args != () # minimum target temp
assert mock_fcn.await_args.kwargs == {"until": None} assert mock_fcn.await_args.kwargs == {"until": None}
@ -243,7 +247,7 @@ async def test_zone_set_preset_mode(
results = [] results = []
# SERVICE_SET_PRESET_MODE: none # 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( await hass.services.async_call(
Platform.CLIMATE, Platform.CLIMATE,
SERVICE_SET_PRESET_MODE, SERVICE_SET_PRESET_MODE,
@ -254,9 +258,7 @@ async def test_zone_set_preset_mode(
blocking=True, blocking=True,
) )
assert mock_fcn.await_count == 1 mock_fcn.assert_awaited_once_with()
assert mock_fcn.await_args.args == ()
assert mock_fcn.await_args.kwargs == {}
# SERVICE_SET_PRESET_MODE: permanent # SERVICE_SET_PRESET_MODE: permanent
with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn: with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn:
@ -270,7 +272,9 @@ async def test_zone_set_preset_mode(
blocking=True, 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.args != () # current target temp
assert mock_fcn.await_args.kwargs == {"until": None} assert mock_fcn.await_args.kwargs == {"until": None}
@ -288,7 +292,9 @@ async def test_zone_set_preset_mode(
blocking=True, 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.args != () # current target temp
assert mock_fcn.await_args.kwargs != {} # next setpoint dtm 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( async def test_zone_set_temperature(
hass: HomeAssistant, hass: HomeAssistant,
zone_id: str, zone_id: str,
freezer: FrozenDateTimeFactory,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
) -> None: ) -> None:
"""Test SERVICE_SET_TEMPERATURE of an evohome heating zone.""" """Test SERVICE_SET_TEMPERATURE of an evohome heating zone."""
freezer.move_to("2024-07-10T12:00:00Z")
results = [] results = []
# SERVICE_SET_TEMPERATURE: temperature # SERVICE_SET_TEMPERATURE: temperature
@ -322,7 +326,9 @@ async def test_zone_set_temperature(
blocking=True, 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.args == (19.1,)
assert mock_fcn.await_args.kwargs != {} # next setpoint dtm assert mock_fcn.await_args.kwargs != {} # next setpoint dtm
@ -352,7 +358,9 @@ async def test_zone_turn_off(
blocking=True, 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.args != () # minimum target temp
assert mock_fcn.await_args.kwargs == {"until": None} 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.""" """Test SERVICE_TURN_ON of an evohome heating zone."""
# SERVICE_TURN_ON # 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( await hass.services.async_call(
Platform.CLIMATE, Platform.CLIMATE,
SERVICE_TURN_ON, SERVICE_TURN_ON,
@ -379,6 +387,4 @@ async def test_zone_turn_on(
blocking=True, blocking=True,
) )
assert mock_fcn.await_count == 1 mock_fcn.assert_awaited_once_with()
assert mock_fcn.await_args.args == ()
assert mock_fcn.await_args.kwargs == {}

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 from http import HTTPStatus
import logging import logging
from unittest.mock import patch from unittest.mock import Mock, patch
import aiohttp
from evohomeasync2 import EvohomeClient, exceptions as exc from evohomeasync2 import EvohomeClient, exceptions as exc
from evohomeasync2.broker import _ERR_MSG_LOOKUP_AUTH, _ERR_MSG_LOOKUP_BASE
import pytest 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.core import HomeAssistant
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from .conftest import mock_post_request
from .const import TEST_INSTALLS 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", "homeassistant.setup",
logging.ERROR, logging.ERROR,
"Setup failed for 'evohome': Integration failed to initialize.", "Setup failed for 'evohome': Integration failed to initialize.",
) )
SETUP_FAILED_UNEXPECTED = (
"homeassistant.setup", EXC_BAD_CONNECTION = aiohttp.ClientConnectionError(
logging.ERROR, "Connection error",
"Error during setup of component evohome: ",
) )
AUTHENTICATION_FAILED = ( EXC_BAD_CREDENTIALS = exc.AuthenticationFailedError(
"homeassistant.components.evohome.helpers", "Authenticator response is invalid: {'error': 'invalid_grant'}",
logging.ERROR, status=HTTPStatus.BAD_REQUEST,
"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: ",
) )
REQUEST_FAILED_NONE = ( EXC_TOO_MANY_REQUESTS = aiohttp.ClientResponseError(
"homeassistant.components.evohome.helpers", Mock(),
logging.WARNING, (),
"Unable to connect with the vendor's server. " status=HTTPStatus.TOO_MANY_REQUESTS,
"Check your network and the vendor's service status page. " message=HTTPStatus.TOO_MANY_REQUESTS.phrase,
"Message is: ",
) )
REQUEST_FAILED_503 = ( EXC_BAD_GATEWAY = aiohttp.ClientResponseError(
"homeassistant.components.evohome.helpers", Mock(), (), status=HTTPStatus.BAD_GATEWAY, message=HTTPStatus.BAD_GATEWAY.phrase
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",
) )
REQUEST_FAILED_LOOKUP = { AUTHENTICATION_TESTS: dict[Exception, list] = {
None: [ EXC_BAD_CONNECTION: [LOG_HINT_OTH_CREDS, LOG_FAIL_CONNECTION, LOG_SETUP_FAILED],
REQUEST_FAILED_NONE, EXC_BAD_CREDENTIALS: [LOG_HINT_USR_CREDS, LOG_FAIL_CREDENTIALS, LOG_SETUP_FAILED],
SETUP_FAILED_ANTICIPATED, 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],
HTTPStatus.SERVICE_UNAVAILABLE: [ }
REQUEST_FAILED_503,
SETUP_FAILED_ANTICIPATED, CLIENT_REQUEST_TESTS: dict[Exception, list] = {
], EXC_BAD_CONNECTION: [LOG_HINT_OTH_AUTH, LOG_FGET_CONNECTION, LOG_SETUP_FAILED],
HTTPStatus.TOO_MANY_REQUESTS: [ EXC_BAD_GATEWAY: [LOG_HINT_OTH_AUTH, LOG_FGET_GATEWAY, LOG_SETUP_FAILED],
REQUEST_FAILED_429, EXC_TOO_MANY_REQUESTS: [LOG_HINT_429_AUTH, LOG_FGET_TOO_MANY, LOG_SETUP_FAILED],
SETUP_FAILED_ANTICIPATED,
],
} }
@pytest.mark.parametrize( @pytest.mark.parametrize("exception", AUTHENTICATION_TESTS)
"status", [*sorted([*_ERR_MSG_LOOKUP_AUTH, HTTPStatus.BAD_GATEWAY]), None]
)
async def test_authentication_failure_v2( async def test_authentication_failure_v2(
hass: HomeAssistant, hass: HomeAssistant,
config: dict[str, str], config: dict[str, str],
status: HTTPStatus, exception: Exception,
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
) -> None: ) -> None:
"""Test failure to setup an evohome-compatible system. """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. In this instance, the failure occurs in the v2 API.
""" """
with patch("evohomeasync2.broker.Broker.get") as mock_fcn: with (
mock_fcn.side_effect = exc.AuthenticationFailed("", status=status) patch(
"evohome.credentials.CredentialsManagerBase._request", side_effect=exception
with caplog.at_level(logging.WARNING): ),
result = await async_setup_component(hass, DOMAIN, {DOMAIN: config}) caplog.at_level(logging.WARNING),
):
result = await async_setup_component(hass, DOMAIN, {DOMAIN: config})
assert result is False assert result is False
assert caplog.record_tuples == [ assert caplog.record_tuples == AUTHENTICATION_TESTS[exception]
AUTHENTICATION_FAILED,
SETUP_FAILED_ANTICIPATED,
]
@pytest.mark.parametrize( @pytest.mark.parametrize("exception", CLIENT_REQUEST_TESTS)
"status", [*sorted([*_ERR_MSG_LOOKUP_BASE, HTTPStatus.BAD_GATEWAY]), None]
)
async def test_client_request_failure_v2( async def test_client_request_failure_v2(
hass: HomeAssistant, hass: HomeAssistant,
config: dict[str, str], config: dict[str, str],
status: HTTPStatus, exception: Exception,
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
) -> None: ) -> None:
"""Test failure to setup an evohome-compatible system. """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. In this instance, the failure occurs in the v2 API.
""" """
with patch("evohomeasync2.broker.Broker.get") as mock_fcn: with (
mock_fcn.side_effect = exc.RequestFailed("", status=status) patch(
"evohomeasync2.auth.CredentialsManagerBase._post_request",
with caplog.at_level(logging.WARNING): mock_post_request("default"),
result = await async_setup_component(hass, DOMAIN, {DOMAIN: config}) ),
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 result is False
assert caplog.record_tuples == REQUEST_FAILED_LOOKUP.get( assert caplog.record_tuples == CLIENT_REQUEST_TESTS[exception]
status, [SETUP_FAILED_UNEXPECTED]
)
@pytest.mark.parametrize("install", [*TEST_INSTALLS, "botched"]) @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.""" """Test EvoService.REFRESH_SYSTEM of an evohome system."""
# EvoService.REFRESH_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( await hass.services.async_call(
DOMAIN, DOMAIN,
EvoService.REFRESH_SYSTEM, EvoService.REFRESH_SYSTEM,
@ -156,9 +205,7 @@ async def test_service_refresh_system(
blocking=True, blocking=True,
) )
assert mock_fcn.await_count == 1 mock_fcn.assert_awaited_once_with()
assert mock_fcn.await_args.args == ()
assert mock_fcn.await_args.kwargs == {}
@pytest.mark.parametrize("install", ["default"]) @pytest.mark.parametrize("install", ["default"])
@ -169,7 +216,7 @@ async def test_service_reset_system(
"""Test EvoService.RESET_SYSTEM of an evohome system.""" """Test EvoService.RESET_SYSTEM of an evohome system."""
# EvoService.RESET_SYSTEM (if SZ_AUTO_WITH_RESET in modes) # 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( await hass.services.async_call(
DOMAIN, DOMAIN,
EvoService.RESET_SYSTEM, EvoService.RESET_SYSTEM,
@ -177,6 +224,4 @@ async def test_service_reset_system(
blocking=True, blocking=True,
) )
assert mock_fcn.await_count == 1 mock_fcn.assert_awaited_once_with("AutoWithReset", until=None)
assert mock_fcn.await_args.args == ("AutoWithReset",)
assert mock_fcn.await_args.kwargs == {"until": None}

View File

@ -7,13 +7,7 @@ from typing import Any, Final, NotRequired, TypedDict
import pytest import pytest
from homeassistant.components.evohome import ( from homeassistant.components.evohome.const import DOMAIN, STORAGE_KEY, STORAGE_VER
CONF_USERNAME,
DOMAIN,
STORAGE_KEY,
STORAGE_VER,
dt_aware_to_naive,
)
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util 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): class _SessionDataT(TypedDict):
sessionId: str session_id: str
session_id_expires: NotRequired[str] # 2024-07-27T23:57:30+01:00
class _TokenStoreT(TypedDict): class _TokenStoreT(TypedDict):
@ -65,7 +60,7 @@ _TEST_STORAGE_BASE: Final[_TokenStoreT] = {
TEST_STORAGE_DATA: Final[dict[str, _TokenStoreT]] = { TEST_STORAGE_DATA: Final[dict[str, _TokenStoreT]] = {
"sans_session_id": _TEST_STORAGE_BASE, "sans_session_id": _TEST_STORAGE_BASE,
"null_session_id": _TEST_STORAGE_BASE | {SZ_USER_DATA: None}, # type: ignore[dict-item] "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]] = { TEST_STORAGE_NULL: Final[dict[str, _EmptyStoreT | None]] = {
@ -89,15 +84,12 @@ async def test_auth_tokens_null(
idx: str, idx: str,
install: str, install: str,
) -> None: ) -> 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]} hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_STORAGE_NULL[idx]}
async for mock_client in setup_evohome(hass, config, install=install): async for _ in setup_evohome(hass, config, install=install):
# Confirm client was instantiated without tokens, as cache was empty... pass
assert SZ_REFRESH_TOKEN not in mock_client.call_args.kwargs
assert SZ_ACCESS_TOKEN not in mock_client.call_args.kwargs
assert SZ_ACCESS_TOKEN_EXPIRES not in mock_client.call_args.kwarg
# Confirm the expected tokens were cached to storage... # Confirm the expected tokens were cached to storage...
data: _TokenStoreT = hass_storage[DOMAIN]["data"] data: _TokenStoreT = hass_storage[DOMAIN]["data"]
@ -120,17 +112,12 @@ async def test_auth_tokens_same(
idx: str, idx: str,
install: str, install: str,
) -> None: ) -> 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]} hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_STORAGE_DATA[idx]}
async for mock_client in setup_evohome(hass, config, install=install): async for _ in setup_evohome(hass, config, install=install):
# Confirm client was instantiated with the cached tokens... pass
assert mock_client.call_args.kwargs[SZ_REFRESH_TOKEN] == REFRESH_TOKEN
assert mock_client.call_args.kwargs[SZ_ACCESS_TOKEN] == ACCESS_TOKEN
assert mock_client.call_args.kwargs[
SZ_ACCESS_TOKEN_EXPIRES
] == dt_aware_to_naive(ACCESS_TOKEN_EXP_DTM)
# Confirm the expected tokens were cached to storage... # Confirm the expected tokens were cached to storage...
data: _TokenStoreT = hass_storage[DOMAIN]["data"] data: _TokenStoreT = hass_storage[DOMAIN]["data"]
@ -150,7 +137,7 @@ async def test_auth_tokens_past(
idx: str, idx: str,
install: str, install: str,
) -> None: ) -> 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)) 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} hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": test_data}
async for mock_client in setup_evohome(hass, config, install=install): async for _ in setup_evohome(hass, config, install=install):
# Confirm client was instantiated with the cached tokens... pass
assert mock_client.call_args.kwargs[SZ_REFRESH_TOKEN] == REFRESH_TOKEN
assert mock_client.call_args.kwargs[SZ_ACCESS_TOKEN] == ACCESS_TOKEN
assert mock_client.call_args.kwargs[
SZ_ACCESS_TOKEN_EXPIRES
] == dt_aware_to_naive(dt_dtm)
# Confirm the expected tokens were cached to storage... # Confirm the expected tokens were cached to storage...
data: _TokenStoreT = hass_storage[DOMAIN]["data"] data: _TokenStoreT = hass_storage[DOMAIN]["data"]
assert data[SZ_USERNAME] == USERNAME_SAME 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 data[SZ_ACCESS_TOKEN] == f"new_{ACCESS_TOKEN}"
assert ( assert (
dt_util.parse_datetime(data[SZ_ACCESS_TOKEN_EXPIRES], raise_on_error=True) 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, idx: str,
install: str, install: str,
) -> None: ) -> 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]} hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_STORAGE_DATA[idx]}
config["username"] = USERNAME_DIFF
async for mock_client in setup_evohome( async for _ in setup_evohome(hass, config, install=install):
hass, config | {CONF_USERNAME: USERNAME_DIFF}, install=install pass
):
# Confirm client was instantiated without tokens, as username was different...
assert SZ_REFRESH_TOKEN not in mock_client.call_args.kwargs
assert SZ_ACCESS_TOKEN not in mock_client.call_args.kwargs
assert SZ_ACCESS_TOKEN_EXPIRES not in mock_client.call_args.kwarg
# Confirm the expected tokens were cached to storage... # Confirm the expected tokens were cached to storage...
data: _TokenStoreT = hass_storage[DOMAIN]["data"] data: _TokenStoreT = hass_storage[DOMAIN]["data"]

View File

@ -67,7 +67,7 @@ async def test_set_operation_mode(
results = [] results = []
# SERVICE_SET_OPERATION_MODE: auto # 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( await hass.services.async_call(
Platform.WATER_HEATER, Platform.WATER_HEATER,
SERVICE_SET_OPERATION_MODE, SERVICE_SET_OPERATION_MODE,
@ -78,12 +78,10 @@ async def test_set_operation_mode(
blocking=True, blocking=True,
) )
assert mock_fcn.await_count == 1 mock_fcn.assert_awaited_once_with()
assert mock_fcn.await_args.args == ()
assert mock_fcn.await_args.kwargs == {}
# SERVICE_SET_OPERATION_MODE: off (until next scheduled setpoint) # 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( await hass.services.async_call(
Platform.WATER_HEATER, Platform.WATER_HEATER,
SERVICE_SET_OPERATION_MODE, SERVICE_SET_OPERATION_MODE,
@ -94,14 +92,16 @@ async def test_set_operation_mode(
blocking=True, 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.args == ()
assert mock_fcn.await_args.kwargs != {} assert mock_fcn.await_args.kwargs != {}
results.append(mock_fcn.await_args.kwargs) results.append(mock_fcn.await_args.kwargs)
# SERVICE_SET_OPERATION_MODE: on (until next scheduled setpoint) # 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( await hass.services.async_call(
Platform.WATER_HEATER, Platform.WATER_HEATER,
SERVICE_SET_OPERATION_MODE, SERVICE_SET_OPERATION_MODE,
@ -112,7 +112,9 @@ async def test_set_operation_mode(
blocking=True, 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.args == ()
assert mock_fcn.await_args.kwargs != {} 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.""" """Test SERVICE_SET_AWAY_MODE of an evohome DHW zone."""
# set_away_mode: off # 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( await hass.services.async_call(
Platform.WATER_HEATER, Platform.WATER_HEATER,
SERVICE_SET_AWAY_MODE, SERVICE_SET_AWAY_MODE,
@ -137,12 +139,10 @@ async def test_set_away_mode(hass: HomeAssistant, evohome: EvohomeClient) -> Non
blocking=True, blocking=True,
) )
assert mock_fcn.await_count == 1 mock_fcn.assert_awaited_once_with()
assert mock_fcn.await_args.args == ()
assert mock_fcn.await_args.kwargs == {}
# set_away_mode: on # 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( await hass.services.async_call(
Platform.WATER_HEATER, Platform.WATER_HEATER,
SERVICE_SET_AWAY_MODE, SERVICE_SET_AWAY_MODE,
@ -153,9 +153,7 @@ async def test_set_away_mode(hass: HomeAssistant, evohome: EvohomeClient) -> Non
blocking=True, blocking=True,
) )
assert mock_fcn.await_count == 1 mock_fcn.assert_awaited_once_with()
assert mock_fcn.await_args.args == ()
assert mock_fcn.await_args.kwargs == {}
@pytest.mark.parametrize("install", TEST_INSTALLS_WITH_DHW) @pytest.mark.parametrize("install", TEST_INSTALLS_WITH_DHW)