From 8a4206da9914cae6e195b41d34f3c78ba83c5e49 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 31 Jul 2024 20:37:57 +0200 Subject: [PATCH] Matter handle FeatureMap update (#122544) --- homeassistant/components/matter/climate.py | 88 +++++++++++----------- homeassistant/components/matter/fan.py | 14 +++- homeassistant/components/matter/lock.py | 48 ++++++------ tests/components/matter/test_climate.py | 6 ++ tests/components/matter/test_fan.py | 5 ++ tests/components/matter/test_lock.py | 6 ++ 6 files changed, 93 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 713aadf5620..ff00e4ee495 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import IntEnum -from typing import TYPE_CHECKING, Any +from typing import Any from chip.clusters import Objects as clusters from matter_server.client.models import device_types @@ -30,12 +30,6 @@ from .entity import MatterEntity from .helpers import get_matter from .models import MatterDiscoverySchema -if TYPE_CHECKING: - from matter_server.client import MatterClient - from matter_server.client.models.node import MatterEndpoint - - from .discovery import MatterEntityInfo - TEMPERATURE_SCALING_FACTOR = 100 HVAC_SYSTEM_MODE_MAP = { HVACMode.OFF: 0, @@ -105,46 +99,9 @@ class MatterClimate(MatterEntity, ClimateEntity): _attr_temperature_unit: str = UnitOfTemperature.CELSIUS _attr_hvac_mode: HVACMode = HVACMode.OFF + _feature_map: int | None = None _enable_turn_on_off_backwards_compatibility = False - def __init__( - self, - matter_client: MatterClient, - endpoint: MatterEndpoint, - entity_info: MatterEntityInfo, - ) -> None: - """Initialize the Matter climate entity.""" - super().__init__(matter_client, endpoint, entity_info) - product_id = self._endpoint.node.device_info.productID - vendor_id = self._endpoint.node.device_info.vendorID - - # set hvac_modes based on feature map - self._attr_hvac_modes: list[HVACMode] = [HVACMode.OFF] - feature_map = int( - self.get_matter_attribute_value(clusters.Thermostat.Attributes.FeatureMap) - ) - self._attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_OFF - ) - if feature_map & ThermostatFeature.kHeating: - self._attr_hvac_modes.append(HVACMode.HEAT) - if feature_map & ThermostatFeature.kCooling: - self._attr_hvac_modes.append(HVACMode.COOL) - if (vendor_id, product_id) in SUPPORT_DRY_MODE_DEVICES: - self._attr_hvac_modes.append(HVACMode.DRY) - if (vendor_id, product_id) in SUPPORT_FAN_MODE_DEVICES: - self._attr_hvac_modes.append(HVACMode.FAN_ONLY) - if feature_map & ThermostatFeature.kAutoMode: - self._attr_hvac_modes.append(HVACMode.HEAT_COOL) - # only enable temperature_range feature if the device actually supports that - - if (vendor_id, product_id) not in SINGLE_SETPOINT_DEVICES: - self._attr_supported_features |= ( - ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - ) - if any(mode for mode in self.hvac_modes if mode != HVACMode.OFF): - self._attr_supported_features |= ClimateEntityFeature.TURN_ON - async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" target_hvac_mode: HVACMode | None = kwargs.get(ATTR_HVAC_MODE) @@ -224,6 +181,7 @@ class MatterClimate(MatterEntity, ClimateEntity): @callback def _update_from_device(self) -> None: """Update from device.""" + self._calculate_features() self._attr_current_temperature = self._get_temperature_in_degrees( clusters.Thermostat.Attributes.LocalTemperature ) @@ -319,6 +277,46 @@ class MatterClimate(MatterEntity, ClimateEntity): else: self._attr_max_temp = DEFAULT_MAX_TEMP + @callback + def _calculate_features( + self, + ) -> None: + """Calculate features for HA Thermostat platform from Matter FeatureMap.""" + feature_map = int( + self.get_matter_attribute_value(clusters.Thermostat.Attributes.FeatureMap) + ) + # NOTE: the featuremap can dynamically change, so we need to update the + # supported features if the featuremap changes. + # work out supported features and presets from matter featuremap + if self._feature_map == feature_map: + return + self._feature_map = feature_map + product_id = self._endpoint.node.device_info.productID + vendor_id = self._endpoint.node.device_info.vendorID + self._attr_hvac_modes: list[HVACMode] = [HVACMode.OFF] + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_OFF + ) + if feature_map & ThermostatFeature.kHeating: + self._attr_hvac_modes.append(HVACMode.HEAT) + if feature_map & ThermostatFeature.kCooling: + self._attr_hvac_modes.append(HVACMode.COOL) + if (vendor_id, product_id) in SUPPORT_DRY_MODE_DEVICES: + self._attr_hvac_modes.append(HVACMode.DRY) + if (vendor_id, product_id) in SUPPORT_FAN_MODE_DEVICES: + self._attr_hvac_modes.append(HVACMode.FAN_ONLY) + if feature_map & ThermostatFeature.kAutoMode: + self._attr_hvac_modes.append(HVACMode.HEAT_COOL) + # only enable temperature_range feature if the device actually supports that + + if (vendor_id, product_id) not in SINGLE_SETPOINT_DEVICES: + self._attr_supported_features |= ( + ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) + if any(mode for mode in self.hvac_modes if mode != HVACMode.OFF): + self._attr_supported_features |= ClimateEntityFeature.TURN_ON + + @callback def _get_temperature_in_degrees( self, attribute: type[clusters.ClusterAttributeDescriptor] ) -> float | None: diff --git a/homeassistant/components/matter/fan.py b/homeassistant/components/matter/fan.py index 8e5ef617304..458a57538eb 100644 --- a/homeassistant/components/matter/fan.py +++ b/homeassistant/components/matter/fan.py @@ -59,6 +59,7 @@ class MatterFan(MatterEntity, FanEntity): _last_known_preset_mode: str | None = None _last_known_percentage: int = 0 _enable_turn_on_off_backwards_compatibility = False + _feature_map: int | None = None async def async_turn_on( self, @@ -183,8 +184,7 @@ class MatterFan(MatterEntity, FanEntity): @callback def _update_from_device(self) -> None: """Update from device.""" - if not hasattr(self, "_attr_preset_modes"): - self._calculate_features() + self._calculate_features() if self.get_matter_attribute_value(clusters.OnOff.Attributes.OnOff) is False: # special case: the appliance has a dedicated Power switch on the OnOff cluster @@ -257,11 +257,17 @@ class MatterFan(MatterEntity, FanEntity): 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 + """Calculate features for HA Fan platform from Matter FeatureMap.""" feature_map = int( self.get_matter_attribute_value(clusters.FanControl.Attributes.FeatureMap) ) + # NOTE: the featuremap can dynamically change, so we need to update the + # supported features if the featuremap changes. + # work out supported features and presets from matter featuremap + if self._feature_map == feature_map: + return + self._feature_map = feature_map + self._attr_supported_features = FanEntityFeature(0) if feature_map & FanControlFeature.kMultiSpeed: self._attr_supported_features |= FanEntityFeature.SET_SPEED self._attr_speed_count = int( diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py index 31ae5e496ce..8adaecd67ad 100644 --- a/homeassistant/components/matter/lock.py +++ b/homeassistant/components/matter/lock.py @@ -38,7 +38,7 @@ async def async_setup_entry( class MatterLock(MatterEntity, LockEntity): """Representation of a Matter lock.""" - features: int | None = None + _feature_map: int | None = None _optimistic_timer: asyncio.TimerHandle | None = None @property @@ -61,22 +61,6 @@ class MatterLock(MatterEntity, LockEntity): return None - @property - def supports_door_position_sensor(self) -> bool: - """Return True if the lock supports door position sensor.""" - if self.features is None: - return False - - return bool(self.features & DoorLockFeature.kDoorPositionSensor) - - @property - def supports_unbolt(self) -> bool: - """Return True if the lock supports unbolt.""" - if self.features is None: - return False - - return bool(self.features & DoorLockFeature.kUnbolt) - async def send_device_command( self, command: clusters.ClusterCommand, @@ -120,7 +104,7 @@ class MatterLock(MatterEntity, LockEntity): ) code: str | None = kwargs.get(ATTR_CODE) code_bytes = code.encode() if code else None - if self.supports_unbolt: + if self._attr_supported_features & LockEntityFeature.OPEN: # if the lock reports it has separate unbolt support, # the unlock command should unbolt only on the unlock command # and unlatch on the HA 'open' command. @@ -151,13 +135,8 @@ class MatterLock(MatterEntity, LockEntity): @callback def _update_from_device(self) -> None: """Update the entity from the device.""" - - if self.features is None: - self.features = int( - self.get_matter_attribute_value(clusters.DoorLock.Attributes.FeatureMap) - ) - if self.supports_unbolt: - self._attr_supported_features = LockEntityFeature.OPEN + # always calculate the features as they can dynamically change + self._calculate_features() lock_state = self.get_matter_attribute_value( clusters.DoorLock.Attributes.LockState @@ -197,6 +176,25 @@ class MatterLock(MatterEntity, LockEntity): if write_state: self.async_write_ha_state() + @callback + def _calculate_features( + self, + ) -> None: + """Calculate features for HA Lock platform from Matter FeatureMap.""" + feature_map = int( + self.get_matter_attribute_value(clusters.DoorLock.Attributes.FeatureMap) + ) + # NOTE: the featuremap can dynamically change, so we need to update the + # supported features if the featuremap changes. + if self._feature_map == feature_map: + return + self._feature_map = feature_map + supported_features = LockEntityFeature(0) + # determine if lock supports optional open/unbolt feature + if bool(feature_map & DoorLockFeature.kUnbolt): + supported_features |= LockEntityFeature.OPEN + self._attr_supported_features = supported_features + DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index e0015e8b445..4d6978edfde 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -350,3 +350,9 @@ async def test_room_airconditioner( state = hass.states.get("climate.room_airconditioner_thermostat") assert state assert state.state == HVACMode.DRY + + # test featuremap update + set_node_attribute(room_airconditioner, 1, 513, 65532, 1) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("climate.room_airconditioner_thermostat") + assert state.attributes["supported_features"] & ClimateEntityFeature.TURN_ON diff --git a/tests/components/matter/test_fan.py b/tests/components/matter/test_fan.py index 18c2c2ed255..690209b1165 100644 --- a/tests/components/matter/test_fan.py +++ b/tests/components/matter/test_fan.py @@ -107,6 +107,11 @@ async def test_fan_base( state = hass.states.get(entity_id) assert state.attributes["preset_mode"] is None assert state.attributes["percentage"] == 0 + # test featuremap update + set_node_attribute(air_purifier, 1, 514, 65532, 1) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state.attributes["supported_features"] & FanEntityFeature.SET_SPEED @pytest.mark.parametrize("expected_lingering_tasks", [True]) diff --git a/tests/components/matter/test_lock.py b/tests/components/matter/test_lock.py index 1180e6ee469..f279430b393 100644 --- a/tests/components/matter/test_lock.py +++ b/tests/components/matter/test_lock.py @@ -97,6 +97,12 @@ async def test_lock( assert state assert state.state == STATE_UNKNOWN + # test featuremap update + set_node_attribute(door_lock, 1, 257, 65532, 4096) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("lock.mock_door_lock_lock") + assert state.attributes["supported_features"] & LockEntityFeature.OPEN + # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True])