diff --git a/.coveragerc b/.coveragerc index 611cb6cb983..d9772288ba2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -752,6 +752,7 @@ omit = homeassistant/components/matrix/__init__.py homeassistant/components/matrix/notify.py homeassistant/components/matter/__init__.py + homeassistant/components/matter/fan.py homeassistant/components/meater/__init__.py homeassistant/components/meater/sensor.py homeassistant/components/medcom_ble/__init__.py diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index 985ac1c996e..bc922ffffef 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -14,6 +14,7 @@ from .binary_sensor import DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS from .climate import DISCOVERY_SCHEMAS as CLIMATE_SENSOR_SCHEMAS from .cover import DISCOVERY_SCHEMAS as COVER_SCHEMAS from .event import DISCOVERY_SCHEMAS as EVENT_SCHEMAS +from .fan import DISCOVERY_SCHEMAS as FAN_SCHEMAS from .light import DISCOVERY_SCHEMAS as LIGHT_SCHEMAS from .lock import DISCOVERY_SCHEMAS as LOCK_SCHEMAS from .models import MatterDiscoverySchema, MatterEntityInfo @@ -25,6 +26,7 @@ DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = { Platform.CLIMATE: CLIMATE_SENSOR_SCHEMAS, Platform.COVER: COVER_SCHEMAS, Platform.EVENT: EVENT_SCHEMAS, + Platform.FAN: FAN_SCHEMAS, Platform.LIGHT: LIGHT_SCHEMAS, Platform.LOCK: LOCK_SCHEMAS, Platform.SENSOR: SENSOR_SCHEMAS, diff --git a/homeassistant/components/matter/fan.py b/homeassistant/components/matter/fan.py new file mode 100644 index 00000000000..0ce42f14d39 --- /dev/null +++ b/homeassistant/components/matter/fan.py @@ -0,0 +1,304 @@ +"""Matter Fan platform support.""" + +from __future__ import annotations + +from typing import Any + +from chip.clusters import Objects as clusters +from matter_server.common.helpers.util import create_attribute_path_from_attribute + +from homeassistant.components.fan import ( + DIRECTION_FORWARD, + DIRECTION_REVERSE, + FanEntity, + FanEntityDescription, + FanEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .entity import MatterEntity +from .helpers import get_matter +from .models import MatterDiscoverySchema + +FanControlFeature = clusters.FanControl.Bitmaps.Feature +WindBitmap = clusters.FanControl.Bitmaps.WindBitmap +FanModeSequenceEnum = clusters.FanControl.Enums.FanModeSequenceEnum + +PRESET_LOW = "low" +PRESET_MEDIUM = "medium" +PRESET_HIGH = "high" +PRESET_AUTO = "auto" +FAN_MODE_MAP = { + PRESET_LOW: clusters.FanControl.Enums.FanModeEnum.kLow, + PRESET_MEDIUM: clusters.FanControl.Enums.FanModeEnum.kMedium, + PRESET_HIGH: clusters.FanControl.Enums.FanModeEnum.kHigh, + PRESET_AUTO: clusters.FanControl.Enums.FanModeEnum.kAuto, +} +FAN_MODE_MAP_REVERSE = {v: k for k, v in FAN_MODE_MAP.items()} +# special preset modes for wind feature +PRESET_NATURAL_WIND = "natural_wind" +PRESET_SLEEP_WIND = "sleep_wind" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Matter fan from Config Entry.""" + matter = get_matter(hass) + matter.register_platform_handler(Platform.FAN, async_add_entities) + + +class MatterFan(MatterEntity, FanEntity): + """Representation of a Matter fan.""" + + _last_known_preset_mode: str | None = None + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + if percentage is not None: + # handle setting fan speed by percentage + await self.async_set_percentage(percentage) + return + # handle setting fan mode by preset + if preset_mode is None: + # no preset given, try to handle this with the last known value + preset_mode = self._last_known_preset_mode or PRESET_AUTO + await self.async_set_preset_mode(preset_mode) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn fan off.""" + # clear the wind setting if its currently set + if self._attr_preset_mode in [PRESET_NATURAL_WIND, PRESET_SLEEP_WIND]: + await self._set_wind_mode(None) + await self.matter_client.write_attribute( + node_id=self._endpoint.node.node_id, + attribute_path=create_attribute_path_from_attribute( + self._endpoint.endpoint_id, + clusters.FanControl.Attributes.FanMode, + ), + value=clusters.FanControl.Enums.FanModeEnum.kOff, + ) + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed of the fan, as a percentage.""" + await self.matter_client.write_attribute( + node_id=self._endpoint.node.node_id, + attribute_path=create_attribute_path_from_attribute( + self._endpoint.endpoint_id, + clusters.FanControl.Attributes.PercentSetting, + ), + value=percentage, + ) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + # handle wind as preset + if preset_mode in [PRESET_NATURAL_WIND, PRESET_SLEEP_WIND]: + await self._set_wind_mode(preset_mode) + return + + # clear the wind setting if its currently set + if self._attr_preset_mode in [PRESET_NATURAL_WIND, PRESET_SLEEP_WIND]: + await self._set_wind_mode(None) + + await self.matter_client.write_attribute( + node_id=self._endpoint.node.node_id, + attribute_path=create_attribute_path_from_attribute( + self._endpoint.endpoint_id, + clusters.FanControl.Attributes.FanMode, + ), + value=FAN_MODE_MAP[preset_mode], + ) + + async def async_oscillate(self, oscillating: bool) -> None: + """Oscillate the fan.""" + await self.matter_client.write_attribute( + node_id=self._endpoint.node.node_id, + attribute_path=create_attribute_path_from_attribute( + self._endpoint.endpoint_id, + clusters.FanControl.Attributes.RockSetting, + ), + value=self.get_matter_attribute_value( + clusters.FanControl.Attributes.RockSupport + ) + if oscillating + else 0, + ) + + async def async_set_direction(self, direction: str) -> None: + """Set the direction of the fan.""" + await self.matter_client.write_attribute( + node_id=self._endpoint.node.node_id, + attribute_path=create_attribute_path_from_attribute( + self._endpoint.endpoint_id, + clusters.FanControl.Attributes.AirflowDirection, + ), + value=clusters.FanControl.Enums.AirflowDirectionEnum.kReverse + if direction == DIRECTION_REVERSE + else clusters.FanControl.Enums.AirflowDirectionEnum.kForward, + ) + + async def _set_wind_mode(self, wind_mode: str | None) -> None: + """Set wind mode.""" + if wind_mode == PRESET_NATURAL_WIND: + wind_setting = WindBitmap.kNaturalWind + elif wind_mode == PRESET_SLEEP_WIND: + wind_setting = WindBitmap.kSleepWind + else: + wind_setting = 0 + await self.matter_client.write_attribute( + node_id=self._endpoint.node.node_id, + attribute_path=create_attribute_path_from_attribute( + self._endpoint.endpoint_id, + clusters.FanControl.Attributes.WindSetting, + ), + value=wind_setting, + ) + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + if not hasattr(self, "_attr_preset_modes"): + self._calculate_features() + if self._attr_supported_features & FanEntityFeature.DIRECTION: + direction_value = self.get_matter_attribute_value( + clusters.FanControl.Attributes.AirflowDirection + ) + self._attr_current_direction = ( + DIRECTION_REVERSE + if direction_value + == clusters.FanControl.Enums.AirflowDirectionEnum.kReverse + else DIRECTION_FORWARD + ) + if self._attr_supported_features & FanEntityFeature.OSCILLATE: + self._attr_oscillating = ( + self.get_matter_attribute_value( + clusters.FanControl.Attributes.RockSetting + ) + != 0 + ) + + # speed percentage is always provided + current_percent = self.get_matter_attribute_value( + clusters.FanControl.Attributes.PercentCurrent + ) + # NOTE that a device may give back 255 as a special value to indicate that + # the speed is under automatic control and not set to a specific value. + self._attr_percentage = None if current_percent == 255 else current_percent + + # get preset mode from fan mode (and wind feature if available) + wind_setting = self.get_matter_attribute_value( + clusters.FanControl.Attributes.WindSetting + ) + if ( + self._attr_preset_modes + and PRESET_NATURAL_WIND in self._attr_preset_modes + and wind_setting & WindBitmap.kNaturalWind + ): + self._attr_preset_mode = PRESET_NATURAL_WIND + elif ( + self._attr_preset_modes + and PRESET_SLEEP_WIND in self._attr_preset_modes + and wind_setting & WindBitmap.kSleepWind + ): + self._attr_preset_mode = PRESET_SLEEP_WIND + else: + fan_mode = self.get_matter_attribute_value( + clusters.FanControl.Attributes.FanMode + ) + self._attr_preset_mode = FAN_MODE_MAP_REVERSE.get(fan_mode) + + # keep track of the last known mode for turn_on commands without preset + if self._attr_preset_mode is not None: + self._last_known_preset_mode = self._attr_preset_mode + + @callback + def _calculate_features( + self, + ) -> None: + """Calculate features and preset modes for HA Fan platform from Matter attributes..""" + # work out supported features and presets from matter featuremap + feature_map = int( + self.get_matter_attribute_value(clusters.FanControl.Attributes.FeatureMap) + ) + if feature_map & FanControlFeature.kMultiSpeed: + self._attr_supported_features |= FanEntityFeature.SET_SPEED + self._attr_speed_count = int( + self.get_matter_attribute_value(clusters.FanControl.Attributes.SpeedMax) + ) + if feature_map & FanControlFeature.kRocking: + # NOTE: the Matter model allows that a device can have multiple/different + # rock directions while HA doesn't allow this in the entity model. + # For now we just assume that a device has a single rock direction and the + # Matter spec is just future proofing for devices that might have multiple + # rock directions. As soon as devices show up that actually support multiple + # directions, we need to either update the HA Fan entity model or maybe add + # this as a separate entity. + self._attr_supported_features |= FanEntityFeature.OSCILLATE + + # figure out supported preset modes + preset_modes = [] + fan_mode_seq = int( + self.get_matter_attribute_value( + clusters.FanControl.Attributes.FanModeSequence + ) + ) + if fan_mode_seq == FanModeSequenceEnum.kOffLowHigh: + preset_modes = [PRESET_LOW, PRESET_HIGH] + elif fan_mode_seq == FanModeSequenceEnum.kOffLowHighAuto: + preset_modes = [PRESET_LOW, PRESET_HIGH, PRESET_AUTO] + elif fan_mode_seq == FanModeSequenceEnum.kOffLowMedHigh: + preset_modes = [PRESET_LOW, PRESET_MEDIUM, PRESET_HIGH] + elif fan_mode_seq == FanModeSequenceEnum.kOffLowMedHighAuto: + preset_modes = [PRESET_LOW, PRESET_MEDIUM, PRESET_HIGH, PRESET_AUTO] + elif fan_mode_seq == FanModeSequenceEnum.kOffOnAuto: + preset_modes = [PRESET_AUTO] + # treat Matter Wind feature as additional preset(s) + if feature_map & FanControlFeature.kWind: + wind_support = int( + self.get_matter_attribute_value( + clusters.FanControl.Attributes.WindSupport + ) + ) + if wind_support & WindBitmap.kNaturalWind: + preset_modes.append(PRESET_NATURAL_WIND) + if wind_support & WindBitmap.kSleepWind: + preset_modes.append(PRESET_SLEEP_WIND) + if len(preset_modes) > 0: + self._attr_supported_features |= FanEntityFeature.PRESET_MODE + self._attr_preset_modes = preset_modes + if feature_map & FanControlFeature.kAirflowDirection: + self._attr_supported_features |= FanEntityFeature.DIRECTION + + +# Discovery schema(s) to map Matter Attributes to HA entities +DISCOVERY_SCHEMAS = [ + MatterDiscoverySchema( + platform=Platform.FAN, + entity_description=FanEntityDescription( + key="MatterFan", name=None, translation_key="fan" + ), + entity_class=MatterFan, + # FanEntityFeature + required_attributes=( + clusters.FanControl.Attributes.FanMode, + clusters.FanControl.Attributes.PercentCurrent, + ), + optional_attributes=( + clusters.FanControl.Attributes.SpeedSetting, + clusters.FanControl.Attributes.RockSetting, + clusters.FanControl.Attributes.WindSetting, + clusters.FanControl.Attributes.AirflowDirection, + ), + ), +] diff --git a/tests/components/matter/fixtures/nodes/air-purifier.json b/tests/components/matter/fixtures/nodes/air-purifier.json new file mode 100644 index 00000000000..daa143d57e8 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/air-purifier.json @@ -0,0 +1,706 @@ +{ + "node_id": 143, + "date_commissioned": "2024-05-27T08:56:55.931757", + "last_interview": "2024-05-27T08:56:55.931762", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 48, 49, 50, 51, 60, 62, 63], + "0/29/2": [41], + "0/29/3": [1, 2, 3, 4, 5], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 2 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 17, + "0/40/1": "TEST_VENDOR", + "0/40/2": 65521, + "0/40/3": "Air Purifier", + "0/40/4": 32769, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/18": "29E3B8A925484953", + "0/40/19": { + "0": 3, + "1": 65535 + }, + "0/40/21": 16973824, + "0/40/22": 1, + "0/40/65532": 0, + "0/40/65533": 3, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 21, 22, + 65528, 65529, 65531, 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 0, + "0/42/3": 0, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 2, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "ZW5kMA==", + "1": true + } + ], + "0/49/2": 0, + "0/49/3": 0, + "0/49/4": true, + "0/49/5": null, + "0/49/6": null, + "0/49/7": null, + "0/49/65532": 4, + "0/49/65533": 2, + "0/49/65528": [], + "0/49/65529": [], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "veth90ad201", + "1": true, + "2": null, + "3": null, + "4": "niHggbas", + "5": [], + "6": ["/oAAAAAAAACcIeD//oG2rA=="], + "7": 0 + }, + { + "0": "veth5a7d8ed", + "1": true, + "2": null, + "3": null, + "4": "nn997EzL", + "5": [], + "6": ["/oAAAAAAAACcf33//uxMyw=="], + "7": 0 + }, + { + "0": "veth3408146", + "1": true, + "2": null, + "3": null, + "4": "XqhU7ti3", + "5": [], + "6": ["/oAAAAAAAABcqFT//u7Ytw=="], + "7": 0 + }, + { + "0": "veth3f3d040", + "1": true, + "2": null, + "3": null, + "4": "Vlz/o96u", + "5": [], + "6": ["/oAAAAAAAABUXP///qPerg=="], + "7": 0 + }, + { + "0": "vethf3a8950", + "1": true, + "2": null, + "3": null, + "4": "Ikj8iJ0V", + "5": [], + "6": ["/oAAAAAAAAAgSPz//oidFQ=="], + "7": 0 + }, + { + "0": "vethb3a8e95", + "1": true, + "2": null, + "3": null, + "4": "Pm3ij+z4", + "5": [], + "6": ["/oAAAAAAAAA8beL//o/s+A=="], + "7": 0 + }, + { + "0": "veth02a8c45", + "1": true, + "2": null, + "3": null, + "4": "xlbQTHOq", + "5": [], + "6": ["/oAAAAAAAADEVtD//kxzqg=="], + "7": 0 + }, + { + "0": "veth2daa408", + "1": true, + "2": null, + "3": null, + "4": "ZucpYWOy", + "5": [], + "6": ["/oAAAAAAAABk5yn//mFjsg=="], + "7": 0 + }, + { + "0": "hassio", + "1": true, + "2": null, + "3": null, + "4": "AkKEd951", + "5": ["rB4gAQ=="], + "6": ["/oAAAAAAAAAAQoT//nfedQ=="], + "7": 0 + }, + { + "0": "docker0", + "1": true, + "2": null, + "3": null, + "4": "AkI4C0xe", + "5": ["rB7oAQ=="], + "6": [], + "7": 0 + }, + { + "0": "end0", + "1": true, + "2": null, + "3": null, + "4": "redacted", + "5": [], + "6": [], + "7": 2 + }, + { + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": [], + "6": [], + "7": 0 + } + ], + "0/51/1": 2, + "0/51/2": 22, + "0/51/3": 0, + "0/51/4": 0, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "1": "redacted", + "2": "redacted", + "254": 2 + } + ], + "0/62/1": [ + { + "1": "redacted", + "2": 65521, + "3": 1, + "4": 143, + "5": "", + "254": 2 + } + ], + "0/62/2": 16, + "0/62/3": 1, + "0/62/4": ["redacted"], + "0/62/5": 2, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 45, + "1": 1 + } + ], + "1/29/1": [3, 29, 113, 114, 514], + "1/29/2": [], + "1/29/3": [2, 3, 4, 5], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/113/0": 100, + "1/113/1": 1, + "1/113/2": 0, + "1/113/3": true, + "1/113/4": null, + "1/113/5": [ + { + "0": 0, + "1": "111112222233" + }, + { + "0": 1, + "1": "gtin8xxx" + }, + { + "0": 2, + "1": "4444455555666" + }, + { + "0": 3, + "1": "gtin14xxxxxxxx" + }, + { + "0": 4, + "1": "oem20xxxxxxxxxxxxxxx" + } + ], + "1/113/65532": 7, + "1/113/65533": 1, + "1/113/65528": [], + "1/113/65529": [0], + "1/113/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "1/114/0": 100, + "1/114/1": 1, + "1/114/2": 0, + "1/114/3": true, + "1/114/4": null, + "1/114/5": [ + { + "0": 0, + "1": "111112222233" + }, + { + "0": 1, + "1": "gtin8xxx" + }, + { + "0": 2, + "1": "4444455555666" + }, + { + "0": 3, + "1": "gtin14xxxxxxxx" + }, + { + "0": 4, + "1": "oem20xxxxxxxxxxxxxxx" + } + ], + "1/114/65532": 7, + "1/114/65533": 1, + "1/114/65528": [], + "1/114/65529": [0], + "1/114/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "1/514/0": 5, + "1/514/1": 2, + "1/514/2": null, + "1/514/3": 255, + "1/514/4": 10, + "1/514/5": null, + "1/514/6": 255, + "1/514/7": 1, + "1/514/8": 0, + "1/514/9": 3, + "1/514/10": 0, + "1/514/11": 0, + "1/514/65532": 63, + "1/514/65533": 4, + "1/514/65528": [], + "1/514/65529": [0], + "1/514/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 65528, 65529, 65531, 65532, 65533 + ], + "2/3/0": 0, + "2/3/1": 0, + "2/3/65532": 0, + "2/3/65533": 4, + "2/3/65528": [], + "2/3/65529": [0], + "2/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "2/29/0": [ + { + "0": 44, + "1": 1 + } + ], + "2/29/1": [ + 3, 29, 91, 1036, 1037, 1043, 1045, 1066, 1067, 1068, 1069, 1070, 1071 + ], + "2/29/2": [], + "2/29/3": [], + "2/29/65532": 0, + "2/29/65533": 2, + "2/29/65528": [], + "2/29/65529": [], + "2/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "2/91/0": 1, + "2/91/65532": 15, + "2/91/65533": 1, + "2/91/65528": [], + "2/91/65529": [], + "2/91/65531": [0, 65528, 65529, 65531, 65532, 65533], + "2/1036/0": 2.0, + "2/1036/1": 0.0, + "2/1036/2": 1000.0, + "2/1036/3": 1.0, + "2/1036/4": 320, + "2/1036/5": 1.0, + "2/1036/6": 320, + "2/1036/7": 0.0, + "2/1036/8": 0, + "2/1036/9": 0, + "2/1036/10": 1, + "2/1036/65532": 63, + "2/1036/65533": 3, + "2/1036/65528": [], + "2/1036/65529": [], + "2/1036/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "2/1037/0": 2.0, + "2/1037/1": 0.0, + "2/1037/2": 1000.0, + "2/1037/3": 1.0, + "2/1037/4": 320, + "2/1037/5": 1.0, + "2/1037/6": 320, + "2/1037/7": 0.0, + "2/1037/8": 0, + "2/1037/9": 0, + "2/1037/10": 1, + "2/1037/65532": 63, + "2/1037/65533": 3, + "2/1037/65528": [], + "2/1037/65529": [], + "2/1037/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "2/1043/0": 2.0, + "2/1043/1": 0.0, + "2/1043/2": 1000.0, + "2/1043/3": 1.0, + "2/1043/4": 320, + "2/1043/5": 1.0, + "2/1043/6": 320, + "2/1043/7": 0.0, + "2/1043/8": 0, + "2/1043/9": 0, + "2/1043/10": 1, + "2/1043/65532": 63, + "2/1043/65533": 3, + "2/1043/65528": [], + "2/1043/65529": [], + "2/1043/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "2/1045/0": 2.0, + "2/1045/1": 0.0, + "2/1045/2": 1000.0, + "2/1045/3": 1.0, + "2/1045/4": 320, + "2/1045/5": 1.0, + "2/1045/6": 320, + "2/1045/7": 0.0, + "2/1045/8": 0, + "2/1045/9": 0, + "2/1045/10": 1, + "2/1045/65532": 63, + "2/1045/65533": 3, + "2/1045/65528": [], + "2/1045/65529": [], + "2/1045/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "2/1066/0": 2.0, + "2/1066/1": 0.0, + "2/1066/2": 1000.0, + "2/1066/3": 1.0, + "2/1066/4": 320, + "2/1066/5": 1.0, + "2/1066/6": 320, + "2/1066/7": 0.0, + "2/1066/8": 0, + "2/1066/9": 0, + "2/1066/10": 1, + "2/1066/65532": 63, + "2/1066/65533": 3, + "2/1066/65528": [], + "2/1066/65529": [], + "2/1066/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "2/1067/0": 2.0, + "2/1067/1": 0.0, + "2/1067/2": 1000.0, + "2/1067/3": 1.0, + "2/1067/4": 320, + "2/1067/5": 1.0, + "2/1067/6": 320, + "2/1067/7": 0.0, + "2/1067/8": 0, + "2/1067/9": 0, + "2/1067/10": 1, + "2/1067/65532": 63, + "2/1067/65533": 3, + "2/1067/65528": [], + "2/1067/65529": [], + "2/1067/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "2/1068/0": 2.0, + "2/1068/1": 0.0, + "2/1068/2": 1000.0, + "2/1068/3": 1.0, + "2/1068/4": 320, + "2/1068/5": 1.0, + "2/1068/6": 320, + "2/1068/7": 0.0, + "2/1068/8": 0, + "2/1068/9": 0, + "2/1068/10": 1, + "2/1068/65532": 63, + "2/1068/65533": 3, + "2/1068/65528": [], + "2/1068/65529": [], + "2/1068/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "2/1069/0": 2.0, + "2/1069/1": 0.0, + "2/1069/2": 1000.0, + "2/1069/3": 1.0, + "2/1069/4": 320, + "2/1069/5": 1.0, + "2/1069/6": 320, + "2/1069/7": 0.0, + "2/1069/8": 0, + "2/1069/9": 0, + "2/1069/10": 1, + "2/1069/65532": 63, + "2/1069/65533": 3, + "2/1069/65528": [], + "2/1069/65529": [], + "2/1069/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "2/1070/0": 2.0, + "2/1070/1": 0.0, + "2/1070/2": 1000.0, + "2/1070/3": 1.0, + "2/1070/4": 320, + "2/1070/5": 1.0, + "2/1070/6": 320, + "2/1070/7": 0.0, + "2/1070/8": 0, + "2/1070/9": 0, + "2/1070/10": 1, + "2/1070/65532": 63, + "2/1070/65533": 3, + "2/1070/65528": [], + "2/1070/65529": [], + "2/1070/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "2/1071/0": 2.0, + "2/1071/1": 0.0, + "2/1071/2": 1000.0, + "2/1071/3": 1.0, + "2/1071/4": 320, + "2/1071/5": 1.0, + "2/1071/6": 320, + "2/1071/7": 0.0, + "2/1071/8": 0, + "2/1071/9": 0, + "2/1071/10": 1, + "2/1071/65532": 63, + "2/1071/65533": 3, + "2/1071/65528": [], + "2/1071/65529": [], + "2/1071/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "3/3/0": 0, + "3/3/1": 0, + "3/3/65532": 0, + "3/3/65533": 4, + "3/3/65528": [], + "3/3/65529": [0, 64], + "3/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "3/29/0": [ + { + "0": 770, + "1": 2 + } + ], + "3/29/1": [3, 29, 1026], + "3/29/2": [], + "3/29/3": [], + "3/29/65532": 0, + "3/29/65533": 2, + "3/29/65528": [], + "3/29/65529": [], + "3/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "3/1026/0": 2000, + "3/1026/1": -500, + "3/1026/2": 6000, + "3/1026/3": 0, + "3/1026/65532": 0, + "3/1026/65533": 4, + "3/1026/65528": [], + "3/1026/65529": [], + "3/1026/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "4/3/0": 0, + "4/3/1": 0, + "4/3/65532": 0, + "4/3/65533": 4, + "4/3/65528": [], + "4/3/65529": [0, 64], + "4/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "4/29/0": [ + { + "0": 775, + "1": 2 + } + ], + "4/29/1": [3, 29, 1029], + "4/29/2": [], + "4/29/3": [], + "4/29/65532": 0, + "4/29/65533": 2, + "4/29/65528": [], + "4/29/65529": [], + "4/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "4/1029/0": 5000, + "4/1029/1": 0, + "4/1029/2": 10000, + "4/1029/3": 0, + "4/1029/65532": 0, + "4/1029/65533": 3, + "4/1029/65528": [], + "4/1029/65529": [], + "4/1029/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "5/3/0": 0, + "5/3/1": 0, + "5/3/65532": 0, + "5/3/65533": 4, + "5/3/65528": [], + "5/3/65529": [0, 64], + "5/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "5/29/0": [ + { + "0": 769, + "1": 2 + } + ], + "5/29/1": [3, 29, 513], + "5/29/2": [], + "5/29/3": [], + "5/29/65532": 0, + "5/29/65533": 2, + "5/29/65528": [], + "5/29/65529": [], + "5/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "5/513/0": 2000, + "5/513/3": 500, + "5/513/4": 3000, + "5/513/18": 2000, + "5/513/27": 2, + "5/513/28": 0, + "5/513/41": 0, + "5/513/65532": 1, + "5/513/65533": 6, + "5/513/65528": [], + "5/513/65529": [0], + "5/513/65531": [0, 3, 4, 18, 27, 28, 41, 65528, 65529, 65531, 65532, 65533] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/test_fan.py b/tests/components/matter/test_fan.py new file mode 100644 index 00000000000..fe466aa15b3 --- /dev/null +++ b/tests/components/matter/test_fan.py @@ -0,0 +1,275 @@ +"""Test Matter Fan platform.""" + +from unittest.mock import MagicMock, call + +from matter_server.client.models.node import MatterNode +import pytest + +from homeassistant.components.fan import ( + ATTR_DIRECTION, + ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + DIRECTION_FORWARD, + DIRECTION_REVERSE, + DOMAIN as FAN_DOMAIN, + SERVICE_OSCILLATE, + SERVICE_SET_DIRECTION, + FanEntityFeature, +) +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant + +from .common import ( + set_node_attribute, + setup_integration_with_node_fixture, + trigger_subscription_callback, +) + + +@pytest.fixture(name="air_purifier") +async def air_purifier_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a Air Purifier node (containing Fan cluster).""" + return await setup_integration_with_node_fixture( + hass, "air-purifier", matter_client + ) + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_fan_base( + hass: HomeAssistant, + matter_client: MagicMock, + air_purifier: MatterNode, +) -> None: + """Test Fan platform.""" + entity_id = "fan.air_purifier" + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_modes"] == [ + "low", + "medium", + "high", + "auto", + "natural_wind", + "sleep_wind", + ] + assert state.attributes["direction"] == "forward" + assert state.attributes["oscillating"] is False + assert state.attributes["percentage"] is None + assert state.attributes["percentage_step"] == 10 + assert state.attributes["preset_mode"] == "auto" + mask = ( + FanEntityFeature.DIRECTION + | FanEntityFeature.OSCILLATE + | FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED + ) + assert state.attributes["supported_features"] & mask == mask + # handle fan mode update + set_node_attribute(air_purifier, 1, 514, 0, 1) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state.attributes["preset_mode"] == "low" + # handle direction update + set_node_attribute(air_purifier, 1, 514, 11, 1) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state.attributes["direction"] == "reverse" + # handle rock/oscillation update + set_node_attribute(air_purifier, 1, 514, 8, 1) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state.attributes["oscillating"] is True + # handle wind mode active translates to correct preset + set_node_attribute(air_purifier, 1, 514, 10, 2) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state.attributes["preset_mode"] == "natural_wind" + set_node_attribute(air_purifier, 1, 514, 10, 1) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state.attributes["preset_mode"] == "sleep_wind" + + +async def test_fan_turn_on_with_percentage( + hass: HomeAssistant, + matter_client: MagicMock, + air_purifier: MatterNode, +): + """Test turning on the fan with a specific percentage.""" + entity_id = "fan.air_purifier" + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_PERCENTAGE: 50}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=air_purifier.node_id, + attribute_path="1/514/2", + value=50, + ) + + +async def test_fan_turn_on_with_preset_mode( + hass: HomeAssistant, + matter_client: MagicMock, + air_purifier: MatterNode, +): + """Test turning on the fan with a specific preset mode.""" + entity_id = "fan.air_purifier" + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "medium"}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=air_purifier.node_id, + attribute_path="1/514/0", + value=2, + ) + # test again with wind feature as preset mode + for preset_mode, value in (("natural_wind", 2), ("sleep_wind", 1)): + matter_client.write_attribute.reset_mock() + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: preset_mode}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=air_purifier.node_id, + attribute_path="1/514/10", + value=value, + ) + # test again where preset_mode is omitted in the service call + # which should select a default preset mode + matter_client.write_attribute.reset_mock() + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=air_purifier.node_id, + attribute_path="1/514/0", + value=5, + ) + # test again if wind mode is explicitly turned off when we set a new preset mode + matter_client.write_attribute.reset_mock() + set_node_attribute(air_purifier, 1, 514, 10, 2) + await trigger_subscription_callback(hass, matter_client) + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "medium"}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 2 + assert matter_client.write_attribute.call_args_list[0] == call( + node_id=air_purifier.node_id, + attribute_path="1/514/10", + value=0, + ) + assert matter_client.write_attribute.call_args == call( + node_id=air_purifier.node_id, + attribute_path="1/514/0", + value=2, + ) + + +async def test_fan_turn_off( + hass: HomeAssistant, + matter_client: MagicMock, + air_purifier: MatterNode, +): + """Test turning off the fan.""" + entity_id = "fan.air_purifier" + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=air_purifier.node_id, + attribute_path="1/514/0", + value=0, + ) + matter_client.write_attribute.reset_mock() + # test again if wind mode is turned off + set_node_attribute(air_purifier, 1, 514, 10, 2) + await trigger_subscription_callback(hass, matter_client) + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 2 + assert matter_client.write_attribute.call_args_list[0] == call( + node_id=air_purifier.node_id, + attribute_path="1/514/10", + value=0, + ) + assert matter_client.write_attribute.call_args_list[1] == call( + node_id=air_purifier.node_id, + attribute_path="1/514/0", + value=0, + ) + + +async def test_fan_oscillate( + hass: HomeAssistant, + matter_client: MagicMock, + air_purifier: MatterNode, +): + """Test oscillating the fan.""" + entity_id = "fan.air_purifier" + for oscillating, value in ((True, 1), (False, 0)): + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_OSCILLATE, + {ATTR_ENTITY_ID: entity_id, ATTR_OSCILLATING: oscillating}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=air_purifier.node_id, + attribute_path="1/514/8", + value=value, + ) + matter_client.write_attribute.reset_mock() + + +async def test_fan_set_direction( + hass: HomeAssistant, + matter_client: MagicMock, + air_purifier: MatterNode, +): + """Test oscillating the fan.""" + entity_id = "fan.air_purifier" + for direction, value in ((DIRECTION_FORWARD, 0), (DIRECTION_REVERSE, 1)): + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_DIRECTION, + {ATTR_ENTITY_ID: entity_id, ATTR_DIRECTION: direction}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=air_purifier.node_id, + attribute_path="1/514/11", + value=value, + ) + matter_client.write_attribute.reset_mock()