From ae3189a08c348d02f18fd865f1fa288dc8fdedd7 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Thu, 23 Jul 2026 08:50:40 +0300 Subject: [PATCH 1/3] feat(python-client): add get_network_topology + network_topology_updated (schema 13) Client counterpart of the server-side network topology API: typed NetworkTopology / node / connection / direction dataclasses (kind, role and strength stay open string sets so newer servers can't break parsing), get_network_topology(refresh=) gated on require_schema=13, and typed parsing of the network_topology_updated event. --- python_client/matter_server/client/client.py | 21 ++ python_client/matter_server/common/models.py | 74 +++++++ python_client/tests/test_network_topology.py | 194 +++++++++++++++++++ 3 files changed, 289 insertions(+) create mode 100644 python_client/tests/test_network_topology.py diff --git a/python_client/matter_server/client/client.py b/python_client/matter_server/client/client.py index 1feb69cc..7f100d34 100644 --- a/python_client/matter_server/client/client.py +++ b/python_client/matter_server/client/client.py @@ -30,6 +30,7 @@ MatterNodeEvent, MatterSoftwareVersion, MessageType, + NetworkTopology, NodePingResult, ResultMessageBase, ServerDiagnostics, @@ -308,6 +309,22 @@ async def resync_icd(self, node_id: int) -> None: """ await self.send_command(APICommand.RESYNC_ICD, require_schema=12, node_id=node_id) + async def get_network_topology(self, refresh: bool = False) -> NetworkTopology: + """Return the derived network topology graph (Thread mesh + Wi-Fi). + + Issuing this command also subscribes the connection to + EventType.NETWORK_TOPOLOGY_UPDATED pushes. With refresh=True the server + re-reads the Thread/Wi-Fi diagnostics from every online node before + building the snapshot (slower - seconds); otherwise it builds from the + current attribute cache. + + Requires schema 13 (OHF Matter Server). + """ + data = await self.send_command( + APICommand.GET_NETWORK_TOPOLOGY, require_schema=13, refresh=refresh + ) + return dataclass_from_dict(NetworkTopology, data) + async def open_commissioning_window( self, node_id: int, @@ -869,6 +886,10 @@ def _handle_event_message(self, msg: EventMessage) -> None: node_id=node_event.node_id, ) return + if msg.event == EventType.NETWORK_TOPOLOGY_UPDATED: + topology = dataclass_from_dict(NetworkTopology, msg.data) + self._signal_event(EventType.NETWORK_TOPOLOGY_UPDATED, data=topology) + return # An event type unknown to this (older) client is passed through by parse_value as a raw # string; forwarding it would crash on `event.value` in _signal_event. Drop it instead so a # newer server emitting a new event type can never disconnect the client. diff --git a/python_client/matter_server/common/models.py b/python_client/matter_server/common/models.py index c850c93b..25ceeefe 100644 --- a/python_client/matter_server/common/models.py +++ b/python_client/matter_server/common/models.py @@ -25,6 +25,7 @@ class EventType(Enum): ENDPOINT_REMOVED = "endpoint_removed" WEBRTC_CALLBACK = "webrtc_callback" THREAD_DIAGNOSTICS_UPDATED = "thread_diagnostics_updated" # schema 12+ + NETWORK_TOPOLOGY_UPDATED = "network_topology_updated" # schema 13+ class APICommand(str, Enum): @@ -66,6 +67,7 @@ class APICommand(str, Enum): REGISTER_ICD = "register_icd" RESYNC_ICD = "resync_icd" UNREGISTER_ICD = "unregister_icd" + GET_NETWORK_TOPOLOGY = "get_network_topology" EventCallBackType = Callable[[EventType, Any], None] @@ -270,6 +272,78 @@ class IcdStateData: next_expected_checkin: int | None +@dataclass +class TopologyDirectionInfo: + """One direction's observed link quality on a topology connection. + + Note: Only available with OHF Matter Server (schema 13+). + """ + + strength: str + lqi: int | None = None + rssi: int | None = None + + +@dataclass +class NetworkTopologyNode: + """A node in the network topology graph. + + `kind`/`role`/`strength` values are open string sets so a newer server can + introduce values without breaking older clients. + Note: Only available with OHF Matter Server (schema 13+). + """ + + # pylint: disable=too-many-instance-attributes + + id: str + kind: str # matter | border_router | thread_unknown | wifi_ap + network_type: str # thread | wifi | ethernet | unknown + node_id: int | None = None + role: str | None = None + available: bool | None = None + is_bridge: bool | None = None + ext_address: str | None = None + rloc16: int | None = None + ext_pan_id: str | None = None + network_name: str | None = None + vendor_name: str | None = None + model_name: str | None = None + last_seen: int | None = None + + +@dataclass +class NetworkTopologyConnection: + """An edge between two topology nodes. + + Thread links may be asymmetric: `source_to_target`/`target_to_source` carry + each observed direction; the top-level `strength` is the strongest of them. + Note: Only available with OHF Matter Server (schema 13+). + """ + + source: str + target: str + network: str # thread | wifi + strength: str # strong | medium | weak | none + source_to_target: TopologyDirectionInfo | None = None + target_to_source: TopologyDirectionInfo | None = None + via_route_table: bool | None = None + path_cost: int | None = None + + +@dataclass +class NetworkTopology: + """Snapshot of the Matter network topology (Thread mesh + Wi-Fi). + + Returned by the 'get_network_topology' command and pushed via the + 'network_topology_updated' event. + Note: Only available with OHF Matter Server (schema 13+). + """ + + collected_at: int + nodes: list[NetworkTopologyNode] + connections: list[NetworkTopologyConnection] + + class UpdateSource(Enum): """Enum with possible sources for a software update.""" diff --git a/python_client/tests/test_network_topology.py b/python_client/tests/test_network_topology.py new file mode 100644 index 00000000..5b202a0a --- /dev/null +++ b/python_client/tests/test_network_topology.py @@ -0,0 +1,194 @@ +"""Unit tests for the network topology command, schema gating, and event parsing.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from matter_server.client import MatterClient +from matter_server.client.exceptions import ServerVersionTooOld +from matter_server.common.models import ( + APICommand, + EventMessage, + EventType, + NetworkTopology, + NetworkTopologyConnection, + NetworkTopologyNode, + ServerInfoMessage, + TopologyDirectionInfo, +) + + +def _bare_client() -> MatterClient: + """Construct a MatterClient without connecting (for monkeypatching send_command).""" + return MatterClient.__new__(MatterClient) + + +def _gated_client(schema_version: int) -> MatterClient: + """Construct a MatterClient with a stub connection that reports a given schema version.""" + client = MatterClient.__new__(MatterClient) + server_info = ServerInfoMessage( + fabric_id=1, + compressed_fabric_id=1, + schema_version=schema_version, + min_supported_schema_version=1, + sdk_version="0.0.0", + wifi_credentials_set=False, + thread_credentials_set=False, + bluetooth_enabled=False, + ) + connection = MagicMock() + connection.connected = True + connection.server_info = server_info + client.connection = connection + return client + + +_WIRE_TOPOLOGY = { + "collected_at": 1767888000000, + "nodes": [ + { + "id": "1", + "kind": "matter", + "network_type": "thread", + "node_id": 1, + "role": "leader", + "available": True, + "ext_address": "AABBCCDDEEFF0001", + "rloc16": 1024, + "ext_pan_id": "1122334455667788", + "network_name": "OpenThread", + }, + { + "id": "br_1122AABBCC334455", + "kind": "border_router", + "network_type": "thread", + "role": "router", + "ext_address": "1122AABBCC334455", + "vendor_name": "Apple", + "last_seen": 1767887990000, + }, + {"id": "5", "kind": "matter", "network_type": "wifi", "node_id": 5, "role": "station"}, + {"id": "ap_112233445566", "kind": "wifi_ap", "network_type": "wifi", "role": "ap"}, + ], + "connections": [ + { + "source": "1", + "target": "br_1122AABBCC334455", + "network": "thread", + "strength": "medium", + "source_to_target": {"strength": "medium", "lqi": 2, "rssi": -70}, + }, + { + "source": "5", + "target": "ap_112233445566", + "network": "wifi", + "strength": "strong", + "source_to_target": {"strength": "strong", "rssi": -55}, + }, + ], +} + + +async def test_get_network_topology_uses_schema_13() -> None: + c = _bare_client() + c.send_command = AsyncMock(return_value=_WIRE_TOPOLOGY) + result = await c.get_network_topology() + args, kwargs = c.send_command.call_args + assert args[0] == APICommand.GET_NETWORK_TOPOLOGY + assert kwargs.get("require_schema") == 13 + assert kwargs.get("refresh") is False + + assert isinstance(result, NetworkTopology) + assert result.collected_at == 1767888000000 + assert [n.id for n in result.nodes] == ["1", "br_1122AABBCC334455", "5", "ap_112233445566"] + node1 = result.nodes[0] + assert node1 == NetworkTopologyNode( + id="1", + kind="matter", + network_type="thread", + node_id=1, + role="leader", + available=True, + ext_address="AABBCCDDEEFF0001", + rloc16=1024, + ext_pan_id="1122334455667788", + network_name="OpenThread", + ) + thread_link = result.connections[0] + assert thread_link == NetworkTopologyConnection( + source="1", + target="br_1122AABBCC334455", + network="thread", + strength="medium", + source_to_target=TopologyDirectionInfo(strength="medium", lqi=2, rssi=-70), + ) + assert thread_link.target_to_source is None + + +async def test_get_network_topology_refresh_passthrough() -> None: + c = _bare_client() + c.send_command = AsyncMock(return_value=_WIRE_TOPOLOGY) + await c.get_network_topology(refresh=True) + _, kwargs = c.send_command.call_args + assert kwargs.get("refresh") is True + + +def test_get_network_topology_rejected_on_schema_12() -> None: + """Prove require_schema=13 actually enforces the gate against a schema-12 server.""" + c = _gated_client(schema_version=12) + with pytest.raises(ServerVersionTooOld): + c._prepare_message(APICommand.GET_NETWORK_TOPOLOGY, require_schema=13, refresh=False) + + +async def test_wire_dict_with_unknown_keys_and_values_parses() -> None: + """A newer server's extra fields and new kind/role/strength values must not break parsing.""" + wire = { + "collected_at": 1, + "future_field": "ignored", + "nodes": [ + { + "id": "x", + "kind": "quantum_relay", # unknown future kind + "network_type": "thread", + "role": "overlord", # unknown future role + "another_future_field": {"nested": True}, + } + ], + "connections": [ + { + "source": "x", + "target": "y", + "network": "thread", + "strength": "superb", # unknown future strength + "future_metric": 42, + } + ], + } + c = _bare_client() + c.send_command = AsyncMock(return_value=wire) + result = await c.get_network_topology() + assert result.nodes[0].kind == "quantum_relay" + assert result.nodes[0].role == "overlord" + assert result.nodes[0].node_id is None + assert result.connections[0].strength == "superb" + assert result.connections[0].source_to_target is None + + +def test_network_topology_updated_event_parses_typed() -> None: + """The event payload reaches subscribers as a parsed NetworkTopology.""" + client = MatterClient("ws://localhost:5580/ws", MagicMock()) + received: list[tuple[object, object]] = [] + client.subscribe_events(lambda event, data: received.append((event, data))) + + client._handle_event_message( + EventMessage(event=EventType.NETWORK_TOPOLOGY_UPDATED, data=_WIRE_TOPOLOGY) + ) + + assert len(received) == 1 + event, data = received[0] + assert event == EventType.NETWORK_TOPOLOGY_UPDATED + assert isinstance(data, NetworkTopology) + assert len(data.nodes) == 4 + assert data.connections[1].source_to_target == TopologyDirectionInfo(strength="strong", rssi=-55) From b0cb989d2b1e359f04b974272d2d8a952883f4c7 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Thu, 23 Jul 2026 10:50:37 +0300 Subject: [PATCH 2/3] fix(python): correct NetworkTopologyNode docstring field list --- python_client/matter_server/common/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python_client/matter_server/common/models.py b/python_client/matter_server/common/models.py index 25ceeefe..17e42316 100644 --- a/python_client/matter_server/common/models.py +++ b/python_client/matter_server/common/models.py @@ -288,7 +288,7 @@ class TopologyDirectionInfo: class NetworkTopologyNode: """A node in the network topology graph. - `kind`/`role`/`strength` values are open string sets so a newer server can + `kind`/`role` values are open string sets so a newer server can introduce values without breaking older clients. Note: Only available with OHF Matter Server (schema 13+). """ From 4272e5ee16af1328d6d2a53848076d1f34ac9e5f Mon Sep 17 00:00:00 2001 From: Ingo Fischer Date: Thu, 30 Jul 2026 16:33:24 +0200 Subject: [PATCH 3/3] docs(python-client): note get_network_topology(refresh=True) is not pollable The refresh path costs a per-node attribute read fan-out on the server, so it is a user-initiated action; the push event is the source for keeping a graph current. Co-Authored-By: Claude Opus 5 (1M context) --- python_client/matter_server/client/client.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/python_client/matter_server/client/client.py b/python_client/matter_server/client/client.py index 7f100d34..f2216a09 100644 --- a/python_client/matter_server/client/client.py +++ b/python_client/matter_server/client/client.py @@ -318,6 +318,10 @@ async def get_network_topology(self, refresh: bool = False) -> NetworkTopology: building the snapshot (slower - seconds); otherwise it builds from the current attribute cache. + refresh=True costs real radio traffic on every online node, so it is a + user-initiated action - never poll it. The push event is the intended + source for keeping a graph current. + Requires schema 13 (OHF Matter Server). """ data = await self.send_command(