Skip to content

Commit 16ab59b

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 16ab59b

File tree

9 files changed

+745
-68
lines changed

9 files changed

+745
-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: 105 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,71 @@
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+
doctor_warn('Inconsistent use_sim_time parameter detected:')
84+
doctor_warn(
85+
' Nodes with use_sim_time=True: '
86+
f'{", ".join(nodes_with_true)}'
87+
)
88+
doctor_warn(
89+
' Nodes with use_sim_time=False: '
90+
f'{", ".join(nodes_with_false)}'
91+
)
92+
result.add_warning()
93+
7894
return result
7995

8096

@@ -88,7 +104,8 @@ def report(self):
88104
report = Report('PARAMETER LIST')
89105
with NodeStrategy(None) as node:
90106
try:
91-
node_names_and_namespaces = node.get_node_names_and_namespaces()
107+
node_names_and_namespaces = \
108+
node.get_node_names_and_namespaces()
92109
except Exception:
93110
node_names_and_namespaces = []
94111
if not node_names_and_namespaces:
@@ -97,24 +114,59 @@ def report(self):
97114
report.add_to_report('parameter', 'none')
98115
return report
99116

100-
total_param_count = 0
117+
param_count = 0
101118
nodes_checked = 0
102119

103120
with DirectNode(None) as param_node:
104121
for node_name, namespace in sorted(node_names_and_namespaces):
105122
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 []
123+
full_name = f"{namespace.rstrip('/')}/{node_name}"
124+
try:
125+
response = call_list_parameters(
126+
node=param_node.node, node_name=full_name)
127+
if response is None:
128+
continue
129+
elif response.result() is None:
130+
continue
131+
132+
param_names = response.result().result.names
110133
if param_names:
111-
total_param_count += len(param_names)
112-
full_name = f"{namespace.rstrip('/')}/{node_name}"
113-
134+
param_count += len(param_names)
114135
report.add_to_report('node', full_name)
115-
for param_name in sorted(param_names):
116-
report.add_to_report('parameter', param_name)
136+
try:
137+
param_response = call_get_parameters(
138+
node=param_node.node,
139+
node_name=full_name,
140+
parameter_names=param_names
141+
)
142+
param_values = None
143+
if param_response:
144+
param_values = param_response.values
145+
if param_values and len(param_values) == len(
146+
param_names
147+
):
148+
params_with_values = sorted(
149+
zip(param_names, param_values)
150+
)
151+
for name, value_msg in params_with_values:
152+
value = parameter_value_to_python(
153+
value_msg
154+
)
155+
report.add_to_report(
156+
'parameter', f'{name}: {value}')
157+
else:
158+
for param_name in sorted(param_names):
159+
report.add_to_report(
160+
'parameter', param_name
161+
)
162+
except RuntimeError:
163+
for param_name in sorted(param_names):
164+
report.add_to_report(
165+
'parameter', param_name
166+
)
167+
except RuntimeError:
168+
pass
117169

118170
report.add_to_report('total nodes checked', nodes_checked)
119-
report.add_to_report('total parameter count', total_param_count)
171+
report.add_to_report('total parameter count', param_count)
120172
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)