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