Fix composed devices behind Matter bridge losing "via device" (#179118)

This commit is contained in:
TheJulianJES
2026-08-14 13:29:04 +02:00
committed by GitHub
parent c98b2d1509
commit 7cbf647084
5 changed files with 554 additions and 40 deletions
+18 -10
View File
@@ -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
+11 -4
View File
@@ -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)
+2 -2
View File
@@ -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": []
}
+115 -24
View File
@@ -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(