Home Assistant Unofficial Reference 2024.12.1
climate.py
Go to the documentation of this file.
1 """Support for Honeywell Lyric climate platform."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 import enum
7 import logging
8 from time import localtime, strftime, time
9 from typing import Any
10 
11 from aiolyric import Lyric
12 from aiolyric.objects.device import LyricDevice
13 from aiolyric.objects.location import LyricLocation
14 import voluptuous as vol
15 
17  ATTR_TARGET_TEMP_HIGH,
18  ATTR_TARGET_TEMP_LOW,
19  FAN_AUTO,
20  FAN_DIFFUSE,
21  FAN_ON,
22  ClimateEntity,
23  ClimateEntityDescription,
24  ClimateEntityFeature,
25  HVACAction,
26  HVACMode,
27 )
28 from homeassistant.config_entries import ConfigEntry
29 from homeassistant.const import (
30  ATTR_TEMPERATURE,
31  PRECISION_HALVES,
32  PRECISION_WHOLE,
33  UnitOfTemperature,
34 )
35 from homeassistant.core import HomeAssistant
36 from homeassistant.exceptions import HomeAssistantError
37 from homeassistant.helpers import entity_platform
39 from homeassistant.helpers.entity_platform import AddEntitiesCallback
40 from homeassistant.helpers.typing import VolDictType
41 from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
42 
43 from .const import (
44  DOMAIN,
45  LYRIC_EXCEPTIONS,
46  PRESET_HOLD_UNTIL,
47  PRESET_NO_HOLD,
48  PRESET_PERMANENT_HOLD,
49  PRESET_TEMPORARY_HOLD,
50  PRESET_VACATION_HOLD,
51 )
52 from .entity import LyricDeviceEntity
53 
54 _LOGGER = logging.getLogger(__name__)
55 
56 # Only LCC models support presets
57 SUPPORT_FLAGS_LCC = (
58  ClimateEntityFeature.TARGET_TEMPERATURE
59  | ClimateEntityFeature.PRESET_MODE
60  | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
61 )
62 SUPPORT_FLAGS_TCC = (
63  ClimateEntityFeature.TARGET_TEMPERATURE
64  | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
65 )
66 
67 LYRIC_HVAC_ACTION_OFF = "EquipmentOff"
68 LYRIC_HVAC_ACTION_HEAT = "Heat"
69 LYRIC_HVAC_ACTION_COOL = "Cool"
70 
71 LYRIC_HVAC_MODE_OFF = "Off"
72 LYRIC_HVAC_MODE_HEAT = "Heat"
73 LYRIC_HVAC_MODE_COOL = "Cool"
74 LYRIC_HVAC_MODE_HEAT_COOL = "Auto"
75 
76 LYRIC_FAN_MODE_ON = "On"
77 LYRIC_FAN_MODE_AUTO = "Auto"
78 LYRIC_FAN_MODE_DIFFUSE = "Circulate"
79 
80 LYRIC_HVAC_MODES = {
81  HVACMode.OFF: LYRIC_HVAC_MODE_OFF,
82  HVACMode.HEAT: LYRIC_HVAC_MODE_HEAT,
83  HVACMode.COOL: LYRIC_HVAC_MODE_COOL,
84  HVACMode.HEAT_COOL: LYRIC_HVAC_MODE_HEAT_COOL,
85 }
86 
87 HVAC_MODES = {
88  LYRIC_HVAC_MODE_OFF: HVACMode.OFF,
89  LYRIC_HVAC_MODE_HEAT: HVACMode.HEAT,
90  LYRIC_HVAC_MODE_COOL: HVACMode.COOL,
91  LYRIC_HVAC_MODE_HEAT_COOL: HVACMode.HEAT_COOL,
92 }
93 
94 LYRIC_FAN_MODES = {
95  FAN_ON: LYRIC_FAN_MODE_ON,
96  FAN_AUTO: LYRIC_FAN_MODE_AUTO,
97  FAN_DIFFUSE: LYRIC_FAN_MODE_DIFFUSE,
98 }
99 
100 FAN_MODES = {
101  LYRIC_FAN_MODE_ON: FAN_ON,
102  LYRIC_FAN_MODE_AUTO: FAN_AUTO,
103  LYRIC_FAN_MODE_DIFFUSE: FAN_DIFFUSE,
104 }
105 
106 HVAC_ACTIONS = {
107  LYRIC_HVAC_ACTION_OFF: HVACAction.OFF,
108  LYRIC_HVAC_ACTION_HEAT: HVACAction.HEATING,
109  LYRIC_HVAC_ACTION_COOL: HVACAction.COOLING,
110 }
111 
112 SERVICE_HOLD_TIME = "set_hold_time"
113 ATTR_TIME_PERIOD = "time_period"
114 
115 SCHEMA_HOLD_TIME: VolDictType = {
116  vol.Required(ATTR_TIME_PERIOD, default="01:00:00"): vol.All(
117  cv.time_period,
118  cv.positive_timedelta,
119  lambda td: strftime("%H:%M:%S", localtime(time() + td.total_seconds())),
120  )
121 }
122 
123 
125  hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
126 ) -> None:
127  """Set up the Honeywell Lyric climate platform based on a config entry."""
128  coordinator: DataUpdateCoordinator[Lyric] = hass.data[DOMAIN][entry.entry_id]
129 
131  (
132  LyricClimate(
133  coordinator,
135  key=f"{device.mac_id}_thermostat",
136  name=device.name,
137  ),
138  location,
139  device,
140  )
141  for location in coordinator.data.locations
142  for device in location.devices
143  ),
144  True,
145  )
146 
147  platform = entity_platform.async_get_current_platform()
148 
149  platform.async_register_entity_service(
150  SERVICE_HOLD_TIME,
151  SCHEMA_HOLD_TIME,
152  "async_set_hold_time",
153  )
154 
155 
156 class LyricThermostatType(enum.Enum):
157  """Lyric thermostats are classified as TCC or LCC devices."""
158 
159  TCC = enum.auto()
160  LCC = enum.auto()
161 
162 
164  """Defines a Honeywell Lyric climate entity."""
165 
166  coordinator: DataUpdateCoordinator[Lyric]
167  entity_description: ClimateEntityDescription
168 
169  _attr_name = None
170  _attr_preset_modes = [
171  PRESET_NO_HOLD,
172  PRESET_HOLD_UNTIL,
173  PRESET_PERMANENT_HOLD,
174  PRESET_TEMPORARY_HOLD,
175  PRESET_VACATION_HOLD,
176  ]
177  _enable_turn_on_off_backwards_compatibility = False
178 
179  def __init__(
180  self,
181  coordinator: DataUpdateCoordinator[Lyric],
182  description: ClimateEntityDescription,
183  location: LyricLocation,
184  device: LyricDevice,
185  ) -> None:
186  """Initialize Honeywell Lyric climate entity."""
187  # Define thermostat type (TCC - e.g., Lyric round; LCC - e.g., T5,6)
188  if device.changeable_values.thermostat_setpoint_status:
189  self._attr_thermostat_type_attr_thermostat_type = LyricThermostatType.LCC
190  else:
191  self._attr_thermostat_type_attr_thermostat_type = LyricThermostatType.TCC
192 
193  # Use the native temperature unit from the device settings
194  if device.units == "Fahrenheit":
195  self._attr_temperature_unit_attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
196  self._attr_precision_attr_precision = PRECISION_WHOLE
197  else:
198  self._attr_temperature_unit_attr_temperature_unit = UnitOfTemperature.CELSIUS
199  self._attr_precision_attr_precision = PRECISION_HALVES
200 
201  # Setup supported hvac modes
202  self._attr_hvac_modes_attr_hvac_modes = [HVACMode.OFF]
203 
204  # Add supported lyric thermostat features
205  if LYRIC_HVAC_MODE_HEAT in device.allowed_modes:
206  self._attr_hvac_modes_attr_hvac_modes.append(HVACMode.HEAT)
207 
208  if LYRIC_HVAC_MODE_COOL in device.allowed_modes:
209  self._attr_hvac_modes_attr_hvac_modes.append(HVACMode.COOL)
210 
211  # TCC devices like the Lyric round do not have the Auto
212  # option in allowed_modes, but still support Auto mode
213  if LYRIC_HVAC_MODE_HEAT_COOL in device.allowed_modes or (
214  self._attr_thermostat_type_attr_thermostat_type is LyricThermostatType.TCC
215  and LYRIC_HVAC_MODE_HEAT in device.allowed_modes
216  and LYRIC_HVAC_MODE_COOL in device.allowed_modes
217  ):
218  self._attr_hvac_modes_attr_hvac_modes.append(HVACMode.HEAT_COOL)
219 
220  # Setup supported features
221  if self._attr_thermostat_type_attr_thermostat_type is LyricThermostatType.LCC:
222  self._attr_supported_features_attr_supported_features = SUPPORT_FLAGS_LCC
223  else:
224  self._attr_supported_features_attr_supported_features = SUPPORT_FLAGS_TCC
225 
226  # Setup supported fan modes
227  if device_fan_modes := device.settings.attributes.get("fan", {}).get(
228  "allowedModes"
229  ):
230  self._attr_fan_modes_attr_fan_modes = [
231  FAN_MODES[device_fan_mode]
232  for device_fan_mode in device_fan_modes
233  if device_fan_mode in FAN_MODES
234  ]
235  self._attr_supported_features_attr_supported_features = (
236  self._attr_supported_features_attr_supported_features | ClimateEntityFeature.FAN_MODE
237  )
238 
239  if len(self.hvac_modeshvac_modes) > 1:
240  self._attr_supported_features_attr_supported_features |= (
241  ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
242  )
243 
244  super().__init__(
245  coordinator,
246  location,
247  device,
248  f"{device.mac_id}_thermostat",
249  )
250  self.entity_descriptionentity_description = description
251 
252  @property
253  def current_temperature(self) -> float | None:
254  """Return the current temperature."""
255  return self.devicedevice.indoor_temperature
256 
257  @property
258  def hvac_action(self) -> HVACAction | None:
259  """Return the current hvac action."""
260  action = HVAC_ACTIONS.get(self.devicedevice.operation_status.mode, None)
261  if action == HVACAction.OFF and self.hvac_modehvac_modehvac_modehvac_modehvac_mode != HVACMode.OFF:
262  action = HVACAction.IDLE
263  return action
264 
265  @property
266  def hvac_mode(self) -> HVACMode:
267  """Return the hvac mode."""
268  return HVAC_MODES[self.devicedevice.changeable_values.mode]
269 
270  @property
271  def target_temperature(self) -> float | None:
272  """Return the temperature we try to reach."""
273  device = self.devicedevice
274  if (
275  device.changeable_values.auto_changeover_active
276  or HVAC_MODES[device.changeable_values.mode] == HVACMode.OFF
277  ):
278  return None
279  if self.hvac_modehvac_modehvac_modehvac_modehvac_mode == HVACMode.COOL:
280  return device.changeable_values.cool_setpoint
281  return device.changeable_values.heat_setpoint
282 
283  @property
284  def target_temperature_high(self) -> float | None:
285  """Return the highbound target temperature we try to reach."""
286  device = self.devicedevice
287  if (
288  not device.changeable_values.auto_changeover_active
289  or HVAC_MODES[device.changeable_values.mode] == HVACMode.OFF
290  ):
291  return None
292  return device.changeable_values.cool_setpoint
293 
294  @property
295  def target_temperature_low(self) -> float | None:
296  """Return the lowbound target temperature we try to reach."""
297  device = self.devicedevice
298  if (
299  not device.changeable_values.auto_changeover_active
300  or HVAC_MODES[device.changeable_values.mode] == HVACMode.OFF
301  ):
302  return None
303  return device.changeable_values.heat_setpoint
304 
305  @property
306  def preset_mode(self) -> str | None:
307  """Return current preset mode."""
308  return self.devicedevice.changeable_values.thermostat_setpoint_status
309 
310  @property
311  def min_temp(self) -> float:
312  """Identify min_temp in Lyric API or defaults if not available."""
313  device = self.devicedevice
314  if LYRIC_HVAC_MODE_COOL in device.allowed_modes:
315  return device.min_cool_setpoint
316  return device.min_heat_setpoint
317 
318  @property
319  def max_temp(self) -> float:
320  """Identify max_temp in Lyric API or defaults if not available."""
321  device = self.devicedevice
322  if LYRIC_HVAC_MODE_HEAT in device.allowed_modes:
323  return device.max_heat_setpoint
324  return device.max_cool_setpoint
325 
326  @property
327  def fan_mode(self) -> str | None:
328  """Return current fan mode."""
329  device = self.devicedevice
330  return FAN_MODES.get(
331  device.settings.attributes.get("fan", {})
332  .get("changeableValues", {})
333  .get("mode")
334  )
335 
336  async def async_set_temperature(self, **kwargs: Any) -> None:
337  """Set new target temperature."""
338  if self.hvac_modehvac_modehvac_modehvac_modehvac_mode == HVACMode.OFF:
339  return
340 
341  device = self.devicedevice
342  target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW)
343  target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH)
344 
345  if device.changeable_values.mode == LYRIC_HVAC_MODE_HEAT_COOL:
346  if target_temp_low is None or target_temp_high is None:
347  raise HomeAssistantError(
348  "Could not find target_temp_low and/or target_temp_high in"
349  " arguments"
350  )
351 
352  # If TCC device pass the heatCoolMode value, otherwise
353  # if LCC device can skip the mode altogether
354  if self._attr_thermostat_type_attr_thermostat_type is LyricThermostatType.TCC:
355  mode = HVAC_MODES[device.changeable_values.heat_cool_mode]
356  else:
357  mode = None
358 
359  _LOGGER.debug("Set temperature: %s - %s", target_temp_low, target_temp_high)
360  try:
361  await self._update_thermostat_update_thermostat(
362  self.locationlocation,
363  device,
364  cool_setpoint=target_temp_high,
365  heat_setpoint=target_temp_low,
366  mode=mode,
367  )
368  except LYRIC_EXCEPTIONS as exception:
369  _LOGGER.error(exception)
370  await self.coordinator.async_refresh()
371  else:
372  temp = kwargs.get(ATTR_TEMPERATURE)
373  _LOGGER.debug("Set temperature: %s", temp)
374  try:
375  if self.hvac_modehvac_modehvac_modehvac_modehvac_mode == HVACMode.COOL:
376  await self._update_thermostat_update_thermostat(
377  self.locationlocation, device, cool_setpoint=temp
378  )
379  else:
380  await self._update_thermostat_update_thermostat(
381  self.locationlocation, device, heat_setpoint=temp
382  )
383  except LYRIC_EXCEPTIONS as exception:
384  _LOGGER.error(exception)
385  await self.coordinator.async_refresh()
386 
387  async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
388  """Set hvac mode."""
389  _LOGGER.debug("HVAC mode: %s", hvac_mode)
390  try:
391  match self._attr_thermostat_type_attr_thermostat_type:
392  case LyricThermostatType.TCC:
393  await self._async_set_hvac_mode_tcc_async_set_hvac_mode_tcc(hvac_mode)
394  case LyricThermostatType.LCC:
395  await self._async_set_hvac_mode_lcc_async_set_hvac_mode_lcc(hvac_mode)
396  except LYRIC_EXCEPTIONS as exception:
397  _LOGGER.error(exception)
398  await self.coordinator.async_refresh()
399 
400  async def _async_set_hvac_mode_tcc(self, hvac_mode: HVACMode) -> None:
401  """Set hvac mode for TCC devices (e.g., Lyric round)."""
402  if LYRIC_HVAC_MODES[hvac_mode] == LYRIC_HVAC_MODE_HEAT_COOL:
403  # If the system is off, turn it to Heat first then to Auto,
404  # otherwise it turns to Auto briefly and then reverts to Off.
405  # This is the behavior that happens with the native app as well,
406  # so likely a bug in the api itself.
407  if HVAC_MODES[self.devicedevice.changeable_values.mode] == HVACMode.OFF:
408  _LOGGER.debug(
409  "HVAC mode passed to lyric: %s",
410  HVAC_MODES[LYRIC_HVAC_MODE_COOL],
411  )
412  await self._update_thermostat_update_thermostat(
413  self.locationlocation,
414  self.devicedevice,
415  mode=HVAC_MODES[LYRIC_HVAC_MODE_HEAT],
416  auto_changeover_active=False,
417  )
418  # Sleep 3 seconds before proceeding
419  await asyncio.sleep(3)
420  _LOGGER.debug(
421  "HVAC mode passed to lyric: %s",
422  HVAC_MODES[LYRIC_HVAC_MODE_HEAT],
423  )
424  await self._update_thermostat_update_thermostat(
425  self.locationlocation,
426  self.devicedevice,
427  mode=HVAC_MODES[LYRIC_HVAC_MODE_HEAT],
428  auto_changeover_active=True,
429  )
430  else:
431  _LOGGER.debug(
432  "HVAC mode passed to lyric: %s",
433  HVAC_MODES[self.devicedevice.changeable_values.mode],
434  )
435  await self._update_thermostat_update_thermostat(
436  self.locationlocation, self.devicedevice, auto_changeover_active=True
437  )
438  else:
439  _LOGGER.debug("HVAC mode passed to lyric: %s", LYRIC_HVAC_MODES[hvac_mode])
440  await self._update_thermostat_update_thermostat(
441  self.locationlocation,
442  self.devicedevice,
443  mode=LYRIC_HVAC_MODES[hvac_mode],
444  auto_changeover_active=False,
445  )
446 
447  async def _async_set_hvac_mode_lcc(self, hvac_mode: HVACMode) -> None:
448  """Set hvac mode for LCC devices (e.g., T5,6)."""
449  _LOGGER.debug("HVAC mode passed to lyric: %s", LYRIC_HVAC_MODES[hvac_mode])
450  # Set auto_changeover_active to True if the mode being passed is Auto
451  # otherwise leave unchanged.
452  if (
453  LYRIC_HVAC_MODES[hvac_mode] == LYRIC_HVAC_MODE_HEAT_COOL
454  and not self.devicedevice.changeable_values.auto_changeover_active
455  ):
456  auto_changeover = True
457  else:
458  auto_changeover = None
459 
460  await self._update_thermostat_update_thermostat(
461  self.locationlocation,
462  self.devicedevice,
463  mode=LYRIC_HVAC_MODES[hvac_mode],
464  auto_changeover_active=auto_changeover,
465  )
466 
467  async def async_set_preset_mode(self, preset_mode: str) -> None:
468  """Set preset (PermanentHold, HoldUntil, NoHold, VacationHold) mode."""
469  _LOGGER.debug("Set preset mode: %s", preset_mode)
470  try:
471  await self._update_thermostat_update_thermostat(
472  self.locationlocation, self.devicedevice, thermostat_setpoint_status=preset_mode
473  )
474  except LYRIC_EXCEPTIONS as exception:
475  _LOGGER.error(exception)
476  await self.coordinator.async_refresh()
477 
478  async def async_set_hold_time(self, time_period: str) -> None:
479  """Set the time to hold until."""
480  _LOGGER.debug("set_hold_time: %s", time_period)
481  try:
482  await self._update_thermostat_update_thermostat(
483  self.locationlocation,
484  self.devicedevice,
485  thermostat_setpoint_status=PRESET_HOLD_UNTIL,
486  next_period_time=time_period,
487  )
488  except LYRIC_EXCEPTIONS as exception:
489  _LOGGER.error(exception)
490  await self.coordinator.async_refresh()
491 
492  async def async_set_fan_mode(self, fan_mode: str) -> None:
493  """Set fan mode."""
494  _LOGGER.debug("Set fan mode: %s", fan_mode)
495  try:
496  _LOGGER.debug("Fan mode passed to lyric: %s", LYRIC_FAN_MODES[fan_mode])
497  await self._update_fan_update_fan(
498  self.locationlocation, self.devicedevice, mode=LYRIC_FAN_MODES[fan_mode]
499  )
500  except LYRIC_EXCEPTIONS as exception:
501  _LOGGER.error(exception)
502  except KeyError:
503  _LOGGER.error(
504  "The fan mode requested does not have a corresponding mode in lyric: %s",
505  fan_mode,
506  )
507  await self.coordinator.async_refresh()
None async_set_temperature(self, **Any kwargs)
Definition: climate.py:336
None async_set_preset_mode(self, str preset_mode)
Definition: climate.py:467
None async_set_hvac_mode(self, HVACMode hvac_mode)
Definition: climate.py:387
None _async_set_hvac_mode_tcc(self, HVACMode hvac_mode)
Definition: climate.py:400
None async_set_hold_time(self, str time_period)
Definition: climate.py:478
None _async_set_hvac_mode_lcc(self, HVACMode hvac_mode)
Definition: climate.py:447
None __init__(self, DataUpdateCoordinator[Lyric] coordinator, ClimateEntityDescription description, LyricLocation location, LyricDevice device)
Definition: climate.py:185
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
None async_setup_entry(HomeAssistant hass, ConfigEntry entry, AddEntitiesCallback async_add_entities)
Definition: climate.py:126
bool time(HomeAssistant hass, dt_time|str|None before=None, dt_time|str|None after=None, str|Container[str]|None weekday=None)
Definition: condition.py:802