Home Assistant Unofficial Reference 2024.12.1
climate.py
Go to the documentation of this file.
1 """Support for Nexia / Trane XL thermostats."""
2 
3 from __future__ import annotations
4 
5 from typing import Any
6 
7 from nexia.const import (
8  HOLD_PERMANENT,
9  HOLD_RESUME_SCHEDULE,
10  OPERATION_MODE_AUTO,
11  OPERATION_MODE_COOL,
12  OPERATION_MODE_HEAT,
13  OPERATION_MODE_OFF,
14  SYSTEM_STATUS_COOL,
15  SYSTEM_STATUS_HEAT,
16  SYSTEM_STATUS_IDLE,
17 )
18 from nexia.thermostat import NexiaThermostat
19 from nexia.util import find_humidity_setpoint
20 from nexia.zone import NexiaThermostatZone
21 import voluptuous as vol
22 
24  ATTR_HUMIDITY,
25  ATTR_HVAC_MODE,
26  ATTR_TARGET_TEMP_HIGH,
27  ATTR_TARGET_TEMP_LOW,
28  ClimateEntity,
29  ClimateEntityFeature,
30  HVACAction,
31  HVACMode,
32 )
33 from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
34 from homeassistant.core import HomeAssistant
35 from homeassistant.helpers import entity_platform
37 from homeassistant.helpers.entity_platform import AddEntitiesCallback
38 from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
39 from homeassistant.helpers.typing import VolDictType
40 
41 from .const import (
42  ATTR_AIRCLEANER_MODE,
43  ATTR_DEHUMIDIFY_SETPOINT,
44  ATTR_HUMIDIFY_SETPOINT,
45  ATTR_RUN_MODE,
46  DOMAIN,
47 )
48 from .coordinator import NexiaDataUpdateCoordinator
49 from .entity import NexiaThermostatZoneEntity
50 from .types import NexiaConfigEntry
51 from .util import percent_conv
52 
53 PARALLEL_UPDATES = 1 # keep data in sync with only one connection at a time
54 
55 SERVICE_SET_AIRCLEANER_MODE = "set_aircleaner_mode"
56 SERVICE_SET_HUMIDIFY_SETPOINT = "set_humidify_setpoint"
57 SERVICE_SET_HVAC_RUN_MODE = "set_hvac_run_mode"
58 
59 SET_AIRCLEANER_SCHEMA: VolDictType = {
60  vol.Required(ATTR_AIRCLEANER_MODE): cv.string,
61 }
62 
63 SET_HUMIDITY_SCHEMA: VolDictType = {
64  vol.Required(ATTR_HUMIDITY): vol.All(vol.Coerce(int), vol.Range(min=35, max=65)),
65 }
66 
67 SET_HVAC_RUN_MODE_SCHEMA = vol.All(
68  cv.has_at_least_one_key(ATTR_RUN_MODE, ATTR_HVAC_MODE),
69  cv.make_entity_service_schema(
70  {
71  vol.Optional(ATTR_RUN_MODE): vol.In([HOLD_PERMANENT, HOLD_RESUME_SCHEDULE]),
72  vol.Optional(ATTR_HVAC_MODE): vol.In(
73  [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO]
74  ),
75  }
76  ),
77 )
78 
79 #
80 # Nexia has two bits to determine hvac mode
81 # There are actually eight states so we map to
82 # the most significant state
83 #
84 # 1. Zone Mode : Auto / Cooling / Heating / Off
85 # 2. Run Mode : Hold / Run Schedule
86 #
87 #
88 HA_TO_NEXIA_HVAC_MODE_MAP = {
89  HVACMode.HEAT: OPERATION_MODE_HEAT,
90  HVACMode.COOL: OPERATION_MODE_COOL,
91  HVACMode.HEAT_COOL: OPERATION_MODE_AUTO,
92  HVACMode.AUTO: OPERATION_MODE_AUTO,
93  HVACMode.OFF: OPERATION_MODE_OFF,
94 }
95 NEXIA_TO_HA_HVAC_MODE_MAP = {
96  value: key for key, value in HA_TO_NEXIA_HVAC_MODE_MAP.items()
97 }
98 
99 HVAC_MODES = [
100  HVACMode.OFF,
101  HVACMode.AUTO,
102  HVACMode.HEAT_COOL,
103  HVACMode.HEAT,
104  HVACMode.COOL,
105 ]
106 
107 NEXIA_SUPPORTED = (
108  ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
109  | ClimateEntityFeature.TARGET_TEMPERATURE
110  | ClimateEntityFeature.FAN_MODE
111  | ClimateEntityFeature.PRESET_MODE
112  | ClimateEntityFeature.TURN_OFF
113  | ClimateEntityFeature.TURN_ON
114 )
115 
116 
118  hass: HomeAssistant,
119  config_entry: NexiaConfigEntry,
120  async_add_entities: AddEntitiesCallback,
121 ) -> None:
122  """Set up climate for a Nexia device."""
123  coordinator = config_entry.runtime_data
124  nexia_home = coordinator.nexia_home
125 
126  platform = entity_platform.async_get_current_platform()
127 
128  platform.async_register_entity_service(
129  SERVICE_SET_HUMIDIFY_SETPOINT,
130  SET_HUMIDITY_SCHEMA,
131  f"async_{SERVICE_SET_HUMIDIFY_SETPOINT}",
132  )
133  platform.async_register_entity_service(
134  SERVICE_SET_AIRCLEANER_MODE,
135  SET_AIRCLEANER_SCHEMA,
136  f"async_{SERVICE_SET_AIRCLEANER_MODE}",
137  )
138  platform.async_register_entity_service(
139  SERVICE_SET_HVAC_RUN_MODE,
140  SET_HVAC_RUN_MODE_SCHEMA,
141  f"async_{SERVICE_SET_HVAC_RUN_MODE}",
142  )
143 
144  entities: list[NexiaZone] = []
145  for thermostat_id in nexia_home.get_thermostat_ids():
146  thermostat: NexiaThermostat = nexia_home.get_thermostat_by_id(thermostat_id)
147  for zone_id in thermostat.get_zone_ids():
148  zone: NexiaThermostatZone = thermostat.get_zone_by_id(zone_id)
149  entities.append(NexiaZone(coordinator, zone))
150 
151  async_add_entities(entities)
152 
153 
155  """Provides Nexia Climate support."""
156 
157  _attr_name = None
158  _enable_turn_on_off_backwards_compatibility = False
159 
160  def __init__(
161  self, coordinator: NexiaDataUpdateCoordinator, zone: NexiaThermostatZone
162  ) -> None:
163  """Initialize the thermostat."""
164  super().__init__(coordinator, zone, zone.zone_id)
165  thermostat = self._thermostat_thermostat
166  unit = thermostat.get_unit()
167  min_humidity, max_humidity = thermostat.get_humidity_setpoint_limits()
168  min_setpoint, max_setpoint = thermostat.get_setpoint_limits()
169  # The has_* calls are stable for the life of the device
170  # and do not do I/O
171  self._has_relative_humidity_has_relative_humidity = thermostat.has_relative_humidity()
172  self._has_emergency_heat_has_emergency_heat = thermostat.has_emergency_heat()
173  self._has_humidify_support_has_humidify_support = thermostat.has_humidify_support()
174  self._has_dehumidify_support_has_dehumidify_support = thermostat.has_dehumidify_support()
175  self._attr_supported_features_attr_supported_features = NEXIA_SUPPORTED
176  if self._has_humidify_support_has_humidify_support or self._has_dehumidify_support_has_dehumidify_support:
177  self._attr_supported_features_attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY
178  if self._has_emergency_heat_has_emergency_heat:
179  self._attr_supported_features_attr_supported_features |= ClimateEntityFeature.AUX_HEAT
180  self._attr_preset_modes_attr_preset_modes = zone.get_presets()
181  self._attr_fan_modes_attr_fan_modes = thermostat.get_fan_modes()
182  self._attr_hvac_modes_attr_hvac_modes = HVAC_MODES
183  self._attr_min_humidity_attr_min_humidity = percent_conv(min_humidity)
184  self._attr_max_humidity_attr_max_humidity = percent_conv(max_humidity)
185  self._attr_min_temp_attr_min_temp = min_setpoint
186  self._attr_max_temp_attr_max_temp = max_setpoint
187  self._attr_temperature_unit_attr_temperature_unit = (
188  UnitOfTemperature.CELSIUS if unit == "C" else UnitOfTemperature.FAHRENHEIT
189  )
190  self._attr_target_temperature_step_attr_target_temperature_step = 0.5 if unit == "C" else 1.0
191 
192  @property
193  def is_fan_on(self):
194  """Blower is on."""
195  return self._thermostat_thermostat.is_blower_active()
196 
197  @property
199  """Return the current temperature."""
200  return self._zone_zone.get_temperature()
201 
202  @property
203  def fan_mode(self):
204  """Return the fan setting."""
205  return self._thermostat_thermostat.get_fan_mode()
206 
207  async def async_set_fan_mode(self, fan_mode: str) -> None:
208  """Set new target fan mode."""
209  await self._thermostat_thermostat.set_fan_mode(fan_mode)
210  self._signal_thermostat_update_signal_thermostat_update()
211 
212  async def async_set_hvac_run_mode(self, run_mode, hvac_mode):
213  """Set the hvac run mode."""
214  if run_mode is not None:
215  if run_mode == HOLD_PERMANENT:
216  await self._zone_zone.set_permanent_hold()
217  else:
218  await self._zone_zone.call_return_to_schedule()
219  if hvac_mode is not None:
220  await self._zone_zone.set_mode(mode=HA_TO_NEXIA_HVAC_MODE_MAP[hvac_mode])
221  self._signal_thermostat_update_signal_thermostat_update()
222 
223  @property
224  def preset_mode(self):
225  """Preset that is active."""
226  return self._zone_zone.get_preset()
227 
228  async def async_set_humidity(self, humidity: int) -> None:
229  """Dehumidify target."""
230  if self._thermostat_thermostat.has_dehumidify_support():
231  await self.async_set_dehumidify_setpointasync_set_dehumidify_setpoint(humidity)
232  else:
233  await self.async_set_humidify_setpointasync_set_humidify_setpoint(humidity)
234  self._signal_thermostat_update_signal_thermostat_update()
235 
236  @property
237  def target_humidity(self):
238  """Humidity indoors setpoint."""
239  if self._has_dehumidify_support_has_dehumidify_support:
240  return percent_conv(self._thermostat_thermostat.get_dehumidify_setpoint())
241  if self._has_humidify_support_has_humidify_support:
242  return percent_conv(self._thermostat_thermostat.get_humidify_setpoint())
243  return None
244 
245  @property
246  def current_humidity(self):
247  """Humidity indoors."""
248  if self._has_relative_humidity_has_relative_humidity:
249  return percent_conv(self._thermostat_thermostat.get_relative_humidity())
250  return None
251 
252  @property
254  """Temperature we try to reach."""
255  current_mode = self._zone_zone.get_current_mode()
256 
257  if current_mode == OPERATION_MODE_COOL:
258  return self._zone_zone.get_cooling_setpoint()
259  if current_mode == OPERATION_MODE_HEAT:
260  return self._zone_zone.get_heating_setpoint()
261  return None
262 
263  @property
265  """Highest temperature we are trying to reach."""
266  current_mode = self._zone_zone.get_current_mode()
267 
268  if current_mode in (OPERATION_MODE_COOL, OPERATION_MODE_HEAT):
269  return None
270  return self._zone_zone.get_cooling_setpoint()
271 
272  @property
274  """Lowest temperature we are trying to reach."""
275  current_mode = self._zone_zone.get_current_mode()
276 
277  if current_mode in (OPERATION_MODE_COOL, OPERATION_MODE_HEAT):
278  return None
279  return self._zone_zone.get_heating_setpoint()
280 
281  @property
282  def hvac_action(self) -> HVACAction:
283  """Operation ie. heat, cool, idle."""
284  system_status = self._thermostat_thermostat.get_system_status()
285  zone_called = self._zone_zone.is_calling()
286 
287  if self._zone_zone.get_requested_mode() == OPERATION_MODE_OFF:
288  return HVACAction.OFF
289  if not zone_called:
290  return HVACAction.IDLE
291  if system_status == SYSTEM_STATUS_COOL:
292  return HVACAction.COOLING
293  if system_status == SYSTEM_STATUS_HEAT:
294  return HVACAction.HEATING
295  if system_status == SYSTEM_STATUS_IDLE:
296  return HVACAction.IDLE
297  return HVACAction.IDLE
298 
299  @property
300  def hvac_mode(self) -> HVACMode:
301  """Return current mode, as the user-visible name."""
302  mode = self._zone_zone.get_requested_mode()
303  hold = self._zone_zone.is_in_permanent_hold()
304 
305  # If the device is in hold mode with
306  # OPERATION_MODE_AUTO
307  # overriding the schedule by still
308  # heating and cooling to the
309  # temp range.
310  if hold and mode == OPERATION_MODE_AUTO:
311  return HVACMode.HEAT_COOL
312 
313  return NEXIA_TO_HA_HVAC_MODE_MAP[mode]
314 
315  async def async_set_temperature(self, **kwargs: Any) -> None:
316  """Set target temperature."""
317  new_heat_temp = kwargs.get(ATTR_TARGET_TEMP_LOW)
318  new_cool_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH)
319  set_temp = kwargs.get(ATTR_TEMPERATURE)
320 
321  deadband = self._thermostat_thermostat.get_deadband()
322  cur_cool_temp = self._zone_zone.get_cooling_setpoint()
323  cur_heat_temp = self._zone_zone.get_heating_setpoint()
324  (min_temp, max_temp) = self._thermostat_thermostat.get_setpoint_limits()
325 
326  # Check that we're not going to hit any minimum or maximum values
327  if new_heat_temp and new_heat_temp + deadband > max_temp:
328  new_heat_temp = max_temp - deadband
329  if new_cool_temp and new_cool_temp - deadband < min_temp:
330  new_cool_temp = min_temp + deadband
331 
332  # Check that we're within the deadband range, fix it if we're not
333  if (
334  new_heat_temp
335  and new_heat_temp != cur_heat_temp
336  and new_cool_temp - new_heat_temp < deadband
337  ):
338  new_cool_temp = new_heat_temp + deadband
339 
340  if (
341  new_cool_temp
342  and new_cool_temp != cur_cool_temp
343  and new_cool_temp - new_heat_temp < deadband
344  ):
345  new_heat_temp = new_cool_temp - deadband
346 
347  await self._zone_zone.set_heat_cool_temp(
348  heat_temperature=new_heat_temp,
349  cool_temperature=new_cool_temp,
350  set_temperature=set_temp,
351  )
352  self._signal_zone_update_signal_zone_update()
353 
354  @property
355  def is_aux_heat(self) -> bool:
356  """Emergency heat state."""
357  return self._thermostat_thermostat.is_emergency_heat_active()
358 
359  @property
360  def extra_state_attributes(self) -> dict[str, str] | None:
361  """Return the device specific state attributes."""
362  if not self._has_relative_humidity_has_relative_humidity:
363  return None
364 
365  attrs = {}
366  if self._has_dehumidify_support_has_dehumidify_support:
367  dehumdify_setpoint = percent_conv(
368  self._thermostat_thermostat.get_dehumidify_setpoint()
369  )
370  attrs[ATTR_DEHUMIDIFY_SETPOINT] = dehumdify_setpoint
371  if self._has_humidify_support_has_humidify_support:
372  humdify_setpoint = percent_conv(self._thermostat_thermostat.get_humidify_setpoint())
373  attrs[ATTR_HUMIDIFY_SETPOINT] = humdify_setpoint
374  return attrs
375 
376  async def async_set_preset_mode(self, preset_mode: str) -> None:
377  """Set the preset mode."""
378  await self._zone_zone.set_preset(preset_mode)
379  self._signal_zone_update_signal_zone_update()
380 
381  async def async_turn_aux_heat_off(self) -> None:
382  """Turn Aux Heat off."""
384  self.hasshasshass,
385  DOMAIN,
386  "migrate_aux_heat",
387  breaks_in_ha_version="2025.4.0",
388  is_fixable=True,
389  is_persistent=True,
390  translation_key="migrate_aux_heat",
391  severity=IssueSeverity.WARNING,
392  )
393  await self._thermostat_thermostat.set_emergency_heat(False)
394  self._signal_thermostat_update_signal_thermostat_update()
395 
396  async def async_turn_aux_heat_on(self) -> None:
397  """Turn Aux Heat on."""
399  self.hasshasshass,
400  DOMAIN,
401  "migrate_aux_heat",
402  breaks_in_ha_version="2025.4.0",
403  is_fixable=True,
404  is_persistent=True,
405  translation_key="migrate_aux_heat",
406  severity=IssueSeverity.WARNING,
407  )
408  await self._thermostat_thermostat.set_emergency_heat(True)
409  self._signal_thermostat_update_signal_thermostat_update()
410 
411  async def async_turn_off(self) -> None:
412  """Turn off the zone."""
413  await self.async_set_hvac_modeasync_set_hvac_modeasync_set_hvac_mode(HVACMode.OFF)
414  self._signal_zone_update_signal_zone_update()
415 
416  async def async_turn_on(self) -> None:
417  """Turn on the zone."""
418  await self.async_set_hvac_modeasync_set_hvac_modeasync_set_hvac_mode(HVACMode.AUTO)
419  self._signal_zone_update_signal_zone_update()
420 
421  async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
422  """Set the system mode (Auto, Heat_Cool, Cool, Heat, etc)."""
423  if hvac_mode == HVACMode.OFF:
424  await self._zone_zone.call_permanent_off()
425  elif hvac_mode == HVACMode.AUTO:
426  await self._zone_zone.call_return_to_schedule()
427  await self._zone_zone.set_mode(mode=OPERATION_MODE_AUTO)
428  else:
429  await self._zone_zone.set_permanent_hold()
430  await self._zone_zone.set_mode(mode=HA_TO_NEXIA_HVAC_MODE_MAP[hvac_mode])
431 
432  self._signal_zone_update_signal_zone_update()
433 
434  async def async_set_aircleaner_mode(self, aircleaner_mode):
435  """Set the aircleaner mode."""
436  await self._thermostat_thermostat.set_air_cleaner(aircleaner_mode)
437  self._signal_thermostat_update_signal_thermostat_update()
438 
439  async def async_set_humidify_setpoint(self, humidity):
440  """Set the humidify setpoint."""
441  target_humidity = find_humidity_setpoint(humidity / 100.0)
442  if self._thermostat_thermostat.get_humidify_setpoint() == target_humidity:
443  # Trying to set the humidify setpoint to the
444  # same value will cause the api to timeout
445  return
446  await self._thermostat_thermostat.set_humidify_setpoint(target_humidity)
447  self._signal_thermostat_update_signal_thermostat_update()
448 
449  async def async_set_dehumidify_setpoint(self, humidity):
450  """Set the dehumidify setpoint."""
451  target_humidity = find_humidity_setpoint(humidity / 100.0)
452  if self._thermostat_thermostat.get_dehumidify_setpoint() == target_humidity:
453  # Trying to set the dehumidify setpoint to the
454  # same value will cause the api to timeout
455  return
456  await self._thermostat_thermostat.set_dehumidify_setpoint(target_humidity)
457  self._signal_thermostat_update_signal_thermostat_update()
None async_set_hvac_mode(self, HVACMode hvac_mode)
Definition: __init__.py:813
None async_set_hvac_mode(self, HVACMode hvac_mode)
Definition: climate.py:421
dict[str, str]|None extra_state_attributes(self)
Definition: climate.py:360
def async_set_hvac_run_mode(self, run_mode, hvac_mode)
Definition: climate.py:212
None __init__(self, NexiaDataUpdateCoordinator coordinator, NexiaThermostatZone zone)
Definition: climate.py:162
None async_set_humidity(self, int humidity)
Definition: climate.py:228
None async_set_temperature(self, **Any kwargs)
Definition: climate.py:315
None async_set_fan_mode(self, str fan_mode)
Definition: climate.py:207
None async_set_preset_mode(self, str preset_mode)
Definition: climate.py:376
def async_set_aircleaner_mode(self, aircleaner_mode)
Definition: climate.py:434
float|None get_temperature(MadVRCoordinator coordinator, str key)
Definition: sensor.py:57
None async_setup_entry(HomeAssistant hass, NexiaConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: climate.py:121
None async_create_issue(HomeAssistant hass, str entry_id)
Definition: repairs.py:69