blob: c8c4a5d02fa9ad83cc445055c7f2597cf9a14916 [file]
#
# Copyright (c) 2023 Project CHIP Authors
# All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import re
import graphviz
from rich.console import Console
import matter.clusters as Clusters
from matter.clusters.Types import NullValue
from matter.testing.basic_composition import BasicCompositionTests
from matter.testing.decorators import async_test_body
from matter.testing.runner import default_matter_test_main
from matter.testing.spec_parsing import XmlNamespace, build_xml_namespaces
from matter.testing.taglist_and_topology_test import build_tree_for_graph
console = None
def format_cluster_label(clusterName: str, single_line_max: int = 22) -> str:
"""
Split a camelCase cluster name into words, wrapping to two lines when long.
The wrap point is picked near the character-count midpoint so the two lines
are roughly balanced (e.g. ThermostatUserInterfaceConfiguration becomes
'Thermostat User Interface' / 'Configuration').
"""
spaced = re.sub(r"(?<!^)(?=[A-Z])", " ", clusterName)
if len(spaced) <= single_line_max:
return spaced
words = spaced.split(" ")
if len(words) < 2:
return spaced
target = len(spaced) // 2
acc = 0
split = 1
for i, word in enumerate(words):
acc += len(word) + 1
if acc >= target:
split = i + 1
break
return " ".join(words[:split]) + r"\n" + " ".join(words[split:])
def AddServerOrClientNode(graphSection, endpoint, clusterName, color, nodeRef, kind):
# A cluster can appear in both ServerList and ClientList on the same endpoint
# (e.g. an On/Off Light that also binds to OnOff on another endpoint). Include
# the kind in the node id so the two declarations don't collide and overwrite
# each other in graphviz.
nodeId = f"ep{endpoint}_{kind}_{clusterName}"
label = format_cluster_label(clusterName)
graphSection.node(nodeId, label=label, style="filled,rounded",
color=color, shape="box", fixedsize="true", width="3.5", height="0.7")
graphSection.edge(nodeRef, nodeId, style="invis")
return nodeId
def tag_str(tag: Clusters.Objects.Globals.Structs.SemanticTagStruct,
xml_namespaces: dict[int, XmlNamespace] | None = None) -> str:
# Manufacturer-defined namespaces aren't in the spec XMLs, so fall back to hex.
if tag.mfgCode is not None and tag.mfgCode is not NullValue:
body = f"Mfg 0x{tag.mfgCode:04X}: 0x{tag.namespaceID:04X}: 0x{tag.tag:04X}"
else:
ns = xml_namespaces.get(tag.namespaceID) if xml_namespaces else None
if ns is None:
body = f"Unknown Namespace 0x{tag.namespaceID:04X}: 0x{tag.tag:04X}"
else:
xml_tag = ns.tags.get(tag.tag)
if xml_tag is None:
body = f"{ns.name}: Unknown Tag 0x{tag.tag:04X}"
else:
body = f"{ns.name}: {xml_tag.name}"
if tag.label is not None and tag.label is not NullValue and tag.label != "":
body = f'{body} "{tag.label}"'
return f"({body})"
def AddNodeLabel(graphSection, endpoint, wildcardResponse, device_type_xml, aggregator, xml_namespaces=None):
partsListFromWildcardRead = wildcardResponse[endpoint][Clusters.Objects.Descriptor][Clusters.Objects.Descriptor.Attributes.PartsList]
listOfDeviceTypes = []
for deviceTypeStruct in wildcardResponse[endpoint][Clusters.Objects.Descriptor][Clusters.Objects.Descriptor.Attributes.DeviceTypeList]:
try:
listOfDeviceTypes.append(device_type_xml[deviceTypeStruct.deviceType].name)
except KeyError:
listOfDeviceTypes.append(deviceTypeStruct.deviceType)
try:
tags = [tag_str(t, xml_namespaces)
for t in wildcardResponse[endpoint][Clusters.Descriptor][Clusters.Descriptor.Attributes.TagList]]
except KeyError:
tags = []
if tags:
tags_section = r"Tags:\l " + r"\l ".join(tags)
else:
tags_section = "Tags: []"
endpointLabel = fr"Endpoint: {endpoint}\lDeviceTypeList: {listOfDeviceTypes}\lPartsList: {partsListFromWildcardRead}\l{tags_section}\l"
if aggregator:
color = 'grey'
else:
color = 'dodgerblue'
graphSection.node(f"ep{endpoint}", label=endpointLabel, style="filled,rounded",
color=color, shape="box", fixedsize="false", width="5", height="1")
def CreateEndpointGraph(graph, graphSection, endpoint, wildcardResponse, device_type_xml, xml_namespaces=None):
AddNodeLabel(graphSection, endpoint, wildcardResponse, device_type_xml, False, xml_namespaces)
partsListFromWildcardRead = wildcardResponse[endpoint][Clusters.Objects.Descriptor][Clusters.Objects.Descriptor.Attributes.PartsList]
numberOfRowsInEndpoint = 2
nextNodeRef = ""
nodeRef = f"ep{endpoint}"
clusterColumnCount = 0
lastNodeId = nodeRef
serverList = wildcardResponse[endpoint][Clusters.Objects.Descriptor][Clusters.Objects.Descriptor.Attributes.ServerList]
clientList = wildcardResponse[endpoint][Clusters.Objects.Descriptor][Clusters.Objects.Descriptor.Attributes.ClientList]
for clusterId in serverList:
clusterColumnCount += 1
try:
clusterName = Clusters.ClusterObjects.ALL_CLUSTERS[clusterId].__name__
except KeyError:
clusterName = f"MEI 0x{clusterId:08X}"
lastNodeId = AddServerOrClientNode(graphSection, endpoint, clusterName, "olivedrab", nodeRef, "srv")
if clusterColumnCount == 2:
nextNodeRef = lastNodeId
elif clusterColumnCount == 3:
nodeRef = nextNodeRef
clusterColumnCount = 0
numberOfRowsInEndpoint += 1
# Force a new row before client clusters so the green server block and the orange
# client block don't share a row.
if serverList and clientList and clusterColumnCount != 0:
nodeRef = lastNodeId
clusterColumnCount = 0
numberOfRowsInEndpoint += 1
for clusterId in clientList:
clusterColumnCount += 1
try:
clusterName = Clusters.ClusterObjects.ALL_CLUSTERS[clusterId].__name__
except KeyError:
clusterName = f"MEI 0x{clusterId:08X}"
lastNodeId = AddServerOrClientNode(graphSection, endpoint, clusterName, "orange", nodeRef, "cli")
if clusterColumnCount == 2:
nextNodeRef = lastNodeId
elif clusterColumnCount == 3:
nodeRef = nextNodeRef
clusterColumnCount = 0
numberOfRowsInEndpoint += 1
if endpoint != 0:
# Create link to endpoints in the parts list
for part in partsListFromWildcardRead:
graph.edge(f"ep{endpoint}", f"ep{part}", ltail=f"cluster_EP{endpoint}",
lhead=f'cluster_EP{part}', minlen=f"{numberOfRowsInEndpoint}")
def create_graph(wildcardResponse, xml_device_types, xml_namespaces=None, outfile_dir: str = '.'):
# Create console to print
global console
console = Console()
# Perform wildcard read to get all attributes from device
console.print("[blue]Capturing data from device")
vid = wildcardResponse[0][Clusters.BasicInformation][Clusters.BasicInformation.Attributes.VendorID]
pid = wildcardResponse[0][Clusters.BasicInformation][Clusters.BasicInformation.Attributes.ProductID]
software_version = wildcardResponse[0][Clusters.BasicInformation][Clusters.BasicInformation.Attributes.SoftwareVersion]
vendor_name = wildcardResponse[0][Clusters.BasicInformation][Clusters.BasicInformation.Attributes.VendorName]
product_name = wildcardResponse[0][Clusters.BasicInformation][Clusters.BasicInformation.Attributes.ProductName]
try:
spec_version = f'0x{wildcardResponse[0][Clusters.BasicInformation][Clusters.BasicInformation.Attributes.SpecificationVersion]:04X}'
except KeyError:
# TODO: we can probably guess from the data model version, but I don't care that much right now
spec_version = "pre-1.3"
cluster_info_string = f'''
Vendor: {vendor_name} (0x{vid:04X})
Product: {product_name} (0x{pid:04X})
Software version: {software_version}
Specification version: {spec_version}
'''
# Creating graph object
deviceGraph = graphviz.Digraph()
deviceGraph.attr(style="rounded", splines="line", compound="true")
deviceGraph.attr(label=cluster_info_string, labelloc='t')
console.print("[blue]Generating graph")
# Loop through each endpoint in the response from the wildcard read
tree, problems = build_tree_for_graph(wildcardResponse)
if problems:
console.print("Found problems parsing topology:")
for p in problems:
console.print(p)
return
subgraph = {}
currentAggregator = deviceGraph
ROOT_NODE_DEVICE_TYPE_ID = 0x0016
def is_root_node(endpoint: int) -> bool:
device_types = wildcardResponse[endpoint][Clusters.Objects.Descriptor][Clusters.Objects.Descriptor.Attributes.DeviceTypeList]
return any(dt.deviceType == ROOT_NODE_DEVICE_TYPE_ID for dt in device_types)
def create_subgraph(endpoint: int):
nonlocal currentAggregator
# The Root Node carries the is_aggregator_or_root flag but has no application children
# of its own, it collects every other endpoint underneath it. Nesting all the
# children inside a cluster_EP0 subgraph makes the root box visually swallow the rest
# of the device, so render the root label and recurse children at the top level.
if is_root_node(endpoint):
print(f"adding root on EP {endpoint}")
AddNodeLabel(deviceGraph, endpoint, wildcardResponse, xml_device_types, True, xml_namespaces)
for c in tree[endpoint].children:
create_subgraph(c)
for c in tree[endpoint].children:
deviceGraph.edge(f"ep{endpoint}", f'ep{c}', style='invis')
elif tree[endpoint].is_aggregator_or_root:
print(f"adding aggregator on EP {endpoint}")
with currentAggregator.subgraph(name=f'cluster_EP{endpoint}') as subgraph[endpoint]:
AddNodeLabel(subgraph[endpoint], endpoint, wildcardResponse, xml_device_types, True, xml_namespaces)
subgraph[endpoint].attr(rank="same", label='')
currentAggregator = subgraph[endpoint]
for c in tree[endpoint].children:
create_subgraph(c)
# required for ordering these correctly - for some reason graphiz puts these backwards if we don't have
# explicit edge ordering
for c in tree[endpoint].children:
subgraph[endpoint].edge(f"ep{endpoint}", f'ep{c}', style='invis')
else:
print(f"adding tree on EP {endpoint}")
# normal tree, this will appear in its nearest container, but the children are created external
with currentAggregator.subgraph(name=f'cluster_EP{endpoint}') as subgraph[endpoint]:
CreateEndpointGraph(deviceGraph, subgraph[endpoint],
tree[endpoint].endpoint, wildcardResponse, xml_device_types, xml_namespaces)
subgraph[endpoint].attr(rank="same", label='')
for c in tree[endpoint].children:
create_subgraph(c)
create_subgraph(0)
filename = f'{outfile_dir}/matter_device_graph_0x{vid:04X}_0x{pid:04X}_{software_version}.dot'
deviceGraph.render(filename, format='png')
class TC_MatterDeviceGraph(BasicCompositionTests):
@async_test_body
async def setup_class(self):
super().setup_class()
await self.setup_class_helper(False)
self.build_spec_xmls()
self.xml_namespaces, namespace_problems = build_xml_namespaces(self._get_dm())
self.problems.extend(namespace_problems)
@async_test_body
async def test_matter_device_graph(self):
outfile_dir = self.user_params.get("outfile_path", '.')
create_graph(self.endpoints, self.xml_device_types, self.xml_namespaces, outfile_dir)
if __name__ == "__main__":
default_matter_test_main()