Skip to content

Commit 0cafae7

Browse files
committed
ros2doctor: add and test node/parameter reporting
- Add and update tests: test_node.py, test_parameter.py, test_api.py, common.py Signed-off-by: BhuvanB404<bhuvanb1408@gmail.com> Signed-off-by: BhuvanB404 <bhuvanb1408@gmail.com>
1 parent 2353fda commit 0cafae7

File tree

9 files changed

+746
-68
lines changed

9 files changed

+746
-68
lines changed

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: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# you may not use this file except in compliance with the License.
55
# You may obtain a copy of the License at
66
#
7-
# http://www.apache.org/licenses/LICENSE-2.0
7+
# http://www.apache.org/licenses/LICENSE-2.0
88
#
99
# Unless required by applicable law or agreed to in writing, software
1010
# distributed under the License is distributed on an "AS IS" BASIS,
@@ -13,20 +13,21 @@
1313
# limitations under the License.
1414

1515

16-
from collections import defaultdict
16+
from collections import Counter
1717

18+
from ros2node.api import get_node_names
1819
from ros2cli.node.strategy import NodeStrategy
1920
from ros2doctor.api import DoctorCheck
2021
from ros2doctor.api import DoctorReport
2122
from ros2doctor.api import Report
2223
from ros2doctor.api import Result
2324
from ros2doctor.api.format import doctor_warn
24-
from ros2node.api import get_node_names
2525

2626

27-
def has_duplicates(values):
28-
"""Find out if there are any exact duplicates in a list of strings."""
29-
return len(set(values)) < len(values)
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]
3031

3132

3233
class NodeCheck(DoctorCheck):
@@ -40,15 +41,10 @@ def check(self):
4041
with NodeStrategy(None) as node:
4142
node_list = get_node_names(node=node, include_hidden_nodes=True)
4243
node_names = [n.full_name for n in node_list]
43-
if has_duplicates(node_names):
44-
name_counts = defaultdict(int)
45-
for name in node_names:
46-
name_counts[name] += 1
47-
48-
duplicates = [name for name, count in name_counts.items() if count > 1]
49-
for duplicate in duplicates:
50-
doctor_warn(f'Duplicate node name: {duplicate}')
51-
result.add_warning()
44+
duplicates = find_duplicates(node_names)
45+
for duplicate in duplicates:
46+
doctor_warn(f'Duplicate node name: {duplicate}')
47+
result.add_warning()
5248
return result
5349

5450

ros2doctor/ros2doctor/api/parameter.py

Lines changed: 106 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,19 @@
44
# you may not use this file except in compliance with the License.
55
# You may obtain a copy of the License at
66
#
7-
# http://www.apache.org/licenses/LICENSE-2.0
7+
# http://www.apache.org/licenses/LICENSE-2.0
88
#
99
# Unless required by applicable law or agreed to in writing, software
1010
# distributed under the License is distributed on an "AS IS" BASIS,
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
16-
from rcl_interfaces.srv import ListParameters
1715
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+
1820
from ros2cli.node.direct import DirectNode
1921
from ros2cli.node.strategy import NodeStrategy
2022
from ros2doctor.api import DoctorCheck
@@ -24,57 +26,72 @@
2426
from ros2doctor.api.format import doctor_warn
2527

2628

27-
def call_list_parameters(node, node_name, namespace='/'):
28-
"""Call the list_parameters service for a specific node."""
29-
try:
30-
# Create service name and client for the target node's list_parameters service
31-
service_name = f"{namespace.rstrip('/')}/{node_name}/list_parameters"
32-
if service_name.startswith('//'):
33-
service_name = service_name[1:]
34-
35-
client = node.create_client(ListParameters, service_name)
36-
37-
if not client.wait_for_service(timeout_sec=1.0):
38-
return None
39-
40-
request = ListParameters.Request()
41-
future = client.call_async(request)
42-
43-
# Spin until the service call completes or times out
44-
rclpy.spin_until_future_complete(node, future, timeout_sec=2.0)
45-
46-
if future.done():
47-
response = future.result()
48-
node.destroy_client(client)
49-
return response
50-
else:
51-
node.destroy_client(client)
52-
return None
53-
54-
except Exception:
55-
return None
56-
57-
5829
class ParameterCheck(DoctorCheck):
59-
"""Check for nodes without parameter services."""
30+
"""Check for nodes with parameters using sim_time presence."""
6031

6132
def category(self):
6233
return 'parameter'
6334

6435
def check(self):
6536
result = Result()
37+
use_sim_time_values = {}
38+
6639
with NodeStrategy(None) as node:
6740
try:
68-
node_names_and_namespaces = node.get_node_names_and_namespaces()
41+
node_names_and_namespaces = \
42+
node.get_node_names_and_namespaces()
6943
except Exception:
7044
node_names_and_namespaces = []
45+
7146
with DirectNode(None) as param_node:
7247
for node_name, namespace in node_names_and_namespaces:
73-
response = call_list_parameters(param_node.node, node_name, namespace)
74-
if response is None:
75-
full_name = f"{namespace.rstrip('/')}/{node_name}"
76-
doctor_warn(f'Node {full_name} has no parameter services.')
77-
result.add_warning()
48+
full_name = f"{namespace.rstrip('/')}/{node_name}"
49+
try:
50+
response = call_list_parameters(
51+
node=param_node.node, node_name=full_name)
52+
if response is None:
53+
""" Service timed out, skip this node and continue """
54+
continue
55+
elif response.result() is None:
56+
"""call failed, skip this node"""
57+
continue
58+
59+
param_names = response.result().result.names
60+
if 'use_sim_time' in param_names:
61+
sim_time_return = call_get_parameters(
62+
node=param_node.node,
63+
node_name=full_name,
64+
parameter_names=['use_sim_time'])
65+
if sim_time_return and sim_time_return.values:
66+
sim_time_value = parameter_value_to_python(
67+
sim_time_return.values[0])
68+
use_sim_time_values[full_name] = sim_time_value
69+
except RuntimeError:
70+
""" Node configured to make Parameter service unavailable ,expected behavior """
71+
pass
72+
73+
""" Warnings on presence of nodes with real and sim time for debugging"""
74+
if use_sim_time_values:
75+
values = set(use_sim_time_values.values())
76+
if len(values) > 1:
77+
nodes_with_true = [
78+
name for name, val in use_sim_time_values.items() if val
79+
]
80+
nodes_with_false = [
81+
name for name, val in use_sim_time_values.items() if not val
82+
]
83+
if nodes_with_true and nodes_with_false:
84+
doctor_warn('Inconsistent use_sim_time parameter detected:')
85+
doctor_warn(
86+
' Nodes with use_sim_time=True: '
87+
f'{", ".join(nodes_with_true)}'
88+
)
89+
doctor_warn(
90+
' Nodes with use_sim_time=False: '
91+
f'{", ".join(nodes_with_false)}'
92+
)
93+
result.add_warning()
94+
7895
return result
7996

8097

@@ -88,7 +105,8 @@ def report(self):
88105
report = Report('PARAMETER LIST')
89106
with NodeStrategy(None) as node:
90107
try:
91-
node_names_and_namespaces = node.get_node_names_and_namespaces()
108+
node_names_and_namespaces = \
109+
node.get_node_names_and_namespaces()
92110
except Exception:
93111
node_names_and_namespaces = []
94112
if not node_names_and_namespaces:
@@ -97,24 +115,59 @@ def report(self):
97115
report.add_to_report('parameter', 'none')
98116
return report
99117

100-
total_param_count = 0
118+
param_count = 0
101119
nodes_checked = 0
102120

103121
with DirectNode(None) as param_node:
104122
for node_name, namespace in sorted(node_names_and_namespaces):
105123
nodes_checked += 1
106-
response = call_list_parameters(param_node.node, node_name, namespace)
107-
if response and hasattr(response, 'result') and response.result:
108-
result = response.result
109-
param_names = result.names if hasattr(result, 'names') else []
124+
full_name = f"{namespace.rstrip('/')}/{node_name}"
125+
try:
126+
response = call_list_parameters(
127+
node=param_node.node, node_name=full_name)
128+
if response is None:
129+
continue
130+
elif response.result() is None:
131+
continue
132+
133+
param_names = response.result().result.names
110134
if param_names:
111-
total_param_count += len(param_names)
112-
full_name = f"{namespace.rstrip('/')}/{node_name}"
113-
135+
param_count += len(param_names)
114136
report.add_to_report('node', full_name)
115-
for param_name in sorted(param_names):
116-
report.add_to_report('parameter', param_name)
137+
try:
138+
param_response = call_get_parameters(
139+
node=param_node.node,
140+
node_name=full_name,
141+
parameter_names=param_names
142+
)
143+
param_values = None
144+
if param_response:
145+
param_values = param_response.values
146+
if param_values and len(param_values) == len(
147+
param_names
148+
):
149+
params_with_values = sorted(
150+
zip(param_names, param_values)
151+
)
152+
for name, value_msg in params_with_values:
153+
value = parameter_value_to_python(
154+
value_msg
155+
)
156+
report.add_to_report(
157+
'parameter', f'{name}: {value}')
158+
else:
159+
for param_name in sorted(param_names):
160+
report.add_to_report(
161+
'parameter', param_name
162+
)
163+
except RuntimeError:
164+
for param_name in sorted(param_names):
165+
report.add_to_report(
166+
'parameter', param_name
167+
)
168+
except RuntimeError:
169+
pass
117170

118171
report.add_to_report('total nodes checked', nodes_checked)
119-
report.add_to_report('total parameter count', total_param_count)
172+
report.add_to_report('total parameter count', param_count)
120173
return report

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()

0 commit comments

Comments
 (0)