mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 10:13:52 -05:00
Fix composed devices behind Matter bridge losing "via device" (#179118)
This commit is contained in:
@@ -10,10 +10,11 @@ from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||
|
||||
from .const import DOMAIN, ID_TYPE_DEVICE_ID, ID_TYPE_SERIAL, LOGGER
|
||||
from .discovery import async_discover_entities
|
||||
from .helpers import MatterConfigEntry, get_device_id
|
||||
from .helpers import MatterConfigEntry, get_device_endpoint, get_device_id
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from matter_server.client import MatterClient
|
||||
@@ -73,7 +74,11 @@ class MatterAdapter:
|
||||
endpoint = node.endpoints[data["endpoint_id"]]
|
||||
# Ensure the bridge device (endpoint 0) is registered before a
|
||||
# bridged child endpoint resolves it as its via_device.
|
||||
if endpoint.is_bridged_device and node.endpoints[0] != endpoint:
|
||||
device_endpoint = get_device_endpoint(endpoint)
|
||||
if (
|
||||
device_endpoint.is_bridged_device
|
||||
and node.endpoints[0] != device_endpoint
|
||||
):
|
||||
self._setup_endpoint(node.endpoints[0])
|
||||
self._setup_endpoint(endpoint)
|
||||
|
||||
@@ -88,10 +93,11 @@ class MatterAdapter:
|
||||
endpoint = node.endpoints.get(data["endpoint_id"])
|
||||
if not endpoint:
|
||||
return # race condition
|
||||
node_device_id = get_device_id(
|
||||
server_info,
|
||||
node.endpoints[data["endpoint_id"]],
|
||||
)
|
||||
if get_device_endpoint(endpoint) != endpoint:
|
||||
# A composed device is represented by a single HA device, which is
|
||||
# only removed once its compose parent endpoint is removed.
|
||||
return
|
||||
node_device_id = get_device_id(server_info, endpoint)
|
||||
identifier = (DOMAIN, f"{ID_TYPE_DEVICE_ID}_{node_device_id}")
|
||||
if device := device_registry.async_get_device_by_identifier(
|
||||
identifier, self.config_entry.entry_id
|
||||
@@ -163,6 +169,10 @@ class MatterAdapter:
|
||||
"""Create a device registry entry for a MatterNode."""
|
||||
server_info = cast(ServerInfoMessage, self.matter_client.server_info)
|
||||
|
||||
# All endpoints of a composed device share a single device registry entry,
|
||||
# so derive that entry from the compose parent for every one of them.
|
||||
endpoint = get_device_endpoint(endpoint)
|
||||
|
||||
basic_info = endpoint.device_info
|
||||
# use (first) DeviceType of the endpoint as fallback product name
|
||||
device_type = next(
|
||||
@@ -183,7 +193,7 @@ class MatterAdapter:
|
||||
device_registry = dr.async_get(self.hass)
|
||||
|
||||
# handle bridged devices
|
||||
via_device_id: str | None = None
|
||||
via_device_id: str | UndefinedType = UNDEFINED
|
||||
if endpoint.is_bridged_device and endpoint.node.endpoints[0] != endpoint:
|
||||
bridge_device_id = get_device_id(
|
||||
server_info,
|
||||
@@ -216,9 +226,7 @@ class MatterAdapter:
|
||||
# alternative is the productName (e.g. LCT001)
|
||||
or get_clean_name(basic_info.productName)
|
||||
# if no product name, use the device type name
|
||||
or device_type.__name__
|
||||
if device_type
|
||||
else None
|
||||
or (device_type.__name__ if device_type else None)
|
||||
)
|
||||
# Model ID is the non-human readable product ID
|
||||
# we prefer the matter product ID so we can look it up in Matter DCL
|
||||
|
||||
@@ -57,16 +57,23 @@ def get_operational_instance_id(
|
||||
return f"{fabric_id_hex}-{node_id_hex}"
|
||||
|
||||
|
||||
def get_device_endpoint(endpoint: MatterEndpoint) -> MatterEndpoint:
|
||||
"""Return the endpoint the HA device for the given MatterEndpoint is derived from.
|
||||
|
||||
All endpoints of a composed device are represented by a single HA device, derived
|
||||
from the compose parent.
|
||||
Example: Philips Hue motion sensor on Hue Hub (bridged to Matter).
|
||||
"""
|
||||
return endpoint.node.get_compose_parent(endpoint.endpoint_id) or endpoint
|
||||
|
||||
|
||||
def get_device_id(
|
||||
server_info: ServerInfoMessage,
|
||||
endpoint: MatterEndpoint,
|
||||
) -> str:
|
||||
"""Return HA device_id for the given MatterEndpoint."""
|
||||
operational_instance_id = get_operational_instance_id(server_info, endpoint.node)
|
||||
# if this is a composed device we need to get the compose parent
|
||||
# example: Philips Hue motion sensor on Hue Hub (bridged to Matter)
|
||||
if compose_parent := endpoint.node.get_compose_parent(endpoint.endpoint_id):
|
||||
endpoint = compose_parent
|
||||
endpoint = get_device_endpoint(endpoint)
|
||||
if endpoint.is_bridged_device:
|
||||
# Append endpoint ID if this endpoint is a bridged device
|
||||
postfix = str(endpoint.endpoint_id)
|
||||
|
||||
@@ -129,13 +129,13 @@ async def _setup_integration_with_nodes(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
nodes: list[MatterNode],
|
||||
) -> MatterNode:
|
||||
) -> None:
|
||||
"""Set up Matter integration with nodes."""
|
||||
client.get_nodes.return_value = nodes
|
||||
|
||||
def _get_node(node_id: int) -> MatterNode:
|
||||
try:
|
||||
next(node for node in nodes if node.node_id == node_id)
|
||||
return next(node for node in nodes if node.node_id == node_id)
|
||||
except StopIteration as err:
|
||||
raise KeyError(f"Node with id {node_id} not found") from err
|
||||
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
{
|
||||
"node_id": 99,
|
||||
"date_commissioned": "2025-01-12T08:39:01.638415",
|
||||
"last_interview": "2026-04-21T18:46:33.683241",
|
||||
"interview_version": 6,
|
||||
"available": true,
|
||||
"is_bridge": true,
|
||||
"attributes": {
|
||||
"0/29/0": [
|
||||
{
|
||||
"0": 22,
|
||||
"1": 1
|
||||
}
|
||||
],
|
||||
"0/29/1": [29, 31, 40, 43, 44, 45, 48, 49, 50, 51, 55, 60, 62, 63, 65],
|
||||
"0/29/2": [31],
|
||||
"0/29/3": [1, 2, 3, 4],
|
||||
"0/29/65528": [],
|
||||
"0/29/65529": [],
|
||||
"0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
|
||||
"0/29/65532": 0,
|
||||
"0/29/65533": 2,
|
||||
"0/31/0": [
|
||||
{
|
||||
"1": 5,
|
||||
"2": 2,
|
||||
"3": [112233],
|
||||
"4": null,
|
||||
"254": 2
|
||||
}
|
||||
],
|
||||
"0/31/1": [],
|
||||
"0/31/2": 4,
|
||||
"0/31/3": 3,
|
||||
"0/31/4": 4,
|
||||
"0/31/65528": [],
|
||||
"0/31/65529": [],
|
||||
"0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533],
|
||||
"0/31/65532": 0,
|
||||
"0/31/65533": 2,
|
||||
"0/40/0": 18,
|
||||
"0/40/1": "Mock Vendor",
|
||||
"0/40/2": 4757,
|
||||
"0/40/3": "Mock Bridge",
|
||||
"0/40/4": 265,
|
||||
"0/40/5": "Mock Bridge",
|
||||
"0/40/6": "XX",
|
||||
"0/40/7": 265,
|
||||
"0/40/8": "265.3",
|
||||
"0/40/9": 1,
|
||||
"0/40/10": "1",
|
||||
"0/40/11": "20200101",
|
||||
"0/40/12": "",
|
||||
"0/40/13": "",
|
||||
"0/40/14": "Mock Bridge",
|
||||
"0/40/15": "MB-1234",
|
||||
"0/40/16": false,
|
||||
"0/40/18": "AABBCCDDEEFF0011",
|
||||
"0/40/19": {
|
||||
"0": 3,
|
||||
"1": 65535
|
||||
},
|
||||
"0/40/21": 17039360,
|
||||
"0/40/22": 1,
|
||||
"0/40/65528": [],
|
||||
"0/40/65529": [],
|
||||
"0/40/65531": [
|
||||
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 21, 22,
|
||||
65528, 65529, 65531, 65532, 65533
|
||||
],
|
||||
"0/40/65532": 0,
|
||||
"0/40/65533": 3,
|
||||
"0/43/0": "en-US",
|
||||
"0/43/1": [
|
||||
"en-US",
|
||||
"de-DE",
|
||||
"fr-FR",
|
||||
"en-GB",
|
||||
"es-ES",
|
||||
"zh-CN",
|
||||
"it-IT",
|
||||
"ja-JP"
|
||||
],
|
||||
"0/43/65528": [],
|
||||
"0/43/65529": [],
|
||||
"0/43/65531": [0, 1, 65528, 65529, 65531, 65532, 65533],
|
||||
"0/43/65532": 0,
|
||||
"0/43/65533": 1,
|
||||
"0/44/0": 0,
|
||||
"0/44/1": 0,
|
||||
"0/44/2": [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7],
|
||||
"0/44/65528": [],
|
||||
"0/44/65529": [],
|
||||
"0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533],
|
||||
"0/44/65532": 0,
|
||||
"0/44/65533": 1,
|
||||
"0/45/0": 0,
|
||||
"0/45/65528": [],
|
||||
"0/45/65529": [],
|
||||
"0/45/65531": [0, 65528, 65529, 65531, 65532, 65533],
|
||||
"0/45/65532": 1,
|
||||
"0/45/65533": 1,
|
||||
"0/48/0": 0,
|
||||
"0/48/1": {
|
||||
"0": 60,
|
||||
"1": 900
|
||||
},
|
||||
"0/48/2": 0,
|
||||
"0/48/3": 2,
|
||||
"0/48/4": true,
|
||||
"0/48/65528": [1, 3, 5],
|
||||
"0/48/65529": [0, 2, 4],
|
||||
"0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533],
|
||||
"0/48/65532": 0,
|
||||
"0/48/65533": 1,
|
||||
"0/49/0": 1,
|
||||
"0/49/1": [
|
||||
{
|
||||
"0": "ZXRoMA==",
|
||||
"1": true
|
||||
}
|
||||
],
|
||||
"0/49/2": 0,
|
||||
"0/49/3": 0,
|
||||
"0/49/4": true,
|
||||
"0/49/5": null,
|
||||
"0/49/6": null,
|
||||
"0/49/7": null,
|
||||
"0/49/65528": [],
|
||||
"0/49/65529": [],
|
||||
"0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533],
|
||||
"0/49/65532": 4,
|
||||
"0/49/65533": 2,
|
||||
"0/50/65528": [1],
|
||||
"0/50/65529": [0],
|
||||
"0/50/65531": [65528, 65529, 65531, 65532, 65533],
|
||||
"0/50/65532": 0,
|
||||
"0/50/65533": 1,
|
||||
"0/51/0": [
|
||||
{
|
||||
"0": "guest_ct1",
|
||||
"1": true,
|
||||
"2": null,
|
||||
"3": null,
|
||||
"4": "**REDACTED**",
|
||||
"5": [],
|
||||
"6": [],
|
||||
"7": 0
|
||||
},
|
||||
{
|
||||
"0": "wifi0",
|
||||
"1": true,
|
||||
"2": null,
|
||||
"3": null,
|
||||
"4": "**REDACTED**",
|
||||
"5": [],
|
||||
"6": [],
|
||||
"7": 0
|
||||
},
|
||||
{
|
||||
"0": "hotspot",
|
||||
"1": false,
|
||||
"2": null,
|
||||
"3": null,
|
||||
"4": "**REDACTED**",
|
||||
"5": [],
|
||||
"6": [],
|
||||
"7": 0
|
||||
},
|
||||
{
|
||||
"0": "guest",
|
||||
"1": true,
|
||||
"2": null,
|
||||
"3": null,
|
||||
"4": "**REDACTED**",
|
||||
"5": [],
|
||||
"6": [],
|
||||
"7": 0
|
||||
},
|
||||
{
|
||||
"0": "lan",
|
||||
"1": true,
|
||||
"2": null,
|
||||
"3": null,
|
||||
"4": "**REDACTED**",
|
||||
"5": ["**REDACTED**"],
|
||||
"6": ["**REDACTED**", "**REDACTED**"],
|
||||
"7": 0
|
||||
},
|
||||
{
|
||||
"0": "eth0",
|
||||
"1": true,
|
||||
"2": null,
|
||||
"3": null,
|
||||
"4": "**REDACTED**",
|
||||
"5": [],
|
||||
"6": [],
|
||||
"7": 2
|
||||
},
|
||||
{
|
||||
"0": "ip6tnl0",
|
||||
"1": false,
|
||||
"2": null,
|
||||
"3": null,
|
||||
"4": "**REDACTED**",
|
||||
"5": [],
|
||||
"6": [],
|
||||
"7": 0
|
||||
},
|
||||
{
|
||||
"0": "sit0",
|
||||
"1": false,
|
||||
"2": null,
|
||||
"3": null,
|
||||
"4": "**REDACTED**",
|
||||
"5": [],
|
||||
"6": [],
|
||||
"7": 0
|
||||
},
|
||||
{
|
||||
"0": "bond0",
|
||||
"1": false,
|
||||
"2": null,
|
||||
"3": null,
|
||||
"4": "**REDACTED**",
|
||||
"5": [],
|
||||
"6": [],
|
||||
"7": 0
|
||||
},
|
||||
{
|
||||
"0": "lo",
|
||||
"1": true,
|
||||
"2": null,
|
||||
"3": null,
|
||||
"4": "**REDACTED**",
|
||||
"5": ["**REDACTED**"],
|
||||
"6": ["**REDACTED**"],
|
||||
"7": 0
|
||||
}
|
||||
],
|
||||
"0/51/1": 1,
|
||||
"0/51/2": 146,
|
||||
"0/51/3": 0,
|
||||
"0/51/4": 0,
|
||||
"0/51/5": [],
|
||||
"0/51/6": [],
|
||||
"0/51/7": [],
|
||||
"0/51/8": false,
|
||||
"0/51/65528": [2],
|
||||
"0/51/65529": [0, 1],
|
||||
"0/51/65531": [
|
||||
0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533
|
||||
],
|
||||
"0/51/65532": 0,
|
||||
"0/51/65533": 2,
|
||||
"0/55/0": 2,
|
||||
"0/55/1": true,
|
||||
"0/55/2": 1608,
|
||||
"0/55/3": 1148,
|
||||
"0/55/4": 0,
|
||||
"0/55/5": 0,
|
||||
"0/55/6": 0,
|
||||
"0/55/7": null,
|
||||
"0/55/8": 149,
|
||||
"0/55/65528": [],
|
||||
"0/55/65529": [0],
|
||||
"0/55/65531": [
|
||||
0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533
|
||||
],
|
||||
"0/55/65532": 3,
|
||||
"0/55/65533": 1,
|
||||
"0/60/0": 0,
|
||||
"0/60/1": null,
|
||||
"0/60/2": null,
|
||||
"0/60/65528": [],
|
||||
"0/60/65529": [0, 1, 2],
|
||||
"0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533],
|
||||
"0/60/65532": 0,
|
||||
"0/60/65533": 1,
|
||||
"0/62/0": [
|
||||
{
|
||||
"1": "**REDACTED**",
|
||||
"2": "**REDACTED**",
|
||||
"254": 2
|
||||
}
|
||||
],
|
||||
"0/62/1": [
|
||||
{
|
||||
"1": "**REDACTED**",
|
||||
"2": 65521,
|
||||
"3": 1,
|
||||
"4": 1,
|
||||
"5": "Home Assistant",
|
||||
"254": 2
|
||||
}
|
||||
],
|
||||
"0/62/2": 16,
|
||||
"0/62/3": 1,
|
||||
"0/62/4": ["**REDACTED**"],
|
||||
"0/62/5": 2,
|
||||
"0/62/65528": [1, 3, 5, 8],
|
||||
"0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11],
|
||||
"0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533],
|
||||
"0/62/65532": 0,
|
||||
"0/62/65533": 1,
|
||||
"0/63/0": [],
|
||||
"0/63/1": [],
|
||||
"0/63/2": 4,
|
||||
"0/63/3": 3,
|
||||
"0/63/65528": [2, 5],
|
||||
"0/63/65529": [0, 1, 3, 4],
|
||||
"0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
|
||||
"0/63/65532": 0,
|
||||
"0/63/65533": 2,
|
||||
"0/65/0": [],
|
||||
"0/65/65528": [],
|
||||
"0/65/65529": [],
|
||||
"0/65/65531": [0, 65528, 65529, 65531, 65532, 65533],
|
||||
"0/65/65532": 0,
|
||||
"0/65/65533": 1,
|
||||
"1/29/0": [
|
||||
{
|
||||
"0": 14,
|
||||
"1": 1
|
||||
}
|
||||
],
|
||||
"1/29/1": [29],
|
||||
"1/29/2": [],
|
||||
"1/29/3": [2, 3, 4],
|
||||
"1/29/65528": [],
|
||||
"1/29/65529": [],
|
||||
"1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
|
||||
"1/29/65532": 0,
|
||||
"1/29/65533": 2,
|
||||
"2/29/0": [
|
||||
{
|
||||
"0": 19,
|
||||
"1": 2
|
||||
}
|
||||
],
|
||||
"2/29/1": [29, 57],
|
||||
"2/29/2": [],
|
||||
"2/29/3": [3, 4],
|
||||
"2/29/65528": [],
|
||||
"2/29/65529": [],
|
||||
"2/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
|
||||
"2/29/65532": 0,
|
||||
"2/29/65533": 2,
|
||||
"2/57/1": "Mock Vendor",
|
||||
"2/57/3": "Mock Bridged Plug",
|
||||
"2/57/5": "Kitchen Plug",
|
||||
"2/57/8": "1.0",
|
||||
"2/57/10": "2.0",
|
||||
"2/57/15": "MBP-5678",
|
||||
"2/57/17": true,
|
||||
"2/57/18": "0011223344556677",
|
||||
"2/57/65528": [],
|
||||
"2/57/65529": [],
|
||||
"2/57/65531": [
|
||||
1, 3, 5, 8, 10, 15, 17, 18, 65528, 65529, 65531, 65532, 65533
|
||||
],
|
||||
"2/57/65532": 0,
|
||||
"2/57/65533": 2,
|
||||
"3/6/0": false,
|
||||
"3/6/65528": [],
|
||||
"3/6/65529": [0, 1, 2, 64, 65, 66],
|
||||
"3/6/65531": [65528, 65529, 65531, 65533, 65532, 0, 65533],
|
||||
"3/6/65532": 0,
|
||||
"3/6/65533": 5,
|
||||
"3/29/0": [
|
||||
{
|
||||
"0": 266,
|
||||
"1": 2
|
||||
}
|
||||
],
|
||||
"3/29/1": [6, 29],
|
||||
"3/29/2": [],
|
||||
"3/29/3": [],
|
||||
"3/29/65528": [],
|
||||
"3/29/65529": [],
|
||||
"3/29/65531": [65528, 65529, 65531, 65533, 65532, 0, 1, 2, 3, 65533],
|
||||
"3/29/65532": 0,
|
||||
"3/29/65533": 2,
|
||||
"4/29/0": [
|
||||
{
|
||||
"0": 770,
|
||||
"1": 2
|
||||
}
|
||||
],
|
||||
"4/29/1": [1026, 29],
|
||||
"4/29/2": [],
|
||||
"4/29/3": [],
|
||||
"4/29/65528": [],
|
||||
"4/29/65529": [],
|
||||
"4/29/65531": [65528, 65529, 65531, 65533, 65532, 0, 1, 2, 3, 65533],
|
||||
"4/29/65532": 0,
|
||||
"4/29/65533": 2,
|
||||
"4/1026/0": 0,
|
||||
"4/1026/1": 255,
|
||||
"4/1026/2": 255,
|
||||
"4/1026/65528": [],
|
||||
"4/1026/65529": [],
|
||||
"4/1026/65531": [65528, 65529, 65531, 65533, 65532, 0, 1, 2, 65533],
|
||||
"4/1026/65532": 0,
|
||||
"4/1026/65533": 4
|
||||
},
|
||||
"attribute_subscriptions": []
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from matter_server.client.models.node import MatterNode
|
||||
from matter_server.common.models import EventType
|
||||
import pytest
|
||||
|
||||
@@ -16,6 +17,26 @@ from .common import create_node_from_fixture
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
def identifier_for(
|
||||
matter_client: MagicMock, node: MatterNode, endpoint_id: int
|
||||
) -> tuple[str, str]:
|
||||
"""Return the device registry identifier for a node endpoint."""
|
||||
device_id = get_device_id(matter_client.server_info, node.endpoints[endpoint_id])
|
||||
return (DOMAIN, f"{ID_TYPE_DEVICE_ID}_{device_id}")
|
||||
|
||||
|
||||
def fire_endpoint_event(
|
||||
matter_client: MagicMock, event: EventType, node: MatterNode, endpoint_id: int
|
||||
) -> None:
|
||||
"""Fire an endpoint added/removed event for a node endpoint."""
|
||||
callback = next(
|
||||
call.kwargs["callback"]
|
||||
for call in matter_client.subscribe_events.call_args_list
|
||||
if call.kwargs["event_filter"] == event
|
||||
)
|
||||
callback(event, {"node_id": node.node_id, "endpoint_id": endpoint_id})
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("matter_node")
|
||||
@pytest.mark.parametrize(
|
||||
("node_fixture", "unique_id", "name"),
|
||||
@@ -160,36 +181,23 @@ async def test_endpoint_added_sets_up_bridge_before_child(
|
||||
node = create_node_from_fixture("atios_knx_bridge")
|
||||
matter_client.get_node.return_value = node
|
||||
|
||||
def identifier_for(endpoint_id: int) -> tuple[str, str]:
|
||||
endpoint = node.endpoints[endpoint_id]
|
||||
device_id = get_device_id(matter_client.server_info, endpoint)
|
||||
return (DOMAIN, f"{ID_TYPE_DEVICE_ID}_{device_id}")
|
||||
|
||||
endpoint_added_callback = next(
|
||||
call.kwargs["callback"]
|
||||
for call in matter_client.subscribe_events.call_args_list
|
||||
if call.kwargs["event_filter"] == EventType.ENDPOINT_ADDED
|
||||
)
|
||||
|
||||
assert (
|
||||
device_registry.async_get_device_by_identifier(
|
||||
identifier_for(0), integration.entry_id
|
||||
identifier_for(matter_client, node, 0), integration.entry_id
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
endpoint_added_callback(
|
||||
EventType.ENDPOINT_ADDED, {"node_id": node.node_id, "endpoint_id": 29}
|
||||
)
|
||||
fire_endpoint_event(matter_client, EventType.ENDPOINT_ADDED, node, 29)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
bridge_entry = device_registry.async_get_device_by_identifier(
|
||||
identifier_for(0), integration.entry_id
|
||||
identifier_for(matter_client, node, 0), integration.entry_id
|
||||
)
|
||||
assert bridge_entry is not None
|
||||
|
||||
child_entry = device_registry.async_get_device_by_identifier(
|
||||
identifier_for(29), integration.entry_id
|
||||
identifier_for(matter_client, node, 29), integration.entry_id
|
||||
)
|
||||
assert child_entry is not None
|
||||
assert child_entry.via_device_id == bridge_entry.id
|
||||
@@ -212,11 +220,6 @@ async def test_setup_node_sorts_bridge_before_child(
|
||||
endpoint_id: node.endpoints[endpoint_id] for endpoint_id in (29, 1, 0)
|
||||
}
|
||||
|
||||
def identifier_for(endpoint_id: int) -> tuple[str, str]:
|
||||
endpoint = node.endpoints[endpoint_id]
|
||||
device_id = get_device_id(matter_client.server_info, endpoint)
|
||||
return (DOMAIN, f"{ID_TYPE_DEVICE_ID}_{device_id}")
|
||||
|
||||
matter_client.get_nodes.return_value = [node]
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN, data={"url": "ws://localhost:5580/ws"}
|
||||
@@ -227,17 +230,105 @@ async def test_setup_node_sorts_bridge_before_child(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
bridge_entry = device_registry.async_get_device_by_identifier(
|
||||
identifier_for(0), config_entry.entry_id
|
||||
identifier_for(matter_client, node, 0), config_entry.entry_id
|
||||
)
|
||||
assert bridge_entry is not None
|
||||
|
||||
child_entry = device_registry.async_get_device_by_identifier(
|
||||
identifier_for(29), config_entry.entry_id
|
||||
identifier_for(matter_client, node, 29), config_entry.entry_id
|
||||
)
|
||||
assert child_entry is not None
|
||||
assert child_entry.via_device_id == bridge_entry.id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("node_fixture", ["mock_composed_bridge"])
|
||||
async def test_device_registry_composed_bridged_device(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
matter_client: MagicMock,
|
||||
matter_node: MatterNode,
|
||||
) -> None:
|
||||
"""Test a composed device behind a bridge creates a single device entry.
|
||||
|
||||
All endpoints of the composed device (the `BridgedNode` parent endpoint 2 and
|
||||
its part endpoints 3 and 4) must resolve to the same device entry, derived
|
||||
from the compose parent.
|
||||
"""
|
||||
entry_id = hass.config_entries.async_entries(DOMAIN)[0].entry_id
|
||||
bridge_entry = device_registry.async_get_device_by_identifier(
|
||||
identifier_for(matter_client, matter_node, 0), entry_id
|
||||
)
|
||||
assert bridge_entry is not None
|
||||
assert bridge_entry.name == "Mock Bridge"
|
||||
|
||||
# the part endpoints 3 and 4 are represented by the device of their compose parent
|
||||
identifier = identifier_for(matter_client, matter_node, 2)
|
||||
assert identifier_for(matter_client, matter_node, 3) == identifier
|
||||
assert identifier_for(matter_client, matter_node, 4) == identifier
|
||||
|
||||
device_entry = device_registry.async_get_device_by_identifier(identifier, entry_id)
|
||||
assert device_entry is not None
|
||||
assert device_entry.id != bridge_entry.id
|
||||
assert device_entry.via_device_id == bridge_entry.id
|
||||
assert device_entry.name == "Kitchen Plug"
|
||||
assert device_entry.model == "Mock Bridged Plug"
|
||||
assert device_entry.serial_number == "MBP-5678"
|
||||
|
||||
assert len(device_registry.devices) == 2
|
||||
assert hass.states.get("switch.kitchen_plug")
|
||||
assert hass.states.get("sensor.kitchen_plug_temperature")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("node_fixture", ["mock_composed_bridge"])
|
||||
async def test_composed_bridged_device_child_endpoint_added(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
matter_client: MagicMock,
|
||||
matter_node: MatterNode,
|
||||
) -> None:
|
||||
"""Test a part endpoint of a composed bridged device keeps the via_device."""
|
||||
entry_id = hass.config_entries.async_entries(DOMAIN)[0].entry_id
|
||||
bridge_entry = device_registry.async_get_device_by_identifier(
|
||||
identifier_for(matter_client, matter_node, 0), entry_id
|
||||
)
|
||||
assert bridge_entry is not None
|
||||
|
||||
fire_endpoint_event(matter_client, EventType.ENDPOINT_ADDED, matter_node, 3)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
device_entry = device_registry.async_get_device_by_identifier(
|
||||
identifier_for(matter_client, matter_node, 3), entry_id
|
||||
)
|
||||
assert device_entry is not None
|
||||
assert device_entry.via_device_id == bridge_entry.id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("node_fixture", ["mock_composed_bridge"])
|
||||
async def test_composed_bridged_device_endpoint_removed(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
matter_client: MagicMock,
|
||||
matter_node: MatterNode,
|
||||
) -> None:
|
||||
"""Test the device of a composed bridged device is removed with its parent.
|
||||
|
||||
Removing a single part endpoint must not remove the device entry that the
|
||||
other endpoints of the composed device are still represented by.
|
||||
"""
|
||||
entry_id = hass.config_entries.async_entries(DOMAIN)[0].entry_id
|
||||
identifier = identifier_for(matter_client, matter_node, 2)
|
||||
|
||||
fire_endpoint_event(matter_client, EventType.ENDPOINT_REMOVED, matter_node, 3)
|
||||
await hass.async_block_till_done()
|
||||
assert (
|
||||
device_registry.async_get_device_by_identifier(identifier, entry_id) is not None
|
||||
)
|
||||
|
||||
fire_endpoint_event(matter_client, EventType.ENDPOINT_REMOVED, matter_node, 2)
|
||||
await hass.async_block_till_done()
|
||||
assert device_registry.async_get_device_by_identifier(identifier, entry_id) is None
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("matter_node")
|
||||
@pytest.mark.parametrize("node_fixture", ["mock_air_purifier"])
|
||||
async def test_device_registry_single_node_composed_device(
|
||||
|
||||
Reference in New Issue
Block a user