Skip to content

Commit 7fd807c

Browse files
committed
Split the evohome client from the heating logic
evohome.py mixed two concerns with different reasons to change: API plumbing (token manager, session lifecycle, retries, schedule caching) and heating policy (switchpoint math, grace periods, override detection, mode decisions). Move the plumbing into a new evohome_client module, mirroring how presence/weather sit on top of the homeassistant module. The policy module now operates purely on fetched objects, with set_system_mode() as its only path back to the API. https://claude.ai/code/session_01RcVkTvidSn9HgsFazrfdZW
1 parent 774426e commit 7fd807c

7 files changed

Lines changed: 331 additions & 315 deletions

File tree

src/evohome_helper/evohome.py

Lines changed: 5 additions & 146 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,14 @@
1-
import aiohttp
2-
import asyncio
3-
import json
41
import logging
52
import settings
63

74
from datetime import datetime, timedelta
8-
from evohomeasync2 import EvohomeClient, ControlSystem, Location, Zone
9-
from evohomeasync2.auth import AbstractTokenManager
10-
from evohomeasync2.exceptions import ApiRequestFailedError, AuthenticationFailedError, BadUserCredentialsError
5+
from evohomeasync2 import ControlSystem, Location, Zone
116
from evohomeasync2.schemas import SystemMode, ZoneMode
7+
from evohome_helper import evohome_client
128
from evohome_helper import weather
13-
from tenacity import retry, retry_if_exception_type, retry_if_not_exception_type, stop_after_attempt, wait_exponential
149
from typing import Generator
1510

1611
logger = logging.getLogger(__name__)
17-
_evohome_client: EvohomeClient | None = None
18-
_websession: aiohttp.ClientSession | None = None
19-
20-
# only retry errors that can resolve on their own; invalid credentials never will
21-
_TRANSIENT_ERRORS = (
22-
aiohttp.ClientError,
23-
TimeoutError,
24-
ApiRequestFailedError,
25-
AuthenticationFailedError,
26-
)
27-
_retry = retry(
28-
retry=retry_if_exception_type(_TRANSIENT_ERRORS) & retry_if_not_exception_type(BadUserCredentialsError),
29-
wait=wait_exponential(),
30-
stop=stop_after_attempt(6),
31-
reraise=True,
32-
)
33-
34-
# schedules rarely change; refetching them every cycle wastes the vendor's tight API rate limit
35-
_SCHEDULE_REFRESH_INTERVAL = timedelta(hours=1)
36-
_schedule_refresh_times: dict[str, datetime] = {}
3712

3813
_AWAY_MODE_MAP = {
3914
"auto": SystemMode.AUTO,
@@ -47,121 +22,10 @@
4722
_DAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
4823

4924

50-
class LocationNotFound(Exception):
51-
def __init__(self, location_name: str):
52-
super().__init__(f"the location '{location_name}' does not exist in the evohome account")
53-
54-
55-
class _TokenManager(AbstractTokenManager):
56-
"""Caches auth tokens on disk so restarts reuse them instead of
57-
re-authenticating against the heavily rate-limited vendor API."""
58-
59-
def __init__(self, username: str, password: str, websession: aiohttp.ClientSession, token_cache_path: str):
60-
super().__init__(username, password, websession)
61-
self._token_cache_path = token_cache_path
62-
63-
async def load_access_token(self) -> None:
64-
try:
65-
with open(self._token_cache_path) as f:
66-
self._import_access_token(json.load(f))
67-
except FileNotFoundError:
68-
pass
69-
except (KeyError, ValueError):
70-
logger.warning("ignoring the invalid token cache at '%s'", self._token_cache_path)
71-
72-
async def save_access_token(self) -> None:
73-
with open(self._token_cache_path, "w") as f:
74-
json.dump(self._export_access_token(), f)
75-
76-
77-
async def close() -> None:
78-
global _evohome_client, _websession
79-
80-
_schedule_refresh_times.clear()
81-
_evohome_client = None
82-
83-
if _websession is not None:
84-
await _websession.close()
85-
_websession = None
86-
87-
88-
# discard the cached client so the next call recreates it from scratch
89-
reset_client = close
90-
91-
92-
@_retry
93-
async def _client() -> EvohomeClient:
94-
global _evohome_client, _websession
95-
96-
if _evohome_client is None:
97-
websession = aiohttp.ClientSession()
98-
try:
99-
token_manager = _TokenManager(
100-
settings.EVOHOME_USERNAME,
101-
settings.EVOHOME_PASSWORD,
102-
websession,
103-
settings.EVOHOME_TOKEN_CACHE_PATH,
104-
)
105-
await token_manager.load_access_token()
106-
107-
new_client = EvohomeClient(token_manager)
108-
await new_client.update(dont_update_status=True)
109-
except BaseException:
110-
await websession.close()
111-
raise
112-
113-
_websession = websession
114-
_evohome_client = new_client
115-
116-
return _evohome_client
117-
118-
11925
def get_current_time(location: Location) -> datetime:
12026
return location.now().replace(microsecond=0)
12127

12228

123-
def get_control_systems(location: Location) -> Generator[ControlSystem, None, None]:
124-
for gateway in location.gateways:
125-
for control_system in gateway.systems:
126-
yield control_system
127-
128-
129-
@_retry
130-
async def _update_location(location: Location) -> None:
131-
await location.update()
132-
133-
134-
@_retry
135-
async def _fetch_schedules(system: ControlSystem) -> None:
136-
await system.get_schedules()
137-
_schedule_refresh_times[system.id] = datetime.now()
138-
139-
140-
def _schedules_need_refresh(system: ControlSystem) -> bool:
141-
last_refresh = _schedule_refresh_times.get(system.id)
142-
return last_refresh is None or datetime.now() - last_refresh >= _SCHEDULE_REFRESH_INTERVAL
143-
144-
145-
async def get_location(location_name: str | None = None) -> Location:
146-
if not location_name:
147-
location_name = settings.EVOHOME_LOCATION_NAME
148-
149-
client = await _client()
150-
for location in client.locations:
151-
if location.name == location_name:
152-
await _update_location(location)
153-
await asyncio.gather(
154-
*[
155-
_fetch_schedules(system)
156-
for system in get_control_systems(location)
157-
if _schedules_need_refresh(system)
158-
],
159-
)
160-
return location
161-
162-
raise LocationNotFound(location_name)
163-
164-
16529
def _switchpoint_to_datetime(day_of_week: str, time_of_day: str, now: datetime) -> datetime:
16630
target_weekday = _DAY_NAMES.index(day_of_week)
16731
days_ago = (now.weekday() - target_weekday) % 7
@@ -230,7 +94,7 @@ def is_in_schedule_grace_period(location: Location) -> bool:
23094

23195

23296
def get_zones(location: Location) -> Generator[Zone, None, None]:
233-
for control_system in get_control_systems(location):
97+
for control_system in evohome_client.get_control_systems(location):
23498
for zone in control_system.zones:
23599
if zone.active_faults:
236100
continue
@@ -298,13 +162,8 @@ def _get_highest_set_point_temp(location: Location) -> float | None:
298162
return max(valid_setpoints, default=None)
299163

300164

301-
@_retry
302-
async def _set_control_system_mode(control_system: ControlSystem, new_mode: SystemMode) -> None:
303-
await control_system.set_mode(new_mode)
304-
305-
306165
async def _set_mode(new_mode: SystemMode, location: Location) -> None:
307-
for control_system in get_control_systems(location):
166+
for control_system in evohome_client.get_control_systems(location):
308167
current_mode = control_system.mode
309168
if new_mode == current_mode:
310169
continue
@@ -314,7 +173,7 @@ async def _set_mode(new_mode: SystemMode, location: Location) -> None:
314173
continue
315174

316175
logger.debug("changing thermostat (%s) mode to '%s'", control_system.id, new_mode)
317-
await _set_control_system_mode(control_system, new_mode)
176+
await evohome_client.set_system_mode(control_system, new_mode)
318177

319178

320179
async def set_normal(location: Location) -> None:
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import aiohttp
2+
import asyncio
3+
import json
4+
import logging
5+
import settings
6+
7+
from datetime import datetime, timedelta
8+
from evohomeasync2 import EvohomeClient, ControlSystem, Location
9+
from evohomeasync2.auth import AbstractTokenManager
10+
from evohomeasync2.exceptions import ApiRequestFailedError, AuthenticationFailedError, BadUserCredentialsError
11+
from evohomeasync2.schemas import SystemMode
12+
from tenacity import retry, retry_if_exception_type, retry_if_not_exception_type, stop_after_attempt, wait_exponential
13+
from typing import Generator
14+
15+
logger = logging.getLogger(__name__)
16+
_evohome_client: EvohomeClient | None = None
17+
_websession: aiohttp.ClientSession | None = None
18+
19+
# only retry errors that can resolve on their own; invalid credentials never will
20+
_TRANSIENT_ERRORS = (
21+
aiohttp.ClientError,
22+
TimeoutError,
23+
ApiRequestFailedError,
24+
AuthenticationFailedError,
25+
)
26+
_retry = retry(
27+
retry=retry_if_exception_type(_TRANSIENT_ERRORS) & retry_if_not_exception_type(BadUserCredentialsError),
28+
wait=wait_exponential(),
29+
stop=stop_after_attempt(6),
30+
reraise=True,
31+
)
32+
33+
# schedules rarely change; refetching them every cycle wastes the vendor's tight API rate limit
34+
_SCHEDULE_REFRESH_INTERVAL = timedelta(hours=1)
35+
_schedule_refresh_times: dict[str, datetime] = {}
36+
37+
38+
class LocationNotFound(Exception):
39+
def __init__(self, location_name: str):
40+
super().__init__(f"the location '{location_name}' does not exist in the evohome account")
41+
42+
43+
class _TokenManager(AbstractTokenManager):
44+
"""Caches auth tokens on disk so restarts reuse them instead of
45+
re-authenticating against the heavily rate-limited vendor API."""
46+
47+
def __init__(self, username: str, password: str, websession: aiohttp.ClientSession, token_cache_path: str):
48+
super().__init__(username, password, websession)
49+
self._token_cache_path = token_cache_path
50+
51+
async def load_access_token(self) -> None:
52+
try:
53+
with open(self._token_cache_path) as f:
54+
self._import_access_token(json.load(f))
55+
except FileNotFoundError:
56+
pass
57+
except (KeyError, ValueError):
58+
logger.warning("ignoring the invalid token cache at '%s'", self._token_cache_path)
59+
60+
async def save_access_token(self) -> None:
61+
with open(self._token_cache_path, "w") as f:
62+
json.dump(self._export_access_token(), f)
63+
64+
65+
async def close() -> None:
66+
global _evohome_client, _websession
67+
68+
_schedule_refresh_times.clear()
69+
_evohome_client = None
70+
71+
if _websession is not None:
72+
await _websession.close()
73+
_websession = None
74+
75+
76+
# discard the cached client so the next call recreates it from scratch
77+
reset_client = close
78+
79+
80+
@_retry
81+
async def _client() -> EvohomeClient:
82+
global _evohome_client, _websession
83+
84+
if _evohome_client is None:
85+
websession = aiohttp.ClientSession()
86+
try:
87+
token_manager = _TokenManager(
88+
settings.EVOHOME_USERNAME,
89+
settings.EVOHOME_PASSWORD,
90+
websession,
91+
settings.EVOHOME_TOKEN_CACHE_PATH,
92+
)
93+
await token_manager.load_access_token()
94+
95+
new_client = EvohomeClient(token_manager)
96+
await new_client.update(dont_update_status=True)
97+
except BaseException:
98+
await websession.close()
99+
raise
100+
101+
_websession = websession
102+
_evohome_client = new_client
103+
104+
return _evohome_client
105+
106+
107+
def get_control_systems(location: Location) -> Generator[ControlSystem, None, None]:
108+
for gateway in location.gateways:
109+
for control_system in gateway.systems:
110+
yield control_system
111+
112+
113+
async def get_location(location_name: str | None = None) -> Location:
114+
if not location_name:
115+
location_name = settings.EVOHOME_LOCATION_NAME
116+
117+
client = await _client()
118+
for location in client.locations:
119+
if location.name == location_name:
120+
await _update_location(location)
121+
await asyncio.gather(
122+
*[
123+
_fetch_schedules(system)
124+
for system in get_control_systems(location)
125+
if _schedules_need_refresh(system)
126+
],
127+
)
128+
return location
129+
130+
raise LocationNotFound(location_name)
131+
132+
133+
@_retry
134+
async def set_system_mode(control_system: ControlSystem, new_mode: SystemMode) -> None:
135+
await control_system.set_mode(new_mode)
136+
137+
138+
@_retry
139+
async def _update_location(location: Location) -> None:
140+
await location.update()
141+
142+
143+
@_retry
144+
async def _fetch_schedules(system: ControlSystem) -> None:
145+
await system.get_schedules()
146+
_schedule_refresh_times[system.id] = datetime.now()
147+
148+
149+
def _schedules_need_refresh(system: ControlSystem) -> bool:
150+
last_refresh = _schedule_refresh_times.get(system.id)
151+
return last_refresh is None or datetime.now() - last_refresh >= _SCHEDULE_REFRESH_INTERVAL

src/main.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from logging import config as log_config
1010

1111
from evohome_helper import evohome
12+
from evohome_helper import evohome_client
1213
from evohome_helper import homeassistant
1314
from evohome_helper import presence
1415

@@ -19,7 +20,7 @@
1920

2021

2122
async def determine_and_set_thermostat_mode() -> None:
22-
location = await evohome.get_location()
23+
location = await evohome_client.get_location()
2324

2425
zones = evohome.get_zones(location)
2526
for zone in zones:
@@ -68,7 +69,7 @@ async def main() -> None:
6869

6970
if consecutive_failures >= CONSECUTIVE_FAILURES_BEFORE_RESET:
7071
logger.warning("resetting the evohome client after %d consecutive failures", consecutive_failures)
71-
await evohome.reset_client()
72+
await evohome_client.reset_client()
7273
consecutive_failures = 0
7374

7475
try:
@@ -82,7 +83,7 @@ async def main() -> None:
8283
loop.remove_signal_handler(shutdown_signal)
8384

8485
await homeassistant.close()
85-
await evohome.close()
86+
await evohome_client.close()
8687

8788

8889
if __name__ == "__main__":

0 commit comments

Comments
 (0)