Skip to content

Commit e9f8540

Browse files
marcelveldtagners
andauthored
Fix ping node and retrieval of IP addresses (#585)
Co-authored-by: Stefan Agner <stefan@agner.ch>
1 parent 740bf7d commit e9f8540

File tree

4 files changed

+106
-45
lines changed

4 files changed

+106
-45
lines changed

matter_server/client/client.py

+39-8
Original file line numberDiff line numberDiff line change
@@ -257,16 +257,54 @@ async def ping_node(self, node_id: int) -> NodePingResult:
257257
await self.send_command(APICommand.PING_NODE, node_id=node_id),
258258
)
259259

260+
async def get_node_ip_addresses(
261+
self, node_id: int, prefer_cache: bool = True, scoped: bool = False
262+
) -> list[str]:
263+
"""Return the currently known (scoped) IP-adress(es)."""
264+
if TYPE_CHECKING:
265+
assert self.server_info is not None
266+
if self.server_info.schema_version >= 8:
267+
return cast(
268+
list[str],
269+
await self.send_command(
270+
APICommand.GET_NODE_IP_ADRESSES,
271+
require_schema=8,
272+
node_id=node_id,
273+
prefer_cache=prefer_cache,
274+
scoped=scoped,
275+
),
276+
)
277+
# alternative method of fetching ip addresses by enumerating NetworkInterfaces
278+
node = self.get_node(node_id)
279+
attribute = Clusters.GeneralDiagnostics.Attributes.NetworkInterfaces
280+
network_interface: Clusters.GeneralDiagnostics.Structs.NetworkInterface
281+
ip_addresses: list[str] = []
282+
for network_interface in node.get_attribute_value(
283+
0, cluster=None, attribute=attribute
284+
):
285+
# ignore invalid/non-operational interfaces
286+
if not network_interface.isOperational:
287+
continue
288+
# enumerate ipv4 and ipv6 addresses
289+
for ipv4_address_hex in network_interface.IPv4Addresses:
290+
ipv4_address = convert_ip_address(ipv4_address_hex)
291+
ip_addresses.append(ipv4_address)
292+
for ipv6_address_hex in network_interface.IPv6Addresses:
293+
ipv6_address = convert_ip_address(ipv6_address_hex, True)
294+
ip_addresses.append(ipv6_address)
295+
break
296+
return ip_addresses
297+
260298
async def node_diagnostics(self, node_id: int) -> NodeDiagnostics:
261299
"""Gather diagnostics for the given node."""
262300
# pylint: disable=too-many-statements
263301
node = self.get_node(node_id)
302+
ip_addresses = await self.get_node_ip_addresses(node_id)
264303
# grab some details from the first (operational) network interface
265304
network_type = NetworkType.UNKNOWN
266305
mac_address = None
267306
attribute = Clusters.GeneralDiagnostics.Attributes.NetworkInterfaces
268307
network_interface: Clusters.GeneralDiagnostics.Structs.NetworkInterface
269-
ip_addresses: list[str] = []
270308
for network_interface in node.get_attribute_value(
271309
0, cluster=None, attribute=attribute
272310
):
@@ -292,13 +330,6 @@ async def node_diagnostics(self, node_id: int) -> NodeDiagnostics:
292330
# unknown interface: ignore
293331
continue
294332
mac_address = convert_mac_address(network_interface.hardwareAddress)
295-
# enumerate ipv4 and ipv6 addresses
296-
for ipv4_address_hex in network_interface.IPv4Addresses:
297-
ipv4_address = convert_ip_address(ipv4_address_hex)
298-
ip_addresses.append(ipv4_address)
299-
for ipv6_address_hex in network_interface.IPv6Addresses:
300-
ipv6_address = convert_ip_address(ipv6_address_hex, True)
301-
ip_addresses.append(ipv6_address)
302333
break
303334
# get thread/wifi specific info
304335
node_type = NodeType.UNKNOWN

matter_server/common/const.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33
# schema version is used to determine compatibility between server and client
44
# bump schema if we add new features and/or make other (breaking) changes
5-
SCHEMA_VERSION = 7
5+
SCHEMA_VERSION = 8

matter_server/common/models.py

+1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ class APICommand(str, Enum):
4545
READ_ATTRIBUTE = "read_attribute"
4646
WRITE_ATTRIBUTE = "write_attribute"
4747
PING_NODE = "ping_node"
48+
GET_NODE_IP_ADRESSES = "get_node_ip_addresses"
4849

4950

5051
EventCallBackType = Callable[[EventType, Any], None]

matter_server/server/device_controller.py

+65-36
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from functools import partial
1111
import logging
1212
import time
13-
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Iterable, TypeVar, cast
13+
from typing import TYPE_CHECKING, Any, Callable, Iterable, TypeVar, cast
1414

1515
from chip.ChipDeviceCtrl import DeviceProxyWrapper
1616
from chip.clusters import Attribute, Objects as Clusters
@@ -20,7 +20,6 @@
2020
from zeroconf import IPVersion, ServiceStateChange, Zeroconf
2121
from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
2222

23-
from matter_server.common.helpers.util import convert_ip_address
2423
from matter_server.common.models import CommissionableNodeData, CommissioningParameters
2524
from matter_server.server.helpers.attributes import parse_attributes_from_read_result
2625
from matter_server.server.helpers.utils import ping_ip
@@ -38,7 +37,6 @@
3837
dataclass_from_dict,
3938
dataclass_to_dict,
4039
parse_attribute_path,
41-
parse_value,
4240
)
4341
from ..common.models import (
4442
APICommand,
@@ -97,6 +95,7 @@ def __init__(
9795
self._nodes_in_setup: set[int] = set()
9896
self._mdns_last_seen: dict[int, float] = {}
9997
self._nodes: dict[int, MatterNodeData] = {}
98+
self._last_known_ip_addresses: dict[int, list[str]] = {}
10099
self._last_subscription_attempt: dict[int, int] = {}
101100
self.wifi_credentials_set: bool = False
102101
self.thread_credentials_set: bool = False
@@ -679,14 +678,12 @@ async def subscribe_attribute(
679678
async def ping_node(self, node_id: int) -> NodePingResult:
680679
"""Ping node on the currently known IP-adress(es)."""
681680
result: NodePingResult = {}
682-
# the node's ip addresses are stored in the GeneralDiagnostics cluster
683-
attribute = Clusters.GeneralDiagnostics.Attributes.NetworkInterfaces
684-
attr_path = f"0/{attribute.cluster_id}/{attribute.attribute_id}"
685681
node = self._nodes.get(node_id)
686682
if node is None:
687683
raise NodeNotExists(
688684
f"Node {node_id} does not exist or is not yet interviewed"
689685
)
686+
node_logger = LOGGER.getChild(f"[node {node_id}]")
690687

691688
battery_powered = (
692689
node.attributes.get(ROUTING_ROLE_ATTRIBUTE_PATH, 0)
@@ -696,41 +693,71 @@ async def ping_node(self, node_id: int) -> NodePingResult:
696693
async def _do_ping(ip_address: str) -> None:
697694
"""Ping IP and add to result."""
698695
timeout = 10 if battery_powered else 2
699-
result[ip_address] = await ping_ip(ip_address, timeout)
700-
701-
# The network interfaces attribute contains a list of network interfaces.
702-
# For regular nodes this is just a single interface but we iterate them all anyway.
703-
# Create a list of tasks so we can do multiple pings simultanuous.
704-
# NOTE: Upgrade this to a TaskGroup once we bump our minimal python version.
705-
attr_data = cast(list[dict[str, Any]], node.attributes.get(attr_path))
706-
tasks: list[Awaitable] = []
707-
for network_interface_data in attr_data:
708-
network_interface: Clusters.GeneralDiagnostics.Structs.NetworkInterface = (
709-
parse_value(
710-
"network_interface",
711-
network_interface_data,
712-
Clusters.GeneralDiagnostics.Structs.NetworkInterface,
696+
if "%" in ip_address:
697+
# ip address contains an interface index
698+
clean_ip, interface_idx = ip_address.split("%", 1)
699+
node_logger.debug(
700+
"Pinging address %s (using interface %s)", clean_ip, interface_idx
713701
)
714-
)
715-
# ignore invalid/non-operational interfaces
716-
if not network_interface.isOperational:
717-
continue
718-
if network_interface.type in (
719-
Clusters.GeneralDiagnostics.Enums.InterfaceTypeEnum.kUnspecified,
720-
Clusters.GeneralDiagnostics.Enums.InterfaceTypeEnum.kUnknownEnumValue,
721-
):
722-
continue
702+
else:
703+
clean_ip = ip_address
704+
node_logger.debug("Pinging address %s", clean_ip)
705+
result[clean_ip] = await ping_ip(ip_address, timeout)
723706

724-
# enumerate ipv4 and ipv6 addresses
725-
for ipv4_address_hex in network_interface.IPv4Addresses:
726-
ipv4_address = convert_ip_address(ipv4_address_hex)
727-
tasks.append(_do_ping(ipv4_address))
728-
for ipv6_address_hex in network_interface.IPv6Addresses:
729-
ipv6_address = convert_ip_address(ipv6_address_hex, True)
730-
tasks.append(_do_ping(ipv6_address))
707+
ip_addresses = await self.get_node_ip_addresses(
708+
node_id, prefer_cache=False, scoped=True
709+
)
710+
tasks = [_do_ping(x) for x in ip_addresses]
711+
# TODO: replace this gather with a taskgroup once we bump our py version
731712
await asyncio.gather(*tasks)
713+
714+
# retrieve the currently connected/used address which is used
715+
# by the sdk for communicating with the device
716+
if TYPE_CHECKING:
717+
assert self.chip_controller is not None
718+
if sdk_result := await self._call_sdk(
719+
self.chip_controller.GetAddressAndPort, nodeid=node_id
720+
):
721+
active_address = sdk_result[0]
722+
node_logger.info(
723+
"The SDK is communicating with the device using %s", active_address
724+
)
725+
if active_address not in result and node.available:
726+
# if the sdk is connected to a node, treat the address as pingable
727+
result[active_address] = True
728+
732729
return result
733730

731+
@api_command(APICommand.GET_NODE_IP_ADRESSES)
732+
async def get_node_ip_addresses(
733+
self, node_id: int, prefer_cache: bool = False, scoped: bool = False
734+
) -> list[str]:
735+
"""Return the currently known (scoped) IP-adress(es)."""
736+
cached_info = self._last_known_ip_addresses.get(node_id, [])
737+
if prefer_cache and cached_info:
738+
return cached_info if scoped else [x.split("%")[0] for x in cached_info]
739+
node = self._nodes.get(node_id)
740+
if node is None:
741+
raise NodeNotExists(
742+
f"Node {node_id} does not exist or is not yet interviewed"
743+
)
744+
node_logger = LOGGER.getChild(f"[node {node_id}]")
745+
# query mdns for all IP's
746+
# ensure both fabric id and node id have 16 characters (prefix with zero's)
747+
mdns_name = f"{self.compressed_fabric_id:0{16}X}-{node_id:0{16}X}.{MDNS_TYPE_OPERATIONAL_NODE}"
748+
info = AsyncServiceInfo(MDNS_TYPE_OPERATIONAL_NODE, mdns_name)
749+
if TYPE_CHECKING:
750+
assert self._aiozc is not None
751+
if not await info.async_request(self._aiozc.zeroconf, 3000):
752+
node_logger.info(
753+
"Node could not be discovered on the network, returning cached IP's"
754+
)
755+
return cached_info
756+
ip_adresses = info.parsed_scoped_addresses(IPVersion.All)
757+
# cache this info for later use
758+
self._last_known_ip_addresses[node_id] = ip_adresses
759+
return ip_adresses if scoped else [x.split("%")[0] for x in ip_adresses]
760+
734761
async def _subscribe_node(self, node_id: int) -> None:
735762
"""
736763
Subscribe to all node state changes/events for an individual node.
@@ -989,6 +1016,8 @@ async def _setup_node(self, node_id: int) -> None:
9891016
# prevent duplicate setup actions
9901017
return
9911018
self._nodes_in_setup.add(node_id)
1019+
# pre-cache ip-addresses
1020+
await self.get_node_ip_addresses(node_id)
9921021
try:
9931022
# (re)interview node (only) if needed
9941023
node_data = self._nodes[node_id]

0 commit comments

Comments
 (0)