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