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.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
|
||||||
)
|
)
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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."""
|
||||||
|
@ -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,),
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
@ -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
|
||||||
|
@ -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": [
|
||||||
|
@ -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({
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user