Skip to content

Commit 8273214

Browse files
committed
Refactored evohome, weather and presence clients and added caching for evohome schedules
1 parent fa8526f commit 8273214

14 files changed

Lines changed: 521 additions & 184 deletions

src/evohome_helper/evohome.py

Lines changed: 6 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,14 @@
1-
import asyncio
21
import logging
32
import settings
43

54
from datetime import datetime, timedelta
6-
from evohomeasync2 import EvohomeClientOld as EvohomeClient, ControlSystem, Location, Zone
5+
from evohomeasync2 import ControlSystem, Location, Zone
76
from evohomeasync2.schemas import SystemMode, ZoneMode
7+
from evohome_helper import evohome_client
88
from evohome_helper import weather
9-
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
109
from typing import Generator
1110

1211
logger = logging.getLogger(__name__)
13-
_evohome_client = None
14-
_retry = retry(
15-
retry=retry_if_exception_type(Exception),
16-
wait=wait_exponential(),
17-
stop=stop_after_attempt(6),
18-
reraise=True,
19-
)
2012

2113
_AWAY_MODE_MAP = {
2214
"auto": SystemMode.AUTO,
@@ -30,62 +22,10 @@
3022
_DAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
3123

3224

33-
class LocationNotFound(Exception):
34-
def __init__(self, location_name: str):
35-
super().__init__(f"the location '{location_name}' does not exist in the evohome account")
36-
37-
38-
@_retry
39-
async def _client() -> EvohomeClient:
40-
global _evohome_client
41-
42-
if _evohome_client is None:
43-
new_client = EvohomeClient(
44-
settings.EVOHOME_USERNAME,
45-
settings.EVOHOME_PASSWORD,
46-
)
47-
await new_client.update(dont_update_status=True)
48-
_evohome_client = new_client
49-
50-
return _evohome_client
51-
52-
5325
def get_current_time(location: Location) -> datetime:
5426
return location.now().replace(microsecond=0)
5527

5628

57-
def get_control_systems(location: Location) -> Generator[ControlSystem, None, None]:
58-
for gateway in location.gateways:
59-
for control_system in gateway.systems:
60-
yield control_system
61-
62-
63-
@_retry
64-
async def _update_location(location: Location) -> None:
65-
await location.update()
66-
67-
68-
@_retry
69-
async def _fetch_schedules(system: ControlSystem) -> None:
70-
await system.get_schedules()
71-
72-
73-
async def get_location(location_name: str = None) -> Location:
74-
if not location_name:
75-
location_name = settings.EVOHOME_LOCATION_NAME
76-
77-
client = await _client()
78-
for location in client.locations:
79-
if location.name == location_name:
80-
await _update_location(location)
81-
await asyncio.gather(
82-
*[_fetch_schedules(system) for system in get_control_systems(location)],
83-
)
84-
return location
85-
86-
raise LocationNotFound(location_name)
87-
88-
8929
def _switchpoint_to_datetime(day_of_week: str, time_of_day: str, now: datetime) -> datetime:
9030
target_weekday = _DAY_NAMES.index(day_of_week)
9131
days_ago = (now.weekday() - target_weekday) % 7
@@ -154,7 +94,7 @@ def is_in_schedule_grace_period(location: Location) -> bool:
15494

15595

15696
def get_zones(location: Location) -> Generator[Zone, None, None]:
157-
for control_system in get_control_systems(location):
97+
for control_system in evohome_client.get_control_systems(location):
15898
for zone in control_system.zones:
15999
if zone.active_faults:
160100
continue
@@ -197,7 +137,7 @@ async def _is_normal_heating_needed(location: Location) -> bool:
197137

198138
# can we fetch a valid temperature?
199139
outside_current_temp = await weather.get_current_temperature()
200-
if outside_current_temp <= -99:
140+
if outside_current_temp is None:
201141
return True
202142

203143
logger.debug(
@@ -222,13 +162,8 @@ def _get_highest_set_point_temp(location: Location) -> float | None:
222162
return max(valid_setpoints, default=None)
223163

224164

225-
@_retry
226-
async def _set_control_system_mode(control_system: ControlSystem, new_mode: SystemMode) -> None:
227-
await control_system.set_mode(new_mode)
228-
229-
230165
async def _set_mode(new_mode: SystemMode, location: Location) -> None:
231-
for control_system in get_control_systems(location):
166+
for control_system in evohome_client.get_control_systems(location):
232167
current_mode = control_system.mode
233168
if new_mode == current_mode:
234169
continue
@@ -238,7 +173,7 @@ async def _set_mode(new_mode: SystemMode, location: Location) -> None:
238173
continue
239174

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

243178

244179
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
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import aiohttp
2+
import logging
3+
import settings
4+
5+
logger = logging.getLogger(__name__)
6+
_session: aiohttp.ClientSession | None = None
7+
8+
9+
async def get_entity_state(entity_id: str) -> dict | None:
10+
url = f"{settings.HOMEASSISTANT_URL}/api/states/{entity_id}"
11+
12+
try:
13+
async with _get_session().get(url, headers=_headers()) as response:
14+
response.raise_for_status()
15+
return await response.json()
16+
except Exception:
17+
logger.exception("failed getting the state of entity '%s'", entity_id)
18+
return None
19+
20+
21+
async def close() -> None:
22+
global _session
23+
24+
if _session is not None:
25+
await _session.close()
26+
_session = None
27+
28+
29+
def _get_session() -> aiohttp.ClientSession:
30+
global _session
31+
32+
if _session is None or _session.closed:
33+
_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=5))
34+
35+
return _session
36+
37+
38+
def _headers() -> dict:
39+
return {
40+
"Authorization": f"Bearer {settings.HOMEASSISTANT_TOKEN}",
41+
"content-type": "application/json",
42+
}

src/evohome_helper/presence.py

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import aiohttp
21
import logging
32
import settings
43

4+
from evohome_helper import homeassistant
5+
56
logger = logging.getLogger(__name__)
67
last_known_presence_state = {}
78

@@ -26,30 +27,16 @@ async def is_in_away_grace_period() -> bool:
2627

2728

2829
async def _get_data(entity_id: str) -> dict:
29-
url = f"{settings.HOMEASSISTANT_URL}/api/states/{entity_id}"
30-
31-
try:
32-
async with aiohttp.ClientSession() as session:
33-
async with session.get(url, headers=_headers(), timeout=aiohttp.ClientTimeout(total=5)) as response:
34-
response.raise_for_status()
35-
response_data = await response.json()
36-
attributes = response_data.get("attributes", {})
37-
38-
last_known_presence_state[entity_id] = {
39-
"is_someone_home": response_data.get("state") == "home",
40-
"seconds_since_last_seen": attributes.get("seconds_since_last_seen"),
41-
}
42-
except Exception:
43-
logger.exception("failed getting presence information")
30+
entity_state = await homeassistant.get_entity_state(entity_id)
31+
if entity_state is not None:
32+
attributes = entity_state.get("attributes", {})
33+
34+
last_known_presence_state[entity_id] = {
35+
"is_someone_home": entity_state.get("state") == "home",
36+
"seconds_since_last_seen": attributes.get("seconds_since_last_seen"),
37+
}
4438

4539
return last_known_presence_state.setdefault(entity_id, {
4640
"is_someone_home": False,
4741
"seconds_since_last_seen": 0,
4842
})
49-
50-
51-
def _headers() -> dict:
52-
return {
53-
"Authorization": f"Bearer {settings.HOMEASSISTANT_TOKEN}",
54-
"content-type": "application/json",
55-
}

0 commit comments

Comments
 (0)