Home Assistant Unofficial Reference 2024.12.1
type_thermostats.py
Go to the documentation of this file.
1 """Class to hold all thermostat accessories."""
2 
3 import logging
4 from typing import Any
5 
6 from pyhap.const import CATEGORY_THERMOSTAT
7 
9  ATTR_CURRENT_HUMIDITY,
10  ATTR_CURRENT_TEMPERATURE,
11  ATTR_FAN_MODE,
12  ATTR_FAN_MODES,
13  ATTR_HUMIDITY,
14  ATTR_HVAC_ACTION,
15  ATTR_HVAC_MODE,
16  ATTR_HVAC_MODES,
17  ATTR_MAX_TEMP,
18  ATTR_MIN_HUMIDITY,
19  ATTR_MIN_TEMP,
20  ATTR_SWING_MODE,
21  ATTR_SWING_MODES,
22  ATTR_TARGET_TEMP_HIGH,
23  ATTR_TARGET_TEMP_LOW,
24  DEFAULT_MAX_TEMP,
25  DEFAULT_MIN_HUMIDITY,
26  DEFAULT_MIN_TEMP,
27  DOMAIN as DOMAIN_CLIMATE,
28  FAN_AUTO,
29  FAN_HIGH,
30  FAN_LOW,
31  FAN_MEDIUM,
32  FAN_MIDDLE,
33  FAN_OFF,
34  FAN_ON,
35  SERVICE_SET_FAN_MODE,
36  SERVICE_SET_HUMIDITY,
37  SERVICE_SET_HVAC_MODE as SERVICE_SET_HVAC_MODE_THERMOSTAT,
38  SERVICE_SET_SWING_MODE,
39  SERVICE_SET_TEMPERATURE as SERVICE_SET_TEMPERATURE_THERMOSTAT,
40  SWING_BOTH,
41  SWING_HORIZONTAL,
42  SWING_OFF,
43  SWING_ON,
44  SWING_VERTICAL,
45  ClimateEntityFeature,
46  HVACAction,
47  HVACMode,
48 )
50  DOMAIN as DOMAIN_WATER_HEATER,
51  SERVICE_SET_TEMPERATURE as SERVICE_SET_TEMPERATURE_WATER_HEATER,
52 )
53 from homeassistant.const import (
54  ATTR_ENTITY_ID,
55  ATTR_SUPPORTED_FEATURES,
56  ATTR_TEMPERATURE,
57  PERCENTAGE,
58  STATE_UNAVAILABLE,
59  STATE_UNKNOWN,
60  UnitOfTemperature,
61 )
62 from homeassistant.core import State, callback
63 from homeassistant.util.enum import try_parse_enum
65  ordered_list_item_to_percentage,
66  percentage_to_ordered_list_item,
67 )
68 
69 from .accessories import TYPES, HomeAccessory
70 from .const import (
71  CHAR_ACTIVE,
72  CHAR_COOLING_THRESHOLD_TEMPERATURE,
73  CHAR_CURRENT_FAN_STATE,
74  CHAR_CURRENT_HEATING_COOLING,
75  CHAR_CURRENT_HUMIDITY,
76  CHAR_CURRENT_TEMPERATURE,
77  CHAR_HEATING_THRESHOLD_TEMPERATURE,
78  CHAR_ROTATION_SPEED,
79  CHAR_SWING_MODE,
80  CHAR_TARGET_FAN_STATE,
81  CHAR_TARGET_HEATING_COOLING,
82  CHAR_TARGET_HUMIDITY,
83  CHAR_TARGET_TEMPERATURE,
84  CHAR_TEMP_DISPLAY_UNITS,
85  DEFAULT_MAX_TEMP_WATER_HEATER,
86  DEFAULT_MIN_TEMP_WATER_HEATER,
87  PROP_MAX_VALUE,
88  PROP_MIN_STEP,
89  PROP_MIN_VALUE,
90  SERV_FANV2,
91  SERV_THERMOSTAT,
92 )
93 from .util import temperature_to_homekit, temperature_to_states
94 
95 _LOGGER = logging.getLogger(__name__)
96 
97 DEFAULT_HVAC_MODES = [
98  HVACMode.HEAT,
99  HVACMode.COOL,
100  HVACMode.HEAT_COOL,
101  HVACMode.OFF,
102 ]
103 
104 HC_HOMEKIT_VALID_MODES_WATER_HEATER = {"Heat": 1}
105 UNIT_HASS_TO_HOMEKIT = {UnitOfTemperature.CELSIUS: 0, UnitOfTemperature.FAHRENHEIT: 1}
106 
107 HC_HEAT_COOL_OFF = 0
108 HC_HEAT_COOL_HEAT = 1
109 HC_HEAT_COOL_COOL = 2
110 HC_HEAT_COOL_AUTO = 3
111 
112 HC_HEAT_COOL_PREFER_HEAT = [
113  HC_HEAT_COOL_AUTO,
114  HC_HEAT_COOL_HEAT,
115  HC_HEAT_COOL_COOL,
116  HC_HEAT_COOL_OFF,
117 ]
118 
119 HC_HEAT_COOL_PREFER_COOL = [
120  HC_HEAT_COOL_AUTO,
121  HC_HEAT_COOL_COOL,
122  HC_HEAT_COOL_HEAT,
123  HC_HEAT_COOL_OFF,
124 ]
125 
126 ORDERED_FAN_SPEEDS = [FAN_LOW, FAN_MIDDLE, FAN_MEDIUM, FAN_HIGH]
127 PRE_DEFINED_FAN_MODES = set(ORDERED_FAN_SPEEDS)
128 SWING_MODE_PREFERRED_ORDER = [SWING_ON, SWING_BOTH, SWING_HORIZONTAL, SWING_VERTICAL]
129 PRE_DEFINED_SWING_MODES = set(SWING_MODE_PREFERRED_ORDER)
130 
131 HC_MIN_TEMP = 10
132 HC_MAX_TEMP = 38
133 
134 UNIT_HOMEKIT_TO_HASS = {c: s for s, c in UNIT_HASS_TO_HOMEKIT.items()}
135 HC_HASS_TO_HOMEKIT = {
136  HVACMode.OFF: HC_HEAT_COOL_OFF,
137  HVACMode.HEAT: HC_HEAT_COOL_HEAT,
138  HVACMode.COOL: HC_HEAT_COOL_COOL,
139  HVACMode.AUTO: HC_HEAT_COOL_AUTO,
140  HVACMode.HEAT_COOL: HC_HEAT_COOL_AUTO,
141  HVACMode.DRY: HC_HEAT_COOL_COOL,
142  HVACMode.FAN_ONLY: HC_HEAT_COOL_COOL,
143 }
144 HC_HOMEKIT_TO_HASS = {c: s for s, c in HC_HASS_TO_HOMEKIT.items()}
145 
146 HC_HASS_TO_HOMEKIT_ACTION = {
147  HVACAction.OFF: HC_HEAT_COOL_OFF,
148  HVACAction.IDLE: HC_HEAT_COOL_OFF,
149  HVACAction.HEATING: HC_HEAT_COOL_HEAT,
150  HVACAction.COOLING: HC_HEAT_COOL_COOL,
151  HVACAction.DRYING: HC_HEAT_COOL_COOL,
152  HVACAction.FAN: HC_HEAT_COOL_COOL,
153  HVACAction.PREHEATING: HC_HEAT_COOL_HEAT,
154  HVACAction.DEFROSTING: HC_HEAT_COOL_HEAT,
155 }
156 
157 FAN_STATE_INACTIVE = 0
158 FAN_STATE_IDLE = 1
159 FAN_STATE_ACTIVE = 2
160 
161 HC_HASS_TO_HOMEKIT_FAN_STATE = {
162  HVACAction.OFF: FAN_STATE_INACTIVE,
163  HVACAction.IDLE: FAN_STATE_IDLE,
164  HVACAction.HEATING: FAN_STATE_ACTIVE,
165  HVACAction.COOLING: FAN_STATE_ACTIVE,
166  HVACAction.DRYING: FAN_STATE_ACTIVE,
167  HVACAction.FAN: FAN_STATE_ACTIVE,
168 }
169 
170 HEAT_COOL_DEADBAND = 5
171 
172 
173 def _hk_hvac_mode_from_state(state: State) -> int | None:
174  """Return the equivalent HomeKit HVAC mode for a given state."""
175  if (current_state := state.state) in (STATE_UNKNOWN, STATE_UNAVAILABLE):
176  return None
177  if not (hvac_mode := try_parse_enum(HVACMode, current_state)):
178  _LOGGER.error(
179  "%s: Received invalid HVAC mode: %s", state.entity_id, state.state
180  )
181  return None
182  return HC_HASS_TO_HOMEKIT.get(hvac_mode)
183 
184 
185 @TYPES.register("Thermostat")
187  """Generate a Thermostat accessory for a climate."""
188 
189  def __init__(self, *args: Any) -> None:
190  """Initialize a Thermostat accessory object."""
191  super().__init__(*args, category=CATEGORY_THERMOSTAT)
192  self._unit_unit = self.hasshass.config.units.temperature_unit
193  state = self.hasshass.states.get(self.entity_identity_id)
194  assert state
195  hc_min_temp, hc_max_temp = self.get_temperature_rangeget_temperature_range(state)
196  self._reload_on_change_attrs_reload_on_change_attrs.extend(
197  (
198  ATTR_MIN_HUMIDITY,
199  ATTR_MAX_TEMP,
200  ATTR_MIN_TEMP,
201  ATTR_FAN_MODES,
202  ATTR_HVAC_MODES,
203  )
204  )
205 
206  # Add additional characteristics if auto mode is supported
207  self.chars: list[str] = []
208  self.fan_chars: list[str] = []
209 
210  attributes = state.attributes
211  min_humidity = attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY)
212  features = attributes.get(ATTR_SUPPORTED_FEATURES, 0)
213 
214  if features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE:
215  self.chars.extend(
216  (CHAR_COOLING_THRESHOLD_TEMPERATURE, CHAR_HEATING_THRESHOLD_TEMPERATURE)
217  )
218 
219  if (
220  ATTR_CURRENT_HUMIDITY in attributes
221  or features & ClimateEntityFeature.TARGET_HUMIDITY
222  ):
223  self.chars.append(CHAR_CURRENT_HUMIDITY)
224 
225  if features & ClimateEntityFeature.TARGET_HUMIDITY:
226  self.chars.append(CHAR_TARGET_HUMIDITY)
227 
228  serv_thermostat = self.add_preload_service(SERV_THERMOSTAT, self.chars)
229  self.set_primary_service(serv_thermostat)
230 
231  # Current mode characteristics
232  self.char_current_heat_coolchar_current_heat_cool = serv_thermostat.configure_char(
233  CHAR_CURRENT_HEATING_COOLING, value=0
234  )
235 
236  self._configure_hvac_modes_configure_hvac_modes(state)
237  # Must set the value first as setting
238  # valid_values happens before setting
239  # the value and if 0 is not a valid
240  # value this will throw
241  self.char_target_heat_coolchar_target_heat_cool = serv_thermostat.configure_char(
242  CHAR_TARGET_HEATING_COOLING, value=list(self.hc_homekit_to_hasshc_homekit_to_hass)[0]
243  )
244  self.char_target_heat_coolchar_target_heat_cool.override_properties(
245  valid_values=self.hc_hass_to_homekithc_hass_to_homekit
246  )
247  self.char_target_heat_coolchar_target_heat_cool.allow_invalid_client_values = True
248  # Current and target temperature characteristics
249 
250  self.char_current_tempchar_current_temp = serv_thermostat.configure_char(
251  CHAR_CURRENT_TEMPERATURE, value=21.0
252  )
253 
254  self.char_target_tempchar_target_temp = serv_thermostat.configure_char(
255  CHAR_TARGET_TEMPERATURE,
256  value=21.0,
257  # We do not set PROP_MIN_STEP here and instead use the HomeKit
258  # default of 0.1 in order to have enough precision to convert
259  # temperature units and avoid setting to 73F will result in 74F
260  properties={PROP_MIN_VALUE: hc_min_temp, PROP_MAX_VALUE: hc_max_temp},
261  )
262 
263  # Display units characteristic
264  self.char_display_unitschar_display_units = serv_thermostat.configure_char(
265  CHAR_TEMP_DISPLAY_UNITS, value=0
266  )
267 
268  # If the device supports it: high and low temperature characteristics
269  self.char_cooling_thresh_tempchar_cooling_thresh_temp = None
270  self.char_heating_thresh_tempchar_heating_thresh_temp = None
271  if CHAR_COOLING_THRESHOLD_TEMPERATURE in self.chars:
272  self.char_cooling_thresh_tempchar_cooling_thresh_temp = serv_thermostat.configure_char(
273  CHAR_COOLING_THRESHOLD_TEMPERATURE,
274  value=23.0,
275  # We do not set PROP_MIN_STEP here and instead use the HomeKit
276  # default of 0.1 in order to have enough precision to convert
277  # temperature units and avoid setting to 73F will result in 74F
278  properties={PROP_MIN_VALUE: hc_min_temp, PROP_MAX_VALUE: hc_max_temp},
279  )
280  if CHAR_HEATING_THRESHOLD_TEMPERATURE in self.chars:
281  self.char_heating_thresh_tempchar_heating_thresh_temp = serv_thermostat.configure_char(
282  CHAR_HEATING_THRESHOLD_TEMPERATURE,
283  value=19.0,
284  # We do not set PROP_MIN_STEP here and instead use the HomeKit
285  # default of 0.1 in order to have enough precision to convert
286  # temperature units and avoid setting to 73F will result in 74F
287  properties={PROP_MIN_VALUE: hc_min_temp, PROP_MAX_VALUE: hc_max_temp},
288  )
289  self.char_target_humiditychar_target_humidity = None
290  if CHAR_TARGET_HUMIDITY in self.chars:
291  self.char_target_humiditychar_target_humidity = serv_thermostat.configure_char(
292  CHAR_TARGET_HUMIDITY,
293  value=50,
294  # We do not set a max humidity because
295  # homekit currently has a bug that will show the lower bound
296  # shifted upwards. For example if you have a max humidity
297  # of 80% homekit will give you the options 20%-100% instead
298  # of 0-80%
299  properties={PROP_MIN_VALUE: min_humidity},
300  )
301  self.char_current_humiditychar_current_humidity = None
302  if CHAR_CURRENT_HUMIDITY in self.chars:
303  self.char_current_humiditychar_current_humidity = serv_thermostat.configure_char(
304  CHAR_CURRENT_HUMIDITY, value=50
305  )
306 
307  fan_modes: dict[str, str] = {}
308  self.ordered_fan_speedsordered_fan_speeds: list[str] = []
309 
310  if features & ClimateEntityFeature.FAN_MODE:
311  fan_modes = {
312  fan_mode.lower(): fan_mode
313  for fan_mode in attributes.get(ATTR_FAN_MODES) or []
314  }
315  if fan_modes and PRE_DEFINED_FAN_MODES.intersection(fan_modes):
316  self.ordered_fan_speedsordered_fan_speeds = [
317  speed for speed in ORDERED_FAN_SPEEDS if speed in fan_modes
318  ]
319  self.fan_chars.append(CHAR_ROTATION_SPEED)
320 
321  if FAN_AUTO in fan_modes and (FAN_ON in fan_modes or self.ordered_fan_speedsordered_fan_speeds):
322  self.fan_chars.append(CHAR_TARGET_FAN_STATE)
323 
324  self.fan_modesfan_modes = fan_modes
325  if (
326  features & ClimateEntityFeature.SWING_MODE
327  and (swing_modes := attributes.get(ATTR_SWING_MODES))
328  and PRE_DEFINED_SWING_MODES.intersection(swing_modes)
329  ):
330  self.swing_on_modeswing_on_mode = next(
331  iter(
332  swing_mode
333  for swing_mode in SWING_MODE_PREFERRED_ORDER
334  if swing_mode in swing_modes
335  )
336  )
337  self.fan_chars.append(CHAR_SWING_MODE)
338 
339  if self.fan_chars:
340  if attributes.get(ATTR_HVAC_ACTION) is not None:
341  self.fan_chars.append(CHAR_CURRENT_FAN_STATE)
342  serv_fan = self.add_preload_service(SERV_FANV2, self.fan_chars)
343  serv_thermostat.add_linked_service(serv_fan)
344  self.char_activechar_active = serv_fan.configure_char(
345  CHAR_ACTIVE, value=1, setter_callback=self._set_fan_active_set_fan_active
346  )
347  if CHAR_SWING_MODE in self.fan_chars:
348  self.char_swingchar_swing = serv_fan.configure_char(
349  CHAR_SWING_MODE,
350  value=0,
351  setter_callback=self._set_fan_swing_mode_set_fan_swing_mode,
352  )
353  self.char_swingchar_swing.display_name = "Swing Mode"
354  if CHAR_ROTATION_SPEED in self.fan_chars:
355  self.char_speedchar_speed = serv_fan.configure_char(
356  CHAR_ROTATION_SPEED,
357  value=100,
358  properties={PROP_MIN_STEP: 100 / len(self.ordered_fan_speedsordered_fan_speeds)},
359  setter_callback=self._set_fan_speed_set_fan_speed,
360  )
361  self.char_speedchar_speed.display_name = "Fan Mode"
362  if CHAR_CURRENT_FAN_STATE in self.fan_chars:
363  self.char_current_fan_statechar_current_fan_state = serv_fan.configure_char(
364  CHAR_CURRENT_FAN_STATE,
365  value=0,
366  )
367  self.char_current_fan_statechar_current_fan_state.display_name = "Fan State"
368  if CHAR_TARGET_FAN_STATE in self.fan_chars and FAN_AUTO in self.fan_modesfan_modes:
369  self.char_target_fan_statechar_target_fan_state = serv_fan.configure_char(
370  CHAR_TARGET_FAN_STATE,
371  value=0,
372  setter_callback=self._set_fan_auto_set_fan_auto,
373  )
374  self.char_target_fan_statechar_target_fan_state.display_name = "Fan Auto"
375 
376  self.async_update_stateasync_update_stateasync_update_state(state)
377 
378  serv_thermostat.setter_callback = self._set_chars_set_chars
379 
380  def _set_fan_swing_mode(self, swing_on: int) -> None:
381  _LOGGER.debug("%s: Set swing mode to %s", self.entity_identity_id, swing_on)
382  mode = self.swing_on_modeswing_on_mode if swing_on else SWING_OFF
383  params = {ATTR_ENTITY_ID: self.entity_identity_id, ATTR_SWING_MODE: mode}
384  self.async_call_serviceasync_call_service(DOMAIN_CLIMATE, SERVICE_SET_SWING_MODE, params)
385 
386  def _set_fan_speed(self, speed: int) -> None:
387  _LOGGER.debug("%s: Set fan speed to %s", self.entity_identity_id, speed)
388  mode = percentage_to_ordered_list_item(self.ordered_fan_speedsordered_fan_speeds, speed - 1)
389  params = {ATTR_ENTITY_ID: self.entity_identity_id, ATTR_FAN_MODE: mode}
390  self.async_call_serviceasync_call_service(DOMAIN_CLIMATE, SERVICE_SET_FAN_MODE, params)
391 
392  def _get_on_mode(self) -> str:
393  if self.ordered_fan_speedsordered_fan_speeds:
394  return percentage_to_ordered_list_item(self.ordered_fan_speedsordered_fan_speeds, 50)
395  return self.fan_modesfan_modes[FAN_ON]
396 
397  def _set_fan_active(self, active: int) -> None:
398  _LOGGER.debug("%s: Set fan active to %s", self.entity_identity_id, active)
399  if FAN_OFF not in self.fan_modesfan_modes:
400  _LOGGER.debug(
401  "%s: Fan does not support off, resetting to on", self.entity_identity_id
402  )
403  self.char_activechar_active.value = 1
404  self.char_activechar_active.notify()
405  return
406  mode = self._get_on_mode_get_on_mode() if active else self.fan_modesfan_modes[FAN_OFF]
407  params = {ATTR_ENTITY_ID: self.entity_identity_id, ATTR_FAN_MODE: mode}
408  self.async_call_serviceasync_call_service(DOMAIN_CLIMATE, SERVICE_SET_FAN_MODE, params)
409 
410  def _set_fan_auto(self, auto: int) -> None:
411  _LOGGER.debug("%s: Set fan auto to %s", self.entity_identity_id, auto)
412  mode = self.fan_modesfan_modes[FAN_AUTO] if auto else self._get_on_mode_get_on_mode()
413  params = {ATTR_ENTITY_ID: self.entity_identity_id, ATTR_FAN_MODE: mode}
414  self.async_call_serviceasync_call_service(DOMAIN_CLIMATE, SERVICE_SET_FAN_MODE, params)
415 
416  def _temperature_to_homekit(self, temp: float) -> float:
417  return temperature_to_homekit(temp, self._unit_unit)
418 
419  def _temperature_to_states(self, temp: float) -> float:
420  return temperature_to_states(temp, self._unit_unit)
421 
422  def _set_chars(self, char_values: dict[str, Any]) -> None:
423  _LOGGER.debug("Thermostat _set_chars: %s", char_values)
424  events = []
425  params: dict[str, Any] = {ATTR_ENTITY_ID: self.entity_identity_id}
426  service = None
427  state = self.hasshass.states.get(self.entity_identity_id)
428  assert state
429  features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
430  homekit_hvac_mode = _hk_hvac_mode_from_state(state)
431  # Homekit will reset the mode when VIEWING the temp
432  # Ignore it if its the same mode
433  if (
434  CHAR_TARGET_HEATING_COOLING in char_values
435  and char_values[CHAR_TARGET_HEATING_COOLING] != homekit_hvac_mode
436  ):
437  target_hc = char_values[CHAR_TARGET_HEATING_COOLING]
438  if target_hc not in self.hc_homekit_to_hasshc_homekit_to_hass:
439  # If the target heating cooling state we want does not
440  # exist on the device, we have to sort it out
441  # based on the current and target temperature since
442  # siri will always send HC_HEAT_COOL_AUTO in this case
443  # and hope for the best.
444  hc_target_temp = char_values.get(CHAR_TARGET_TEMPERATURE)
445  hc_current_temp = _get_current_temperature(state, self._unit_unit)
446  hc_fallback_order = HC_HEAT_COOL_PREFER_HEAT
447  if (
448  hc_target_temp is not None
449  and hc_current_temp is not None
450  and hc_target_temp < hc_current_temp
451  ):
452  hc_fallback_order = HC_HEAT_COOL_PREFER_COOL
453  for hc_fallback in hc_fallback_order:
454  if hc_fallback in self.hc_homekit_to_hasshc_homekit_to_hass:
455  _LOGGER.debug(
456  (
457  "Siri requested target mode: %s and the device does not"
458  " support, falling back to %s"
459  ),
460  target_hc,
461  hc_fallback,
462  )
463  self.char_target_heat_coolchar_target_heat_cool.value = target_hc = hc_fallback
464  break
465 
466  params[ATTR_HVAC_MODE] = self.hc_homekit_to_hasshc_homekit_to_hass[target_hc]
467  events.append(
468  f"{CHAR_TARGET_HEATING_COOLING} to"
469  f" {char_values[CHAR_TARGET_HEATING_COOLING]}"
470  )
471  # Many integrations do not actually implement `hvac_mode` for the
472  # `SERVICE_SET_TEMPERATURE_THERMOSTAT` service so we made a call to
473  # `SERVICE_SET_HVAC_MODE_THERMOSTAT` before calling `SERVICE_SET_TEMPERATURE_THERMOSTAT`
474  # to ensure the device is in the right mode before setting the temp.
475  self.async_call_serviceasync_call_service(
476  DOMAIN_CLIMATE,
477  SERVICE_SET_HVAC_MODE_THERMOSTAT,
478  params.copy(),
479  ", ".join(events),
480  )
481 
482  if CHAR_TARGET_TEMPERATURE in char_values:
483  hc_target_temp = char_values[CHAR_TARGET_TEMPERATURE]
484  if features & ClimateEntityFeature.TARGET_TEMPERATURE:
485  service = SERVICE_SET_TEMPERATURE_THERMOSTAT
486  temperature = self._temperature_to_states_temperature_to_states(hc_target_temp)
487  events.append(
488  f"{CHAR_TARGET_TEMPERATURE} to"
489  f" {char_values[CHAR_TARGET_TEMPERATURE]}°C"
490  )
491  params[ATTR_TEMPERATURE] = temperature
492  elif features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE:
493  # Homekit will send us a target temperature
494  # even if the device does not support it
495  _LOGGER.debug(
496  "Homekit requested target temp: %s and the device does not support",
497  hc_target_temp,
498  )
499  if (
500  homekit_hvac_mode == HC_HEAT_COOL_HEAT
501  and CHAR_HEATING_THRESHOLD_TEMPERATURE not in char_values
502  ):
503  char_values[CHAR_HEATING_THRESHOLD_TEMPERATURE] = hc_target_temp
504  if (
505  homekit_hvac_mode == HC_HEAT_COOL_COOL
506  and CHAR_COOLING_THRESHOLD_TEMPERATURE not in char_values
507  ):
508  char_values[CHAR_COOLING_THRESHOLD_TEMPERATURE] = hc_target_temp
509 
510  if (
511  CHAR_HEATING_THRESHOLD_TEMPERATURE in char_values
512  or CHAR_COOLING_THRESHOLD_TEMPERATURE in char_values
513  ):
514  assert self.char_cooling_thresh_tempchar_cooling_thresh_temp
515  assert self.char_heating_thresh_tempchar_heating_thresh_temp
516  service = SERVICE_SET_TEMPERATURE_THERMOSTAT
517  high = self.char_cooling_thresh_tempchar_cooling_thresh_temp.value
518  low = self.char_heating_thresh_tempchar_heating_thresh_temp.value
519  min_temp, max_temp = self.get_temperature_rangeget_temperature_range(state)
520  if CHAR_COOLING_THRESHOLD_TEMPERATURE in char_values:
521  events.append(
522  f"{CHAR_COOLING_THRESHOLD_TEMPERATURE} to"
523  f" {char_values[CHAR_COOLING_THRESHOLD_TEMPERATURE]}°C"
524  )
525  high = char_values[CHAR_COOLING_THRESHOLD_TEMPERATURE]
526  # If the device doesn't support TARGET_TEMPATURE
527  # this can happen
528  if high < low:
529  low = high - HEAT_COOL_DEADBAND
530  if CHAR_HEATING_THRESHOLD_TEMPERATURE in char_values:
531  events.append(
532  f"{CHAR_HEATING_THRESHOLD_TEMPERATURE} to"
533  f" {char_values[CHAR_HEATING_THRESHOLD_TEMPERATURE]}°C"
534  )
535  low = char_values[CHAR_HEATING_THRESHOLD_TEMPERATURE]
536  # If the device doesn't support TARGET_TEMPATURE
537  # this can happen
538  if low > high:
539  high = low + HEAT_COOL_DEADBAND
540 
541  high = min(high, max_temp)
542  low = max(low, min_temp)
543 
544  params.update(
545  {
546  ATTR_TARGET_TEMP_HIGH: self._temperature_to_states_temperature_to_states(high),
547  ATTR_TARGET_TEMP_LOW: self._temperature_to_states_temperature_to_states(low),
548  }
549  )
550 
551  if service:
552  self.async_call_serviceasync_call_service(
553  DOMAIN_CLIMATE,
554  service,
555  params,
556  ", ".join(events),
557  )
558 
559  if CHAR_TARGET_HUMIDITY in char_values:
560  self.set_target_humidityset_target_humidity(char_values[CHAR_TARGET_HUMIDITY])
561 
562  def _configure_hvac_modes(self, state: State) -> None:
563  """Configure target mode characteristics."""
564  # This cannot be none OR an empty list
565  hc_modes = state.attributes.get(ATTR_HVAC_MODES) or DEFAULT_HVAC_MODES
566  # Determine available modes for this entity,
567  # Prefer HEAT_COOL over AUTO and COOL over FAN_ONLY, DRY
568  #
569  # HEAT_COOL is preferred over auto because HomeKit Accessory Protocol describes
570  # heating or cooling comes on to maintain a target temp which is closest to
571  # the Home Assistant spec
572  #
573  # HVACMode.HEAT_COOL: The device supports heating/cooling to a range
574  self.hc_homekit_to_hasshc_homekit_to_hass = {
575  c: s
576  for s, c in HC_HASS_TO_HOMEKIT.items()
577  if (
578  s in hc_modes
579  and not (
580  (s == HVACMode.AUTO and HVACMode.HEAT_COOL in hc_modes)
581  or (
582  s in (HVACMode.DRY, HVACMode.FAN_ONLY)
583  and HVACMode.COOL in hc_modes
584  )
585  )
586  )
587  }
588  self.hc_hass_to_homekithc_hass_to_homekit = {k: v for v, k in self.hc_homekit_to_hasshc_homekit_to_hass.items()}
589 
590  def get_temperature_range(self, state: State) -> tuple[float, float]:
591  """Return min and max temperature range."""
593  state,
594  self._unit_unit,
595  DEFAULT_MIN_TEMP,
596  DEFAULT_MAX_TEMP,
597  )
598 
599  def set_target_humidity(self, value: float) -> None:
600  """Set target humidity to value if call came from HomeKit."""
601  _LOGGER.debug("%s: Set target humidity to %d", self.entity_identity_id, value)
602  params = {ATTR_ENTITY_ID: self.entity_identity_id, ATTR_HUMIDITY: value}
603  self.async_call_serviceasync_call_service(
604  DOMAIN_CLIMATE, SERVICE_SET_HUMIDITY, params, f"{value}{PERCENTAGE}"
605  )
606 
607  @callback
608  def async_update_state(self, new_state: State) -> None:
609  """Update state without rechecking the device features."""
610  attributes = new_state.attributes
611  features = attributes.get(ATTR_SUPPORTED_FEATURES, 0)
612 
613  # Update target operation mode FIRST
614  if (homekit_hvac_mode := _hk_hvac_mode_from_state(new_state)) is not None:
615  if homekit_hvac_mode in self.hc_homekit_to_hasshc_homekit_to_hass:
616  self.char_target_heat_coolchar_target_heat_cool.set_value(homekit_hvac_mode)
617  else:
618  _LOGGER.error(
619  (
620  "Cannot map hvac target mode: %s to homekit as only %s modes"
621  " are supported"
622  ),
623  new_state.state,
624  self.hc_homekit_to_hasshc_homekit_to_hass,
625  )
626 
627  # Set current operation mode for supported thermostats
628  if hvac_action := attributes.get(ATTR_HVAC_ACTION):
629  self.char_current_heat_coolchar_current_heat_cool.set_value(
630  HC_HASS_TO_HOMEKIT_ACTION.get(hvac_action, HC_HEAT_COOL_OFF)
631  )
632 
633  # Update current temperature
634  current_temp = _get_current_temperature(new_state, self._unit_unit)
635  if current_temp is not None:
636  self.char_current_tempchar_current_temp.set_value(current_temp)
637 
638  # Update current humidity
639  if CHAR_CURRENT_HUMIDITY in self.chars:
640  assert self.char_current_humiditychar_current_humidity
641  current_humdity = attributes.get(ATTR_CURRENT_HUMIDITY)
642  if isinstance(current_humdity, (int, float)):
643  self.char_current_humiditychar_current_humidity.set_value(current_humdity)
644 
645  # Update target humidity
646  if CHAR_TARGET_HUMIDITY in self.chars:
647  assert self.char_target_humiditychar_target_humidity
648  target_humdity = attributes.get(ATTR_HUMIDITY)
649  if isinstance(target_humdity, (int, float)):
650  self.char_target_humiditychar_target_humidity.set_value(target_humdity)
651 
652  # Update cooling threshold temperature if characteristic exists
653  if self.char_cooling_thresh_tempchar_cooling_thresh_temp:
654  cooling_thresh = attributes.get(ATTR_TARGET_TEMP_HIGH)
655  if isinstance(cooling_thresh, (int, float)):
656  cooling_thresh = self._temperature_to_homekit_temperature_to_homekit(cooling_thresh)
657  self.char_cooling_thresh_tempchar_cooling_thresh_temp.set_value(cooling_thresh)
658 
659  # Update heating threshold temperature if characteristic exists
660  if self.char_heating_thresh_tempchar_heating_thresh_temp:
661  heating_thresh = attributes.get(ATTR_TARGET_TEMP_LOW)
662  if isinstance(heating_thresh, (int, float)):
663  heating_thresh = self._temperature_to_homekit_temperature_to_homekit(heating_thresh)
664  self.char_heating_thresh_tempchar_heating_thresh_temp.set_value(heating_thresh)
665 
666  # Update target temperature
667  target_temp = _get_target_temperature(new_state, self._unit_unit)
668  if (
669  target_temp is None
670  and features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
671  ):
672  # Homekit expects a target temperature
673  # even if the device does not support it
674  hc_hvac_mode = self.char_target_heat_coolchar_target_heat_cool.value
675  if hc_hvac_mode == HC_HEAT_COOL_HEAT:
676  temp_low = attributes.get(ATTR_TARGET_TEMP_LOW)
677  if isinstance(temp_low, (int, float)):
678  target_temp = self._temperature_to_homekit_temperature_to_homekit(temp_low)
679  elif hc_hvac_mode == HC_HEAT_COOL_COOL:
680  temp_high = attributes.get(ATTR_TARGET_TEMP_HIGH)
681  if isinstance(temp_high, (int, float)):
682  target_temp = self._temperature_to_homekit_temperature_to_homekit(temp_high)
683  if target_temp:
684  self.char_target_tempchar_target_temp.set_value(target_temp)
685 
686  # Update display units
687  if self._unit_unit and self._unit_unit in UNIT_HASS_TO_HOMEKIT:
688  unit = UNIT_HASS_TO_HOMEKIT[self._unit_unit]
689  self.char_display_unitschar_display_units.set_value(unit)
690 
691  if self.fan_chars:
692  self._async_update_fan_state_async_update_fan_state(new_state)
693 
694  @callback
695  def _async_update_fan_state(self, new_state: State) -> None:
696  """Update state without rechecking the device features."""
697  attributes = new_state.attributes
698 
699  if CHAR_SWING_MODE in self.fan_chars and (
700  swing_mode := attributes.get(ATTR_SWING_MODE)
701  ):
702  swing = 1 if swing_mode in PRE_DEFINED_SWING_MODES else 0
703  self.char_swingchar_swing.set_value(swing)
704 
705  fan_mode = attributes.get(ATTR_FAN_MODE)
706  fan_mode_lower = fan_mode.lower() if isinstance(fan_mode, str) else None
707  if (
708  CHAR_ROTATION_SPEED in self.fan_chars
709  and fan_mode_lower in self.ordered_fan_speedsordered_fan_speeds
710  ):
711  self.char_speedchar_speed.set_value(
712  ordered_list_item_to_percentage(self.ordered_fan_speedsordered_fan_speeds, fan_mode_lower)
713  )
714 
715  if CHAR_TARGET_FAN_STATE in self.fan_chars:
716  self.char_target_fan_statechar_target_fan_state.set_value(1 if fan_mode_lower == FAN_AUTO else 0)
717 
718  if CHAR_CURRENT_FAN_STATE in self.fan_chars and (
719  hvac_action := attributes.get(ATTR_HVAC_ACTION)
720  ):
721  self.char_current_fan_statechar_current_fan_state.set_value(
722  HC_HASS_TO_HOMEKIT_FAN_STATE[hvac_action]
723  )
724 
725  self.char_activechar_active.set_value(
726  int(new_state.state != HVACMode.OFF and fan_mode_lower != FAN_OFF)
727  )
728 
729 
730 @TYPES.register("WaterHeater")
732  """Generate a WaterHeater accessory for a water_heater."""
733 
734  def __init__(self, *args: Any) -> None:
735  """Initialize a WaterHeater accessory object."""
736  super().__init__(*args, category=CATEGORY_THERMOSTAT)
737  self._reload_on_change_attrs_reload_on_change_attrs.extend(
738  (
739  ATTR_MAX_TEMP,
740  ATTR_MIN_TEMP,
741  )
742  )
743  self._unit_unit = self.hasshass.config.units.temperature_unit
744  state = self.hasshass.states.get(self.entity_identity_id)
745  assert state
746  min_temp, max_temp = self.get_temperature_rangeget_temperature_range(state)
747 
748  serv_thermostat = self.add_preload_service(SERV_THERMOSTAT)
749 
750  self.char_current_heat_coolchar_current_heat_cool = serv_thermostat.configure_char(
751  CHAR_CURRENT_HEATING_COOLING, value=1
752  )
753  self.char_target_heat_coolchar_target_heat_cool = serv_thermostat.configure_char(
754  CHAR_TARGET_HEATING_COOLING,
755  value=1,
756  setter_callback=self.set_heat_coolset_heat_cool,
757  valid_values=HC_HOMEKIT_VALID_MODES_WATER_HEATER,
758  )
759 
760  self.char_current_tempchar_current_temp = serv_thermostat.configure_char(
761  CHAR_CURRENT_TEMPERATURE, value=50.0
762  )
763  self.char_target_tempchar_target_temp = serv_thermostat.configure_char(
764  CHAR_TARGET_TEMPERATURE,
765  value=50.0,
766  # We do not set PROP_MIN_STEP here and instead use the HomeKit
767  # default of 0.1 in order to have enough precision to convert
768  # temperature units and avoid setting to 73F will result in 74F
769  properties={PROP_MIN_VALUE: min_temp, PROP_MAX_VALUE: max_temp},
770  setter_callback=self.set_target_temperatureset_target_temperature,
771  )
772 
773  self.char_display_unitschar_display_units = serv_thermostat.configure_char(
774  CHAR_TEMP_DISPLAY_UNITS, value=0
775  )
776 
777  self.async_update_stateasync_update_stateasync_update_state(state)
778 
779  def get_temperature_range(self, state: State) -> tuple[float, float]:
780  """Return min and max temperature range."""
782  state,
783  self._unit_unit,
784  DEFAULT_MIN_TEMP_WATER_HEATER,
785  DEFAULT_MAX_TEMP_WATER_HEATER,
786  )
787 
788  def set_heat_cool(self, value: int) -> None:
789  """Change operation mode to value if call came from HomeKit."""
790  _LOGGER.debug("%s: Set heat-cool to %d", self.entity_identity_id, value)
791  if HC_HOMEKIT_TO_HASS[value] != HVACMode.HEAT:
792  self.char_target_heat_coolchar_target_heat_cool.set_value(1) # Heat
793 
794  def set_target_temperature(self, value: float) -> None:
795  """Set target temperature to value if call came from HomeKit."""
796  _LOGGER.debug("%s: Set target temperature to %.1f°C", self.entity_identity_id, value)
797  temperature = temperature_to_states(value, self._unit_unit)
798  params = {ATTR_ENTITY_ID: self.entity_identity_id, ATTR_TEMPERATURE: temperature}
799  self.async_call_serviceasync_call_service(
800  DOMAIN_WATER_HEATER,
801  SERVICE_SET_TEMPERATURE_WATER_HEATER,
802  params,
803  f"{temperature}{self._unit}",
804  )
805 
806  @callback
807  def async_update_state(self, new_state: State) -> None:
808  """Update water_heater state after state change."""
809  # Update current and target temperature
810  target_temperature = _get_target_temperature(new_state, self._unit_unit)
811  if target_temperature is not None:
812  self.char_target_tempchar_target_temp.set_value(target_temperature)
813 
814  current_temperature = _get_current_temperature(new_state, self._unit_unit)
815  if current_temperature is not None:
816  self.char_current_tempchar_current_temp.set_value(current_temperature)
817 
818  # Update display units
819  if self._unit_unit and self._unit_unit in UNIT_HASS_TO_HOMEKIT:
820  unit = UNIT_HASS_TO_HOMEKIT[self._unit_unit]
821  self.char_display_unitschar_display_units.set_value(unit)
822 
823  # Update target operation mode
824  if new_state.state:
825  self.char_target_heat_coolchar_target_heat_cool.set_value(1) # Heat
826 
827 
829  state: State, unit: str, default_min: float, default_max: float
830 ) -> tuple[float, float]:
831  """Calculate the temperature range from a state."""
832  if min_temp := state.attributes.get(ATTR_MIN_TEMP):
833  min_temp = round(temperature_to_homekit(min_temp, unit) * 2) / 2
834  else:
835  min_temp = default_min
836 
837  if max_temp := state.attributes.get(ATTR_MAX_TEMP):
838  max_temp = round(temperature_to_homekit(max_temp, unit) * 2) / 2
839  else:
840  max_temp = default_max
841 
842  # Homekit only supports 10-38, overwriting
843  # the max to appears to work, but less than 0 causes
844  # a crash on the home app
845  min_temp = max(min_temp, 0)
846  max_temp = max(max_temp, min_temp)
847 
848  return min_temp, max_temp
849 
850 
851 def _get_target_temperature(state: State, unit: str) -> float | None:
852  """Calculate the target temperature from a state."""
853  target_temp = state.attributes.get(ATTR_TEMPERATURE)
854  if isinstance(target_temp, (int, float)):
855  return temperature_to_homekit(target_temp, unit)
856  return None
857 
858 
859 def _get_current_temperature(state: State, unit: str) -> float | None:
860  """Calculate the current temperature from a state."""
861  target_temp = state.attributes.get(ATTR_CURRENT_TEMPERATURE)
862  if isinstance(target_temp, (int, float)):
863  return temperature_to_homekit(target_temp, unit)
864  return None
None async_call_service(self, str domain, str service, dict[str, Any]|None service_data, Any|None value=None)
Definition: accessories.py:609
tuple[float, float] get_temperature_range(self, State state)
tuple[float, float] get_temperature_range(self, State state)
float|None _get_current_temperature(State state, str unit)
float|None _get_target_temperature(State state, str unit)
tuple[float, float] _get_temperature_range_from_state(State state, str unit, float default_min, float default_max)