Add support for features changing at runtime in Matter integration (#129426)

This commit is contained in:
Marcel van der Veldt 2024-12-03 13:06:18 +01:00 committed by GitHub
parent aeab8a0143
commit 50936b4e28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 113 additions and 61 deletions

View File

@ -45,6 +45,7 @@ class MatterAdapter:
self.hass = hass self.hass = hass
self.config_entry = config_entry self.config_entry = config_entry
self.platform_handlers: dict[Platform, AddEntitiesCallback] = {} self.platform_handlers: dict[Platform, AddEntitiesCallback] = {}
self.discovered_entities: set[str] = set()
def register_platform_handler( def register_platform_handler(
self, platform: Platform, add_entities: AddEntitiesCallback self, platform: Platform, add_entities: AddEntitiesCallback
@ -54,23 +55,19 @@ class MatterAdapter:
async def setup_nodes(self) -> None: async def setup_nodes(self) -> None:
"""Set up all existing nodes and subscribe to new nodes.""" """Set up all existing nodes and subscribe to new nodes."""
initialized_nodes: set[int] = set()
for node in self.matter_client.get_nodes(): for node in self.matter_client.get_nodes():
initialized_nodes.add(node.node_id)
self._setup_node(node) self._setup_node(node)
def node_added_callback(event: EventType, node: MatterNode) -> None: def node_added_callback(event: EventType, node: MatterNode) -> None:
"""Handle node added event.""" """Handle node added event."""
initialized_nodes.add(node.node_id)
self._setup_node(node) self._setup_node(node)
def node_updated_callback(event: EventType, node: MatterNode) -> None: def node_updated_callback(event: EventType, node: MatterNode) -> None:
"""Handle node updated event.""" """Handle node updated event."""
if node.node_id in initialized_nodes:
return
if not node.available: if not node.available:
return return
initialized_nodes.add(node.node_id) # We always run the discovery logic again,
# because the firmware version could have been changed or features added.
self._setup_node(node) self._setup_node(node)
def endpoint_added_callback(event: EventType, data: dict[str, int]) -> None: def endpoint_added_callback(event: EventType, data: dict[str, int]) -> None:
@ -237,11 +234,20 @@ class MatterAdapter:
self._create_device_registry(endpoint) self._create_device_registry(endpoint)
# run platform discovery from device type instances # run platform discovery from device type instances
for entity_info in async_discover_entities(endpoint): for entity_info in async_discover_entities(endpoint):
discovery_key = (
f"{entity_info.platform}_{endpoint.node.node_id}_{endpoint.endpoint_id}_"
f"{entity_info.primary_attribute.cluster_id}_"
f"{entity_info.primary_attribute.attribute_id}_"
f"{entity_info.entity_description.key}"
)
if discovery_key in self.discovered_entities:
continue
LOGGER.debug( LOGGER.debug(
"Creating %s entity for %s", "Creating %s entity for %s",
entity_info.platform, entity_info.platform,
entity_info.primary_attribute, entity_info.primary_attribute,
) )
self.discovered_entities.add(discovery_key)
new_entity = entity_info.entity_class( new_entity = entity_info.entity_class(
self.matter_client, endpoint, entity_info self.matter_client, endpoint, entity_info
) )

View File

@ -159,6 +159,7 @@ DISCOVERY_SCHEMAS = [
), ),
entity_class=MatterBinarySensor, entity_class=MatterBinarySensor,
required_attributes=(clusters.DoorLock.Attributes.DoorState,), required_attributes=(clusters.DoorLock.Attributes.DoorState,),
featuremap_contains=clusters.DoorLock.Bitmaps.Feature.kDoorPositionSensor,
), ),
MatterDiscoverySchema( MatterDiscoverySchema(
platform=Platform.BINARY_SENSOR, platform=Platform.BINARY_SENSOR,

View File

@ -69,6 +69,7 @@ DISCOVERY_SCHEMAS = [
entity_class=MatterCommandButton, entity_class=MatterCommandButton,
required_attributes=(clusters.Identify.Attributes.AcceptedCommandList,), required_attributes=(clusters.Identify.Attributes.AcceptedCommandList,),
value_contains=clusters.Identify.Commands.Identify.command_id, value_contains=clusters.Identify.Commands.Identify.command_id,
allow_multi=True,
), ),
MatterDiscoverySchema( MatterDiscoverySchema(
platform=Platform.BUTTON, platform=Platform.BUTTON,

View File

@ -13,3 +13,5 @@ LOGGER = logging.getLogger(__package__)
# prefixes to identify device identifier id types # prefixes to identify device identifier id types
ID_TYPE_DEVICE_ID = "deviceid" ID_TYPE_DEVICE_ID = "deviceid"
ID_TYPE_SERIAL = "serial" ID_TYPE_SERIAL = "serial"
FEATUREMAP_ATTRIBUTE_ID = 65532

View File

@ -13,6 +13,7 @@ from homeassistant.core import callback
from .binary_sensor import DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS from .binary_sensor import DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS
from .button import DISCOVERY_SCHEMAS as BUTTON_SCHEMAS from .button import DISCOVERY_SCHEMAS as BUTTON_SCHEMAS
from .climate import DISCOVERY_SCHEMAS as CLIMATE_SENSOR_SCHEMAS from .climate import DISCOVERY_SCHEMAS as CLIMATE_SENSOR_SCHEMAS
from .const import FEATUREMAP_ATTRIBUTE_ID
from .cover import DISCOVERY_SCHEMAS as COVER_SCHEMAS from .cover import DISCOVERY_SCHEMAS as COVER_SCHEMAS
from .event import DISCOVERY_SCHEMAS as EVENT_SCHEMAS from .event import DISCOVERY_SCHEMAS as EVENT_SCHEMAS
from .fan import DISCOVERY_SCHEMAS as FAN_SCHEMAS from .fan import DISCOVERY_SCHEMAS as FAN_SCHEMAS
@ -121,12 +122,24 @@ def async_discover_entities(
continue continue
# check for required value in (primary) attribute # check for required value in (primary) attribute
primary_attribute = schema.required_attributes[0]
primary_value = endpoint.get_attribute_value(None, primary_attribute)
if schema.value_contains is not None and ( if schema.value_contains is not None and (
(primary_attribute := next((x for x in schema.required_attributes), None)) isinstance(primary_value, list)
is None and schema.value_contains not in primary_value
or (value := endpoint.get_attribute_value(None, primary_attribute)) is None ):
or not isinstance(value, list) continue
or schema.value_contains not in value
# check for required value in cluster featuremap
if schema.featuremap_contains is not None and (
not bool(
int(
endpoint.get_attribute_value(
primary_attribute.cluster_id, FEATUREMAP_ATTRIBUTE_ID
)
)
& schema.featuremap_contains
)
): ):
continue continue
@ -147,6 +160,7 @@ def async_discover_entities(
attributes_to_watch=attributes_to_watch, attributes_to_watch=attributes_to_watch,
entity_description=schema.entity_description, entity_description=schema.entity_description,
entity_class=schema.entity_class, entity_class=schema.entity_class,
discovery_schema=schema,
) )
# prevent re-discovery of the primary attribute if not allowed # prevent re-discovery of the primary attribute if not allowed

View File

@ -16,9 +16,10 @@ from propcache import cached_property
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity import Entity, EntityDescription
import homeassistant.helpers.entity_registry as er
from homeassistant.helpers.typing import UndefinedType from homeassistant.helpers.typing import UndefinedType
from .const import DOMAIN, ID_TYPE_DEVICE_ID from .const import DOMAIN, FEATUREMAP_ATTRIBUTE_ID, ID_TYPE_DEVICE_ID
from .helpers import get_device_id from .helpers import get_device_id
if TYPE_CHECKING: if TYPE_CHECKING:
@ -140,6 +141,19 @@ class MatterEntity(Entity):
node_filter=self._endpoint.node.node_id, node_filter=self._endpoint.node.node_id,
) )
) )
# subscribe to FeatureMap attribute (as that can dynamically change)
self._unsubscribes.append(
self.matter_client.subscribe_events(
callback=self._on_featuremap_update,
event_filter=EventType.ATTRIBUTE_UPDATED,
node_filter=self._endpoint.node.node_id,
attr_path_filter=create_attribute_path(
endpoint=self._endpoint.endpoint_id,
cluster_id=self._entity_info.primary_attribute.cluster_id,
attribute_id=FEATUREMAP_ATTRIBUTE_ID,
),
)
)
@cached_property @cached_property
def name(self) -> str | UndefinedType | None: def name(self) -> str | UndefinedType | None:
@ -159,6 +173,29 @@ class MatterEntity(Entity):
self._update_from_device() self._update_from_device()
self.async_write_ha_state() self.async_write_ha_state()
@callback
def _on_featuremap_update(
self, event: EventType, data: tuple[int, str, int] | None
) -> None:
"""Handle FeatureMap attribute updates."""
if data is None:
return
new_value = data[2]
# handle edge case where a Feature is removed from a cluster
if (
self._entity_info.discovery_schema.featuremap_contains is not None
and not bool(
new_value & self._entity_info.discovery_schema.featuremap_contains
)
):
# this entity is no longer supported by the device
ent_reg = er.async_get(self.hass)
ent_reg.async_remove(self.entity_id)
return
# all other cases, just update the entity
self._on_matter_event(event, data)
@callback @callback
def _update_from_device(self) -> None: def _update_from_device(self) -> None:
"""Update data from Matter device.""" """Update data from Matter device."""

View File

@ -206,6 +206,5 @@ DISCOVERY_SCHEMAS = [
), ),
entity_class=MatterLock, entity_class=MatterLock,
required_attributes=(clusters.DoorLock.Attributes.LockState,), required_attributes=(clusters.DoorLock.Attributes.LockState,),
optional_attributes=(clusters.DoorLock.Attributes.DoorState,),
), ),
] ]

View File

@ -51,6 +51,9 @@ class MatterEntityInfo:
# entity class to use to instantiate the entity # entity class to use to instantiate the entity
entity_class: type entity_class: type
# the original discovery schema used to create this entity
discovery_schema: MatterDiscoverySchema
@property @property
def primary_attribute(self) -> type[ClusterAttributeDescriptor]: def primary_attribute(self) -> type[ClusterAttributeDescriptor]:
"""Return Primary Attribute belonging to the entity.""" """Return Primary Attribute belonging to the entity."""
@ -113,6 +116,10 @@ class MatterDiscoverySchema:
# NOTE: only works for list values # NOTE: only works for list values
value_contains: Any | None = None value_contains: Any | None = None
# [optional] the primary attribute's cluster featuremap must contain this value
# for example for the DoorSensor on a DoorLock Cluster
featuremap_contains: int | None = None
# [optional] bool to specify if this primary value may be discovered # [optional] bool to specify if this primary value may be discovered
# by multiple platforms # by multiple platforms
allow_multi: bool = False allow_multi: bool = False

View File

@ -495,7 +495,7 @@
"1/257/48": 3, "1/257/48": 3,
"1/257/49": 10, "1/257/49": 10,
"1/257/51": false, "1/257/51": false,
"1/257/65532": 3507, "1/257/65532": 0,
"1/257/65533": 6, "1/257/65533": 6,
"1/257/65528": [12, 15, 18, 28, 35, 37], "1/257/65528": [12, 15, 18, 28, 35, 37],
"1/257/65529": [ "1/257/65529": [

View File

@ -46,53 +46,6 @@
'state': 'off', 'state': 'off',
}) })
# --- # ---
# name: test_binary_sensors[door_lock][binary_sensor.mock_door_lock_door-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.mock_door_lock_door',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.DOOR: 'door'>,
'original_icon': None,
'original_name': 'Door',
'platform': 'matter',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-LockDoorStateSensor-257-3',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[door_lock][binary_sensor.mock_door_lock_door-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'door',
'friendly_name': 'Mock Door Lock Door',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.mock_door_lock_door',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_binary_sensors[door_lock_with_unbolt][binary_sensor.mock_door_lock_battery-entry] # name: test_binary_sensors[door_lock_with_unbolt][binary_sensor.mock_door_lock_battery-entry]
EntityRegistryEntrySnapshot({ EntityRegistryEntrySnapshot({
'aliases': set({ 'aliases': set({

View File

@ -4,6 +4,7 @@ from collections.abc import Generator
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from matter_server.client.models.node import MatterNode from matter_server.client.models.node import MatterNode
from matter_server.common.models import EventType
import pytest import pytest
from syrupy import SnapshotAssertion from syrupy import SnapshotAssertion
@ -115,3 +116,34 @@ async def test_battery_sensor(
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state assert state
assert state.state == "on" assert state.state == "on"
@pytest.mark.parametrize("node_fixture", ["door_lock"])
async def test_optional_sensor_from_featuremap(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test discovery of optional doorsensor in doorlock featuremap."""
entity_id = "binary_sensor.mock_door_lock_door"
state = hass.states.get(entity_id)
assert state is None
# update the feature map to include the optional door sensor feature
# and fire a node updated event
set_node_attribute(matter_node, 1, 257, 65532, 32)
await trigger_subscription_callback(
hass, matter_client, event=EventType.NODE_UPDATED, data=matter_node
)
# this should result in a new binary sensor entity being discovered
state = hass.states.get(entity_id)
assert state
assert state.state == "off"
# now test the reverse, by removing the feature from the feature map
set_node_attribute(matter_node, 1, 257, 65532, 0)
await trigger_subscription_callback(
hass, matter_client, data=(matter_node.node_id, "1/257/65532", 0)
)
state = hass.states.get(entity_id)
assert state is None