Home Assistant Unofficial Reference 2024.12.1
fan.py
Go to the documentation of this file.
1 """Matter Fan platform support."""
2 
3 from __future__ import annotations
4 
5 from typing import TYPE_CHECKING, Any
6 
7 from chip.clusters import Objects as clusters
8 from matter_server.common.helpers.util import create_attribute_path_from_attribute
9 
10 from homeassistant.components.fan import (
11  DIRECTION_FORWARD,
12  DIRECTION_REVERSE,
13  FanEntity,
14  FanEntityDescription,
15  FanEntityFeature,
16 )
17 from homeassistant.config_entries import ConfigEntry
18 from homeassistant.const import Platform
19 from homeassistant.core import HomeAssistant, callback
20 from homeassistant.helpers.entity_platform import AddEntitiesCallback
21 
22 from .entity import MatterEntity
23 from .helpers import get_matter
24 from .models import MatterDiscoverySchema
25 
26 FanControlFeature = clusters.FanControl.Bitmaps.Feature
27 WindBitmap = clusters.FanControl.Bitmaps.WindBitmap
28 FanModeSequenceEnum = clusters.FanControl.Enums.FanModeSequenceEnum
29 
30 PRESET_LOW = "low"
31 PRESET_MEDIUM = "medium"
32 PRESET_HIGH = "high"
33 PRESET_AUTO = "auto"
34 FAN_MODE_MAP = {
35  PRESET_LOW: clusters.FanControl.Enums.FanModeEnum.kLow,
36  PRESET_MEDIUM: clusters.FanControl.Enums.FanModeEnum.kMedium,
37  PRESET_HIGH: clusters.FanControl.Enums.FanModeEnum.kHigh,
38  PRESET_AUTO: clusters.FanControl.Enums.FanModeEnum.kAuto,
39 }
40 FAN_MODE_MAP_REVERSE = {v: k for k, v in FAN_MODE_MAP.items()}
41 # special preset modes for wind feature
42 PRESET_NATURAL_WIND = "natural_wind"
43 PRESET_SLEEP_WIND = "sleep_wind"
44 
45 
47  hass: HomeAssistant,
48  config_entry: ConfigEntry,
49  async_add_entities: AddEntitiesCallback,
50 ) -> None:
51  """Set up Matter fan from Config Entry."""
52  matter = get_matter(hass)
53  matter.register_platform_handler(Platform.FAN, async_add_entities)
54 
55 
57  """Representation of a Matter fan."""
58 
59  _last_known_preset_mode: str | None = None
60  _last_known_percentage: int = 0
61  _enable_turn_on_off_backwards_compatibility = False
62  _feature_map: int | None = None
63  _platform_translation_key = "fan"
64 
65  async def async_turn_on(
66  self,
67  percentage: int | None = None,
68  preset_mode: str | None = None,
69  **kwargs: Any,
70  ) -> None:
71  """Turn on the fan."""
72  if percentage is None and preset_mode is None:
73  # turn_on without explicit percentage or preset_mode given
74  # try to handle this with the last known value
75  if self._last_known_percentage_last_known_percentage != 0:
76  percentage = self._last_known_percentage_last_known_percentage
77  elif self._last_known_preset_mode_last_known_preset_mode is not None:
78  preset_mode = self._last_known_preset_mode_last_known_preset_mode
79  elif self._attr_preset_modes_attr_preset_modes:
80  # fallback: default to first supported preset
81  preset_mode = self._attr_preset_modes_attr_preset_modes[0]
82  else:
83  # this really should not be possible but handle it anyways
84  percentage = 50
85 
86  # prefer setting fan speed by percentage
87  if percentage is not None:
88  await self.async_set_percentageasync_set_percentageasync_set_percentage(percentage)
89  return
90  # handle setting fan mode by preset
91  if TYPE_CHECKING:
92  assert preset_mode is not None
93  await self.async_set_preset_modeasync_set_preset_modeasync_set_preset_mode(preset_mode)
94 
95  async def async_turn_off(self, **kwargs: Any) -> None:
96  """Turn fan off."""
97  # clear the wind setting if its currently set
98  if self._attr_preset_mode_attr_preset_mode in [PRESET_NATURAL_WIND, PRESET_SLEEP_WIND]:
99  await self._set_wind_mode_set_wind_mode(None)
100  await self.matter_clientmatter_client.write_attribute(
101  node_id=self._endpoint_endpoint.node.node_id,
102  attribute_path=create_attribute_path_from_attribute(
103  self._endpoint_endpoint.endpoint_id,
104  clusters.FanControl.Attributes.FanMode,
105  ),
106  value=clusters.FanControl.Enums.FanModeEnum.kOff,
107  )
108 
109  async def async_set_percentage(self, percentage: int) -> None:
110  """Set the speed of the fan, as a percentage."""
111  await self.matter_clientmatter_client.write_attribute(
112  node_id=self._endpoint_endpoint.node.node_id,
113  attribute_path=create_attribute_path_from_attribute(
114  self._endpoint_endpoint.endpoint_id,
115  clusters.FanControl.Attributes.PercentSetting,
116  ),
117  value=percentage,
118  )
119 
120  async def async_set_preset_mode(self, preset_mode: str) -> None:
121  """Set new preset mode."""
122  # handle wind as preset
123  if preset_mode in [PRESET_NATURAL_WIND, PRESET_SLEEP_WIND]:
124  await self._set_wind_mode_set_wind_mode(preset_mode)
125  return
126 
127  # clear the wind setting if its currently set
128  if self._attr_preset_mode_attr_preset_mode in [PRESET_NATURAL_WIND, PRESET_SLEEP_WIND]:
129  await self._set_wind_mode_set_wind_mode(None)
130 
131  await self.matter_clientmatter_client.write_attribute(
132  node_id=self._endpoint_endpoint.node.node_id,
133  attribute_path=create_attribute_path_from_attribute(
134  self._endpoint_endpoint.endpoint_id,
135  clusters.FanControl.Attributes.FanMode,
136  ),
137  value=FAN_MODE_MAP[preset_mode],
138  )
139 
140  async def async_oscillate(self, oscillating: bool) -> None:
141  """Oscillate the fan."""
142  await self.matter_clientmatter_client.write_attribute(
143  node_id=self._endpoint_endpoint.node.node_id,
144  attribute_path=create_attribute_path_from_attribute(
145  self._endpoint_endpoint.endpoint_id,
146  clusters.FanControl.Attributes.RockSetting,
147  ),
148  value=self.get_matter_attribute_valueget_matter_attribute_value(
149  clusters.FanControl.Attributes.RockSupport
150  )
151  if oscillating
152  else 0,
153  )
154 
155  async def async_set_direction(self, direction: str) -> None:
156  """Set the direction of the fan."""
157  await self.matter_clientmatter_client.write_attribute(
158  node_id=self._endpoint_endpoint.node.node_id,
159  attribute_path=create_attribute_path_from_attribute(
160  self._endpoint_endpoint.endpoint_id,
161  clusters.FanControl.Attributes.AirflowDirection,
162  ),
163  value=clusters.FanControl.Enums.AirflowDirectionEnum.kReverse
164  if direction == DIRECTION_REVERSE
165  else clusters.FanControl.Enums.AirflowDirectionEnum.kForward,
166  )
167 
168  async def _set_wind_mode(self, wind_mode: str | None) -> None:
169  """Set wind mode."""
170  if wind_mode == PRESET_NATURAL_WIND:
171  wind_setting = WindBitmap.kNaturalWind
172  elif wind_mode == PRESET_SLEEP_WIND:
173  wind_setting = WindBitmap.kSleepWind
174  else:
175  wind_setting = 0
176  await self.matter_clientmatter_client.write_attribute(
177  node_id=self._endpoint_endpoint.node.node_id,
178  attribute_path=create_attribute_path_from_attribute(
179  self._endpoint_endpoint.endpoint_id,
180  clusters.FanControl.Attributes.WindSetting,
181  ),
182  value=wind_setting,
183  )
184 
185  @callback
186  def _update_from_device(self) -> None:
187  """Update from device."""
188  self._calculate_features_calculate_features()
189 
190  if self.get_matter_attribute_valueget_matter_attribute_value(clusters.OnOff.Attributes.OnOff) is False:
191  # special case: the appliance has a dedicated Power switch on the OnOff cluster
192  # if the mains power is off - treat it as if the fan mode is off
193  self._attr_preset_mode_attr_preset_mode = None
194  self._attr_percentage_attr_percentage = 0
195  return
196 
197  if self._attr_supported_features_attr_supported_features & FanEntityFeature.DIRECTION:
198  direction_value = self.get_matter_attribute_valueget_matter_attribute_value(
199  clusters.FanControl.Attributes.AirflowDirection
200  )
201  self._attr_current_direction_attr_current_direction = (
202  DIRECTION_REVERSE
203  if direction_value
204  == clusters.FanControl.Enums.AirflowDirectionEnum.kReverse
205  else DIRECTION_FORWARD
206  )
207  if self._attr_supported_features_attr_supported_features & FanEntityFeature.OSCILLATE:
208  self._attr_oscillating_attr_oscillating = (
209  self.get_matter_attribute_valueget_matter_attribute_value(
210  clusters.FanControl.Attributes.RockSetting
211  )
212  != 0
213  )
214 
215  # speed percentage is always provided
216  current_percent = self.get_matter_attribute_valueget_matter_attribute_value(
217  clusters.FanControl.Attributes.PercentCurrent
218  )
219  # NOTE that a device may give back 255 as a special value to indicate that
220  # the speed is under automatic control and not set to a specific value.
221  self._attr_percentage_attr_percentage = None if current_percent == 255 else current_percent
222 
223  # get preset mode from fan mode (and wind feature if available)
224  wind_setting = self.get_matter_attribute_valueget_matter_attribute_value(
225  clusters.FanControl.Attributes.WindSetting
226  )
227  fan_mode = self.get_matter_attribute_valueget_matter_attribute_value(
228  clusters.FanControl.Attributes.FanMode
229  )
230  if fan_mode == clusters.FanControl.Enums.FanModeEnum.kOff:
231  self._attr_preset_mode_attr_preset_mode = None
232  self._attr_percentage_attr_percentage = 0
233  elif (
234  self._attr_preset_modes_attr_preset_modes
235  and PRESET_NATURAL_WIND in self._attr_preset_modes_attr_preset_modes
236  and wind_setting & WindBitmap.kNaturalWind
237  ):
238  self._attr_preset_mode_attr_preset_mode = PRESET_NATURAL_WIND
239  elif (
240  self._attr_preset_modes_attr_preset_modes
241  and PRESET_SLEEP_WIND in self._attr_preset_modes_attr_preset_modes
242  and wind_setting & WindBitmap.kSleepWind
243  ):
244  self._attr_preset_mode_attr_preset_mode = PRESET_SLEEP_WIND
245  else:
246  fan_mode = self.get_matter_attribute_valueget_matter_attribute_value(
247  clusters.FanControl.Attributes.FanMode
248  )
249  self._attr_preset_mode_attr_preset_mode = FAN_MODE_MAP_REVERSE.get(fan_mode)
250 
251  # keep track of the last known mode for turn_on commands without preset
252  if self._attr_preset_mode_attr_preset_mode is not None:
253  self._last_known_preset_mode_last_known_preset_mode = self._attr_preset_mode_attr_preset_mode
254  if current_percent:
255  self._last_known_percentage_last_known_percentage = current_percent
256 
257  @callback
259  self,
260  ) -> None:
261  """Calculate features for HA Fan platform from Matter FeatureMap."""
262  feature_map = int(
263  self.get_matter_attribute_valueget_matter_attribute_value(clusters.FanControl.Attributes.FeatureMap)
264  )
265  # NOTE: the featuremap can dynamically change, so we need to update the
266  # supported features if the featuremap changes.
267  # work out supported features and presets from matter featuremap
268  if self._feature_map_feature_map == feature_map:
269  return
270  self._feature_map_feature_map = feature_map
271  self._attr_supported_features_attr_supported_features = FanEntityFeature(0)
272  if feature_map & FanControlFeature.kMultiSpeed:
273  self._attr_supported_features_attr_supported_features |= FanEntityFeature.SET_SPEED
274  self._attr_speed_count_attr_speed_count = int(
275  self.get_matter_attribute_valueget_matter_attribute_value(clusters.FanControl.Attributes.SpeedMax)
276  )
277  if feature_map & FanControlFeature.kRocking:
278  # NOTE: the Matter model allows that a device can have multiple/different
279  # rock directions while HA doesn't allow this in the entity model.
280  # For now we just assume that a device has a single rock direction and the
281  # Matter spec is just future proofing for devices that might have multiple
282  # rock directions. As soon as devices show up that actually support multiple
283  # directions, we need to either update the HA Fan entity model or maybe add
284  # this as a separate entity.
285  self._attr_supported_features_attr_supported_features |= FanEntityFeature.OSCILLATE
286 
287  # figure out supported preset modes
288  preset_modes = []
289  fan_mode_seq = int(
290  self.get_matter_attribute_valueget_matter_attribute_value(
291  clusters.FanControl.Attributes.FanModeSequence
292  )
293  )
294  if fan_mode_seq == FanModeSequenceEnum.kOffLowHigh:
295  preset_modes = [PRESET_LOW, PRESET_HIGH]
296  elif fan_mode_seq == FanModeSequenceEnum.kOffLowHighAuto:
297  preset_modes = [PRESET_LOW, PRESET_HIGH, PRESET_AUTO]
298  elif fan_mode_seq == FanModeSequenceEnum.kOffLowMedHigh:
299  preset_modes = [PRESET_LOW, PRESET_MEDIUM, PRESET_HIGH]
300  elif fan_mode_seq == FanModeSequenceEnum.kOffLowMedHighAuto:
301  preset_modes = [PRESET_LOW, PRESET_MEDIUM, PRESET_HIGH, PRESET_AUTO]
302  elif fan_mode_seq == FanModeSequenceEnum.kOffHighAuto:
303  preset_modes = [PRESET_HIGH, PRESET_AUTO]
304  elif fan_mode_seq == FanModeSequenceEnum.kOffHigh:
305  preset_modes = [PRESET_HIGH]
306  # treat Matter Wind feature as additional preset(s)
307  if feature_map & FanControlFeature.kWind:
308  wind_support = int(
309  self.get_matter_attribute_valueget_matter_attribute_value(
310  clusters.FanControl.Attributes.WindSupport
311  )
312  )
313  if wind_support & WindBitmap.kNaturalWind:
314  preset_modes.append(PRESET_NATURAL_WIND)
315  if wind_support & WindBitmap.kSleepWind:
316  preset_modes.append(PRESET_SLEEP_WIND)
317  if len(preset_modes) > 0:
318  self._attr_supported_features_attr_supported_features |= FanEntityFeature.PRESET_MODE
319  self._attr_preset_modes_attr_preset_modes = preset_modes
320  if feature_map & FanControlFeature.kAirflowDirection:
321  self._attr_supported_features_attr_supported_features |= FanEntityFeature.DIRECTION
322 
323  self._attr_supported_features_attr_supported_features |= (
324  FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON
325  )
326 
327 
328 # Discovery schema(s) to map Matter Attributes to HA entities
329 DISCOVERY_SCHEMAS = [
331  platform=Platform.FAN,
332  entity_description=FanEntityDescription(
333  key="MatterFan",
334  name=None,
335  ),
336  entity_class=MatterFan,
337  # FanEntityFeature
338  required_attributes=(
339  clusters.FanControl.Attributes.FanMode,
340  clusters.FanControl.Attributes.PercentCurrent,
341  ),
342  optional_attributes=(
343  clusters.FanControl.Attributes.SpeedSetting,
344  clusters.FanControl.Attributes.RockSetting,
345  clusters.FanControl.Attributes.WindSetting,
346  clusters.FanControl.Attributes.AirflowDirection,
347  clusters.OnOff.Attributes.OnOff,
348  ),
349  ),
350 ]
None async_set_percentage(self, int percentage)
Definition: __init__.py:340
None async_set_preset_mode(self, str preset_mode)
Definition: __init__.py:385
Any get_matter_attribute_value(self, type[ClusterAttributeDescriptor] attribute, bool null_as_none=True)
Definition: entity.py:206
None async_set_percentage(self, int percentage)
Definition: fan.py:109
None _set_wind_mode(self, str|None wind_mode)
Definition: fan.py:168
None async_oscillate(self, bool oscillating)
Definition: fan.py:140
None async_set_direction(self, str direction)
Definition: fan.py:155
None async_turn_off(self, **Any kwargs)
Definition: fan.py:95
None async_set_preset_mode(self, str preset_mode)
Definition: fan.py:120
None async_turn_on(self, int|None percentage=None, str|None preset_mode=None, **Any kwargs)
Definition: fan.py:70
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: fan.py:50
MatterAdapter get_matter(HomeAssistant hass)
Definition: helpers.py:35