Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Support for (EMEA/EU-based) Honeywell TCC systems.
2 
3 Such systems provide heating/cooling and DHW and include Evohome, Round Thermostat, and
4 others.
5 """
6 
7 from __future__ import annotations
8 
9 from datetime import timedelta
10 import logging
11 from typing import Any, Final
12 
13 import evohomeasync as ev1
14 from evohomeasync.schema import SZ_SESSION_ID
15 import evohomeasync2 as evo
16 from evohomeasync2.schema.const import (
17  SZ_AUTO_WITH_RESET,
18  SZ_CAN_BE_TEMPORARY,
19  SZ_SYSTEM_MODE,
20  SZ_TIMING_MODE,
21 )
22 import voluptuous as vol
23 
24 from homeassistant.const import (
25  ATTR_ENTITY_ID,
26  CONF_PASSWORD,
27  CONF_SCAN_INTERVAL,
28  CONF_USERNAME,
29  Platform,
30 )
31 from homeassistant.core import HomeAssistant, ServiceCall, callback
32 from homeassistant.helpers import entity_registry as er
33 from homeassistant.helpers.aiohttp_client import async_get_clientsession
35 from homeassistant.helpers.discovery import async_load_platform
36 from homeassistant.helpers.dispatcher import async_dispatcher_send
37 from homeassistant.helpers.service import verify_domain_control
38 from homeassistant.helpers.storage import Store
39 from homeassistant.helpers.typing import ConfigType
40 from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
41 import homeassistant.util.dt as dt_util
42 
43 from .const import (
44  ACCESS_TOKEN,
45  ACCESS_TOKEN_EXPIRES,
46  ATTR_DURATION_DAYS,
47  ATTR_DURATION_HOURS,
48  ATTR_DURATION_UNTIL,
49  ATTR_SYSTEM_MODE,
50  ATTR_ZONE_TEMP,
51  CONF_LOCATION_IDX,
52  DOMAIN,
53  REFRESH_TOKEN,
54  SCAN_INTERVAL_DEFAULT,
55  SCAN_INTERVAL_MINIMUM,
56  STORAGE_KEY,
57  STORAGE_VER,
58  USER_DATA,
59  EvoService,
60 )
61 from .coordinator import EvoBroker
62 from .helpers import dt_aware_to_naive, dt_local_to_aware, handle_evo_exception
63 
64 _LOGGER = logging.getLogger(__name__)
65 
66 CONFIG_SCHEMA: Final = vol.Schema(
67  {
68  DOMAIN: vol.Schema(
69  {
70  vol.Required(CONF_USERNAME): cv.string,
71  vol.Required(CONF_PASSWORD): cv.string,
72  vol.Optional(CONF_LOCATION_IDX, default=0): cv.positive_int,
73  vol.Optional(
74  CONF_SCAN_INTERVAL, default=SCAN_INTERVAL_DEFAULT
75  ): vol.All(cv.time_period, vol.Range(min=SCAN_INTERVAL_MINIMUM)),
76  }
77  )
78  },
79  extra=vol.ALLOW_EXTRA,
80 )
81 
82 # system mode schemas are built dynamically when the services are registered
83 # because supported modes can vary for edge-case systems
84 
85 RESET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema(
86  {vol.Required(ATTR_ENTITY_ID): cv.entity_id}
87 )
88 SET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema(
89  {
90  vol.Required(ATTR_ENTITY_ID): cv.entity_id,
91  vol.Required(ATTR_ZONE_TEMP): vol.All(
92  vol.Coerce(float), vol.Range(min=4.0, max=35.0)
93  ),
94  vol.Optional(ATTR_DURATION_UNTIL): vol.All(
95  cv.time_period, vol.Range(min=timedelta(days=0), max=timedelta(days=1))
96  ),
97  }
98 )
99 
100 
102  """Class for evohome client instantiation & authentication."""
103 
104  def __init__(self, hass: HomeAssistant) -> None:
105  """Initialize the evohome broker and its data structure."""
106 
107  self.hasshass = hass
108 
109  self._session_session = async_get_clientsession(hass)
110  self._store_store = Store[dict[str, Any]](hass, STORAGE_VER, STORAGE_KEY)
111 
112  # the main client, which uses the newer API
113  self.client_v2client_v2: evo.EvohomeClient | None = None
114  self._tokens_tokens: dict[str, Any] = {}
115 
116  # the older client can be used to obtain high-precision temps (only)
117  self.client_v1client_v1: ev1.EvohomeClient | None = None
118  self.session_idsession_id: str | None = None
119 
120  async def authenticate(self, username: str, password: str) -> None:
121  """Check the user credentials against the web API.
122 
123  Will raise evo.AuthenticationFailed if the credentials are invalid.
124  """
125 
126  if (
127  self.client_v2client_v2 is None
128  or username != self.client_v2client_v2.username
129  or password != self.client_v2client_v2.password
130  ):
131  await self._load_auth_tokens_load_auth_tokens(username)
132 
133  client_v2 = evo.EvohomeClient(
134  username,
135  password,
136  **self._tokens_tokens,
137  session=self._session_session,
138  )
139 
140  else: # force a re-authentication
141  client_v2 = self.client_v2client_v2
142  client_v2._user_account = None # noqa: SLF001
143 
144  await client_v2.login()
145  self.client_v2client_v2 = client_v2 # only set attr if authentication succeeded
146 
147  await self.save_auth_tokenssave_auth_tokens()
148 
149  self.client_v1client_v1 = ev1.EvohomeClient(
150  username,
151  password,
152  session_id=self.session_idsession_id,
153  session=self._session_session,
154  )
155 
156  async def _load_auth_tokens(self, username: str) -> None:
157  """Load access tokens and session_id from the store and validate them.
158 
159  Sets self._tokens and self._session_id to the latest values.
160  """
161 
162  app_storage: dict[str, Any] = dict(await self._store_store.async_load() or {})
163 
164  if app_storage.pop(CONF_USERNAME, None) != username:
165  # any tokens won't be valid, and store might be corrupt
166  await self._store_store.async_save({})
167 
168  self.session_idsession_id = None
169  self._tokens_tokens = {}
170 
171  return
172 
173  # evohomeasync2 requires naive/local datetimes as strings
174  if app_storage.get(ACCESS_TOKEN_EXPIRES) is not None and (
175  expires := dt_util.parse_datetime(app_storage[ACCESS_TOKEN_EXPIRES])
176  ):
177  app_storage[ACCESS_TOKEN_EXPIRES] = dt_aware_to_naive(expires)
178 
179  user_data: dict[str, str] = app_storage.pop(USER_DATA, {}) or {}
180 
181  self.session_idsession_id = user_data.get(SZ_SESSION_ID)
182  self._tokens_tokens = app_storage
183 
184  async def save_auth_tokens(self) -> None:
185  """Save access tokens and session_id to the store.
186 
187  Sets self._tokens and self._session_id to the latest values.
188  """
189 
190  if self.client_v2client_v2 is None:
191  await self._store_store.async_save({})
192  return
193 
194  # evohomeasync2 uses naive/local datetimes
195  access_token_expires = dt_local_to_aware(
196  self.client_v2client_v2.access_token_expires # type: ignore[arg-type]
197  )
198 
199  self._tokens_tokens = {
200  CONF_USERNAME: self.client_v2client_v2.username,
201  REFRESH_TOKEN: self.client_v2client_v2.refresh_token,
202  ACCESS_TOKEN: self.client_v2client_v2.access_token,
203  ACCESS_TOKEN_EXPIRES: access_token_expires.isoformat(),
204  }
205 
206  self.session_idsession_id = self.client_v1client_v1.broker.session_id if self.client_v1client_v1 else None
207 
208  app_storage = self._tokens_tokens
209  if self.client_v1client_v1:
210  app_storage[USER_DATA] = {SZ_SESSION_ID: self.session_idsession_id}
211 
212  await self._store_store.async_save(app_storage)
213 
214 
215 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
216  """Set up the Evohome integration."""
217 
218  sess = EvoSession(hass)
219 
220  try:
221  await sess.authenticate(
222  config[DOMAIN][CONF_USERNAME],
223  config[DOMAIN][CONF_PASSWORD],
224  )
225 
226  except (evo.AuthenticationFailed, evo.RequestFailed) as err:
228  return False
229 
230  finally:
231  config[DOMAIN][CONF_PASSWORD] = "REDACTED"
232 
233  broker = EvoBroker(sess)
234 
235  if not broker.validate_location(
236  config[DOMAIN][CONF_LOCATION_IDX],
237  ):
238  return False
239 
240  coordinator = DataUpdateCoordinator(
241  hass,
242  _LOGGER,
243  config_entry=None,
244  name=f"{DOMAIN}_coordinator",
245  update_interval=config[DOMAIN][CONF_SCAN_INTERVAL],
246  update_method=broker.async_update,
247  )
248  await coordinator.async_register_shutdown()
249 
250  hass.data[DOMAIN] = {"broker": broker, "coordinator": coordinator}
251 
252  # without a listener, _schedule_refresh() won't be invoked by _async_refresh()
253  coordinator.async_add_listener(lambda: None)
254  await coordinator.async_refresh() # get initial state
255 
256  hass.async_create_task(
257  async_load_platform(hass, Platform.CLIMATE, DOMAIN, {}, config)
258  )
259  if broker.tcs.hotwater:
260  hass.async_create_task(
261  async_load_platform(hass, Platform.WATER_HEATER, DOMAIN, {}, config)
262  )
263 
264  setup_service_functions(hass, broker)
265 
266  return True
267 
268 
269 @callback
270 def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None:
271  """Set up the service handlers for the system/zone operating modes.
272 
273  Not all Honeywell TCC-compatible systems support all operating modes. In addition,
274  each mode will require any of four distinct service schemas. This has to be
275  enumerated before registering the appropriate handlers.
276 
277  It appears that all TCC-compatible systems support the same three zones modes.
278  """
279 
280  @verify_domain_control(hass, DOMAIN)
281  async def force_refresh(call: ServiceCall) -> None:
282  """Obtain the latest state data via the vendor's RESTful API."""
283  await broker.async_update()
284 
285  @verify_domain_control(hass, DOMAIN)
286  async def set_system_mode(call: ServiceCall) -> None:
287  """Set the system mode."""
288  payload = {
289  "unique_id": broker.tcs.systemId,
290  "service": call.service,
291  "data": call.data,
292  }
293  async_dispatcher_send(hass, DOMAIN, payload)
294 
295  @verify_domain_control(hass, DOMAIN)
296  async def set_zone_override(call: ServiceCall) -> None:
297  """Set the zone override (setpoint)."""
298  entity_id = call.data[ATTR_ENTITY_ID]
299 
300  registry = er.async_get(hass)
301  registry_entry = registry.async_get(entity_id)
302 
303  if registry_entry is None or registry_entry.platform != DOMAIN:
304  raise ValueError(f"'{entity_id}' is not a known {DOMAIN} entity")
305 
306  if registry_entry.domain != "climate":
307  raise ValueError(f"'{entity_id}' is not an {DOMAIN} controller/zone")
308 
309  payload = {
310  "unique_id": registry_entry.unique_id,
311  "service": call.service,
312  "data": call.data,
313  }
314 
315  async_dispatcher_send(hass, DOMAIN, payload)
316 
317  hass.services.async_register(DOMAIN, EvoService.REFRESH_SYSTEM, force_refresh)
318 
319  # Enumerate which operating modes are supported by this system
320  modes = broker.tcs.allowedSystemModes
321 
322  # Not all systems support "AutoWithReset": register this handler only if required
323  if [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_SYSTEM_MODE] == SZ_AUTO_WITH_RESET]:
324  hass.services.async_register(DOMAIN, EvoService.RESET_SYSTEM, set_system_mode)
325 
326  system_mode_schemas = []
327  modes = [m for m in modes if m[SZ_SYSTEM_MODE] != SZ_AUTO_WITH_RESET]
328 
329  # Permanent-only modes will use this schema
330  perm_modes = [m[SZ_SYSTEM_MODE] for m in modes if not m[SZ_CAN_BE_TEMPORARY]]
331  if perm_modes: # any of: "Auto", "HeatingOff": permanent only
332  schema = vol.Schema({vol.Required(ATTR_SYSTEM_MODE): vol.In(perm_modes)})
333  system_mode_schemas.append(schema)
334 
335  modes = [m for m in modes if m[SZ_CAN_BE_TEMPORARY]]
336 
337  # These modes are set for a number of hours (or indefinitely): use this schema
338  temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == "Duration"]
339  if temp_modes: # any of: "AutoWithEco", permanent or for 0-24 hours
340  schema = vol.Schema(
341  {
342  vol.Required(ATTR_SYSTEM_MODE): vol.In(temp_modes),
343  vol.Optional(ATTR_DURATION_HOURS): vol.All(
344  cv.time_period,
345  vol.Range(min=timedelta(hours=0), max=timedelta(hours=24)),
346  ),
347  }
348  )
349  system_mode_schemas.append(schema)
350 
351  # These modes are set for a number of days (or indefinitely): use this schema
352  temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == "Period"]
353  if temp_modes: # any of: "Away", "Custom", "DayOff", permanent or for 1-99 days
354  schema = vol.Schema(
355  {
356  vol.Required(ATTR_SYSTEM_MODE): vol.In(temp_modes),
357  vol.Optional(ATTR_DURATION_DAYS): vol.All(
358  cv.time_period,
359  vol.Range(min=timedelta(days=1), max=timedelta(days=99)),
360  ),
361  }
362  )
363  system_mode_schemas.append(schema)
364 
365  if system_mode_schemas:
366  hass.services.async_register(
367  DOMAIN,
368  EvoService.SET_SYSTEM_MODE,
369  set_system_mode,
370  schema=vol.Schema(vol.Any(*system_mode_schemas)),
371  )
372 
373  # The zone modes are consistent across all systems and use the same schema
374  hass.services.async_register(
375  DOMAIN,
376  EvoService.RESET_ZONE_OVERRIDE,
377  set_zone_override,
378  schema=RESET_ZONE_OVERRIDE_SCHEMA,
379  )
380  hass.services.async_register(
381  DOMAIN,
382  EvoService.SET_ZONE_OVERRIDE,
383  set_zone_override,
384  schema=SET_ZONE_OVERRIDE_SCHEMA,
385  )
None authenticate(self, str username, str password)
Definition: __init__.py:120
None _load_auth_tokens(self, str username)
Definition: __init__.py:156
None __init__(self, HomeAssistant hass)
Definition: __init__.py:104
None handle_evo_exception(evo.RequestFailed err)
Definition: helpers.py:66
datetime dt_local_to_aware(datetime dt_naive)
Definition: helpers.py:19
datetime dt_aware_to_naive(datetime dt_aware)
Definition: helpers.py:27
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:215
None setup_service_functions(HomeAssistant hass, EvoBroker broker)
Definition: __init__.py:270
aiohttp.ClientSession async_get_clientsession(HomeAssistant hass, bool verify_ssl=True, socket.AddressFamily family=socket.AF_UNSPEC, ssl_util.SSLCipherList ssl_cipher=ssl_util.SSLCipherList.PYTHON_DEFAULT)
None async_load(HomeAssistant hass)
None async_load_platform(core.HomeAssistant hass, Platform|str component, str platform, DiscoveryInfoType|None discovered, ConfigType hass_config)
Definition: discovery.py:152
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:193
None async_save(self, _T data)
Definition: storage.py:424