Home Assistant Unofficial Reference 2024.12.1
climate.py
Go to the documentation of this file.
1 """Support for Climate entities of the Evohome integration."""
2 
3 from __future__ import annotations
4 
5 from datetime import datetime, timedelta
6 import logging
7 from typing import TYPE_CHECKING, Any
8 
9 import evohomeasync2 as evo
10 from evohomeasync2.schema.const import (
11  SZ_ACTIVE_FAULTS,
12  SZ_SETPOINT_STATUS,
13  SZ_SYSTEM_ID,
14  SZ_SYSTEM_MODE,
15  SZ_SYSTEM_MODE_STATUS,
16  SZ_TEMPERATURE_STATUS,
17  SZ_UNTIL,
18  SZ_ZONE_ID,
19  ZoneModelType,
20  ZoneType,
21 )
22 
24  PRESET_AWAY,
25  PRESET_ECO,
26  PRESET_HOME,
27  PRESET_NONE,
28  ClimateEntity,
29  ClimateEntityFeature,
30  HVACMode,
31 )
32 from homeassistant.const import PRECISION_TENTHS, UnitOfTemperature
33 from homeassistant.core import HomeAssistant
34 from homeassistant.exceptions import HomeAssistantError
35 from homeassistant.helpers.entity_platform import AddEntitiesCallback
36 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
37 import homeassistant.util.dt as dt_util
38 
39 from .const import (
40  ATTR_DURATION_DAYS,
41  ATTR_DURATION_HOURS,
42  ATTR_DURATION_UNTIL,
43  ATTR_SYSTEM_MODE,
44  ATTR_ZONE_TEMP,
45  DOMAIN,
46  EVO_AUTO,
47  EVO_AUTOECO,
48  EVO_AWAY,
49  EVO_CUSTOM,
50  EVO_DAYOFF,
51  EVO_FOLLOW,
52  EVO_HEATOFF,
53  EVO_PERMOVER,
54  EVO_RESET,
55  EVO_TEMPOVER,
56  EvoService,
57 )
58 from .entity import EvoChild, EvoDevice
59 
60 if TYPE_CHECKING:
61  from . import EvoBroker
62 
63 
64 _LOGGER = logging.getLogger(__name__)
65 
66 PRESET_RESET = "Reset" # reset all child zones to EVO_FOLLOW
67 PRESET_CUSTOM = "Custom"
68 
69 TCS_PRESET_TO_HA = {
70  EVO_AWAY: PRESET_AWAY,
71  EVO_CUSTOM: PRESET_CUSTOM,
72  EVO_AUTOECO: PRESET_ECO,
73  EVO_DAYOFF: PRESET_HOME,
74  EVO_RESET: PRESET_RESET,
75 } # EVO_AUTO: None,
76 
77 HA_PRESET_TO_TCS = {v: k for k, v in TCS_PRESET_TO_HA.items()}
78 
79 EVO_PRESET_TO_HA = {
80  EVO_FOLLOW: PRESET_NONE,
81  EVO_TEMPOVER: "temporary",
82  EVO_PERMOVER: "permanent",
83 }
84 HA_PRESET_TO_EVO = {v: k for k, v in EVO_PRESET_TO_HA.items()}
85 
86 STATE_ATTRS_TCS = [SZ_SYSTEM_ID, SZ_ACTIVE_FAULTS, SZ_SYSTEM_MODE_STATUS]
87 STATE_ATTRS_ZONES = [
88  SZ_ZONE_ID,
89  SZ_ACTIVE_FAULTS,
90  SZ_SETPOINT_STATUS,
91  SZ_TEMPERATURE_STATUS,
92 ]
93 
94 
96  hass: HomeAssistant,
97  config: ConfigType,
98  async_add_entities: AddEntitiesCallback,
99  discovery_info: DiscoveryInfoType | None = None,
100 ) -> None:
101  """Create the evohome Controller, and its Zones, if any."""
102  if discovery_info is None:
103  return
104 
105  broker: EvoBroker = hass.data[DOMAIN]["broker"]
106 
107  _LOGGER.debug(
108  "Found the Location/Controller (%s), id=%s, name=%s (location_idx=%s)",
109  broker.tcs.modelType,
110  broker.tcs.systemId,
111  broker.loc.name,
112  broker.loc_idx,
113  )
114 
115  entities: list[EvoClimateEntity] = [EvoController(broker, broker.tcs)]
116 
117  for zone in broker.tcs.zones.values():
118  if (
119  zone.modelType == ZoneModelType.HEATING_ZONE
120  or zone.zoneType == ZoneType.THERMOSTAT
121  ):
122  _LOGGER.debug(
123  "Adding: %s (%s), id=%s, name=%s",
124  zone.zoneType,
125  zone.modelType,
126  zone.zoneId,
127  zone.name,
128  )
129 
130  new_entity = EvoZone(broker, zone)
131  entities.append(new_entity)
132 
133  else:
134  _LOGGER.warning(
135  (
136  "Ignoring: %s (%s), id=%s, name=%s: unknown/invalid zone type, "
137  "report as an issue if you feel this zone type should be supported"
138  ),
139  zone.zoneType,
140  zone.modelType,
141  zone.zoneId,
142  zone.name,
143  )
144 
145  async_add_entities(entities, update_before_add=True)
146 
147 
149  """Base for any evohome-compatible climate entity (controller, zone)."""
150 
151  _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
152  _attr_temperature_unit = UnitOfTemperature.CELSIUS
153  _enable_turn_on_off_backwards_compatibility = False
154 
155 
157  """Base for any evohome-compatible heating zone."""
158 
159  _attr_preset_modes = list(HA_PRESET_TO_EVO)
160 
161  _evo_device: evo.Zone # mypy hint
162 
163  def __init__(self, evo_broker: EvoBroker, evo_device: evo.Zone) -> None:
164  """Initialize an evohome-compatible heating zone."""
165 
166  super().__init__(evo_broker, evo_device)
167  self._evo_id_evo_id = evo_device.zoneId
168 
169  if evo_device.modelType.startswith("VisionProWifi"):
170  # this system does not have a distinct ID for the zone
171  self._attr_unique_id_attr_unique_id = f"{evo_device.zoneId}z"
172  else:
173  self._attr_unique_id_attr_unique_id = evo_device.zoneId
174 
175  if evo_broker.client_v1:
176  self._attr_precision_attr_precision = PRECISION_TENTHS
177  else:
178  self._attr_precision_attr_precision = self._evo_device_evo_device.setpointCapabilities[
179  "valueResolution"
180  ]
181 
182  self._attr_supported_features_attr_supported_features = (
183  ClimateEntityFeature.PRESET_MODE
184  | ClimateEntityFeature.TARGET_TEMPERATURE
185  | ClimateEntityFeature.TURN_OFF
186  | ClimateEntityFeature.TURN_ON
187  )
188 
189  async def async_zone_svc_request(self, service: str, data: dict[str, Any]) -> None:
190  """Process a service request (setpoint override) for a zone."""
191  if service == EvoService.RESET_ZONE_OVERRIDE:
192  await self._evo_broker_evo_broker.call_client_api(self._evo_device_evo_device.reset_mode())
193  return
194 
195  # otherwise it is EvoService.SET_ZONE_OVERRIDE
196  temperature = max(min(data[ATTR_ZONE_TEMP], self.max_tempmax_tempmax_temp), self.min_tempmin_tempmin_temp)
197 
198  if ATTR_DURATION_UNTIL in data:
199  duration: timedelta = data[ATTR_DURATION_UNTIL]
200  if duration.total_seconds() == 0:
201  await self._update_schedule_update_schedule()
202  until = dt_util.parse_datetime(self.setpointssetpoints.get("next_sp_from", ""))
203  else:
204  until = dt_util.now() + data[ATTR_DURATION_UNTIL]
205  else:
206  until = None # indefinitely
207 
208  until = dt_util.as_utc(until) if until else None
209  await self._evo_broker_evo_broker.call_client_api(
210  self._evo_device_evo_device.set_temperature(temperature, until=until)
211  )
212 
213  @property
214  def name(self) -> str | None:
215  """Return the name of the evohome entity."""
216  return self._evo_device_evo_device.name # zones can be easily renamed
217 
218  @property
219  def hvac_mode(self) -> HVACMode | None:
220  """Return the current operating mode of a Zone."""
221  if self._evo_tcs_evo_tcs.system_mode in (EVO_AWAY, EVO_HEATOFF):
222  return HVACMode.AUTO
223  if self.target_temperaturetarget_temperaturetarget_temperature is None:
224  return None
225  if self.target_temperaturetarget_temperaturetarget_temperature <= self.min_tempmin_tempmin_temp:
226  return HVACMode.OFF
227  return HVACMode.HEAT
228 
229  @property
230  def target_temperature(self) -> float | None:
231  """Return the target temperature of a Zone."""
232  return self._evo_device_evo_device.target_heat_temperature
233 
234  @property
235  def preset_mode(self) -> str | None:
236  """Return the current preset mode, e.g., home, away, temp."""
237  if self._evo_tcs_evo_tcs.system_mode in (EVO_AWAY, EVO_HEATOFF):
238  return TCS_PRESET_TO_HA.get(self._evo_tcs_evo_tcs.system_mode)
239  if self._evo_device_evo_device.mode is None:
240  return None
241  return EVO_PRESET_TO_HA.get(self._evo_device_evo_device.mode)
242 
243  @property
244  def min_temp(self) -> float:
245  """Return the minimum target temperature of a Zone.
246 
247  The default is 5, but is user-configurable within 5-21 (in Celsius).
248  """
249  if self._evo_device_evo_device.min_heat_setpoint is None:
250  return 5
251  return self._evo_device_evo_device.min_heat_setpoint
252 
253  @property
254  def max_temp(self) -> float:
255  """Return the maximum target temperature of a Zone.
256 
257  The default is 35, but is user-configurable within 21-35 (in Celsius).
258  """
259  if self._evo_device_evo_device.max_heat_setpoint is None:
260  return 35
261  return self._evo_device_evo_device.max_heat_setpoint
262 
263  async def async_set_temperature(self, **kwargs: Any) -> None:
264  """Set a new target temperature."""
265 
266  assert self._evo_device_evo_device.setpointStatus is not None # mypy check
267 
268  temperature = kwargs["temperature"]
269 
270  if (until := kwargs.get("until")) is None:
271  if self._evo_device_evo_device.mode == EVO_FOLLOW:
272  await self._update_schedule_update_schedule()
273  until = dt_util.parse_datetime(self.setpointssetpoints.get("next_sp_from", ""))
274  elif self._evo_device_evo_device.mode == EVO_TEMPOVER:
275  until = dt_util.parse_datetime(
276  self._evo_device_evo_device.setpointStatus[SZ_UNTIL]
277  )
278 
279  until = dt_util.as_utc(until) if until else None
280  await self._evo_broker_evo_broker.call_client_api(
281  self._evo_device_evo_device.set_temperature(temperature, until=until)
282  )
283 
284  async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
285  """Set a Zone to one of its native EVO_* operating modes.
286 
287  Zones inherit their _effective_ operating mode from their Controller.
288 
289  Usually, Zones are in 'FollowSchedule' mode, where their setpoints are a
290  function of their own schedule and the Controller's operating mode, e.g.
291  'AutoWithEco' mode means their setpoint is (by default) 3C less than scheduled.
292 
293  However, Zones can _override_ these setpoints, either indefinitely,
294  'PermanentOverride' mode, or for a set period of time, 'TemporaryOverride' mode
295  (after which they will revert back to 'FollowSchedule' mode).
296 
297  Finally, some of the Controller's operating modes are _forced_ upon the Zones,
298  regardless of any override mode, e.g. 'HeatingOff', Zones to (by default) 5C,
299  and 'Away', Zones to (by default) 12C.
300  """
301  if hvac_mode == HVACMode.OFF:
302  await self._evo_broker_evo_broker.call_client_api(
303  self._evo_device_evo_device.set_temperature(self.min_tempmin_tempmin_temp, until=None)
304  )
305  else: # HVACMode.HEAT
306  await self._evo_broker_evo_broker.call_client_api(self._evo_device_evo_device.reset_mode())
307 
308  async def async_set_preset_mode(self, preset_mode: str) -> None:
309  """Set the preset mode; if None, then revert to following the schedule."""
310  evo_preset_mode = HA_PRESET_TO_EVO.get(preset_mode, EVO_FOLLOW)
311 
312  if evo_preset_mode == EVO_FOLLOW:
313  await self._evo_broker_evo_broker.call_client_api(self._evo_device_evo_device.reset_mode())
314  return
315 
316  if evo_preset_mode == EVO_TEMPOVER:
317  await self._update_schedule_update_schedule()
318  until = dt_util.parse_datetime(self.setpointssetpoints.get("next_sp_from", ""))
319  else: # EVO_PERMOVER
320  until = None
321 
322  temperature = self._evo_device_evo_device.target_heat_temperature
323  assert temperature is not None # mypy check
324 
325  until = dt_util.as_utc(until) if until else None
326  await self._evo_broker_evo_broker.call_client_api(
327  self._evo_device_evo_device.set_temperature(temperature, until=until)
328  )
329 
330  async def async_update(self) -> None:
331  """Get the latest state data for a Zone."""
332  await super().async_update()
333 
334  for attr in STATE_ATTRS_ZONES:
335  self._device_state_attrs_device_state_attrs[attr] = getattr(self._evo_device_evo_device, attr)
336 
337 
339  """Base for any evohome-compatible controller.
340 
341  The Controller (aka TCS, temperature control system) is the parent of all the child
342  (CH/DHW) devices. It is implemented as a Climate entity to expose the controller's
343  operating modes to HA.
344 
345  It is assumed there is only one TCS per location, and they are thus synonymous.
346  """
347 
348  _attr_icon = "mdi:thermostat"
349  _attr_precision = PRECISION_TENTHS
350 
351  _evo_device: evo.ControlSystem # mypy hint
352 
353  def __init__(self, evo_broker: EvoBroker, evo_device: evo.ControlSystem) -> None:
354  """Initialize an evohome-compatible controller."""
355 
356  super().__init__(evo_broker, evo_device)
357  self._evo_id_evo_id = evo_device.systemId
358 
359  self._attr_unique_id_attr_unique_id = evo_device.systemId
360  self._attr_name_attr_name = evo_device.location.name
361 
362  self._evo_modes_evo_modes = [m[SZ_SYSTEM_MODE] for m in evo_device.allowedSystemModes]
363  self._attr_preset_modes_attr_preset_modes = [
364  TCS_PRESET_TO_HA[m] for m in self._evo_modes_evo_modes if m in list(TCS_PRESET_TO_HA)
365  ]
366  if self._attr_preset_modes_attr_preset_modes:
367  self._attr_supported_features_attr_supported_features = ClimateEntityFeature.PRESET_MODE
368  self._attr_supported_features_attr_supported_features |= (
369  ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
370  )
371 
372  async def async_tcs_svc_request(self, service: str, data: dict[str, Any]) -> None:
373  """Process a service request (system mode) for a controller.
374 
375  Data validation is not required, it will have been done upstream.
376  """
377  if service == EvoService.SET_SYSTEM_MODE:
378  mode = data[ATTR_SYSTEM_MODE]
379  else: # otherwise it is EvoService.RESET_SYSTEM
380  mode = EVO_RESET
381 
382  if ATTR_DURATION_DAYS in data:
383  until = dt_util.start_of_local_day()
384  until += data[ATTR_DURATION_DAYS]
385 
386  elif ATTR_DURATION_HOURS in data:
387  until = dt_util.now() + data[ATTR_DURATION_HOURS]
388 
389  else:
390  until = None
391 
392  await self._set_tcs_mode_set_tcs_mode(mode, until=until)
393 
394  async def _set_tcs_mode(self, mode: str, until: datetime | None = None) -> None:
395  """Set a Controller to any of its native EVO_* operating modes."""
396  until = dt_util.as_utc(until) if until else None
397  await self._evo_broker_evo_broker.call_client_api(
398  self._evo_device_evo_device.set_mode(mode, until=until) # type: ignore[arg-type]
399  )
400 
401  @property
402  def hvac_mode(self) -> HVACMode:
403  """Return the current operating mode of a Controller."""
404  evo_mode = self._evo_device_evo_device.system_mode
405  return HVACMode.OFF if evo_mode in (EVO_HEATOFF, "Off") else HVACMode.HEAT
406 
407  @property
408  def current_temperature(self) -> float | None:
409  """Return the average current temperature of the heating Zones.
410 
411  Controllers do not have a current temp, but one is expected by HA.
412  """
413  temps = [
414  z.temperature
415  for z in self._evo_device_evo_device.zones.values()
416  if z.temperature is not None
417  ]
418  return round(sum(temps) / len(temps), 1) if temps else None
419 
420  @property
421  def preset_mode(self) -> str | None:
422  """Return the current preset mode, e.g., home, away, temp."""
423  if not self._evo_device_evo_device.system_mode:
424  return None
425  return TCS_PRESET_TO_HA.get(self._evo_device_evo_device.system_mode)
426 
427  async def async_set_temperature(self, **kwargs: Any) -> None:
428  """Raise exception as Controllers don't have a target temperature."""
429  raise NotImplementedError("Evohome Controllers don't have target temperatures.")
430 
431  async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
432  """Set an operating mode for a Controller."""
433  if hvac_mode == HVACMode.HEAT:
434  evo_mode = EVO_AUTO if EVO_AUTO in self._evo_modes_evo_modes else "Heat"
435  elif hvac_mode == HVACMode.OFF:
436  evo_mode = EVO_HEATOFF if EVO_HEATOFF in self._evo_modes_evo_modes else "Off"
437  else:
438  raise HomeAssistantError(f"Invalid hvac_mode: {hvac_mode}")
439  await self._set_tcs_mode_set_tcs_mode(evo_mode)
440 
441  async def async_set_preset_mode(self, preset_mode: str) -> None:
442  """Set the preset mode; if None, then revert to 'Auto' mode."""
443  await self._set_tcs_mode_set_tcs_mode(HA_PRESET_TO_TCS.get(preset_mode, EVO_AUTO))
444 
445  async def async_update(self) -> None:
446  """Get the latest state data for a Controller."""
447  self._device_state_attrs_device_state_attrs = {}
448 
449  attrs = self._device_state_attrs_device_state_attrs
450  for attr in STATE_ATTRS_TCS:
451  if attr == SZ_ACTIVE_FAULTS:
452  attrs["activeSystemFaults"] = getattr(self._evo_device_evo_device, attr)
453  else:
454  attrs[attr] = getattr(self._evo_device_evo_device, attr)
None set_temperature(self, **Any kwargs)
Definition: __init__.py:771
None async_set_preset_mode(self, str preset_mode)
Definition: climate.py:441
None __init__(self, EvoBroker evo_broker, evo.ControlSystem evo_device)
Definition: climate.py:353
None async_tcs_svc_request(self, str service, dict[str, Any] data)
Definition: climate.py:372
None async_set_hvac_mode(self, HVACMode hvac_mode)
Definition: climate.py:431
None _set_tcs_mode(self, str mode, datetime|None until=None)
Definition: climate.py:394
None __init__(self, EvoBroker evo_broker, evo.Zone evo_device)
Definition: climate.py:163
None async_set_hvac_mode(self, HVACMode hvac_mode)
Definition: climate.py:284
None async_set_temperature(self, **Any kwargs)
Definition: climate.py:263
None async_zone_svc_request(self, str service, dict[str, Any] data)
Definition: climate.py:189
None async_set_preset_mode(self, str preset_mode)
Definition: climate.py:308
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
None async_setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback async_add_entities, DiscoveryInfoType|None discovery_info=None)
Definition: climate.py:100