Add Matter fan platform (#111212)

Co-authored-by: Marcel van der Veldt <m.vanderveldt@outlook.com>
This commit is contained in:
Ludovic BOUÉ 2024-05-28 12:24:58 +02:00 committed by GitHub
parent 7f934bafc2
commit f44dfe8fef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 1288 additions and 0 deletions

View File

@ -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

View File

@ -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,

View File

@ -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,
),
),
]

View File

@ -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": []
}

View File

@ -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()