Home Assistant Unofficial Reference 2024.12.1
climate.py
Go to the documentation of this file.
1 """Support for KNX/IP climate devices."""
2 
3 from __future__ import annotations
4 
5 from typing import Any
6 
7 from xknx import XKNX
8 from xknx.devices import (
9  Climate as XknxClimate,
10  ClimateMode as XknxClimateMode,
11  Device as XknxDevice,
12 )
13 from xknx.devices.fan import FanSpeedMode
14 from xknx.dpt.dpt_20 import HVACControllerMode, HVACOperationMode
15 
16 from homeassistant import config_entries
18  FAN_HIGH,
19  FAN_LOW,
20  FAN_MEDIUM,
21  FAN_ON,
22  ClimateEntity,
23  ClimateEntityFeature,
24  HVACAction,
25  HVACMode,
26 )
27 from homeassistant.const import (
28  ATTR_TEMPERATURE,
29  CONF_ENTITY_CATEGORY,
30  CONF_NAME,
31  Platform,
32  UnitOfTemperature,
33 )
34 from homeassistant.core import HomeAssistant
35 from homeassistant.helpers.entity_platform import AddEntitiesCallback
36 from homeassistant.helpers.typing import ConfigType
37 
38 from . import KNXModule
39 from .const import CONTROLLER_MODES, CURRENT_HVAC_ACTIONS, KNX_MODULE_KEY
40 from .entity import KnxYamlEntity
41 from .schema import ClimateSchema
42 
43 ATTR_COMMAND_VALUE = "command_value"
44 CONTROLLER_MODES_INV = {value: key for key, value in CONTROLLER_MODES.items()}
45 
46 
48  hass: HomeAssistant,
49  config_entry: config_entries.ConfigEntry,
50  async_add_entities: AddEntitiesCallback,
51 ) -> None:
52  """Set up climate(s) for KNX platform."""
53  knx_module = hass.data[KNX_MODULE_KEY]
54  config: list[ConfigType] = knx_module.config_yaml[Platform.CLIMATE]
55 
57  KNXClimate(knx_module, entity_config) for entity_config in config
58  )
59 
60 
61 def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate:
62  """Return a KNX Climate device to be used within XKNX."""
63  climate_mode = XknxClimateMode(
64  xknx,
65  name=f"{config[CONF_NAME]} Mode",
66  group_address_operation_mode=config.get(
67  ClimateSchema.CONF_OPERATION_MODE_ADDRESS
68  ),
69  group_address_operation_mode_state=config.get(
70  ClimateSchema.CONF_OPERATION_MODE_STATE_ADDRESS
71  ),
72  group_address_controller_status=config.get(
73  ClimateSchema.CONF_CONTROLLER_STATUS_ADDRESS
74  ),
75  group_address_controller_status_state=config.get(
76  ClimateSchema.CONF_CONTROLLER_STATUS_STATE_ADDRESS
77  ),
78  group_address_controller_mode=config.get(
79  ClimateSchema.CONF_CONTROLLER_MODE_ADDRESS
80  ),
81  group_address_controller_mode_state=config.get(
82  ClimateSchema.CONF_CONTROLLER_MODE_STATE_ADDRESS
83  ),
84  group_address_operation_mode_protection=config.get(
85  ClimateSchema.CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS
86  ),
87  group_address_operation_mode_economy=config.get(
88  ClimateSchema.CONF_OPERATION_MODE_NIGHT_ADDRESS
89  ),
90  group_address_operation_mode_comfort=config.get(
91  ClimateSchema.CONF_OPERATION_MODE_COMFORT_ADDRESS
92  ),
93  group_address_operation_mode_standby=config.get(
94  ClimateSchema.CONF_OPERATION_MODE_STANDBY_ADDRESS
95  ),
96  group_address_heat_cool=config.get(ClimateSchema.CONF_HEAT_COOL_ADDRESS),
97  group_address_heat_cool_state=config.get(
98  ClimateSchema.CONF_HEAT_COOL_STATE_ADDRESS
99  ),
100  operation_modes=config.get(ClimateSchema.CONF_OPERATION_MODES),
101  controller_modes=config.get(ClimateSchema.CONF_CONTROLLER_MODES),
102  )
103 
104  return XknxClimate(
105  xknx,
106  name=config[CONF_NAME],
107  group_address_temperature=config[ClimateSchema.CONF_TEMPERATURE_ADDRESS],
108  group_address_target_temperature=config.get(
109  ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS
110  ),
111  group_address_target_temperature_state=config[
112  ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS
113  ],
114  group_address_setpoint_shift=config.get(
115  ClimateSchema.CONF_SETPOINT_SHIFT_ADDRESS
116  ),
117  group_address_setpoint_shift_state=config.get(
118  ClimateSchema.CONF_SETPOINT_SHIFT_STATE_ADDRESS
119  ),
120  setpoint_shift_mode=config.get(ClimateSchema.CONF_SETPOINT_SHIFT_MODE),
121  setpoint_shift_max=config[ClimateSchema.CONF_SETPOINT_SHIFT_MAX],
122  setpoint_shift_min=config[ClimateSchema.CONF_SETPOINT_SHIFT_MIN],
123  temperature_step=config[ClimateSchema.CONF_TEMPERATURE_STEP],
124  group_address_on_off=config.get(ClimateSchema.CONF_ON_OFF_ADDRESS),
125  group_address_on_off_state=config.get(ClimateSchema.CONF_ON_OFF_STATE_ADDRESS),
126  on_off_invert=config[ClimateSchema.CONF_ON_OFF_INVERT],
127  group_address_active_state=config.get(ClimateSchema.CONF_ACTIVE_STATE_ADDRESS),
128  group_address_command_value_state=config.get(
129  ClimateSchema.CONF_COMMAND_VALUE_STATE_ADDRESS
130  ),
131  min_temp=config.get(ClimateSchema.CONF_MIN_TEMP),
132  max_temp=config.get(ClimateSchema.CONF_MAX_TEMP),
133  mode=climate_mode,
134  group_address_fan_speed=config.get(ClimateSchema.CONF_FAN_SPEED_ADDRESS),
135  group_address_fan_speed_state=config.get(
136  ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS
137  ),
138  fan_speed_mode=config[ClimateSchema.CONF_FAN_SPEED_MODE],
139  group_address_humidity_state=config.get(
140  ClimateSchema.CONF_HUMIDITY_STATE_ADDRESS
141  ),
142  )
143 
144 
146  """Representation of a KNX climate device."""
147 
148  _device: XknxClimate
149  _attr_temperature_unit = UnitOfTemperature.CELSIUS
150  _attr_translation_key = "knx_climate"
151  _enable_turn_on_off_backwards_compatibility = False
152 
153  def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
154  """Initialize of a KNX climate device."""
155  super().__init__(
156  knx_module=knx_module,
157  device=_create_climate(knx_module.xknx, config),
158  )
159  self._attr_entity_category_attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
160  self._attr_supported_features_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
161  if self._device_device.supports_on_off:
162  self._attr_supported_features_attr_supported_features |= (
163  ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
164  )
165  if (
166  self._device_device.mode is not None
167  and len(self._device_device.mode.controller_modes) >= 2
168  and HVACControllerMode.OFF in self._device_device.mode.controller_modes
169  ):
170  self._attr_supported_features_attr_supported_features |= (
171  ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
172  )
173 
174  if (
175  self._device_device.mode is not None
176  and self._device_device.mode.operation_modes # empty list when not writable
177  ):
178  self._attr_supported_features_attr_supported_features |= ClimateEntityFeature.PRESET_MODE
179  self._attr_preset_modes_attr_preset_modes = [
180  mode.name.lower() for mode in self._device_device.mode.operation_modes
181  ]
182 
183  fan_max_step = config[ClimateSchema.CONF_FAN_MAX_STEP]
184  self._fan_modes_percentages_fan_modes_percentages = [
185  int(100 * i / fan_max_step) for i in range(fan_max_step + 1)
186  ]
187  self.fan_zero_mode: str = config[ClimateSchema.CONF_FAN_ZERO_MODE]
188 
189  if self._device_device.fan_speed is not None and self._device_device.fan_speed.initialized:
190  self._attr_supported_features_attr_supported_features |= ClimateEntityFeature.FAN_MODE
191 
192  if fan_max_step == 3:
193  self._attr_fan_modes_attr_fan_modes = [
194  self.fan_zero_mode,
195  FAN_LOW,
196  FAN_MEDIUM,
197  FAN_HIGH,
198  ]
199  elif fan_max_step == 2:
200  self._attr_fan_modes_attr_fan_modes = [self.fan_zero_mode, FAN_LOW, FAN_HIGH]
201  elif fan_max_step == 1:
202  self._attr_fan_modes_attr_fan_modes = [self.fan_zero_mode, FAN_ON]
203  elif self._device_device.fan_speed_mode == FanSpeedMode.STEP:
204  self._attr_fan_modes_attr_fan_modes = [self.fan_zero_mode] + [
205  str(i) for i in range(1, fan_max_step + 1)
206  ]
207  else:
208  self._attr_fan_modes_attr_fan_modes = [self.fan_zero_mode] + [
209  f"{percentage}%" for percentage in self._fan_modes_percentages_fan_modes_percentages[1:]
210  ]
211 
212  self._attr_target_temperature_step_attr_target_temperature_step = self._device_device.temperature_step
213  self._attr_unique_id_attr_unique_id = (
214  f"{self._device.temperature.group_address_state}_"
215  f"{self._device.target_temperature.group_address_state}_"
216  f"{self._device.target_temperature.group_address}_"
217  f"{self._device._setpoint_shift.group_address}" # noqa: SLF001
218  )
219  self.default_hvac_mode: HVACMode = config[
220  ClimateSchema.CONF_DEFAULT_CONTROLLER_MODE
221  ]
222  # non-OFF HVAC mode to be used when turning on the device without on_off address
223  self._last_hvac_mode_last_hvac_mode: HVACMode = self.default_hvac_mode
224 
225  @property
226  def current_temperature(self) -> float | None:
227  """Return the current temperature."""
228  return self._device_device.temperature.value
229 
230  @property
231  def target_temperature(self) -> float | None:
232  """Return the temperature we try to reach."""
233  return self._device_device.target_temperature.value
234 
235  @property
236  def min_temp(self) -> float:
237  """Return the minimum temperature."""
238  temp = self._device_device.target_temperature_min
239  return temp if temp is not None else super().min_temp
240 
241  @property
242  def max_temp(self) -> float:
243  """Return the maximum temperature."""
244  temp = self._device_device.target_temperature_max
245  return temp if temp is not None else super().max_temp
246 
247  async def async_turn_on(self) -> None:
248  """Turn the entity on."""
249  if self._device_device.supports_on_off:
250  await self._device_device.turn_on()
251  self.async_write_ha_stateasync_write_ha_state()
252  return
253 
254  if (
255  self._device_device.mode is not None
256  and self._device_device.mode.supports_controller_mode
257  and (knx_controller_mode := CONTROLLER_MODES_INV.get(self._last_hvac_mode_last_hvac_mode))
258  is not None
259  ):
260  await self._device_device.mode.set_controller_mode(knx_controller_mode)
261  self.async_write_ha_stateasync_write_ha_state()
262 
263  async def async_turn_off(self) -> None:
264  """Turn the entity off."""
265  if self._device_device.supports_on_off:
266  await self._device_device.turn_off()
267  self.async_write_ha_stateasync_write_ha_state()
268  return
269 
270  if (
271  self._device_device.mode is not None
272  and HVACControllerMode.OFF in self._device_device.mode.controller_modes
273  ):
274  await self._device_device.mode.set_controller_mode(HVACControllerMode.OFF)
275  self.async_write_ha_stateasync_write_ha_state()
276 
277  async def async_set_temperature(self, **kwargs: Any) -> None:
278  """Set new target temperature."""
279  temperature = kwargs.get(ATTR_TEMPERATURE)
280  if temperature is not None:
281  await self._device_device.set_target_temperature(temperature)
282  self.async_write_ha_stateasync_write_ha_state()
283 
284  @property
285  def hvac_mode(self) -> HVACMode:
286  """Return current operation ie. heat, cool, idle."""
287  if self._device_device.supports_on_off and not self._device_device.is_on:
288  return HVACMode.OFF
289  if self._device_device.mode is not None and self._device_device.mode.supports_controller_mode:
290  return CONTROLLER_MODES.get(
291  self._device_device.mode.controller_mode, self.default_hvac_mode
292  )
293  return self.default_hvac_mode
294 
295  @property
296  def hvac_modes(self) -> list[HVACMode]:
297  """Return the list of available operation/controller modes."""
298  ha_controller_modes: list[HVACMode | None] = []
299  if self._device_device.mode is not None:
300  ha_controller_modes.extend(
301  CONTROLLER_MODES.get(knx_controller_mode)
302  for knx_controller_mode in self._device_device.mode.controller_modes
303  )
304 
305  if self._device_device.supports_on_off:
306  if not ha_controller_modes:
307  ha_controller_modes.append(self._last_hvac_mode_last_hvac_mode)
308  ha_controller_modes.append(HVACMode.OFF)
309 
310  hvac_modes = list(set(filter(None, ha_controller_modes)))
311  return (
312  hvac_modes
313  if hvac_modes
314  else [self.hvac_modehvac_modehvac_modehvac_mode] # mode read-only -> fall back to only current mode
315  )
316 
317  @property
318  def hvac_action(self) -> HVACAction | None:
319  """Return the current running hvac operation if supported.
320 
321  Need to be one of CURRENT_HVAC_*.
322  """
323  if self._device_device.supports_on_off and not self._device_device.is_on:
324  return HVACAction.OFF
325  if self._device_device.is_active is False:
326  return HVACAction.IDLE
327  if (
328  self._device_device.mode is not None and self._device_device.mode.supports_controller_mode
329  ) or self._device_device.is_active:
330  return CURRENT_HVAC_ACTIONS.get(self.hvac_modehvac_modehvac_modehvac_mode, HVACAction.IDLE)
331  return None
332 
333  async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
334  """Set controller mode."""
335  if self._device_device.mode is not None and self._device_device.mode.supports_controller_mode:
336  knx_controller_mode = CONTROLLER_MODES_INV.get(hvac_mode)
337  if knx_controller_mode in self._device_device.mode.controller_modes:
338  await self._device_device.mode.set_controller_mode(knx_controller_mode)
339 
340  if self._device_device.supports_on_off:
341  if hvac_mode == HVACMode.OFF:
342  await self._device_device.turn_off()
343  elif not self._device_device.is_on:
344  await self._device_device.turn_on()
345  self.async_write_ha_stateasync_write_ha_state()
346 
347  @property
348  def preset_mode(self) -> str | None:
349  """Return the current preset mode, e.g., home, away, temp.
350 
351  Requires ClimateEntityFeature.PRESET_MODE.
352  """
353  if self._device_device.mode is not None and self._device_device.mode.supports_operation_mode:
354  return self._device_device.mode.operation_mode.name.lower()
355  return None
356 
357  async def async_set_preset_mode(self, preset_mode: str) -> None:
358  """Set new preset mode."""
359  if (
360  self._device_device.mode is not None
361  and self._device_device.mode.operation_modes # empty list when not writable
362  ):
363  await self._device_device.mode.set_operation_mode(
364  HVACOperationMode[preset_mode.upper()]
365  )
366  self.async_write_ha_stateasync_write_ha_state()
367 
368  @property
369  def fan_mode(self) -> str:
370  """Return the fan setting."""
371 
372  fan_speed = self._device_device.current_fan_speed
373 
374  if not fan_speed or self._attr_fan_modes_attr_fan_modes is None:
375  return self.fan_zero_mode
376 
377  if self._device_device.fan_speed_mode == FanSpeedMode.STEP:
378  return self._attr_fan_modes_attr_fan_modes[fan_speed]
379 
380  # Find the closest fan mode percentage
381  closest_percentage = min(
382  self._fan_modes_percentages_fan_modes_percentages[1:], # fan_speed == 0 is handled above
383  key=lambda x: abs(x - fan_speed),
384  )
385  return self._attr_fan_modes_attr_fan_modes[
386  self._fan_modes_percentages_fan_modes_percentages.index(closest_percentage)
387  ]
388 
389  async def async_set_fan_mode(self, fan_mode: str) -> None:
390  """Set fan mode."""
391 
392  if self._attr_fan_modes_attr_fan_modes is None:
393  return
394 
395  fan_mode_index = self._attr_fan_modes_attr_fan_modes.index(fan_mode)
396 
397  if self._device_device.fan_speed_mode == FanSpeedMode.STEP:
398  await self._device_device.set_fan_speed(fan_mode_index)
399  return
400 
401  await self._device_device.set_fan_speed(self._fan_modes_percentages_fan_modes_percentages[fan_mode_index])
402 
403  @property
404  def current_humidity(self) -> float | None:
405  """Return the current humidity."""
406  return self._device_device.humidity.value
407 
408  @property
409  def extra_state_attributes(self) -> dict[str, Any] | None:
410  """Return device specific state attributes."""
411  attr: dict[str, Any] = {}
412 
413  if self._device_device.command_value.initialized:
414  attr[ATTR_COMMAND_VALUE] = self._device_device.command_value.value
415  return attr
416 
417  async def async_added_to_hass(self) -> None:
418  """Store register state change callback and start device object."""
419  await super().async_added_to_hass()
420  if self._device_device.mode is not None:
421  self._device_device.mode.register_device_updated_cb(self.after_update_callbackafter_update_callbackafter_update_callback)
422  self._device_device.mode.xknx.devices.async_add(self._device_device.mode)
423 
424  async def async_will_remove_from_hass(self) -> None:
425  """Disconnect device object when removed."""
426  if self._device_device.mode is not None:
427  self._device_device.mode.unregister_device_updated_cb(self.after_update_callbackafter_update_callbackafter_update_callback)
428  self._device_device.mode.xknx.devices.async_remove(self._device_device.mode)
429  await super().async_will_remove_from_hass()
430 
431  def after_update_callback(self, _device: XknxDevice) -> None:
432  """Call after device was updated."""
433  if self._device_device.mode is not None and self._device_device.mode.supports_controller_mode:
434  hvac_mode = CONTROLLER_MODES.get(
435  self._device_device.mode.controller_mode, self.default_hvac_mode
436  )
437  if hvac_mode is not HVACMode.OFF:
438  self._last_hvac_mode_last_hvac_mode = hvac_mode
439  super().after_update_callback(_device)
None __init__(self, KNXModule knx_module, ConfigType config)
Definition: climate.py:153
None async_set_preset_mode(self, str preset_mode)
Definition: climate.py:357
None async_set_fan_mode(self, str fan_mode)
Definition: climate.py:389
None async_set_hvac_mode(self, HVACMode hvac_mode)
Definition: climate.py:333
dict[str, Any]|None extra_state_attributes(self)
Definition: climate.py:409
None after_update_callback(self, XknxDevice _device)
Definition: climate.py:431
None async_set_temperature(self, **Any kwargs)
Definition: climate.py:277
None after_update_callback(self, XknxDevice _device)
Definition: entity.py:72
XknxClimate _create_climate(XKNX xknx, ConfigType config)
Definition: climate.py:61
None async_setup_entry(HomeAssistant hass, config_entries.ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: climate.py:51