Home Assistant Unofficial Reference 2024.12.1
climate.py
Go to the documentation of this file.
1 """Support for MQTT climate devices."""
2 
3 from __future__ import annotations
4 
5 from abc import ABC, abstractmethod
6 from collections.abc import Callable
7 from functools import partial
8 import logging
9 from typing import Any
10 
11 import voluptuous as vol
12 
13 from homeassistant.components import climate
15  ATTR_HVAC_MODE,
16  ATTR_TARGET_TEMP_HIGH,
17  ATTR_TARGET_TEMP_LOW,
18  DEFAULT_MAX_HUMIDITY,
19  DEFAULT_MIN_HUMIDITY,
20  FAN_AUTO,
21  FAN_HIGH,
22  FAN_LOW,
23  FAN_MEDIUM,
24  PRESET_NONE,
25  SWING_OFF,
26  SWING_ON,
27  ClimateEntity,
28  ClimateEntityFeature,
29  HVACAction,
30  HVACMode,
31 )
32 from homeassistant.config_entries import ConfigEntry
33 from homeassistant.const import (
34  ATTR_TEMPERATURE,
35  CONF_NAME,
36  CONF_OPTIMISTIC,
37  CONF_PAYLOAD_OFF,
38  CONF_PAYLOAD_ON,
39  CONF_TEMPERATURE_UNIT,
40  CONF_VALUE_TEMPLATE,
41  PRECISION_HALVES,
42  PRECISION_TENTHS,
43  PRECISION_WHOLE,
44  UnitOfTemperature,
45 )
46 from homeassistant.core import HomeAssistant, callback
48 from homeassistant.helpers.entity_platform import AddEntitiesCallback
49 from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
50 from homeassistant.helpers.template import Template
51 from homeassistant.helpers.typing import ConfigType, VolSchemaType
52 from homeassistant.util.unit_conversion import TemperatureConverter
53 
54 from . import subscription
55 from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA
56 from .const import (
57  CONF_ACTION_TEMPLATE,
58  CONF_ACTION_TOPIC,
59  CONF_CURRENT_HUMIDITY_TEMPLATE,
60  CONF_CURRENT_HUMIDITY_TOPIC,
61  CONF_CURRENT_TEMP_TEMPLATE,
62  CONF_CURRENT_TEMP_TOPIC,
63  CONF_MODE_COMMAND_TEMPLATE,
64  CONF_MODE_COMMAND_TOPIC,
65  CONF_MODE_LIST,
66  CONF_MODE_STATE_TEMPLATE,
67  CONF_MODE_STATE_TOPIC,
68  CONF_POWER_COMMAND_TEMPLATE,
69  CONF_POWER_COMMAND_TOPIC,
70  CONF_PRECISION,
71  CONF_RETAIN,
72  CONF_TEMP_COMMAND_TEMPLATE,
73  CONF_TEMP_COMMAND_TOPIC,
74  CONF_TEMP_INITIAL,
75  CONF_TEMP_MAX,
76  CONF_TEMP_MIN,
77  CONF_TEMP_STATE_TEMPLATE,
78  CONF_TEMP_STATE_TOPIC,
79  DEFAULT_OPTIMISTIC,
80  PAYLOAD_NONE,
81 )
82 from .entity import MqttEntity, async_setup_entity_entry_helper
83 from .models import (
84  MqttCommandTemplate,
85  MqttValueTemplate,
86  PublishPayloadType,
87  ReceiveMessage,
88 )
89 from .schemas import MQTT_ENTITY_COMMON_SCHEMA
90 from .util import valid_publish_topic, valid_subscribe_topic
91 
92 _LOGGER = logging.getLogger(__name__)
93 
94 PARALLEL_UPDATES = 0
95 
96 DEFAULT_NAME = "MQTT HVAC"
97 
98 CONF_FAN_MODE_COMMAND_TEMPLATE = "fan_mode_command_template"
99 CONF_FAN_MODE_COMMAND_TOPIC = "fan_mode_command_topic"
100 CONF_FAN_MODE_LIST = "fan_modes"
101 CONF_FAN_MODE_STATE_TEMPLATE = "fan_mode_state_template"
102 CONF_FAN_MODE_STATE_TOPIC = "fan_mode_state_topic"
103 
104 CONF_HUMIDITY_COMMAND_TEMPLATE = "target_humidity_command_template"
105 CONF_HUMIDITY_COMMAND_TOPIC = "target_humidity_command_topic"
106 CONF_HUMIDITY_STATE_TEMPLATE = "target_humidity_state_template"
107 CONF_HUMIDITY_STATE_TOPIC = "target_humidity_state_topic"
108 CONF_HUMIDITY_MAX = "max_humidity"
109 CONF_HUMIDITY_MIN = "min_humidity"
110 
111 CONF_PRESET_MODE_STATE_TOPIC = "preset_mode_state_topic"
112 CONF_PRESET_MODE_COMMAND_TOPIC = "preset_mode_command_topic"
113 CONF_PRESET_MODE_VALUE_TEMPLATE = "preset_mode_value_template"
114 CONF_PRESET_MODE_COMMAND_TEMPLATE = "preset_mode_command_template"
115 CONF_PRESET_MODES_LIST = "preset_modes"
116 CONF_SWING_MODE_COMMAND_TEMPLATE = "swing_mode_command_template"
117 CONF_SWING_MODE_COMMAND_TOPIC = "swing_mode_command_topic"
118 CONF_SWING_MODE_LIST = "swing_modes"
119 CONF_SWING_MODE_STATE_TEMPLATE = "swing_mode_state_template"
120 CONF_SWING_MODE_STATE_TOPIC = "swing_mode_state_topic"
121 CONF_TEMP_HIGH_COMMAND_TEMPLATE = "temperature_high_command_template"
122 CONF_TEMP_HIGH_COMMAND_TOPIC = "temperature_high_command_topic"
123 CONF_TEMP_HIGH_STATE_TEMPLATE = "temperature_high_state_template"
124 CONF_TEMP_HIGH_STATE_TOPIC = "temperature_high_state_topic"
125 CONF_TEMP_LOW_COMMAND_TEMPLATE = "temperature_low_command_template"
126 CONF_TEMP_LOW_COMMAND_TOPIC = "temperature_low_command_topic"
127 CONF_TEMP_LOW_STATE_TEMPLATE = "temperature_low_state_template"
128 CONF_TEMP_LOW_STATE_TOPIC = "temperature_low_state_topic"
129 CONF_TEMP_STEP = "temp_step"
130 
131 DEFAULT_INITIAL_TEMPERATURE = 21.0
132 
133 MQTT_CLIMATE_ATTRIBUTES_BLOCKED = frozenset(
134  {
135  climate.ATTR_CURRENT_HUMIDITY,
136  climate.ATTR_CURRENT_TEMPERATURE,
137  climate.ATTR_FAN_MODE,
138  climate.ATTR_FAN_MODES,
139  climate.ATTR_HUMIDITY,
140  climate.ATTR_HVAC_ACTION,
141  climate.ATTR_HVAC_MODES,
142  climate.ATTR_MAX_HUMIDITY,
143  climate.ATTR_MAX_TEMP,
144  climate.ATTR_MIN_HUMIDITY,
145  climate.ATTR_MIN_TEMP,
146  climate.ATTR_PRESET_MODE,
147  climate.ATTR_PRESET_MODES,
148  climate.ATTR_SWING_MODE,
149  climate.ATTR_SWING_MODES,
150  climate.ATTR_TARGET_TEMP_HIGH,
151  climate.ATTR_TARGET_TEMP_LOW,
152  climate.ATTR_TARGET_TEMP_STEP,
153  climate.ATTR_TEMPERATURE,
154  }
155 )
156 
157 VALUE_TEMPLATE_KEYS = (
158  CONF_CURRENT_HUMIDITY_TEMPLATE,
159  CONF_CURRENT_TEMP_TEMPLATE,
160  CONF_FAN_MODE_STATE_TEMPLATE,
161  CONF_HUMIDITY_STATE_TEMPLATE,
162  CONF_MODE_STATE_TEMPLATE,
163  CONF_ACTION_TEMPLATE,
164  CONF_PRESET_MODE_VALUE_TEMPLATE,
165  CONF_SWING_MODE_STATE_TEMPLATE,
166  CONF_TEMP_HIGH_STATE_TEMPLATE,
167  CONF_TEMP_LOW_STATE_TEMPLATE,
168  CONF_TEMP_STATE_TEMPLATE,
169 )
170 
171 COMMAND_TEMPLATE_KEYS = {
172  CONF_FAN_MODE_COMMAND_TEMPLATE,
173  CONF_HUMIDITY_COMMAND_TEMPLATE,
174  CONF_MODE_COMMAND_TEMPLATE,
175  CONF_POWER_COMMAND_TEMPLATE,
176  CONF_PRESET_MODE_COMMAND_TEMPLATE,
177  CONF_SWING_MODE_COMMAND_TEMPLATE,
178  CONF_TEMP_COMMAND_TEMPLATE,
179  CONF_TEMP_HIGH_COMMAND_TEMPLATE,
180  CONF_TEMP_LOW_COMMAND_TEMPLATE,
181 }
182 
183 
184 TOPIC_KEYS = (
185  CONF_ACTION_TOPIC,
186  CONF_CURRENT_HUMIDITY_TOPIC,
187  CONF_CURRENT_TEMP_TOPIC,
188  CONF_FAN_MODE_COMMAND_TOPIC,
189  CONF_FAN_MODE_STATE_TOPIC,
190  CONF_HUMIDITY_COMMAND_TOPIC,
191  CONF_HUMIDITY_STATE_TOPIC,
192  CONF_MODE_COMMAND_TOPIC,
193  CONF_MODE_STATE_TOPIC,
194  CONF_POWER_COMMAND_TOPIC,
195  CONF_PRESET_MODE_COMMAND_TOPIC,
196  CONF_PRESET_MODE_STATE_TOPIC,
197  CONF_SWING_MODE_COMMAND_TOPIC,
198  CONF_SWING_MODE_STATE_TOPIC,
199  CONF_TEMP_COMMAND_TOPIC,
200  CONF_TEMP_HIGH_COMMAND_TOPIC,
201  CONF_TEMP_HIGH_STATE_TOPIC,
202  CONF_TEMP_LOW_COMMAND_TOPIC,
203  CONF_TEMP_LOW_STATE_TOPIC,
204  CONF_TEMP_STATE_TOPIC,
205 )
206 
207 
208 def valid_preset_mode_configuration(config: ConfigType) -> ConfigType:
209  """Validate that the preset mode reset payload is not one of the preset modes."""
210  if PRESET_NONE in config[CONF_PRESET_MODES_LIST]:
211  raise vol.Invalid("preset_modes must not include preset mode 'none'")
212  return config
213 
214 
215 def valid_humidity_range_configuration(config: ConfigType) -> ConfigType:
216  """Validate a target_humidity range configuration, throws otherwise."""
217  if config[CONF_HUMIDITY_MIN] >= config[CONF_HUMIDITY_MAX]:
218  raise vol.Invalid("target_humidity_max must be > target_humidity_min")
219  if config[CONF_HUMIDITY_MAX] > 100:
220  raise vol.Invalid("max_humidity must be <= 100")
221 
222  return config
223 
224 
225 def valid_humidity_state_configuration(config: ConfigType) -> ConfigType:
226  """Validate humidity state.
227 
228  Ensure that if CONF_HUMIDITY_STATE_TOPIC is set then
229  CONF_HUMIDITY_COMMAND_TOPIC is also set.
230  """
231  if (
232  CONF_HUMIDITY_STATE_TOPIC in config
233  and CONF_HUMIDITY_COMMAND_TOPIC not in config
234  ):
235  raise vol.Invalid(
236  f"{CONF_HUMIDITY_STATE_TOPIC} cannot be used without"
237  f" {CONF_HUMIDITY_COMMAND_TOPIC}"
238  )
239 
240  return config
241 
242 
243 _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend(
244  {
245  vol.Optional(CONF_CURRENT_HUMIDITY_TEMPLATE): cv.template,
246  vol.Optional(CONF_CURRENT_HUMIDITY_TOPIC): valid_subscribe_topic,
247  vol.Optional(CONF_CURRENT_TEMP_TEMPLATE): cv.template,
248  vol.Optional(CONF_CURRENT_TEMP_TOPIC): valid_subscribe_topic,
249  vol.Optional(CONF_FAN_MODE_COMMAND_TEMPLATE): cv.template,
250  vol.Optional(CONF_FAN_MODE_COMMAND_TOPIC): valid_publish_topic,
251  vol.Optional(
252  CONF_FAN_MODE_LIST,
253  default=[FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH],
254  ): cv.ensure_list,
255  vol.Optional(CONF_FAN_MODE_STATE_TEMPLATE): cv.template,
256  vol.Optional(CONF_FAN_MODE_STATE_TOPIC): valid_subscribe_topic,
257  vol.Optional(CONF_HUMIDITY_COMMAND_TEMPLATE): cv.template,
258  vol.Optional(CONF_HUMIDITY_COMMAND_TOPIC): valid_publish_topic,
259  vol.Optional(
260  CONF_HUMIDITY_MIN, default=DEFAULT_MIN_HUMIDITY
261  ): cv.positive_float,
262  vol.Optional(
263  CONF_HUMIDITY_MAX, default=DEFAULT_MAX_HUMIDITY
264  ): cv.positive_float,
265  vol.Optional(CONF_HUMIDITY_STATE_TEMPLATE): cv.template,
266  vol.Optional(CONF_HUMIDITY_STATE_TOPIC): valid_subscribe_topic,
267  vol.Optional(CONF_MODE_COMMAND_TEMPLATE): cv.template,
268  vol.Optional(CONF_MODE_COMMAND_TOPIC): valid_publish_topic,
269  vol.Optional(
270  CONF_MODE_LIST,
271  default=[
272  HVACMode.AUTO,
273  HVACMode.OFF,
274  HVACMode.COOL,
275  HVACMode.HEAT,
276  HVACMode.DRY,
277  HVACMode.FAN_ONLY,
278  ],
279  ): cv.ensure_list,
280  vol.Optional(CONF_MODE_STATE_TEMPLATE): cv.template,
281  vol.Optional(CONF_MODE_STATE_TOPIC): valid_subscribe_topic,
282  vol.Optional(CONF_NAME): vol.Any(cv.string, None),
283  vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean,
284  vol.Optional(CONF_PAYLOAD_ON, default="ON"): cv.string,
285  vol.Optional(CONF_PAYLOAD_OFF, default="OFF"): cv.string,
286  vol.Optional(CONF_POWER_COMMAND_TOPIC): valid_publish_topic,
287  vol.Optional(CONF_POWER_COMMAND_TEMPLATE): cv.template,
288  vol.Optional(CONF_PRECISION): vol.In(
289  [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE]
290  ),
291  vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean,
292  vol.Optional(CONF_ACTION_TEMPLATE): cv.template,
293  vol.Optional(CONF_ACTION_TOPIC): valid_subscribe_topic,
294  # CONF_PRESET_MODE_COMMAND_TOPIC and CONF_PRESET_MODES_LIST
295  # must be used together
296  vol.Inclusive(
297  CONF_PRESET_MODE_COMMAND_TOPIC, "preset_modes"
298  ): valid_publish_topic,
299  vol.Inclusive(
300  CONF_PRESET_MODES_LIST, "preset_modes", default=[]
301  ): cv.ensure_list,
302  vol.Optional(CONF_PRESET_MODE_COMMAND_TEMPLATE): cv.template,
303  vol.Optional(CONF_PRESET_MODE_STATE_TOPIC): valid_subscribe_topic,
304  vol.Optional(CONF_PRESET_MODE_VALUE_TEMPLATE): cv.template,
305  vol.Optional(CONF_SWING_MODE_COMMAND_TEMPLATE): cv.template,
306  vol.Optional(CONF_SWING_MODE_COMMAND_TOPIC): valid_publish_topic,
307  vol.Optional(
308  CONF_SWING_MODE_LIST, default=[SWING_ON, SWING_OFF]
309  ): cv.ensure_list,
310  vol.Optional(CONF_SWING_MODE_STATE_TEMPLATE): cv.template,
311  vol.Optional(CONF_SWING_MODE_STATE_TOPIC): valid_subscribe_topic,
312  vol.Optional(CONF_TEMP_INITIAL): vol.All(vol.Coerce(float)),
313  vol.Optional(CONF_TEMP_MIN): vol.Coerce(float),
314  vol.Optional(CONF_TEMP_MAX): vol.Coerce(float),
315  vol.Optional(CONF_TEMP_STEP, default=1.0): vol.Coerce(float),
316  vol.Optional(CONF_TEMP_COMMAND_TEMPLATE): cv.template,
317  vol.Optional(CONF_TEMP_COMMAND_TOPIC): valid_publish_topic,
318  vol.Optional(CONF_TEMP_HIGH_COMMAND_TEMPLATE): cv.template,
319  vol.Optional(CONF_TEMP_HIGH_COMMAND_TOPIC): valid_publish_topic,
320  vol.Optional(CONF_TEMP_HIGH_STATE_TOPIC): valid_subscribe_topic,
321  vol.Optional(CONF_TEMP_HIGH_STATE_TEMPLATE): cv.template,
322  vol.Optional(CONF_TEMP_LOW_COMMAND_TEMPLATE): cv.template,
323  vol.Optional(CONF_TEMP_LOW_COMMAND_TOPIC): valid_publish_topic,
324  vol.Optional(CONF_TEMP_LOW_STATE_TEMPLATE): cv.template,
325  vol.Optional(CONF_TEMP_LOW_STATE_TOPIC): valid_subscribe_topic,
326  vol.Optional(CONF_TEMP_STATE_TEMPLATE): cv.template,
327  vol.Optional(CONF_TEMP_STATE_TOPIC): valid_subscribe_topic,
328  vol.Optional(CONF_TEMPERATURE_UNIT): cv.temperature_unit,
329  vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
330  }
331 ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
332 
333 PLATFORM_SCHEMA_MODERN = vol.All(
334  _PLATFORM_SCHEMA_BASE,
335  valid_preset_mode_configuration,
336  valid_humidity_range_configuration,
337  valid_humidity_state_configuration,
338 )
339 
340 _DISCOVERY_SCHEMA_BASE = _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA)
341 
342 DISCOVERY_SCHEMA = vol.All(
343  _DISCOVERY_SCHEMA_BASE,
344  valid_preset_mode_configuration,
345  valid_humidity_range_configuration,
346  valid_humidity_state_configuration,
347 )
348 
349 
351  hass: HomeAssistant,
352  config_entry: ConfigEntry,
353  async_add_entities: AddEntitiesCallback,
354 ) -> None:
355  """Set up MQTT climate through YAML and through MQTT discovery."""
357  hass,
358  config_entry,
359  MqttClimate,
360  climate.DOMAIN,
361  async_add_entities,
362  DISCOVERY_SCHEMA,
363  PLATFORM_SCHEMA_MODERN,
364  )
365 
366 
368  """Helper entity class to control temperature.
369 
370  MqttTemperatureControlEntity supports shared methods for
371  climate and water_heater platforms.
372  """
373 
374  _attr_target_temperature_low: float | None
375  _attr_target_temperature_high: float | None
376 
377  _feature_preset_mode: bool = False
378  _optimistic: bool
379  _topic: dict[str, Any]
380 
381  _command_templates: dict[str, Callable[[PublishPayloadType], PublishPayloadType]]
382  _value_templates: dict[str, Callable[[ReceivePayloadType], ReceivePayloadType]]
383 
385  self, msg: ReceiveMessage, template_name: str
386  ) -> ReceivePayloadType:
387  """Render a template by name."""
388  template = self._value_templates[template_name]
389  return template(msg.payload)
390 
391  @callback
393  self, template_name: str, attr: str, msg: ReceiveMessage
394  ) -> None:
395  """Handle climate attributes coming via MQTT."""
396  payload = self.render_templaterender_template(msg, template_name)
397  if not payload:
398  _LOGGER.debug(
399  "Invalid empty payload for attribute %s, ignoring update",
400  attr,
401  )
402  return
403  if payload == PAYLOAD_NONE:
404  setattr(self, attr, None)
405  return
406  try:
407  setattr(self, attr, float(payload))
408  except ValueError:
409  _LOGGER.error("Could not parse %s from %s", template_name, payload)
410 
411  @callback
413  self,
414  ) -> None:
415  """(Re)Subscribe to topics."""
416  self.add_subscriptionadd_subscription(
417  CONF_CURRENT_TEMP_TOPIC,
418  partial(
419  self.handle_climate_attribute_receivedhandle_climate_attribute_received,
420  CONF_CURRENT_TEMP_TEMPLATE,
421  "_attr_current_temperature",
422  ),
423  {"_attr_current_temperature"},
424  )
425  self.add_subscriptionadd_subscription(
426  CONF_TEMP_STATE_TOPIC,
427  partial(
428  self.handle_climate_attribute_receivedhandle_climate_attribute_received,
429  CONF_TEMP_STATE_TEMPLATE,
430  "_attr_target_temperature",
431  ),
432  {"_attr_target_temperature"},
433  )
434  self.add_subscriptionadd_subscription(
435  CONF_TEMP_LOW_STATE_TOPIC,
436  partial(
437  self.handle_climate_attribute_receivedhandle_climate_attribute_received,
438  CONF_TEMP_LOW_STATE_TEMPLATE,
439  "_attr_target_temperature_low",
440  ),
441  {"_attr_target_temperature_low"},
442  )
443  self.add_subscriptionadd_subscription(
444  CONF_TEMP_HIGH_STATE_TOPIC,
445  partial(
446  self.handle_climate_attribute_receivedhandle_climate_attribute_received,
447  CONF_TEMP_HIGH_STATE_TEMPLATE,
448  "_attr_target_temperature_high",
449  ),
450  {"_attr_target_temperature_high"},
451  )
452 
453  async def _subscribe_topics(self) -> None:
454  """(Re)Subscribe to topics."""
455  subscription.async_subscribe_topics_internal(self.hasshasshass, self._sub_state_sub_state)
456 
457  async def _publish(self, topic: str, payload: PublishPayloadType) -> None:
458  if self._topic[topic] is not None:
459  await self.async_publish_with_configasync_publish_with_config(self._topic[topic], payload)
460 
462  self,
463  temp: float | None,
464  cmnd_topic: str,
465  cmnd_template: str,
466  state_topic: str,
467  attr: str,
468  ) -> bool:
469  if temp is None:
470  return False
471  changed = False
472  if self._optimistic or self._topic[state_topic] is None:
473  # optimistic mode
474  changed = True
475  setattr(self, attr, temp)
476 
477  payload = self._command_templates[cmnd_template](temp)
478  await self._publish_publish(cmnd_topic, payload)
479  return changed
480 
481  @abstractmethod
482  async def async_set_temperature(self, **kwargs: Any) -> None:
483  """Set new target temperatures."""
484  changed = await self._set_climate_attribute_set_climate_attribute(
485  kwargs.get(ATTR_TEMPERATURE),
486  CONF_TEMP_COMMAND_TOPIC,
487  CONF_TEMP_COMMAND_TEMPLATE,
488  CONF_TEMP_STATE_TOPIC,
489  "_attr_target_temperature",
490  )
491 
492  changed |= await self._set_climate_attribute_set_climate_attribute(
493  kwargs.get(ATTR_TARGET_TEMP_LOW),
494  CONF_TEMP_LOW_COMMAND_TOPIC,
495  CONF_TEMP_LOW_COMMAND_TEMPLATE,
496  CONF_TEMP_LOW_STATE_TOPIC,
497  "_attr_target_temperature_low",
498  )
499 
500  changed |= await self._set_climate_attribute_set_climate_attribute(
501  kwargs.get(ATTR_TARGET_TEMP_HIGH),
502  CONF_TEMP_HIGH_COMMAND_TOPIC,
503  CONF_TEMP_HIGH_COMMAND_TEMPLATE,
504  CONF_TEMP_HIGH_STATE_TOPIC,
505  "_attr_target_temperature_high",
506  )
507 
508  if not changed:
509  return
510  self.async_write_ha_stateasync_write_ha_state()
511 
512 
514  """Representation of an MQTT climate device."""
515 
516  _attr_fan_mode: str | None = None
517  _attr_hvac_mode: HVACMode | None = None
518  _attr_swing_mode: str | None = None
519  _default_name = DEFAULT_NAME
520  _entity_id_format = climate.ENTITY_ID_FORMAT
521  _attributes_extra_blocked = MQTT_CLIMATE_ATTRIBUTES_BLOCKED
522  _attr_target_temperature_low: float | None = None
523  _attr_target_temperature_high: float | None = None
524  _enable_turn_on_off_backwards_compatibility = False
525 
526  @staticmethod
527  def config_schema() -> VolSchemaType:
528  """Return the config schema."""
529  return DISCOVERY_SCHEMA
530 
531  def _setup_from_config(self, config: ConfigType) -> None:
532  """(Re)Setup the entity."""
533  self._attr_hvac_modes_attr_hvac_modes = config[CONF_MODE_LIST]
534  # Make sure the min an max temp is converted to the correct when not set
535  self._attr_temperature_unit_attr_temperature_unit = config.get(
536  CONF_TEMPERATURE_UNIT, self.hasshasshass.config.units.temperature_unit
537  )
538  if (min_temp := config.get(CONF_TEMP_MIN)) is not None:
539  self._attr_min_temp_attr_min_temp = min_temp
540  if (max_temp := config.get(CONF_TEMP_MAX)) is not None:
541  self._attr_max_temp_attr_max_temp = max_temp
542  self._attr_min_humidity_attr_min_humidity = config[CONF_HUMIDITY_MIN]
543  self._attr_max_humidity_attr_max_humidity = config[CONF_HUMIDITY_MAX]
544  if (precision := config.get(CONF_PRECISION)) is not None:
545  self._attr_precision_attr_precision = precision
546  self._attr_fan_modes_attr_fan_modes = config[CONF_FAN_MODE_LIST]
547  self._attr_swing_modes_attr_swing_modes = config[CONF_SWING_MODE_LIST]
548  self._attr_target_temperature_step_attr_target_temperature_step = config[CONF_TEMP_STEP]
549 
550  self._topic_topic = {key: config.get(key) for key in TOPIC_KEYS}
551 
552  self._optimistic_optimistic = config[CONF_OPTIMISTIC]
553 
554  # Set init temp, if it is missing convert the default to the temperature units
555  init_temp: float = config.get(
556  CONF_TEMP_INITIAL,
557  TemperatureConverter.convert(
558  DEFAULT_INITIAL_TEMPERATURE,
559  UnitOfTemperature.CELSIUS,
560  self.temperature_unittemperature_unit,
561  ),
562  )
563  if self._topic_topic[CONF_TEMP_STATE_TOPIC] is None or self._optimistic_optimistic:
564  self._attr_target_temperature_attr_target_temperature = init_temp
565  if self._topic_topic[CONF_TEMP_LOW_STATE_TOPIC] is None or self._optimistic_optimistic:
566  self._attr_target_temperature_low_attr_target_temperature_low = init_temp
567  if self._topic_topic[CONF_TEMP_HIGH_STATE_TOPIC] is None or self._optimistic_optimistic:
568  self._attr_target_temperature_high_attr_target_temperature_high = init_temp
569 
570  if self._topic_topic[CONF_FAN_MODE_STATE_TOPIC] is None or self._optimistic_optimistic:
571  self._attr_fan_mode_attr_fan_mode = FAN_LOW
572  if self._topic_topic[CONF_SWING_MODE_STATE_TOPIC] is None or self._optimistic_optimistic:
573  self._attr_swing_mode_attr_swing_mode = SWING_OFF
574  if self._topic_topic[CONF_MODE_STATE_TOPIC] is None or self._optimistic_optimistic:
575  self._attr_hvac_mode_attr_hvac_mode = HVACMode.OFF
576  self._feature_preset_mode_feature_preset_mode = CONF_PRESET_MODE_COMMAND_TOPIC in config
577  if self._feature_preset_mode_feature_preset_mode:
578  presets = []
579  presets.extend(config[CONF_PRESET_MODES_LIST])
580  if presets:
581  presets.insert(0, PRESET_NONE)
582  self._attr_preset_modes_attr_preset_modes = presets
583  self._attr_preset_mode_attr_preset_mode = PRESET_NONE
584  else:
585  self._attr_preset_modes_attr_preset_modes = []
586  self._optimistic_preset_mode_optimistic_preset_mode = (
587  self._optimistic_optimistic or CONF_PRESET_MODE_STATE_TOPIC not in config
588  )
589 
590  value_templates: dict[str, Template | None] = {
591  key: config.get(CONF_VALUE_TEMPLATE) for key in VALUE_TEMPLATE_KEYS
592  }
593  value_templates.update(
594  {key: config[key] for key in VALUE_TEMPLATE_KEYS & config.keys()}
595  )
596  self._value_templates_value_templates = {
597  key: MqttValueTemplate(
598  template,
599  entity=self,
600  ).async_render_with_possible_json_value
601  for key, template in value_templates.items()
602  }
603 
604  self._command_templates_command_templates = {
605  key: MqttCommandTemplate(config.get(key), entity=self).async_render
606  for key in COMMAND_TEMPLATE_KEYS
607  }
608 
609  support = ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
610  if (self._topic_topic[CONF_TEMP_STATE_TOPIC] is not None) or (
611  self._topic_topic[CONF_TEMP_COMMAND_TOPIC] is not None
612  ):
613  support |= ClimateEntityFeature.TARGET_TEMPERATURE
614 
615  if (self._topic_topic[CONF_TEMP_LOW_STATE_TOPIC] is not None) or (
616  self._topic_topic[CONF_TEMP_LOW_COMMAND_TOPIC] is not None
617  ):
618  support |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
619 
620  if (self._topic_topic[CONF_TEMP_HIGH_STATE_TOPIC] is not None) or (
621  self._topic_topic[CONF_TEMP_HIGH_COMMAND_TOPIC] is not None
622  ):
623  support |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
624 
625  if self._topic_topic[CONF_HUMIDITY_COMMAND_TOPIC] is not None:
626  support |= ClimateEntityFeature.TARGET_HUMIDITY
627 
628  if (self._topic_topic[CONF_FAN_MODE_STATE_TOPIC] is not None) or (
629  self._topic_topic[CONF_FAN_MODE_COMMAND_TOPIC] is not None
630  ):
631  support |= ClimateEntityFeature.FAN_MODE
632 
633  if (self._topic_topic[CONF_SWING_MODE_STATE_TOPIC] is not None) or (
634  self._topic_topic[CONF_SWING_MODE_COMMAND_TOPIC] is not None
635  ):
636  support |= ClimateEntityFeature.SWING_MODE
637 
638  if self._feature_preset_mode_feature_preset_mode:
639  support |= ClimateEntityFeature.PRESET_MODE
640 
641  self._attr_supported_features_attr_supported_features = support
642 
643  @callback
644  def _handle_action_received(self, msg: ReceiveMessage) -> None:
645  """Handle receiving action via MQTT."""
646  payload = self.render_templaterender_template(msg, CONF_ACTION_TEMPLATE)
647  if not payload:
648  _LOGGER.debug(
649  "Invalid %s action: %s, ignoring",
650  [e.value for e in HVACAction],
651  payload,
652  )
653  return
654  if payload == PAYLOAD_NONE:
655  self._attr_hvac_action_attr_hvac_action = None
656  return
657  try:
658  self._attr_hvac_action_attr_hvac_action = HVACAction(str(payload))
659  except ValueError:
660  _LOGGER.warning(
661  "Invalid %s action: %s",
662  [e.value for e in HVACAction],
663  payload,
664  )
665  return
666 
667  @callback
669  self, template_name: str, attr: str, mode_list: str, msg: ReceiveMessage
670  ) -> None:
671  """Handle receiving listed mode via MQTT."""
672  payload = self.render_templaterender_template(msg, template_name)
673 
674  if payload == PAYLOAD_NONE:
675  setattr(self, attr, None)
676  elif payload not in self._config_config[mode_list]:
677  _LOGGER.warning("Invalid %s mode: %s", mode_list, payload)
678  else:
679  setattr(self, attr, payload)
680 
681  @callback
682  def _handle_preset_mode_received(self, msg: ReceiveMessage) -> None:
683  """Handle receiving preset mode via MQTT."""
684  preset_mode = self.render_templaterender_template(msg, CONF_PRESET_MODE_VALUE_TEMPLATE)
685  if preset_mode in [PRESET_NONE, PAYLOAD_NONE]:
686  self._attr_preset_mode_attr_preset_mode = PRESET_NONE
687  return
688  if not preset_mode:
689  _LOGGER.debug("Ignoring empty preset_mode from '%s'", msg.topic)
690  return
691  if not self._attr_preset_modes_attr_preset_modes or preset_mode not in self._attr_preset_modes_attr_preset_modes:
692  _LOGGER.warning(
693  "'%s' received on topic %s. '%s' is not a valid preset mode",
694  msg.payload,
695  msg.topic,
696  preset_mode,
697  )
698  else:
699  self._attr_preset_mode_attr_preset_mode = str(preset_mode)
700 
701  @callback
702  def _prepare_subscribe_topics(self) -> None:
703  """(Re)Subscribe to topics."""
704  # add subscriptions for MqttClimate
705  self.add_subscriptionadd_subscription(
706  CONF_ACTION_TOPIC,
707  self._handle_action_received_handle_action_received,
708  {"_attr_hvac_action"},
709  )
710  self.add_subscriptionadd_subscription(
711  CONF_CURRENT_HUMIDITY_TOPIC,
712  partial(
713  self.handle_climate_attribute_receivedhandle_climate_attribute_received,
714  CONF_CURRENT_HUMIDITY_TEMPLATE,
715  "_attr_current_humidity",
716  ),
717  {"_attr_current_humidity"},
718  )
719  self.add_subscriptionadd_subscription(
720  CONF_HUMIDITY_STATE_TOPIC,
721  partial(
722  self.handle_climate_attribute_receivedhandle_climate_attribute_received,
723  CONF_HUMIDITY_STATE_TEMPLATE,
724  "_attr_target_humidity",
725  ),
726  {"_attr_target_humidity"},
727  )
728  self.add_subscriptionadd_subscription(
729  CONF_MODE_STATE_TOPIC,
730  partial(
731  self._handle_mode_received_handle_mode_received,
732  CONF_MODE_STATE_TEMPLATE,
733  "_attr_hvac_mode",
734  CONF_MODE_LIST,
735  ),
736  {"_attr_hvac_mode"},
737  )
738  self.add_subscriptionadd_subscription(
739  CONF_FAN_MODE_STATE_TOPIC,
740  partial(
741  self._handle_mode_received_handle_mode_received,
742  CONF_FAN_MODE_STATE_TEMPLATE,
743  "_attr_fan_mode",
744  CONF_FAN_MODE_LIST,
745  ),
746  {"_attr_fan_mode"},
747  )
748  self.add_subscriptionadd_subscription(
749  CONF_SWING_MODE_STATE_TOPIC,
750  partial(
751  self._handle_mode_received_handle_mode_received,
752  CONF_SWING_MODE_STATE_TEMPLATE,
753  "_attr_swing_mode",
754  CONF_SWING_MODE_LIST,
755  ),
756  {"_attr_swing_mode"},
757  )
758  self.add_subscriptionadd_subscription(
759  CONF_PRESET_MODE_STATE_TOPIC,
760  self._handle_preset_mode_received_handle_preset_mode_received,
761  {"_attr_preset_mode"},
762  )
763  # add subscriptions for MqttTemperatureControlEntity
764  self.prepare_subscribe_topicsprepare_subscribe_topics()
765 
766  async def async_set_temperature(self, **kwargs: Any) -> None:
767  """Set new target temperatures."""
768  operation_mode: HVACMode | None
769  if (operation_mode := kwargs.get(ATTR_HVAC_MODE)) is not None:
770  await self.async_set_hvac_modeasync_set_hvac_modeasync_set_hvac_mode(operation_mode)
771  await super().async_set_temperature(**kwargs)
772 
773  async def async_set_humidity(self, humidity: float) -> None:
774  """Set new target humidity."""
775 
776  await self._set_climate_attribute_set_climate_attribute(
777  humidity,
778  CONF_HUMIDITY_COMMAND_TOPIC,
779  CONF_HUMIDITY_COMMAND_TEMPLATE,
780  CONF_HUMIDITY_STATE_TOPIC,
781  "_attr_target_humidity",
782  )
783 
784  self.async_write_ha_stateasync_write_ha_state()
785 
786  async def async_set_swing_mode(self, swing_mode: str) -> None:
787  """Set new swing mode."""
788  payload = self._command_templates_command_templates[CONF_SWING_MODE_COMMAND_TEMPLATE](swing_mode)
789  await self._publish_publish(CONF_SWING_MODE_COMMAND_TOPIC, payload)
790 
791  if self._optimistic_optimistic or self._topic_topic[CONF_SWING_MODE_STATE_TOPIC] is None:
792  self._attr_swing_mode_attr_swing_mode = swing_mode
793  self.async_write_ha_stateasync_write_ha_state()
794 
795  async def async_set_fan_mode(self, fan_mode: str) -> None:
796  """Set new target temperature."""
797  payload = self._command_templates_command_templates[CONF_FAN_MODE_COMMAND_TEMPLATE](fan_mode)
798  await self._publish_publish(CONF_FAN_MODE_COMMAND_TOPIC, payload)
799 
800  if self._optimistic_optimistic or self._topic_topic[CONF_FAN_MODE_STATE_TOPIC] is None:
801  self._attr_fan_mode_attr_fan_mode = fan_mode
802  self.async_write_ha_stateasync_write_ha_state()
803 
804  async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
805  """Set new operation mode."""
806  payload = self._command_templates_command_templates[CONF_MODE_COMMAND_TEMPLATE](hvac_mode)
807  await self._publish_publish(CONF_MODE_COMMAND_TOPIC, payload)
808 
809  if self._optimistic_optimistic or self._topic_topic[CONF_MODE_STATE_TOPIC] is None:
810  self._attr_hvac_mode_attr_hvac_mode = hvac_mode
811  self.async_write_ha_stateasync_write_ha_state()
812 
813  async def async_set_preset_mode(self, preset_mode: str) -> None:
814  """Set a preset mode."""
815  mqtt_payload = self._command_templates_command_templates[CONF_PRESET_MODE_COMMAND_TEMPLATE](
816  preset_mode
817  )
818  await self._publish_publish(
819  CONF_PRESET_MODE_COMMAND_TOPIC,
820  mqtt_payload,
821  )
822 
823  if self._optimistic_preset_mode_optimistic_preset_mode:
824  self._attr_preset_mode_attr_preset_mode = preset_mode
825  self.async_write_ha_stateasync_write_ha_state()
826 
827  async def async_turn_on(self) -> None:
828  """Turn the entity on."""
829  if CONF_POWER_COMMAND_TOPIC in self._config_config:
830  mqtt_payload = self._command_templates_command_templates[CONF_POWER_COMMAND_TEMPLATE](
831  self._config_config[CONF_PAYLOAD_ON]
832  )
833  await self._publish_publish(CONF_POWER_COMMAND_TOPIC, mqtt_payload)
834  return
835  # Fall back to default behavior without power command topic
836  await super().async_turn_on()
837 
838  async def async_turn_off(self) -> None:
839  """Turn the entity off."""
840  if CONF_POWER_COMMAND_TOPIC in self._config_config:
841  mqtt_payload = self._command_templates_command_templates[CONF_POWER_COMMAND_TEMPLATE](
842  self._config_config[CONF_PAYLOAD_OFF]
843  )
844  await self._publish_publish(CONF_POWER_COMMAND_TOPIC, mqtt_payload)
845  if self._optimistic_optimistic:
846  self._attr_hvac_mode_attr_hvac_mode = HVACMode.OFF
847  self.async_write_ha_stateasync_write_ha_state()
848  return
849  # Fall back to default behavior without power command topic
850  await super().async_turn_off()
None async_set_hvac_mode(self, HVACMode hvac_mode)
Definition: __init__.py:813
None async_set_humidity(self, float humidity)
Definition: climate.py:773
None async_set_preset_mode(self, str preset_mode)
Definition: climate.py:813
None async_set_fan_mode(self, str fan_mode)
Definition: climate.py:795
None async_set_swing_mode(self, str swing_mode)
Definition: climate.py:786
None async_set_hvac_mode(self, HVACMode hvac_mode)
Definition: climate.py:804
None _handle_action_received(self, ReceiveMessage msg)
Definition: climate.py:644
None async_set_temperature(self, **Any kwargs)
Definition: climate.py:766
None _handle_preset_mode_received(self, ReceiveMessage msg)
Definition: climate.py:682
None _handle_mode_received(self, str template_name, str attr, str mode_list, ReceiveMessage msg)
Definition: climate.py:670
None _setup_from_config(self, ConfigType config)
Definition: climate.py:531
None _publish(self, str topic, PublishPayloadType payload)
Definition: climate.py:457
ReceivePayloadType render_template(self, ReceiveMessage msg, str template_name)
Definition: climate.py:386
None handle_climate_attribute_received(self, str template_name, str attr, ReceiveMessage msg)
Definition: climate.py:394
bool _set_climate_attribute(self, float|None temp, str cmnd_topic, str cmnd_template, str state_topic, str attr)
Definition: climate.py:468
None async_publish_with_config(self, str topic, PublishPayloadType payload)
Definition: entity.py:1377
bool add_subscription(self, str state_topic_config_key, Callable[[ReceiveMessage], None] msg_callback, set[str]|None tracked_attributes, bool disable_encoding=False)
Definition: entity.py:1484
ConfigType valid_humidity_state_configuration(ConfigType config)
Definition: climate.py:225
ConfigType valid_preset_mode_configuration(ConfigType config)
Definition: climate.py:208
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: climate.py:354
ConfigType valid_humidity_range_configuration(ConfigType config)
Definition: climate.py:215
None async_setup_entity_entry_helper(HomeAssistant hass, ConfigEntry entry, type[MqttEntity]|None entity_class, str domain, AddEntitiesCallback async_add_entities, VolSchemaType discovery_schema, VolSchemaType platform_schema_modern, dict[str, type[MqttEntity]]|None schema_class_mapping=None)
Definition: entity.py:245
bool template(HomeAssistant hass, Template value_template, TemplateVarsType variables=None)
Definition: condition.py:759