Home Assistant Unofficial Reference 2024.12.1
climate.py
Go to the documentation of this file.
1 """Matter climate platform."""
2 
3 from __future__ import annotations
4 
5 from enum import IntEnum
6 from typing import Any
7 
8 from chip.clusters import Objects as clusters
9 from matter_server.client.models import device_types
10 from matter_server.common.helpers.util import create_attribute_path_from_attribute
11 
13  ATTR_HVAC_MODE,
14  ATTR_TARGET_TEMP_HIGH,
15  ATTR_TARGET_TEMP_LOW,
16  DEFAULT_MAX_TEMP,
17  DEFAULT_MIN_TEMP,
18  ClimateEntity,
19  ClimateEntityDescription,
20  ClimateEntityFeature,
21  HVACAction,
22  HVACMode,
23 )
24 from homeassistant.config_entries import ConfigEntry
25 from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature
26 from homeassistant.core import HomeAssistant, callback
27 from homeassistant.helpers.entity_platform import AddEntitiesCallback
28 
29 from .entity import MatterEntity
30 from .helpers import get_matter
31 from .models import MatterDiscoverySchema
32 
33 TEMPERATURE_SCALING_FACTOR = 100
34 HVAC_SYSTEM_MODE_MAP = {
35  HVACMode.OFF: 0,
36  HVACMode.HEAT_COOL: 1,
37  HVACMode.COOL: 3,
38  HVACMode.HEAT: 4,
39  HVACMode.DRY: 8,
40  HVACMode.FAN_ONLY: 7,
41 }
42 
43 SINGLE_SETPOINT_DEVICES: set[tuple[int, int]] = {
44  # Some devices only have a single setpoint while the matter spec
45  # assumes that you need separate setpoints for heating and cooling.
46  # We were told this is just some legacy inheritance from zigbee specs.
47  # In the list below specify tuples of (vendorid, productid) of devices for
48  # which we just need a single setpoint to control both heating and cooling.
49  (0x1209, 0x8000),
50  (0x1209, 0x8001),
51  (0x1209, 0x8002),
52  (0x1209, 0x8003),
53  (0x1209, 0x8004),
54  (0x1209, 0x8005),
55  (0x1209, 0x8006),
56  (0x1209, 0x8007),
57  (0x1209, 0x8008),
58  (0x1209, 0x8009),
59  (0x1209, 0x800A),
60  (0x1209, 0x800B),
61  (0x1209, 0x800C),
62  (0x1209, 0x800D),
63  (0x1209, 0x800E),
64  (0x1209, 0x8010),
65  (0x1209, 0x8011),
66  (0x1209, 0x8012),
67  (0x1209, 0x8013),
68  (0x1209, 0x8014),
69  (0x1209, 0x8020),
70  (0x1209, 0x8021),
71  (0x1209, 0x8022),
72  (0x1209, 0x8023),
73  (0x1209, 0x8024),
74  (0x1209, 0x8025),
75  (0x1209, 0x8026),
76  (0x1209, 0x8027),
77  (0x1209, 0x8028),
78  (0x1209, 0x8029),
79 }
80 
81 SUPPORT_DRY_MODE_DEVICES: set[tuple[int, int]] = {
82  # The Matter spec is missing a feature flag if the device supports a dry mode.
83  # In the list below specify tuples of (vendorid, productid) of devices that
84  # support dry mode.
85  (0x0001, 0x0108),
86  (0x0001, 0x010A),
87  (0x1209, 0x8000),
88  (0x1209, 0x8001),
89  (0x1209, 0x8002),
90  (0x1209, 0x8003),
91  (0x1209, 0x8004),
92  (0x1209, 0x8005),
93  (0x1209, 0x8006),
94  (0x1209, 0x8007),
95  (0x1209, 0x8008),
96  (0x1209, 0x8009),
97  (0x1209, 0x800A),
98  (0x1209, 0x800B),
99  (0x1209, 0x800C),
100  (0x1209, 0x800D),
101  (0x1209, 0x800E),
102  (0x1209, 0x8010),
103  (0x1209, 0x8011),
104  (0x1209, 0x8012),
105  (0x1209, 0x8013),
106  (0x1209, 0x8014),
107  (0x1209, 0x8020),
108  (0x1209, 0x8021),
109  (0x1209, 0x8022),
110  (0x1209, 0x8023),
111  (0x1209, 0x8024),
112  (0x1209, 0x8025),
113  (0x1209, 0x8026),
114  (0x1209, 0x8027),
115  (0x1209, 0x8028),
116  (0x1209, 0x8029),
117 }
118 
119 SUPPORT_FAN_MODE_DEVICES: set[tuple[int, int]] = {
120  # The Matter spec is missing a feature flag if the device supports a fan-only mode.
121  # In the list below specify tuples of (vendorid, productid) of devices that
122  # support fan-only mode.
123  (0x0001, 0x0108),
124  (0x0001, 0x010A),
125  (0x1209, 0x8000),
126  (0x1209, 0x8001),
127  (0x1209, 0x8002),
128  (0x1209, 0x8003),
129  (0x1209, 0x8004),
130  (0x1209, 0x8005),
131  (0x1209, 0x8006),
132  (0x1209, 0x8007),
133  (0x1209, 0x8008),
134  (0x1209, 0x8009),
135  (0x1209, 0x800A),
136  (0x1209, 0x800B),
137  (0x1209, 0x800C),
138  (0x1209, 0x800D),
139  (0x1209, 0x800E),
140  (0x1209, 0x8010),
141  (0x1209, 0x8011),
142  (0x1209, 0x8012),
143  (0x1209, 0x8013),
144  (0x1209, 0x8014),
145  (0x1209, 0x8020),
146  (0x1209, 0x8021),
147  (0x1209, 0x8022),
148  (0x1209, 0x8023),
149  (0x1209, 0x8024),
150  (0x1209, 0x8025),
151  (0x1209, 0x8026),
152  (0x1209, 0x8027),
153  (0x1209, 0x8028),
154  (0x1209, 0x8029),
155 }
156 
157 SystemModeEnum = clusters.Thermostat.Enums.SystemModeEnum
158 ControlSequenceEnum = clusters.Thermostat.Enums.ControlSequenceOfOperationEnum
159 ThermostatFeature = clusters.Thermostat.Bitmaps.Feature
160 
161 
162 class ThermostatRunningState(IntEnum):
163  """Thermostat Running State, Matter spec Thermostat 7.33."""
164 
165  Heat = 1 # 1 << 0 = 1
166  Cool = 2 # 1 << 1 = 2
167  Fan = 4 # 1 << 2 = 4
168  HeatStage2 = 8 # 1 << 3 = 8
169  CoolStage2 = 16 # 1 << 4 = 16
170  FanStage2 = 32 # 1 << 5 = 32
171  FanStage3 = 64 # 1 << 6 = 64
172 
173 
175  hass: HomeAssistant,
176  config_entry: ConfigEntry,
177  async_add_entities: AddEntitiesCallback,
178 ) -> None:
179  """Set up Matter climate platform from Config Entry."""
180  matter = get_matter(hass)
181  matter.register_platform_handler(Platform.CLIMATE, async_add_entities)
182 
183 
185  """Representation of a Matter climate entity."""
186 
187  _attr_temperature_unit: str = UnitOfTemperature.CELSIUS
188  _attr_hvac_mode: HVACMode = HVACMode.OFF
189  _feature_map: int | None = None
190  _enable_turn_on_off_backwards_compatibility = False
191  _platform_translation_key = "thermostat"
192 
193  async def async_set_temperature(self, **kwargs: Any) -> None:
194  """Set new target temperature."""
195  target_hvac_mode: HVACMode | None = kwargs.get(ATTR_HVAC_MODE)
196  target_temperature: float | None = kwargs.get(ATTR_TEMPERATURE)
197  target_temperature_low: float | None = kwargs.get(ATTR_TARGET_TEMP_LOW)
198  target_temperature_high: float | None = kwargs.get(ATTR_TARGET_TEMP_HIGH)
199 
200  if target_hvac_mode is not None:
201  await self.async_set_hvac_modeasync_set_hvac_modeasync_set_hvac_mode(target_hvac_mode)
202  current_mode = target_hvac_mode or self.hvac_modehvac_modehvac_mode
203 
204  if target_temperature is not None:
205  # single setpoint control
206  if self.target_temperaturetarget_temperature != target_temperature:
207  if current_mode == HVACMode.COOL:
208  matter_attribute = (
209  clusters.Thermostat.Attributes.OccupiedCoolingSetpoint
210  )
211  else:
212  matter_attribute = (
213  clusters.Thermostat.Attributes.OccupiedHeatingSetpoint
214  )
215  await self.matter_clientmatter_client.write_attribute(
216  node_id=self._endpoint_endpoint.node.node_id,
217  attribute_path=create_attribute_path_from_attribute(
218  self._endpoint_endpoint.endpoint_id,
219  matter_attribute,
220  ),
221  value=int(target_temperature * TEMPERATURE_SCALING_FACTOR),
222  )
223  return
224 
225  if target_temperature_low is not None:
226  # multi setpoint control - low setpoint (heat)
227  if self.target_temperature_lowtarget_temperature_low != target_temperature_low:
228  await self.matter_clientmatter_client.write_attribute(
229  node_id=self._endpoint_endpoint.node.node_id,
230  attribute_path=create_attribute_path_from_attribute(
231  self._endpoint_endpoint.endpoint_id,
232  clusters.Thermostat.Attributes.OccupiedHeatingSetpoint,
233  ),
234  value=int(target_temperature_low * TEMPERATURE_SCALING_FACTOR),
235  )
236 
237  if target_temperature_high is not None:
238  # multi setpoint control - high setpoint (cool)
239  if self.target_temperature_hightarget_temperature_high != target_temperature_high:
240  await self.matter_clientmatter_client.write_attribute(
241  node_id=self._endpoint_endpoint.node.node_id,
242  attribute_path=create_attribute_path_from_attribute(
243  self._endpoint_endpoint.endpoint_id,
244  clusters.Thermostat.Attributes.OccupiedCoolingSetpoint,
245  ),
246  value=int(target_temperature_high * TEMPERATURE_SCALING_FACTOR),
247  )
248 
249  async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
250  """Set new target hvac mode."""
251  system_mode_path = create_attribute_path_from_attribute(
252  endpoint_id=self._endpoint_endpoint.endpoint_id,
253  attribute=clusters.Thermostat.Attributes.SystemMode,
254  )
255  system_mode_value = HVAC_SYSTEM_MODE_MAP.get(hvac_mode)
256  if system_mode_value is None:
257  raise ValueError(f"Unsupported hvac mode {hvac_mode} in Matter")
258  await self.matter_clientmatter_client.write_attribute(
259  node_id=self._endpoint_endpoint.node.node_id,
260  attribute_path=system_mode_path,
261  value=system_mode_value,
262  )
263  # we need to optimistically update the attribute's value here
264  # to prevent a race condition when adjusting the mode and temperature
265  # in the same call
266  self._endpoint_endpoint.set_attribute_value(system_mode_path, system_mode_value)
267  self._update_from_device_update_from_device_update_from_device()
268 
269  @callback
270  def _update_from_device(self) -> None:
271  """Update from device."""
272  self._calculate_features_calculate_features()
273  self._attr_current_temperature_attr_current_temperature = self._get_temperature_in_degrees_get_temperature_in_degrees(
274  clusters.Thermostat.Attributes.LocalTemperature
275  )
276  if self.get_matter_attribute_valueget_matter_attribute_value(clusters.OnOff.Attributes.OnOff) is False:
277  # special case: the appliance has a dedicated Power switch on the OnOff cluster
278  # if the mains power is off - treat it as if the HVAC mode is off
279  self._attr_hvac_mode_attr_hvac_mode = HVACMode.OFF
280  self._attr_hvac_action_attr_hvac_action = None
281  else:
282  # update hvac_mode from SystemMode
283  system_mode_value = int(
284  self.get_matter_attribute_valueget_matter_attribute_value(
285  clusters.Thermostat.Attributes.SystemMode
286  )
287  )
288  match system_mode_value:
289  case SystemModeEnum.kAuto:
290  self._attr_hvac_mode_attr_hvac_mode = HVACMode.HEAT_COOL
291  case SystemModeEnum.kDry:
292  self._attr_hvac_mode_attr_hvac_mode = HVACMode.DRY
293  case SystemModeEnum.kFanOnly:
294  self._attr_hvac_mode_attr_hvac_mode = HVACMode.FAN_ONLY
295  case SystemModeEnum.kCool | SystemModeEnum.kPrecooling:
296  self._attr_hvac_mode_attr_hvac_mode = HVACMode.COOL
297  case SystemModeEnum.kHeat | SystemModeEnum.kEmergencyHeat:
298  self._attr_hvac_mode_attr_hvac_mode = HVACMode.HEAT
299  case SystemModeEnum.kFanOnly:
300  self._attr_hvac_mode_attr_hvac_mode = HVACMode.FAN_ONLY
301  case SystemModeEnum.kDry:
302  self._attr_hvac_mode_attr_hvac_mode = HVACMode.DRY
303  case _:
304  self._attr_hvac_mode_attr_hvac_mode = HVACMode.OFF
305  # running state is an optional attribute
306  # which we map to hvac_action if it exists (its value is not None)
307  self._attr_hvac_action_attr_hvac_action = None
308  if running_state_value := self.get_matter_attribute_valueget_matter_attribute_value(
309  clusters.Thermostat.Attributes.ThermostatRunningState
310  ):
311  match running_state_value:
312  case (
313  ThermostatRunningState.Heat
314  | ThermostatRunningState.HeatStage2
315  ):
316  self._attr_hvac_action_attr_hvac_action = HVACAction.HEATING
317  case (
318  ThermostatRunningState.Cool
319  | ThermostatRunningState.CoolStage2
320  ):
321  self._attr_hvac_action_attr_hvac_action = HVACAction.COOLING
322  case (
323  ThermostatRunningState.Fan
324  | ThermostatRunningState.FanStage2
325  | ThermostatRunningState.FanStage3
326  ):
327  self._attr_hvac_action_attr_hvac_action = HVACAction.FAN
328  case _:
329  self._attr_hvac_action_attr_hvac_action = HVACAction.OFF
330 
331  # update target temperature high/low
332  supports_range = (
333  self._attr_supported_features_attr_supported_features
334  & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
335  )
336  if supports_range and self._attr_hvac_mode_attr_hvac_mode == HVACMode.HEAT_COOL:
337  self._attr_target_temperature_attr_target_temperature = None
338  self._attr_target_temperature_high_attr_target_temperature_high = self._get_temperature_in_degrees_get_temperature_in_degrees(
339  clusters.Thermostat.Attributes.OccupiedCoolingSetpoint
340  )
341  self._attr_target_temperature_low_attr_target_temperature_low = self._get_temperature_in_degrees_get_temperature_in_degrees(
342  clusters.Thermostat.Attributes.OccupiedHeatingSetpoint
343  )
344  else:
345  self._attr_target_temperature_high_attr_target_temperature_high = None
346  self._attr_target_temperature_low_attr_target_temperature_low = None
347  # update target_temperature
348  if self._attr_hvac_mode_attr_hvac_mode == HVACMode.COOL:
349  self._attr_target_temperature_attr_target_temperature = self._get_temperature_in_degrees_get_temperature_in_degrees(
350  clusters.Thermostat.Attributes.OccupiedCoolingSetpoint
351  )
352  else:
353  self._attr_target_temperature_attr_target_temperature = self._get_temperature_in_degrees_get_temperature_in_degrees(
354  clusters.Thermostat.Attributes.OccupiedHeatingSetpoint
355  )
356 
357  # update min_temp
358  if self._attr_hvac_mode_attr_hvac_mode == HVACMode.COOL:
359  attribute = clusters.Thermostat.Attributes.AbsMinCoolSetpointLimit
360  else:
361  attribute = clusters.Thermostat.Attributes.AbsMinHeatSetpointLimit
362  if (value := self._get_temperature_in_degrees_get_temperature_in_degrees(attribute)) is not None:
363  self._attr_min_temp_attr_min_temp = value
364  else:
365  self._attr_min_temp_attr_min_temp = DEFAULT_MIN_TEMP
366  # update max_temp
367  if self._attr_hvac_mode_attr_hvac_mode in (HVACMode.COOL, HVACMode.HEAT_COOL):
368  attribute = clusters.Thermostat.Attributes.AbsMaxCoolSetpointLimit
369  else:
370  attribute = clusters.Thermostat.Attributes.AbsMaxHeatSetpointLimit
371  if (value := self._get_temperature_in_degrees_get_temperature_in_degrees(attribute)) is not None:
372  self._attr_max_temp_attr_max_temp = value
373  else:
374  self._attr_max_temp_attr_max_temp = DEFAULT_MAX_TEMP
375 
376  @callback
378  self,
379  ) -> None:
380  """Calculate features for HA Thermostat platform from Matter FeatureMap."""
381  feature_map = int(
382  self.get_matter_attribute_valueget_matter_attribute_value(clusters.Thermostat.Attributes.FeatureMap)
383  )
384  # NOTE: the featuremap can dynamically change, so we need to update the
385  # supported features if the featuremap changes.
386  # work out supported features and presets from matter featuremap
387  if self._feature_map_feature_map == feature_map:
388  return
389  self._feature_map_feature_map = feature_map
390  product_id = self._endpoint_endpoint.node.device_info.productID
391  vendor_id = self._endpoint_endpoint.node.device_info.vendorID
392  self._attr_hvac_modes: list[HVACMode] = [HVACMode.OFF]
393  self._attr_supported_features_attr_supported_features = (
394  ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_OFF
395  )
396  if feature_map & ThermostatFeature.kHeating:
397  self._attr_hvac_modes.append(HVACMode.HEAT)
398  if feature_map & ThermostatFeature.kCooling:
399  self._attr_hvac_modes.append(HVACMode.COOL)
400  if (vendor_id, product_id) in SUPPORT_DRY_MODE_DEVICES:
401  self._attr_hvac_modes.append(HVACMode.DRY)
402  if (vendor_id, product_id) in SUPPORT_FAN_MODE_DEVICES:
403  self._attr_hvac_modes.append(HVACMode.FAN_ONLY)
404  if feature_map & ThermostatFeature.kAutoMode:
405  self._attr_hvac_modes.append(HVACMode.HEAT_COOL)
406  # only enable temperature_range feature if the device actually supports that
407 
408  if (vendor_id, product_id) not in SINGLE_SETPOINT_DEVICES:
409  self._attr_supported_features_attr_supported_features |= (
410  ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
411  )
412  if any(mode for mode in self.hvac_modeshvac_modes if mode != HVACMode.OFF):
413  self._attr_supported_features_attr_supported_features |= ClimateEntityFeature.TURN_ON
414 
415  @callback
417  self, attribute: type[clusters.ClusterAttributeDescriptor]
418  ) -> float | None:
419  """Return the scaled temperature value for the given attribute."""
420  if value := self.get_matter_attribute_valueget_matter_attribute_value(attribute):
421  return float(value) / TEMPERATURE_SCALING_FACTOR
422  return None
423 
424 
425 # Discovery schema(s) to map Matter Attributes to HA entities
426 DISCOVERY_SCHEMAS = [
428  platform=Platform.CLIMATE,
429  entity_description=ClimateEntityDescription(
430  key="MatterThermostat",
431  name=None,
432  ),
433  entity_class=MatterClimate,
434  required_attributes=(clusters.Thermostat.Attributes.LocalTemperature,),
435  optional_attributes=(
436  clusters.Thermostat.Attributes.FeatureMap,
437  clusters.Thermostat.Attributes.ControlSequenceOfOperation,
438  clusters.Thermostat.Attributes.Occupancy,
439  clusters.Thermostat.Attributes.OccupiedCoolingSetpoint,
440  clusters.Thermostat.Attributes.OccupiedHeatingSetpoint,
441  clusters.Thermostat.Attributes.SystemMode,
442  clusters.Thermostat.Attributes.ThermostatRunningMode,
443  clusters.Thermostat.Attributes.ThermostatRunningState,
444  clusters.Thermostat.Attributes.TemperatureSetpointHold,
445  clusters.Thermostat.Attributes.UnoccupiedCoolingSetpoint,
446  clusters.Thermostat.Attributes.UnoccupiedHeatingSetpoint,
447  clusters.OnOff.Attributes.OnOff,
448  ),
449  device_type=(device_types.Thermostat, device_types.RoomAirConditioner),
450  ),
451 ]
None async_set_hvac_mode(self, HVACMode hvac_mode)
Definition: __init__.py:813
float|None _get_temperature_in_degrees(self, type[clusters.ClusterAttributeDescriptor] attribute)
Definition: climate.py:418
None async_set_hvac_mode(self, HVACMode hvac_mode)
Definition: climate.py:249
Any get_matter_attribute_value(self, type[ClusterAttributeDescriptor] attribute, bool null_as_none=True)
Definition: entity.py:206
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: climate.py:178
MatterAdapter get_matter(HomeAssistant hass)
Definition: helpers.py:35