Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions python_client/matter_server/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
MatterNodeEvent,
MatterSoftwareVersion,
MessageType,
NetworkTopology,
NodePingResult,
ResultMessageBase,
ServerDiagnostics,
Expand Down Expand Up @@ -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:
Comment thread
MindFreeze marked this conversation as resolved.
"""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,
Expand Down Expand Up @@ -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.
Expand Down
74 changes: 74 additions & 0 deletions python_client/matter_server/common/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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` 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."""

Expand Down
194 changes: 194 additions & 0 deletions python_client/tests/test_network_topology.py
Original file line number Diff line number Diff line change
@@ -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)