Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor evohome for major bump of client to 1.0.2 #135436

Merged
merged 46 commits into from
Feb 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
f98827b
bump client to 1.2.0
zxdavb Dec 2, 2024
caa5061
working test_init
zxdavb Jan 7, 2025
04f8570
update fixtures to be compliant with new schema
zxdavb Jan 11, 2025
8b17013
test_storage is now working
zxdavb Jan 11, 2025
7492d96
all tests passing
zxdavb Jan 12, 2025
a2d3f30
bump client to 1.0.1b0
zxdavb Jan 26, 2025
b8bdb0e
test commit (working tests)
zxdavb Jan 26, 2025
dd42694
use only id (not e.g. zoneId), use StrEnums
zxdavb Jan 26, 2025
d6a0432
mypy, lint
zxdavb Jan 26, 2025
3f42f89
remove deprecated module
zxdavb Jan 26, 2025
722327a
remove waffle
zxdavb Jan 28, 2025
599dffa
improve typing of asserts
zxdavb Jan 29, 2025
22735ad
broker is now coordinator
zxdavb Jan 30, 2025
33a77a5
WIP - test failing
zxdavb Jan 31, 2025
7f03c93
rename class
zxdavb Feb 1, 2025
03c6f9b
remove unneeded async_dispatcher_send()
zxdavb Feb 1, 2025
5223a25
restore missing code
zxdavb Feb 1, 2025
036076a
harden test
zxdavb Feb 2, 2025
2b3ba3c
bugfix failing test
zxdavb Feb 2, 2025
5b4736b
don't capture blind except
zxdavb Feb 2, 2025
fce873e
shrink log messages
zxdavb Feb 2, 2025
3ca1b9d
doctweak
zxdavb Feb 2, 2025
c8cb59f
rationalize asserts
zxdavb Feb 2, 2025
c582edd
remove unneeded listerner
zxdavb Feb 2, 2025
b6deedf
refactor setup
zxdavb Feb 2, 2025
8f89fa0
bump client to 1.0.2b0
zxdavb Feb 2, 2025
4aeddf0
bump client to 1.0.2b1
zxdavb Feb 2, 2025
ebcdc5b
refactor extended state attrs
zxdavb Feb 2, 2025
96f7146
pass UpdateFailed to _async_refresh()
zxdavb Feb 3, 2025
bd7e70a
Update homeassistant/components/evohome/entity.py
zxdavb Feb 3, 2025
75532b8
Update homeassistant/components/evohome/entity.py
zxdavb Feb 3, 2025
00b08fb
not even lint
zxdavb Feb 3, 2025
7689166
undo not even lint
zxdavb Feb 3, 2025
f79ba65
remove unused logger
zxdavb Feb 3, 2025
cf80924
restore old namespace for e_s_a
zxdavb Feb 3, 2025
f387328
minimize diff
zxdavb Feb 3, 2025
0645f93
doctweak
zxdavb Feb 3, 2025
5d22643
remove unused method
zxdavb Feb 3, 2025
559649b
lint
zxdavb Feb 4, 2025
de73ade
DUC now working
zxdavb Feb 4, 2025
c0e4f78
restore old camelCase keynames
zxdavb Feb 5, 2025
e803f58
tweak
zxdavb Feb 5, 2025
5fd367c
small tweak to _handle_coordinator_update()
zxdavb Feb 5, 2025
4c9487f
Update homeassistant/components/evohome/coordinator.py
zxdavb Feb 5, 2025
1f59c41
add test of coordinator
zxdavb Feb 7, 2025
6c232bc
bump client to 1.0.2
zxdavb Feb 7, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 64 additions & 170 deletions homeassistant/components/evohome/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,24 @@

Such systems provide heating/cooling and DHW and include Evohome, Round Thermostat, and
others.

Note that the API used by this integration's client does not support cooling.
"""

from __future__ import annotations

from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import Any, Final

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,
from typing import Final

import evohomeasync as ec1
import evohomeasync2 as ec2
from evohomeasync2.const import SZ_CAN_BE_TEMPORARY, SZ_SYSTEM_MODE, SZ_TIMING_MODE
from evohomeasync2.schemas.const import (
S2_DURATION as SZ_DURATION,
S2_PERIOD as SZ_PERIOD,
SystemMode as EvoSystemMode,
)
import voluptuous as vol

Expand All @@ -34,31 +36,23 @@
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.service import verify_domain_control
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
from homeassistant.util.hass_dict import HassKey

from .const import (
ACCESS_TOKEN,
ACCESS_TOKEN_EXPIRES,
ATTR_DURATION_DAYS,
ATTR_DURATION_HOURS,
ATTR_DURATION_UNTIL,
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
from .coordinator import EvoDataUpdateCoordinator
from .storage import TokenManager

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -96,177 +90,69 @@
}
)

EVOHOME_KEY: HassKey[EvoData] = HassKey(DOMAIN)

class EvoSession:
"""Class for evohome client instantiation & authentication."""

def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the evohome broker and its data structure."""

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
@dataclass
class EvoData:
"""Dataclass for storing evohome data."""

# evohomeasync2 requires naive/local datetimes as strings
if app_storage.get(ACCESS_TOKEN_EXPIRES) is not None and (
expires := dt_util.parse_datetime(app_storage[ACCESS_TOKEN_EXPIRES])
):
app_storage[ACCESS_TOKEN_EXPIRES] = dt_aware_to_naive(expires)

user_data: dict[str, str] = app_storage.pop(USER_DATA, {}) or {}

self.session_id = user_data.get(SZ_SESSION_ID)
self._tokens = app_storage

async def save_auth_tokens(self) -> None:
"""Save access tokens and session_id to the store.

Sets self._tokens and self._session_id to the latest values.
"""

if self.client_v2 is None:
await self._store.async_save({})
return

# evohomeasync2 uses naive/local datetimes
access_token_expires = dt_local_to_aware(
self.client_v2.access_token_expires # type: ignore[arg-type]
)

self._tokens = {
CONF_USERNAME: self.client_v2.username,
REFRESH_TOKEN: self.client_v2.refresh_token,
ACCESS_TOKEN: self.client_v2.access_token,
ACCESS_TOKEN_EXPIRES: access_token_expires.isoformat(),
}

self.session_id = self.client_v1.broker.session_id if self.client_v1 else None

app_storage = self._tokens
if self.client_v1:
app_storage[USER_DATA] = {SZ_SESSION_ID: self.session_id}

await self._store.async_save(app_storage)
coordinator: EvoDataUpdateCoordinator
loc_idx: int
tcs: ec2.ControlSystem


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Evohome integration."""

sess = EvoSession(hass)

try:
await sess.authenticate(
config[DOMAIN][CONF_USERNAME],
config[DOMAIN][CONF_PASSWORD],
)

except (evo.AuthenticationFailed, evo.RequestFailed) as err:
handle_evo_exception(err)
return False

finally:
config[DOMAIN][CONF_PASSWORD] = "REDACTED"

broker = EvoBroker(sess)

if not broker.validate_location(
config[DOMAIN][CONF_LOCATION_IDX],
):
return False

coordinator = DataUpdateCoordinator(
token_manager = TokenManager(
hass,
config[DOMAIN][CONF_USERNAME],
config[DOMAIN][CONF_PASSWORD],
async_get_clientsession(hass),
)
coordinator = EvoDataUpdateCoordinator(
hass,
_LOGGER,
config_entry=None,
ec2.EvohomeClient(token_manager),
name=f"{DOMAIN}_coordinator",
update_interval=config[DOMAIN][CONF_SCAN_INTERVAL],
update_method=broker.async_update,
location_idx=config[DOMAIN][CONF_LOCATION_IDX],
client_v1=ec1.EvohomeClient(token_manager),
)

await coordinator.async_register_shutdown()
await coordinator.async_first_refresh()

hass.data[DOMAIN] = {"broker": broker, "coordinator": coordinator}
if not coordinator.last_update_success:
_LOGGER.error(f"Failed to fetch initial data: {coordinator.last_exception}") # noqa: G004
return False

assert coordinator.tcs is not None # mypy

# 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.data[EVOHOME_KEY] = EvoData(
coordinator=coordinator,
loc_idx=coordinator.loc_idx,
tcs=coordinator.tcs,
)

hass.async_create_task(
async_load_platform(hass, Platform.CLIMATE, DOMAIN, {}, config)
)
if broker.tcs.hotwater:
if coordinator.tcs.hotwater:
hass.async_create_task(
async_load_platform(hass, Platform.WATER_HEATER, DOMAIN, {}, config)
)

setup_service_functions(hass, broker)
setup_service_functions(hass, coordinator)

return True


@callback
def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None:
def setup_service_functions(
hass: HomeAssistant, coordinator: EvoDataUpdateCoordinator
) -> None:
"""Set up the service handlers for the system/zone operating modes.

Not all Honeywell TCC-compatible systems support all operating modes. In addition,
Expand All @@ -279,13 +165,15 @@ def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None:
@verify_domain_control(hass, DOMAIN)
async def force_refresh(call: ServiceCall) -> None:
"""Obtain the latest state data via the vendor's RESTful API."""
await broker.async_update()
await coordinator.async_refresh()

@verify_domain_control(hass, DOMAIN)
async def set_system_mode(call: ServiceCall) -> None:
"""Set the system mode."""
assert coordinator.tcs is not None # mypy

payload = {
"unique_id": broker.tcs.systemId,
"unique_id": coordinator.tcs.id,
"service": call.service,
"data": call.data,
}
Expand Down Expand Up @@ -313,17 +201,23 @@ async def set_zone_override(call: ServiceCall) -> None:

async_dispatcher_send(hass, DOMAIN, payload)

assert coordinator.tcs is not None # mypy

hass.services.async_register(DOMAIN, EvoService.REFRESH_SYSTEM, force_refresh)

# Enumerate which operating modes are supported by this system
modes = broker.tcs.allowedSystemModes
modes = list(coordinator.tcs.allowed_system_modes)

# Not all systems support "AutoWithReset": register this handler only if required
if [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_SYSTEM_MODE] == SZ_AUTO_WITH_RESET]:
if any(
m[SZ_SYSTEM_MODE]
for m in modes
if m[SZ_SYSTEM_MODE] == EvoSystemMode.AUTO_WITH_RESET
):
hass.services.async_register(DOMAIN, EvoService.RESET_SYSTEM, set_system_mode)

system_mode_schemas = []
modes = [m for m in modes if m[SZ_SYSTEM_MODE] != SZ_AUTO_WITH_RESET]
modes = [m for m in modes if m[SZ_SYSTEM_MODE] != EvoSystemMode.AUTO_WITH_RESET]

# Permanent-only modes will use this schema
perm_modes = [m[SZ_SYSTEM_MODE] for m in modes if not m[SZ_CAN_BE_TEMPORARY]]
Expand All @@ -334,7 +228,7 @@ async def set_zone_override(call: ServiceCall) -> None:
modes = [m for m in modes if m[SZ_CAN_BE_TEMPORARY]]

# These modes are set for a number of hours (or indefinitely): use this schema
temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == "Duration"]
temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == SZ_DURATION]
if temp_modes: # any of: "AutoWithEco", permanent or for 0-24 hours
schema = vol.Schema(
{
Expand All @@ -348,7 +242,7 @@ async def set_zone_override(call: ServiceCall) -> None:
system_mode_schemas.append(schema)

# These modes are set for a number of days (or indefinitely): use this schema
temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == "Period"]
temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == SZ_PERIOD]
if temp_modes: # any of: "Away", "Custom", "DayOff", permanent or for 1-99 days
schema = vol.Schema(
{
Expand Down
Loading