mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 11:17:21 +00:00
Add support for features changing at runtime in Matter integration (#129426)
This commit is contained in:
parent
aeab8a0143
commit
50936b4e28
@ -45,6 +45,7 @@ class MatterAdapter:
|
||||
self.hass = hass
|
||||
self.config_entry = config_entry
|
||||
self.platform_handlers: dict[Platform, AddEntitiesCallback] = {}
|
||||
self.discovered_entities: set[str] = set()
|
||||
|
||||
def register_platform_handler(
|
||||
self, platform: Platform, add_entities: AddEntitiesCallback
|
||||
@ -54,23 +55,19 @@ class MatterAdapter:
|
||||
|
||||
async def setup_nodes(self) -> None:
|
||||
"""Set up all existing nodes and subscribe to new nodes."""
|
||||
initialized_nodes: set[int] = set()
|
||||
for node in self.matter_client.get_nodes():
|
||||
initialized_nodes.add(node.node_id)
|
||||
self._setup_node(node)
|
||||
|
||||
def node_added_callback(event: EventType, node: MatterNode) -> None:
|
||||
"""Handle node added event."""
|
||||
initialized_nodes.add(node.node_id)
|
||||
self._setup_node(node)
|
||||
|
||||
def node_updated_callback(event: EventType, node: MatterNode) -> None:
|
||||
"""Handle node updated event."""
|
||||
if node.node_id in initialized_nodes:
|
||||
return
|
||||
if not node.available:
|
||||
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)
|
||||
|
||||
def endpoint_added_callback(event: EventType, data: dict[str, int]) -> None:
|
||||
@ -237,11 +234,20 @@ class MatterAdapter:
|
||||
self._create_device_registry(endpoint)
|
||||
# run platform discovery from device type instances
|
||||
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(
|
||||
"Creating %s entity for %s",
|
||||
entity_info.platform,
|
||||
entity_info.primary_attribute,
|
||||
)
|
||||
self.discovered_entities.add(discovery_key)
|
||||
new_entity = entity_info.entity_class(
|
||||
self.matter_client, endpoint, entity_info
|
||||
)
|
||||
|
@ -159,6 +159,7 @@ DISCOVERY_SCHEMAS = [
|
||||
),
|
||||
entity_class=MatterBinarySensor,
|
||||
required_attributes=(clusters.DoorLock.Attributes.DoorState,),
|
||||
featuremap_contains=clusters.DoorLock.Bitmaps.Feature.kDoorPositionSensor,
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
|
@ -69,6 +69,7 @@ DISCOVERY_SCHEMAS = [
|
||||
entity_class=MatterCommandButton,
|
||||
required_attributes=(clusters.Identify.Attributes.AcceptedCommandList,),
|
||||
value_contains=clusters.Identify.Commands.Identify.command_id,
|
||||
allow_multi=True,
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.BUTTON,
|
||||
|
@ -13,3 +13,5 @@ LOGGER = logging.getLogger(__package__)
|
||||
# prefixes to identify device identifier id types
|
||||
ID_TYPE_DEVICE_ID = "deviceid"
|
||||
ID_TYPE_SERIAL = "serial"
|
||||
|
||||
FEATUREMAP_ATTRIBUTE_ID = 65532
|
||||
|
@ -13,6 +13,7 @@ from homeassistant.core import callback
|
||||
from .binary_sensor import DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS
|
||||
from .button import DISCOVERY_SCHEMAS as BUTTON_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 .event import DISCOVERY_SCHEMAS as EVENT_SCHEMAS
|
||||
from .fan import DISCOVERY_SCHEMAS as FAN_SCHEMAS
|
||||
@ -121,12 +122,24 @@ def async_discover_entities(
|
||||
continue
|
||||
|
||||
# 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 (
|
||||
(primary_attribute := next((x for x in schema.required_attributes), None))
|
||||
is None
|
||||
or (value := endpoint.get_attribute_value(None, primary_attribute)) is None
|
||||
or not isinstance(value, list)
|
||||
or schema.value_contains not in value
|
||||
isinstance(primary_value, list)
|
||||
and schema.value_contains not in primary_value
|
||||
):
|
||||
continue
|
||||
|
||||
# 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
|
||||
|
||||
@ -147,6 +160,7 @@ def async_discover_entities(
|
||||
attributes_to_watch=attributes_to_watch,
|
||||
entity_description=schema.entity_description,
|
||||
entity_class=schema.entity_class,
|
||||
discovery_schema=schema,
|
||||
)
|
||||
|
||||
# prevent re-discovery of the primary attribute if not allowed
|
||||
|
@ -16,9 +16,10 @@ from propcache import cached_property
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
import homeassistant.helpers.entity_registry as er
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -140,6 +141,19 @@ class MatterEntity(Entity):
|
||||
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
|
||||
def name(self) -> str | UndefinedType | None:
|
||||
@ -159,6 +173,29 @@ class MatterEntity(Entity):
|
||||
self._update_from_device()
|
||||
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
|
||||
def _update_from_device(self) -> None:
|
||||
"""Update data from Matter device."""
|
||||
|
@ -206,6 +206,5 @@ DISCOVERY_SCHEMAS = [
|
||||
),
|
||||
entity_class=MatterLock,
|
||||
required_attributes=(clusters.DoorLock.Attributes.LockState,),
|
||||
optional_attributes=(clusters.DoorLock.Attributes.DoorState,),
|
||||
),
|
||||
]
|
||||
|
@ -51,6 +51,9 @@ class MatterEntityInfo:
|
||||
# entity class to use to instantiate the entity
|
||||
entity_class: type
|
||||
|
||||
# the original discovery schema used to create this entity
|
||||
discovery_schema: MatterDiscoverySchema
|
||||
|
||||
@property
|
||||
def primary_attribute(self) -> type[ClusterAttributeDescriptor]:
|
||||
"""Return Primary Attribute belonging to the entity."""
|
||||
@ -113,6 +116,10 @@ class MatterDiscoverySchema:
|
||||
# NOTE: only works for list values
|
||||
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
|
||||
# by multiple platforms
|
||||
allow_multi: bool = False
|
||||
|
@ -495,7 +495,7 @@
|
||||
"1/257/48": 3,
|
||||
"1/257/49": 10,
|
||||
"1/257/51": false,
|
||||
"1/257/65532": 3507,
|
||||
"1/257/65532": 0,
|
||||
"1/257/65533": 6,
|
||||
"1/257/65528": [12, 15, 18, 28, 35, 37],
|
||||
"1/257/65529": [
|
||||
|
@ -46,53 +46,6 @@
|
||||
'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]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
@ -4,6 +4,7 @@ from collections.abc import Generator
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from matter_server.client.models.node import MatterNode
|
||||
from matter_server.common.models import EventType
|
||||
import pytest
|
||||
from syrupy import SnapshotAssertion
|
||||
|
||||
@ -115,3 +116,34 @@ async def test_battery_sensor(
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
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
|
||||
|
Loading…
x
Reference in New Issue
Block a user