core/homeassistant/components/evohome/__init__.py

386 lines
13 KiB
Python

"""Support for (EMEA/EU-based) Honeywell TCC systems.
Such systems provide heating/cooling and DHW and include Evohome, Round Thermostat, and
others.
"""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any, Final
import evohomeasync as ev1
from evohomeasync.schema import SZ_SESSION_ID
import evohomeasync2 as evo
from evohomeasync2.schema.const import (
SZ_AUTO_WITH_RESET,
SZ_CAN_BE_TEMPORARY,
SZ_SYSTEM_MODE,
SZ_TIMING_MODE,
)
import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_PASSWORD,
CONF_SCAN_INTERVAL,
CONF_USERNAME,
Platform,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.service import verify_domain_control
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
import homeassistant.util.dt as dt_util
from .const import (
ACCESS_TOKEN,
ACCESS_TOKEN_EXPIRES,
ATTR_DURATION_DAYS,
ATTR_DURATION_HOURS,
ATTR_DURATION_UNTIL,
ATTR_SYSTEM_MODE,
ATTR_ZONE_TEMP,
CONF_LOCATION_IDX,
DOMAIN,
REFRESH_TOKEN,
SCAN_INTERVAL_DEFAULT,
SCAN_INTERVAL_MINIMUM,
STORAGE_KEY,
STORAGE_VER,
USER_DATA,
EvoService,
)
from .coordinator import EvoBroker
from .helpers import dt_aware_to_naive, dt_local_to_aware, handle_evo_exception
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA: Final = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(CONF_LOCATION_IDX, default=0): cv.positive_int,
vol.Optional(
CONF_SCAN_INTERVAL, default=SCAN_INTERVAL_DEFAULT
): vol.All(cv.time_period, vol.Range(min=SCAN_INTERVAL_MINIMUM)),
}
)
},
extra=vol.ALLOW_EXTRA,
)
# system mode schemas are built dynamically when the services are registered
# because supported modes can vary for edge-case systems
RESET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema(
{vol.Required(ATTR_ENTITY_ID): cv.entity_id}
)
SET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
vol.Required(ATTR_ZONE_TEMP): vol.All(
vol.Coerce(float), vol.Range(min=4.0, max=35.0)
),
vol.Optional(ATTR_DURATION_UNTIL): vol.All(
cv.time_period, vol.Range(min=timedelta(days=0), max=timedelta(days=1))
),
}
)
class EvoSession:
"""Class for evohome client instantiation & authentication."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the evohome broker and its data structure."""
self.hass = hass
self._session = async_get_clientsession(hass)
self._store = Store[dict[str, Any]](hass, STORAGE_VER, STORAGE_KEY)
# the main client, which uses the newer API
self.client_v2: evo.EvohomeClient | None = None
self._tokens: dict[str, Any] = {}
# the older client can be used to obtain high-precision temps (only)
self.client_v1: ev1.EvohomeClient | None = None
self.session_id: str | None = None
async def authenticate(self, username: str, password: str) -> None:
"""Check the user credentials against the web API.
Will raise evo.AuthenticationFailed if the credentials are invalid.
"""
if (
self.client_v2 is None
or username != self.client_v2.username
or password != self.client_v2.password
):
await self._load_auth_tokens(username)
client_v2 = evo.EvohomeClient(
username,
password,
**self._tokens,
session=self._session,
)
else: # force a re-authentication
client_v2 = self.client_v2
client_v2._user_account = None # noqa: SLF001
await client_v2.login()
self.client_v2 = client_v2 # only set attr if authentication succeeded
await self.save_auth_tokens()
self.client_v1 = ev1.EvohomeClient(
username,
password,
session_id=self.session_id,
session=self._session,
)
async def _load_auth_tokens(self, username: str) -> None:
"""Load access tokens and session_id from the store and validate them.
Sets self._tokens and self._session_id to the latest values.
"""
app_storage: dict[str, Any] = dict(await self._store.async_load() or {})
if app_storage.pop(CONF_USERNAME, None) != username:
# any tokens won't be valid, and store might be corrupt
await self._store.async_save({})
self.session_id = None
self._tokens = {}
return
# evohomeasync2 requires naive/local datetimes as strings
if app_storage.get(ACCESS_TOKEN_EXPIRES) is not None and (
expires := dt_util.parse_datetime(app_storage[ACCESS_TOKEN_EXPIRES])
):
app_storage[ACCESS_TOKEN_EXPIRES] = dt_aware_to_naive(expires)
user_data: dict[str, str] = app_storage.pop(USER_DATA, {}) or {}
self.session_id = user_data.get(SZ_SESSION_ID)
self._tokens = app_storage
async def save_auth_tokens(self) -> None:
"""Save access tokens and session_id to the store.
Sets self._tokens and self._session_id to the latest values.
"""
if self.client_v2 is None:
await self._store.async_save({})
return
# evohomeasync2 uses naive/local datetimes
access_token_expires = dt_local_to_aware(
self.client_v2.access_token_expires # type: ignore[arg-type]
)
self._tokens = {
CONF_USERNAME: self.client_v2.username,
REFRESH_TOKEN: self.client_v2.refresh_token,
ACCESS_TOKEN: self.client_v2.access_token,
ACCESS_TOKEN_EXPIRES: access_token_expires.isoformat(),
}
self.session_id = self.client_v1.broker.session_id if self.client_v1 else None
app_storage = self._tokens
if self.client_v1:
app_storage[USER_DATA] = {SZ_SESSION_ID: self.session_id}
await self._store.async_save(app_storage)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Evohome integration."""
sess = EvoSession(hass)
try:
await sess.authenticate(
config[DOMAIN][CONF_USERNAME],
config[DOMAIN][CONF_PASSWORD],
)
except (evo.AuthenticationFailed, evo.RequestFailed) as err:
handle_evo_exception(err)
return False
finally:
config[DOMAIN][CONF_PASSWORD] = "REDACTED"
broker = EvoBroker(sess)
if not broker.validate_location(
config[DOMAIN][CONF_LOCATION_IDX],
):
return False
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
config_entry=None,
name=f"{DOMAIN}_coordinator",
update_interval=config[DOMAIN][CONF_SCAN_INTERVAL],
update_method=broker.async_update,
)
await coordinator.async_register_shutdown()
hass.data[DOMAIN] = {"broker": broker, "coordinator": coordinator}
# without a listener, _schedule_refresh() won't be invoked by _async_refresh()
coordinator.async_add_listener(lambda: None)
await coordinator.async_refresh() # get initial state
hass.async_create_task(
async_load_platform(hass, Platform.CLIMATE, DOMAIN, {}, config)
)
if broker.tcs.hotwater:
hass.async_create_task(
async_load_platform(hass, Platform.WATER_HEATER, DOMAIN, {}, config)
)
setup_service_functions(hass, broker)
return True
@callback
def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None:
"""Set up the service handlers for the system/zone operating modes.
Not all Honeywell TCC-compatible systems support all operating modes. In addition,
each mode will require any of four distinct service schemas. This has to be
enumerated before registering the appropriate handlers.
It appears that all TCC-compatible systems support the same three zones modes.
"""
@verify_domain_control(hass, DOMAIN)
async def force_refresh(call: ServiceCall) -> None:
"""Obtain the latest state data via the vendor's RESTful API."""
await broker.async_update()
@verify_domain_control(hass, DOMAIN)
async def set_system_mode(call: ServiceCall) -> None:
"""Set the system mode."""
payload = {
"unique_id": broker.tcs.systemId,
"service": call.service,
"data": call.data,
}
async_dispatcher_send(hass, DOMAIN, payload)
@verify_domain_control(hass, DOMAIN)
async def set_zone_override(call: ServiceCall) -> None:
"""Set the zone override (setpoint)."""
entity_id = call.data[ATTR_ENTITY_ID]
registry = er.async_get(hass)
registry_entry = registry.async_get(entity_id)
if registry_entry is None or registry_entry.platform != DOMAIN:
raise ValueError(f"'{entity_id}' is not a known {DOMAIN} entity")
if registry_entry.domain != "climate":
raise ValueError(f"'{entity_id}' is not an {DOMAIN} controller/zone")
payload = {
"unique_id": registry_entry.unique_id,
"service": call.service,
"data": call.data,
}
async_dispatcher_send(hass, DOMAIN, payload)
hass.services.async_register(DOMAIN, EvoService.REFRESH_SYSTEM, force_refresh)
# Enumerate which operating modes are supported by this system
modes = broker.tcs.allowedSystemModes
# 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]:
hass.services.async_register(DOMAIN, EvoService.RESET_SYSTEM, set_system_mode)
system_mode_schemas = []
modes = [m for m in modes if m[SZ_SYSTEM_MODE] != SZ_AUTO_WITH_RESET]
# Permanent-only modes will use this schema
perm_modes = [m[SZ_SYSTEM_MODE] for m in modes if not m[SZ_CAN_BE_TEMPORARY]]
if perm_modes: # any of: "Auto", "HeatingOff": permanent only
schema = vol.Schema({vol.Required(ATTR_SYSTEM_MODE): vol.In(perm_modes)})
system_mode_schemas.append(schema)
modes = [m for m in modes if m[SZ_CAN_BE_TEMPORARY]]
# These modes are set for a number of hours (or indefinitely): use this schema
temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == "Duration"]
if temp_modes: # any of: "AutoWithEco", permanent or for 0-24 hours
schema = vol.Schema(
{
vol.Required(ATTR_SYSTEM_MODE): vol.In(temp_modes),
vol.Optional(ATTR_DURATION_HOURS): vol.All(
cv.time_period,
vol.Range(min=timedelta(hours=0), max=timedelta(hours=24)),
),
}
)
system_mode_schemas.append(schema)
# These modes are set for a number of days (or indefinitely): use this schema
temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == "Period"]
if temp_modes: # any of: "Away", "Custom", "DayOff", permanent or for 1-99 days
schema = vol.Schema(
{
vol.Required(ATTR_SYSTEM_MODE): vol.In(temp_modes),
vol.Optional(ATTR_DURATION_DAYS): vol.All(
cv.time_period,
vol.Range(min=timedelta(days=1), max=timedelta(days=99)),
),
}
)
system_mode_schemas.append(schema)
if system_mode_schemas:
hass.services.async_register(
DOMAIN,
EvoService.SET_SYSTEM_MODE,
set_system_mode,
schema=vol.Schema(vol.Any(*system_mode_schemas)),
)
# The zone modes are consistent across all systems and use the same schema
hass.services.async_register(
DOMAIN,
EvoService.RESET_ZONE_OVERRIDE,
set_zone_override,
schema=RESET_ZONE_OVERRIDE_SCHEMA,
)
hass.services.async_register(
DOMAIN,
EvoService.SET_ZONE_OVERRIDE,
set_zone_override,
schema=SET_ZONE_OVERRIDE_SCHEMA,
)