Matter handle FeatureMap update (#122544)

This commit is contained in:
Marcel van der Veldt 2024-07-31 20:37:57 +02:00 committed by GitHub
parent f1084a57df
commit 8a4206da99
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 93 additions and 74 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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