Compare commits

...
Sign in to create a new pull request.

2 commits

Author SHA1 Message Date
Marcel van der Veldt
bb0ecffbbe Add support for features changing at runtime in Matter integration 2024-10-29 15:26:57 +01:00
Marcel van der Veldt
de4a14903c Add support for featuremap to discovery schema 2024-10-29 15:23:31 +01:00
7 changed files with 78 additions and 8 deletions

View file

@ -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:
@ -78,6 +75,10 @@ class MatterAdapter:
node = self.matter_client.get_node(data["node_id"])
self._setup_endpoint(node.endpoints[data["endpoint_id"]])
def node_event_callback(event: EventType, data: dict[str, int]) -> None:
"""Handle endpoint added event."""
LOGGER.warning("Node event: %s %s", event, data)
def endpoint_removed_callback(event: EventType, data: dict[str, int]) -> None:
"""Handle endpoint removed event."""
server_info = cast(ServerInfoMessage, self.matter_client.server_info)
@ -135,6 +136,11 @@ class MatterAdapter:
callback=node_updated_callback, event_filter=EventType.NODE_UPDATED
)
)
self.config_entry.async_on_unload(
self.matter_client.subscribe_events(
callback=node_event_callback, event_filter=EventType.NODE_EVENT
)
)
def _setup_node(self, node: MatterNode) -> None:
"""Set up an node."""
@ -237,11 +243,19 @@ 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}"
)
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
)

View file

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

View file

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

View file

@ -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
@ -128,6 +129,21 @@ def async_discover_entities(
):
continue
# check for required value in cluster featuremap
if schema.featuremap_contains is not None and (
(primary_attribute := next((x for x in schema.required_attributes), None))
is None
or not bool(
int(
endpoint.get_attribute_value(
primary_attribute.cluster_id, FEATUREMAP_ATTRIBUTE_ID
)
)
& schema.featuremap_contains
)
):
continue
# all checks passed, this value belongs to an entity
attributes_to_watch = list(schema.required_attributes)
@ -145,6 +161,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

View file

@ -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,22 @@ class MatterEntity(Entity):
self._update_from_device()
self.async_write_ha_state()
@callback
def _on_featuremap_update(self, event: EventType, data: int) -> None:
"""Handle FeatureMap attribute updates."""
# 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(data & 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."""

View file

@ -206,6 +206,5 @@ DISCOVERY_SCHEMAS = [
),
entity_class=MatterLock,
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: 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