Skip to content

Commit 0dc6c0a

Browse files
committed
ros2doctor: add node and parameter info to report
Add NodeCheck/NodeReport and ParameterCheck/ParameterReport so the ros2doctor report shows active nodes and their parameters. - Add `ros2doctor/api/node.py` (node discovery, NodeCheck, NodeReport) - Add `ros2doctor/api/parameter.py` (parameter discovery, ParameterCheck, ParameterReport) - Update entry points in `ros2doctor/setup.py`: - `ros2doctor.checks: NodeCheck` and `ParameterCheck` - `ros2doctor.report: NodeReport` and `ParameterReport` Why: This enhancement provides valuable insights into the active nodes and their parameters within a ROS 2 system. By including this information in the `ros2 doctor` report sections in the `ros2 doctor --report` output, giving users better visibility into the runtime ROS 2 system configuration. Fixes: #1090 Signed-off-by: BhuvanB404 <bhuvanb404@gmail.com> Signed-off-by: BhuvanB404 <bhuvanb1408@gmail.com>
1 parent 8fd43d8 commit 0dc6c0a

10 files changed

Lines changed: 800 additions & 1 deletion

File tree

ros2doctor/package.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
<exec_depend>rclpy</exec_depend>
2323
<exec_depend>ros2action</exec_depend>
2424
<exec_depend>ros2cli</exec_depend>
25+
<exec_depend>ros2node</exec_depend>
26+
<exec_depend>ros2param</exec_depend>
2527
<exec_depend>ros_environment</exec_depend>
2628
<exec_depend>std_msgs</exec_depend>
2729

ros2doctor/ros2doctor/api/node.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Copyright 2025 Open Source Robotics Foundation, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
16+
from collections import Counter
17+
18+
from ros2node.api import get_node_names
19+
from ros2cli.node.strategy import NodeStrategy
20+
from ros2doctor.api import DoctorCheck
21+
from ros2doctor.api import DoctorReport
22+
from ros2doctor.api import Report
23+
from ros2doctor.api import Result
24+
from ros2doctor.api.format import doctor_warn
25+
26+
27+
def find_duplicates(values):
28+
"""Return values that appear more than once."""
29+
counts = Counter(values)
30+
return [v for v, c in counts.items() if c > 1]
31+
32+
33+
class NodeCheck(DoctorCheck):
34+
"""Check for duplicate node names."""
35+
36+
def category(self):
37+
return 'node'
38+
39+
def check(self):
40+
result = Result()
41+
with NodeStrategy(None) as node:
42+
node_list = get_node_names(node=node, include_hidden_nodes=True)
43+
node_names = [n.full_name for n in node_list]
44+
duplicates = find_duplicates(node_names)
45+
for duplicate in duplicates:
46+
doctor_warn(f'Duplicate node name: {duplicate}')
47+
result.add_warning()
48+
return result
49+
50+
51+
class NodeReport(DoctorReport):
52+
"""Report node related information."""
53+
54+
def category(self):
55+
return 'node'
56+
57+
def report(self):
58+
report = Report('NODE LIST')
59+
with NodeStrategy(None) as node:
60+
node_list = get_node_names(node=node, include_hidden_nodes=True)
61+
node_names = [n.full_name for n in node_list]
62+
if not node_names:
63+
report.add_to_report('node count', 0)
64+
report.add_to_report('node', 'none')
65+
else:
66+
report.add_to_report('node count', len(node_names))
67+
for node_name in sorted(node_names):
68+
report.add_to_report('node', node_name)
69+
return report
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# Copyright 2025 Open Source Robotics Foundation, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import rclpy
16+
from rclpy.parameter import parameter_value_to_python
17+
from ros2param.api import call_get_parameters
18+
from ros2param.api import call_list_parameters
19+
20+
from ros2cli.node.direct import DirectNode
21+
from ros2cli.node.strategy import NodeStrategy
22+
from ros2doctor.api import DoctorCheck
23+
from ros2doctor.api import DoctorReport
24+
from ros2doctor.api import Report
25+
from ros2doctor.api import Result
26+
from ros2doctor.api.format import doctor_warn
27+
28+
29+
class ParameterReport(DoctorReport):
30+
"""Report parameter related information."""
31+
32+
def category(self):
33+
return 'parameter'
34+
35+
def report(self):
36+
report = Report('PARAMETER LIST')
37+
with NodeStrategy(None) as node:
38+
try:
39+
node_names_and_namespaces = \
40+
node.get_node_names_and_namespaces()
41+
except Exception:
42+
node_names_and_namespaces = []
43+
if not node_names_and_namespaces:
44+
report.add_to_report('total nodes checked', 0)
45+
report.add_to_report('total parameter count', 0)
46+
report.add_to_report('parameter', 'none')
47+
return report
48+
49+
param_count = 0
50+
nodes_checked = 0
51+
52+
with DirectNode(None) as param_node:
53+
for node_name, namespace in sorted(node_names_and_namespaces):
54+
nodes_checked += 1
55+
full_name = f"{namespace.rstrip('/')}/{node_name}"
56+
try:
57+
response = call_list_parameters(
58+
node=param_node.node, node_name=full_name)
59+
if response is None:
60+
continue
61+
elif response.result() is None:
62+
continue
63+
64+
param_names = response.result().result.names
65+
if param_names:
66+
param_count += len(param_names)
67+
report.add_to_report('node', full_name)
68+
try:
69+
param_response = call_get_parameters(
70+
node=param_node.node,
71+
node_name=full_name,
72+
parameter_names=param_names
73+
)
74+
param_values = None
75+
if param_response:
76+
param_values = param_response.values
77+
if param_values and len(param_values) == len(
78+
param_names
79+
):
80+
params_with_values = sorted(
81+
zip(param_names, param_values)
82+
)
83+
for name, value_msg in params_with_values:
84+
value = parameter_value_to_python(
85+
value_msg
86+
)
87+
report.add_to_report(
88+
'parameter', f'{name}: {value}')
89+
else:
90+
for param_name in sorted(param_names):
91+
report.add_to_report(
92+
'parameter', param_name
93+
)
94+
except RuntimeError:
95+
for param_name in sorted(param_names):
96+
report.add_to_report(
97+
'parameter', param_name
98+
)
99+
except RuntimeError:
100+
pass
101+
102+
report.add_to_report('total nodes checked', nodes_checked)
103+
report.add_to_report('total parameter count', param_count)
104+
return report

ros2doctor/setup.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@
4747
'TopicCheck = ros2doctor.api.topic:TopicCheck',
4848
'QoSCompatibilityCheck = ros2doctor.api.qos_compatibility:QoSCompatibilityCheck',
4949
'PackageCheck = ros2doctor.api.package:PackageCheck',
50+
'NodeCheck = ros2doctor.api.node:NodeCheck',
51+
'ParameterCheck = ros2doctor.api.parameter:ParameterCheck',
5052
],
5153
'ros2doctor.report': [
5254
'PlatformReport = ros2doctor.api.platform:PlatformReport',
@@ -58,7 +60,9 @@
5860
'ActionReport = ros2doctor.api.action:ActionReport',
5961
'QoSCompatibilityReport = ros2doctor.api.qos_compatibility:QoSCompatibilityReport',
6062
'PackageReport = ros2doctor.api.package:PackageReport',
61-
'EnvironmentReport = ros2doctor.api.environment:EnvironmentReport'
63+
'EnvironmentReport = ros2doctor.api.environment:EnvironmentReport',
64+
'NodeReport = ros2doctor.api.node:NodeReport',
65+
'ParameterReport = ros2doctor.api.parameter:ParameterReport'
6266
],
6367
'ros2cli.extension_point': [
6468
'ros2doctor.verb = ros2doctor.verb:VerbExtension',

ros2doctor/test/common.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,32 @@ def generate_expected_service_report(services: Iterable[str], serv_counts: Itera
3333
expected_report.add_to_report('service count', serv_count)
3434
expected_report.add_to_report('client count', cli_count)
3535
return expected_report
36+
37+
38+
def generate_expected_node_report(node_count: int, nodes: Iterable[str]) -> Report:
39+
expected_report = Report('NODE LIST')
40+
if node_count == 0:
41+
expected_report.add_to_report('node count', 0)
42+
expected_report.add_to_report('node', 'none')
43+
else:
44+
expected_report.add_to_report('node count', node_count)
45+
for node in nodes:
46+
expected_report.add_to_report('node', node)
47+
return expected_report
48+
49+
50+
def generate_expected_parameter_report(nodes_checked: int, param_count: int,
51+
node_params: Iterable[tuple]) -> Report:
52+
expected_report = Report('PARAMETER LIST')
53+
if nodes_checked == 0:
54+
expected_report.add_to_report('total nodes checked', 0)
55+
expected_report.add_to_report('total parameter count', 0)
56+
expected_report.add_to_report('parameter', 'none')
57+
else:
58+
for node_name, params in node_params:
59+
expected_report.add_to_report('node', node_name)
60+
for param in params:
61+
expected_report.add_to_report('parameter', param)
62+
expected_report.add_to_report('total nodes checked', nodes_checked)
63+
expected_report.add_to_report('total parameter count', param_count)
64+
return expected_report
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Copyright 2026 Open Source Robotics Foundation, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import rclpy
16+
from rclpy.action import ActionServer
17+
from rclpy.executors import ExternalShutdownException
18+
from rclpy.node import Node
19+
from rclpy.qos import qos_profile_system_default
20+
21+
from test_msgs.action import Fibonacci
22+
from test_msgs.msg import Arrays
23+
from test_msgs.msg import Strings
24+
from test_msgs.srv import BasicTypes
25+
26+
27+
class ComplexNode(Node):
28+
29+
def __init__(self):
30+
super().__init__('complex_node')
31+
self.publisher = self.create_publisher(
32+
Arrays, 'arrays', qos_profile_system_default
33+
)
34+
self.subscription = self.create_subscription(
35+
Strings, 'strings', lambda msg: None, qos_profile_system_default
36+
)
37+
self.server = self.create_service(BasicTypes, 'basic', lambda req, res: res)
38+
self.action_server = ActionServer(
39+
self, Fibonacci, 'fibonacci', self.action_callback
40+
)
41+
self.timer = self.create_timer(1.0, self.pub_callback)
42+
43+
def destroy_node(self):
44+
self.timer.destroy()
45+
self.publisher.destroy()
46+
self.subscription.destroy()
47+
self.server.destroy()
48+
self.action_server.destroy()
49+
super().destroy_node()
50+
51+
def pub_callback(self):
52+
self.publisher.publish(Arrays())
53+
54+
def action_callback(self, goal_handle):
55+
goal_handle.succeed()
56+
return Fibonacci.Result()
57+
58+
59+
def main(args=None):
60+
try:
61+
with rclpy.init(args=args):
62+
node = ComplexNode()
63+
rclpy.spin(node)
64+
except (KeyboardInterrupt, ExternalShutdownException):
65+
print('node stopped cleanly')
66+
67+
68+
if __name__ == '__main__':
69+
main()
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Copyright 2025 Open Source Robotics Foundation, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Test fixture node with parameters for ros2doctor testing."""
16+
17+
import rclpy
18+
from rclpy.executors import ExternalShutdownException
19+
from rclpy.parameter import PARAMETER_SEPARATOR_STRING
20+
21+
22+
def main():
23+
try:
24+
with rclpy.init():
25+
node = rclpy.create_node('parameter_node')
26+
# Declare various parameter types for testing
27+
node.declare_parameter('bool_param', True)
28+
node.declare_parameter('int_param', 42)
29+
node.declare_parameter('double_param', 3.14)
30+
node.declare_parameter('str_param', 'hello')
31+
node.declare_parameter('bool_array_param', [True, False])
32+
node.declare_parameter('int_array_param', [1, 2, 3])
33+
node.declare_parameter('double_array_param', [1.0, 2.0])
34+
node.declare_parameter('str_array_param', ['foo', 'bar'])
35+
node.declare_parameter(
36+
'nested' + PARAMETER_SEPARATOR_STRING + 'param', 'nested_value'
37+
)
38+
rclpy.spin(node)
39+
except (KeyboardInterrupt, ExternalShutdownException):
40+
print('parameter_node stopped cleanly')
41+
42+
43+
if __name__ == '__main__':
44+
main()

ros2doctor/test/test_api.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import unittest
1616

17+
from common import generate_expected_node_report
1718
from common import generate_expected_service_report
1819
from common import generate_expected_topic_report
1920

@@ -25,6 +26,9 @@
2526

2627
import pytest
2728

29+
from ros2doctor.api.node import NodeCheck
30+
from ros2doctor.api.node import NodeReport
31+
from ros2doctor.api.parameter import ParameterReport
2832
from ros2doctor.api.service import ServiceReport
2933
from ros2doctor.api.topic import TopicCheck
3034
from ros2doctor.api.topic import TopicReport
@@ -74,3 +78,23 @@ def test_no_service_report(self):
7478
self.assertEqual(report.name, expected_report.name)
7579
self.assertEqual(report.items, expected_report.items)
7680
self.assertEqual(report, expected_report)
81+
82+
def test_node_check(self):
83+
"""Assume no duplicate nodes exist in a clean environment."""
84+
node_check = NodeCheck()
85+
check_result = node_check.check()
86+
self.assertEqual(check_result.error, 0)
87+
self.assertEqual(check_result.warning, 0)
88+
89+
def test_no_node_report(self):
90+
"""Assume no nodes are running in a clean environment."""
91+
report = NodeReport().report()
92+
expected_report = generate_expected_node_report(0, [])
93+
self.assertEqual(report.name, expected_report.name)
94+
self.assertEqual(report.name, 'NODE LIST')
95+
96+
def test_no_parameter_report(self):
97+
"""Assume no parameters in a clean environment."""
98+
report = ParameterReport().report()
99+
self.assertEqual(report.name, 'PARAMETER LIST')
100+
self.assertIsNotNone(report)

0 commit comments

Comments
 (0)