ros2-term 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ros2_term-0.1.0/.gitignore +14 -0
- ros2_term-0.1.0/.old/app.css +76 -0
- ros2_term-0.1.0/.old/config.yaml +3 -0
- ros2_term-0.1.0/.old/node_test.py +19 -0
- ros2_term-0.1.0/.old/ros2_explorer.py +326 -0
- ros2_term-0.1.0/CHANGELOG.md +126 -0
- ros2_term-0.1.0/PKG-INFO +113 -0
- ros2_term-0.1.0/README.md +86 -0
- ros2_term-0.1.0/pyproject.toml +61 -0
- ros2_term-0.1.0/src/ros2_term/__init__.py +0 -0
- ros2_term-0.1.0/src/ros2_term/app.css +76 -0
- ros2_term-0.1.0/src/ros2_term/app.py +558 -0
- ros2_term-0.1.0/src/ros2_term/lib/__init__.py +0 -0
- ros2_term-0.1.0/src/ros2_term/lib/config.py +90 -0
- ros2_term-0.1.0/src/ros2_term/lib/entity.py +259 -0
- ros2_term-0.1.0/src/ros2_term/lib/history.py +62 -0
- ros2_term-0.1.0/src/ros2_term/lib/messages.py +43 -0
- ros2_term-0.1.0/src/ros2_term/lib/ros2_client.py +438 -0
- ros2_term-0.1.0/src/ros2_term/lib/topic_watcher.py +281 -0
- ros2_term-0.1.0/src/ros2_term/ui/__init__.py +0 -0
- ros2_term-0.1.0/src/ros2_term/ui/help_screen.py +161 -0
- ros2_term-0.1.0/src/ros2_term/ui/profile_screen.py +86 -0
- ros2_term-0.1.0/src/ros2_term/ui/screens.py +93 -0
- ros2_term-0.1.0/src/ros2_term/ui/search_bar.py +140 -0
- ros2_term-0.1.0/src/ros2_term/ui/widgets.py +1602 -0
- ros2_term-0.1.0/test/node/dummy_node1.py +36 -0
- ros2_term-0.1.0/test/node/dummy_node2.py +32 -0
- ros2_term-0.1.0/test/run_app.sh +2 -0
- ros2_term-0.1.0/test/test.py +10 -0
- ros2_term-0.1.0/test/test_client.py +25 -0
- ros2_term-0.1.0/test/test_interfaces.py +134 -0
- ros2_term-0.1.0/test/test_node.py +51 -0
- ros2_term-0.1.0/test/test_ros2_client.py +8 -0
- ros2_term-0.1.0/uv.lock +191 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Screen {
|
|
2
|
+
layout: vertical;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/* Multi-screen layout */
|
|
6
|
+
.container {
|
|
7
|
+
height: 100%;
|
|
8
|
+
background: $panel;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
#left-panel-container {
|
|
12
|
+
width: 50%;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
NodeTreePanel, TopicTreePanel, RosEntityListPanel {
|
|
16
|
+
padding-left: 2;
|
|
17
|
+
width: 100%;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
RosEntityInfoPanel, RosTypeDefinitionPanel {
|
|
21
|
+
padding: 1 2;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#main {
|
|
25
|
+
width: 70%;
|
|
26
|
+
border-left: inner $primary;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#main-upper {
|
|
30
|
+
border-bottom: inner $primary;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.main-half {
|
|
34
|
+
height: 50%;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* ------------------------------------------------- */
|
|
38
|
+
SearchBar {
|
|
39
|
+
max-width: 100%;
|
|
40
|
+
height: 5;
|
|
41
|
+
padding: 0 1;
|
|
42
|
+
border: solid green;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
SearchBar Input {
|
|
46
|
+
width: 1fr;
|
|
47
|
+
min-width: 10;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
SearchBar .search-button {
|
|
51
|
+
width: 4;
|
|
52
|
+
margin-left: 1;
|
|
53
|
+
min-width: 4;
|
|
54
|
+
text-style: bold;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
SearchBar .search-button.hover {
|
|
58
|
+
background: $surface;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
SearchBar .search-button.active {
|
|
62
|
+
background: $secondary;
|
|
63
|
+
text-style: bold;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* ------------------------------------------------- */
|
|
67
|
+
|
|
68
|
+
#close-button {
|
|
69
|
+
width: 30;
|
|
70
|
+
margin: 1;
|
|
71
|
+
color: white;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#button-container {
|
|
75
|
+
align-horizontal: center;
|
|
76
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import rclpy
|
|
2
|
+
from rclpy.node import Node
|
|
3
|
+
|
|
4
|
+
class NodeLister(Node):
|
|
5
|
+
def __init__(self):
|
|
6
|
+
super().__init__('node_lister')
|
|
7
|
+
# Get list of node names and namespaces
|
|
8
|
+
node_names = self.get_node_names_and_namespaces()
|
|
9
|
+
self.get_logger().info("Active nodes:")
|
|
10
|
+
for name, namespace in node_names:
|
|
11
|
+
self.get_logger().info(f"{namespace}/{name}")
|
|
12
|
+
|
|
13
|
+
def main():
|
|
14
|
+
rclpy.init()
|
|
15
|
+
node = NodeLister()
|
|
16
|
+
rclpy.shutdown()
|
|
17
|
+
|
|
18
|
+
if __name__ == '__main__':
|
|
19
|
+
main()
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from textual.app import App, ComposeResult
|
|
3
|
+
from textual.widget import Widget
|
|
4
|
+
from textual.widgets import (
|
|
5
|
+
Header,
|
|
6
|
+
Footer,
|
|
7
|
+
Tree,
|
|
8
|
+
Label,
|
|
9
|
+
Markdown,
|
|
10
|
+
MarkdownViewer,
|
|
11
|
+
Input,
|
|
12
|
+
Button,
|
|
13
|
+
Tooltip,
|
|
14
|
+
Static
|
|
15
|
+
)
|
|
16
|
+
from textual.containers import Container, Horizontal
|
|
17
|
+
from textual.screen import ModalScreen, Screen
|
|
18
|
+
from textual.containers import Vertical
|
|
19
|
+
from textual.message import Message
|
|
20
|
+
from textual import events
|
|
21
|
+
from textual.widgets._tree import TreeNode
|
|
22
|
+
from textual.binding import Binding
|
|
23
|
+
from rich.text import Text
|
|
24
|
+
import re
|
|
25
|
+
import yaml
|
|
26
|
+
|
|
27
|
+
"""TODO
|
|
28
|
+
1. List count of nodes
|
|
29
|
+
2.
|
|
30
|
+
|
|
31
|
+
# from remote_pdb import set_trace; set_trace(port=4444)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def run_ros2_command(cmd: list[str]) -> list[str]:
|
|
35
|
+
try:
|
|
36
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
37
|
+
return result.stdout.strip().splitlines()
|
|
38
|
+
except subprocess.CalledProcessError as e:
|
|
39
|
+
return [f"Error: {e}"]
|
|
40
|
+
|
|
41
|
+
class ToggleButton(Button):
|
|
42
|
+
def on_mount(self):
|
|
43
|
+
# Set initial state
|
|
44
|
+
self.state = False
|
|
45
|
+
|
|
46
|
+
def toggle(self):
|
|
47
|
+
# Toggle the state
|
|
48
|
+
self.state = True if self.state == False else False
|
|
49
|
+
self.log.info(f"State of {self.id} is {self.state}")
|
|
50
|
+
self.update_style()
|
|
51
|
+
|
|
52
|
+
def update_style(self):
|
|
53
|
+
# Change visual style based on state
|
|
54
|
+
if self.state == True:
|
|
55
|
+
self.add_class("active")
|
|
56
|
+
else:
|
|
57
|
+
self.remove_class("active")
|
|
58
|
+
|
|
59
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
60
|
+
if isinstance(event.button, ToggleButton):
|
|
61
|
+
event.button.toggle()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
HELP_TEXT = """
|
|
65
|
+
# Help Menu
|
|
66
|
+
|
|
67
|
+
Welcome to the **ROS2 TUI App**! Press 'q' to exit the help.
|
|
68
|
+
|
|
69
|
+
## Key Bindings
|
|
70
|
+
|
|
71
|
+
- `Ctrl+q`: Exit the app.
|
|
72
|
+
- `Tab/Shift+Tab`: Go to next/previous widget.
|
|
73
|
+
- `Space`: Expand a selection when in node/topic tree view.
|
|
74
|
+
- `Enter`: Go to link when a link is highlighted (or click on it directly).
|
|
75
|
+
|
|
76
|
+
## Tips
|
|
77
|
+
|
|
78
|
+
- Search boxes use semicolon (;) as a delimiter so you can search for multiple node/topic names
|
|
79
|
+
at once.
|
|
80
|
+
|
|
81
|
+
## Configuration
|
|
82
|
+
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
# Create the help screen as a modal
|
|
86
|
+
class HelpScreen(ModalScreen):
|
|
87
|
+
CSS_PATH="app.css"
|
|
88
|
+
def compose(self) -> ComposeResult:
|
|
89
|
+
yield Vertical(
|
|
90
|
+
Markdown(HELP_TEXT, id="help-markdown"),
|
|
91
|
+
Container(
|
|
92
|
+
Button("Close", id="close-button"),
|
|
93
|
+
id="button-container"
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
98
|
+
self.app.pop_screen()
|
|
99
|
+
|
|
100
|
+
def on_key(self, event: events.Key) -> None:
|
|
101
|
+
if event.key == "q":
|
|
102
|
+
self.app.pop_screen()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Ros2NodeTree(Tree[str]):
|
|
106
|
+
"""Tree widget to show ROS 2 nodes and their info."""
|
|
107
|
+
|
|
108
|
+
def __init__(self, *args, **kwargs):
|
|
109
|
+
super().__init__(*args, **kwargs)
|
|
110
|
+
self.nodes = []
|
|
111
|
+
self.filtered_out = []
|
|
112
|
+
self.search_text = ""
|
|
113
|
+
|
|
114
|
+
def on_mount(self) -> None:
|
|
115
|
+
|
|
116
|
+
with open("config.yaml", "r") as f:
|
|
117
|
+
self.config = yaml.safe_load(f)
|
|
118
|
+
|
|
119
|
+
interval = float(self.config['refresh_interval'])
|
|
120
|
+
|
|
121
|
+
self.root.label = "ROS 2 Nodes"
|
|
122
|
+
self.set_interval(interval, self.load_nodes)
|
|
123
|
+
self.load_nodes()
|
|
124
|
+
|
|
125
|
+
def get_ros_node_names(self):
|
|
126
|
+
"""Get the name of the ros node names that exist in top layer of the tree"""
|
|
127
|
+
return [str(child.label) for child in self.root.children]
|
|
128
|
+
|
|
129
|
+
def get_child_node(self, name):
|
|
130
|
+
"""Just looking at the top tree layer. Should ideally traverse the tree"""
|
|
131
|
+
for child in self.root.children:
|
|
132
|
+
if str(child.label) == name:
|
|
133
|
+
return child
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def load_nodes(self):
|
|
138
|
+
nodes = [x.strip() for x in run_ros2_command(["ros2", "node", "list"])]
|
|
139
|
+
|
|
140
|
+
node_msg = "\n".join(nodes)
|
|
141
|
+
if "error:" in node_msg.lower():
|
|
142
|
+
self.root.remove_children()
|
|
143
|
+
self.root.add_leaf(Text(node_msg, style="white on red"))
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
# Remove any existing children that are not in updated node list
|
|
147
|
+
for child in self.root.children:
|
|
148
|
+
if str(child.label) not in nodes:
|
|
149
|
+
child.remove()
|
|
150
|
+
|
|
151
|
+
# Do similar updates on list of nodes that are filtered out
|
|
152
|
+
self.filtered_out = [x for x in self.filtered_out if x in nodes]
|
|
153
|
+
|
|
154
|
+
# Add any missing children from updated node list
|
|
155
|
+
children = self.get_ros_node_names()
|
|
156
|
+
for node in nodes:
|
|
157
|
+
if node not in children:
|
|
158
|
+
self.root.add(Text(node.strip(), style="bold deep_sky_blue3"))
|
|
159
|
+
|
|
160
|
+
# Filter out any added nodes that don't meet the search criteria
|
|
161
|
+
self.filter_nodes()
|
|
162
|
+
|
|
163
|
+
self.root.expand()
|
|
164
|
+
self.nodes = nodes
|
|
165
|
+
|
|
166
|
+
def filter_nodes(self):
|
|
167
|
+
"""From the search input, filter the shown ROS2 node names"""
|
|
168
|
+
|
|
169
|
+
case_button = self.screen.query_one("#case_insensitive", ToggleButton)
|
|
170
|
+
regex_button = self.screen.query_one("#regex_mode", ToggleButton)
|
|
171
|
+
|
|
172
|
+
if len(self.search_text) > 0:
|
|
173
|
+
search = self.search_text
|
|
174
|
+
case_insensitive = case_button.state
|
|
175
|
+
use_regex = regex_button.state
|
|
176
|
+
else:
|
|
177
|
+
search = ".*"
|
|
178
|
+
case_insensitive = False
|
|
179
|
+
use_regex = True
|
|
180
|
+
|
|
181
|
+
node_names = self.get_ros_node_names()
|
|
182
|
+
show = {}
|
|
183
|
+
for name in (node_names + self.filtered_out):
|
|
184
|
+
if use_regex:
|
|
185
|
+
flags = re.IGNORECASE if case_insensitive else 0
|
|
186
|
+
if re.search(search, name, flags):
|
|
187
|
+
show[name] = True
|
|
188
|
+
else:
|
|
189
|
+
show[name] = False
|
|
190
|
+
else:
|
|
191
|
+
if case_insensitive:
|
|
192
|
+
name = name.lower()
|
|
193
|
+
search = search.lower()
|
|
194
|
+
if search in name:
|
|
195
|
+
show[name] = True
|
|
196
|
+
else:
|
|
197
|
+
show[name] = False
|
|
198
|
+
|
|
199
|
+
for name in show:
|
|
200
|
+
if show[name] and name not in node_names:
|
|
201
|
+
self.root.add(Text(name, style="bold deep_sky_blue3"))
|
|
202
|
+
if name in self.filtered_out:
|
|
203
|
+
self.filtered_out.remove(name)
|
|
204
|
+
|
|
205
|
+
if not show[name] and name in node_names:
|
|
206
|
+
self.get_child_node(name).remove()
|
|
207
|
+
self.filtered_out.append(name)
|
|
208
|
+
|
|
209
|
+
# Sort node order
|
|
210
|
+
sorted_children = sorted(self.root.children, key=lambda child: str(child.label))
|
|
211
|
+
self.root._children = sorted_children
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def on_key(self, event: events.Key) -> None:
|
|
215
|
+
if event.key:
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
def parse_node_info(self, node_info: list[str]):
|
|
219
|
+
"""Parse the output of 'ros2 node info <node>' """
|
|
220
|
+
key = None
|
|
221
|
+
|
|
222
|
+
target_keys = [
|
|
223
|
+
"Subscribers",
|
|
224
|
+
"Publishers",
|
|
225
|
+
"Service Servers",
|
|
226
|
+
"Service Clients",
|
|
227
|
+
"Action Servers",
|
|
228
|
+
"Action Clients",
|
|
229
|
+
]
|
|
230
|
+
data = {k: [] for k in target_keys}
|
|
231
|
+
|
|
232
|
+
for line in node_info:
|
|
233
|
+
for k in target_keys:
|
|
234
|
+
if k in line:
|
|
235
|
+
key = k
|
|
236
|
+
break
|
|
237
|
+
else: # This else only executes if inner for loop completes
|
|
238
|
+
if key is not None and len(line.strip()) > 0:
|
|
239
|
+
data[key].append(line.strip())
|
|
240
|
+
|
|
241
|
+
return data
|
|
242
|
+
|
|
243
|
+
def add_node_info(self, parent: TreeNode, node_info: dict[str, list], expand=True):
|
|
244
|
+
parent.remove_children()
|
|
245
|
+
for key, value in node_info.items():
|
|
246
|
+
if len(value) > 0:
|
|
247
|
+
new_section = parent.add(Text(key, style="bold dark_turquoise"))
|
|
248
|
+
for v in value:
|
|
249
|
+
new_section.add_leaf(v)
|
|
250
|
+
new_section.expand()
|
|
251
|
+
# else:
|
|
252
|
+
# parent.add_leaf(f"No {key}")
|
|
253
|
+
|
|
254
|
+
def populate_node_info(self, selected_node: TreeNode):
|
|
255
|
+
node_name = selected_node.label
|
|
256
|
+
node_info = run_ros2_command(["ros2", "node", "info", str(node_name)])
|
|
257
|
+
self.log.info(node_info)
|
|
258
|
+
|
|
259
|
+
if not node_info or node_info[0].startswith("Error"):
|
|
260
|
+
selected_node.add_leaf(Text("Could not fetch node info", style="bold white on red"))
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
node_info_dict = self.parse_node_info(node_info)
|
|
264
|
+
self.add_node_info(selected_node, node_info_dict)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
async def on_tree_node_expanded(self, event: Tree.NodeExpanded[str]) -> None:
|
|
268
|
+
selected_node = event.node
|
|
269
|
+
# from remote_pdb import set_trace; set_trace(port=4444)
|
|
270
|
+
if str(selected_node.label) in self.nodes:
|
|
271
|
+
self.populate_node_info(selected_node)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class SearchBar(Widget):
|
|
275
|
+
def compose(self) -> ComposeResult:
|
|
276
|
+
# Horizontal layout for inline arrangement
|
|
277
|
+
with Horizontal():
|
|
278
|
+
yield Input(placeholder="Search...", id="search_input") #, compact=True)
|
|
279
|
+
|
|
280
|
+
# 'i' button (case insensitive)
|
|
281
|
+
yield ToggleButton("i", id="case_insensitive", tooltip="case insensitive", classes="search-button")
|
|
282
|
+
|
|
283
|
+
# 're' button (regex)
|
|
284
|
+
yield ToggleButton("re", id="regex_mode", tooltip="regex matching", classes="search-button")
|
|
285
|
+
|
|
286
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
287
|
+
|
|
288
|
+
self.log(f"Search submitted: {event.value}")
|
|
289
|
+
tree = self.screen.query_one("#main_tree", Ros2NodeTree)
|
|
290
|
+
tree.search_text = event.value
|
|
291
|
+
tree.filter_nodes()
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class Ros2TUI(App):
|
|
295
|
+
|
|
296
|
+
CSS_PATH = "app.css"
|
|
297
|
+
|
|
298
|
+
BINDINGS = [
|
|
299
|
+
("h", "help", "Help"),
|
|
300
|
+
("r", "refresh", "Refresh"),
|
|
301
|
+
]
|
|
302
|
+
|
|
303
|
+
def compose(self) -> ComposeResult:
|
|
304
|
+
yield Header()
|
|
305
|
+
yield SearchBar()
|
|
306
|
+
with Container(id="main"):
|
|
307
|
+
yield Ros2NodeTree(id="main_tree", label="root")
|
|
308
|
+
yield Footer()
|
|
309
|
+
|
|
310
|
+
def on_mount(self) -> None:
|
|
311
|
+
# Focus the main_tree widget on startup
|
|
312
|
+
self.query_one("#main_tree").focus()
|
|
313
|
+
|
|
314
|
+
def action_help(self):
|
|
315
|
+
self.console.log("Help requested")
|
|
316
|
+
self.push_screen(HelpScreen())
|
|
317
|
+
|
|
318
|
+
def action_refresh(self):
|
|
319
|
+
self.console.log("Refresh requested")
|
|
320
|
+
tree = self.screen.query_one("#main_tree", Ros2NodeTree)
|
|
321
|
+
tree.load_nodes()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
if __name__ == "__main__":
|
|
325
|
+
app = Ros2TUI()
|
|
326
|
+
app.run()
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## v2.0 - Complete Rewrite (Current)
|
|
4
|
+
|
|
5
|
+
### ✅ Completed
|
|
6
|
+
|
|
7
|
+
**Architecture Refactor:**
|
|
8
|
+
- Replaced subprocess-based CLI calls with native `rclpy` + `ros2*.api` integration
|
|
9
|
+
- Background ROS2 executor thread for non-blocking updates
|
|
10
|
+
- Clean entity abstraction layer supporting all ROS2 entity types
|
|
11
|
+
- Event-driven architecture with Textual messages
|
|
12
|
+
|
|
13
|
+
**Tree-Based Layout (like old ros2_explorer.py):**
|
|
14
|
+
- **Nodes view**: Expandable tree showing Publishers/Subscribers/Services/Actions under each node
|
|
15
|
+
- **Topics view**: Expandable tree showing Publishers/Subscribers under each topic
|
|
16
|
+
- **Services/Actions/Types views**: Flat list with namespace grouping
|
|
17
|
+
- Right panel shows detailed info for selected items
|
|
18
|
+
|
|
19
|
+
**Navigation:**
|
|
20
|
+
- Clickable links between entities (node → topic → message type)
|
|
21
|
+
- Browser-like history navigation (`b`/`f` keys for back/forward)
|
|
22
|
+
- 20-item history buffer
|
|
23
|
+
|
|
24
|
+
**Search:**
|
|
25
|
+
- Search bar with case-insensitive toggle (`i` button)
|
|
26
|
+
- Regex mode toggle (`re` button)
|
|
27
|
+
- Live filtering of tree lists
|
|
28
|
+
|
|
29
|
+
**View Switching:**
|
|
30
|
+
- `1` - Nodes view
|
|
31
|
+
- `2` - Topics view
|
|
32
|
+
- `3` - Services view
|
|
33
|
+
- `4` - Actions view
|
|
34
|
+
- `5` - Message Types view
|
|
35
|
+
|
|
36
|
+
**Auto-Refresh:**
|
|
37
|
+
- Entity lists refresh every 2 seconds (configurable via `--refresh-rate`)
|
|
38
|
+
- Burst refresh at startup (0s, 0.5s, 1.0s) for fast topic discovery
|
|
39
|
+
- Info panels update every 5 seconds
|
|
40
|
+
- Manual reload with `r` key
|
|
41
|
+
|
|
42
|
+
**Topic Watching:**
|
|
43
|
+
- `w` key to toggle watching a topic
|
|
44
|
+
- Watched topics show spinning propeller indicator (speed matches publishing rate)
|
|
45
|
+
- Propeller animation matches ros2 topic hz algorithm (1/mean(intervals), 10000 window)
|
|
46
|
+
- Watch mode automatically shows Hz statistics
|
|
47
|
+
- Watched topics use persistent subscriptions (reused for echo/hz/sample)
|
|
48
|
+
- Auto-watch topics from loaded profile when discovered
|
|
49
|
+
|
|
50
|
+
**Profile System:**
|
|
51
|
+
- Auto-save current state (watched topics + view) to default profile
|
|
52
|
+
- `p` key to save profile with custom name
|
|
53
|
+
- `--profile <name>` to load specific profile on startup
|
|
54
|
+
- `--continue` to resume last used profile
|
|
55
|
+
- Profiles stored in `~/.config/ros2_term/`
|
|
56
|
+
- Profile list shown in `--help` output
|
|
57
|
+
|
|
58
|
+
**Topic Data Inspection:**
|
|
59
|
+
- `h` key - Show Hz (publishing rate) for selected topic
|
|
60
|
+
- `e` key - Echo (continuous) messages from topic
|
|
61
|
+
- `s` key - Sample single message from topic
|
|
62
|
+
- `a` key - Toggle array visibility in echo/sample output
|
|
63
|
+
- Watched topics show Hz stats automatically
|
|
64
|
+
|
|
65
|
+
**UI Improvements:**
|
|
66
|
+
- In-place tree updates eliminate cursor flicker during refresh
|
|
67
|
+
- Tree expansion state preserved when switching views
|
|
68
|
+
- Path-based cursor tracking prevents jumping to wrong nodes
|
|
69
|
+
- `c` key - Collapse all top-level tree nodes
|
|
70
|
+
- `v` key - Toggle verbose mode (show/hide default ROS2 topics/services)
|
|
71
|
+
- Smooth propeller animation (0.1s update interval)
|
|
72
|
+
|
|
73
|
+
### 🚧 Pending Features
|
|
74
|
+
|
|
75
|
+
From original notes:
|
|
76
|
+
- Topic search in Node view with highlighting (Task #1)
|
|
77
|
+
- Collapsible services/actions sections with persistent state (Task #4)
|
|
78
|
+
- Topic echo screen with field selection (Task #2)
|
|
79
|
+
|
|
80
|
+
### 📁 File Structure
|
|
81
|
+
|
|
82
|
+
**New Files:**
|
|
83
|
+
- `app.py` - Main application (replaces `ros2_explorer.py`)
|
|
84
|
+
- `entity.py` - ROS2 entity types and info classes
|
|
85
|
+
- `ros2_client.py` - Native ROS2 client using rclpy
|
|
86
|
+
- `topic_watcher.py` - Topic watching system with Hz tracking and propeller animation
|
|
87
|
+
- `config.py` - Profile management (save/load watched topics and views)
|
|
88
|
+
- `widgets.py` - Reusable UI widgets (NodeTreePanel, TopicTreePanel, etc.)
|
|
89
|
+
- `messages.py` - Custom Textual messages for events
|
|
90
|
+
- `history.py` - Navigation history implementation
|
|
91
|
+
- `search_bar.py` - Search bar widget with toggles
|
|
92
|
+
- `profile_screen.py` - Modal screen for saving profiles
|
|
93
|
+
|
|
94
|
+
**Legacy Files (can be deleted):**
|
|
95
|
+
- `ros2_explorer.py` - Original implementation
|
|
96
|
+
- `multi_screen.py` - Multi-screen example
|
|
97
|
+
- `node_test.py` - rclpy test
|
|
98
|
+
- `screens.py` - Legacy screen definitions (no longer used)
|
|
99
|
+
|
|
100
|
+
**Config:**
|
|
101
|
+
- `app.css` - Textual CSS styling
|
|
102
|
+
|
|
103
|
+
### 🎯 Key Improvements
|
|
104
|
+
|
|
105
|
+
1. **Performance**: Native rclpy APIs are much faster than subprocess calls
|
|
106
|
+
2. **Flexibility**: Supports all ROS2 entity types, not just nodes
|
|
107
|
+
3. **Navigation**: Clickable links and history make exploration intuitive
|
|
108
|
+
4. **Filtering**: Real-time search with regex support
|
|
109
|
+
5. **Architecture**: Clean separation of concerns, reusable components
|
|
110
|
+
6. **Topic Monitoring**: Visual propeller indicators show topic activity at a glance
|
|
111
|
+
7. **State Persistence**: Profiles preserve your workflow across sessions
|
|
112
|
+
8. **Smooth UI**: In-place tree updates eliminate flicker and cursor jumping
|
|
113
|
+
|
|
114
|
+
### 🐛 Known Issues
|
|
115
|
+
|
|
116
|
+
None currently
|
|
117
|
+
|
|
118
|
+
### 📝 Notes
|
|
119
|
+
|
|
120
|
+
The layout now matches the old `ros2_explorer.py` style where:
|
|
121
|
+
- Nodes expand to show their publishers/subscribers/services/actions
|
|
122
|
+
- Topics expand to show their publishers/subscribers
|
|
123
|
+
- Selecting a topic/service in the tree shows its details in the right panel
|
|
124
|
+
- Selecting a node name under a topic shows that node's details
|
|
125
|
+
|
|
126
|
+
This combines the best of both worlds: the familiar tree interface with modern architecture and full ROS2 support.
|
ros2_term-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ros2-term
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A terminal UI for inspecting ROS2 topics and nodes
|
|
5
|
+
Author-email: Spencer Diehl <docdiehl@gmail.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Keywords: robotics,ros2,terminal,tui
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: numpy<2.0
|
|
20
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
21
|
+
Requires-Dist: textual>=8.2.3
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: black; extra == 'dev'
|
|
24
|
+
Requires-Dist: isort; extra == 'dev'
|
|
25
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# ROS2 Explorer TUI
|
|
29
|
+
|
|
30
|
+
A Terminal User Interface (TUI) app for exploring ROS2 nodes, topics, services, actions, and message types.
|
|
31
|
+
|
|
32
|
+
## Requirements
|
|
33
|
+
|
|
34
|
+
- ROS2 Humble, Iron, or Jazzy already installed on system
|
|
35
|
+
- pipx (`sudo apt install pipx`)
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
Source your ROS2 installation first, then install with pipx:
|
|
40
|
+
|
|
41
|
+
source /opt/ros/humble/setup.bash # or iron, jazzy, etc.
|
|
42
|
+
pipx install ros2-term
|
|
43
|
+
|
|
44
|
+
Then run:
|
|
45
|
+
|
|
46
|
+
ros2-term
|
|
47
|
+
|
|
48
|
+
**Note:** ROS2 must be sourced in any shell where you run `ros2-term`.
|
|
49
|
+
|
|
50
|
+
### Command Line Options
|
|
51
|
+
|
|
52
|
+
ros2-term [--profile NAME] [--continue] [--refresh-rate SECONDS]
|
|
53
|
+
|
|
54
|
+
- `--profile NAME` - Load specific profile on startup
|
|
55
|
+
- `--continue` - Resume last used profile
|
|
56
|
+
- `--refresh-rate SECONDS` - Set refresh interval (default: 2.0)
|
|
57
|
+
- `--help` - Show available profiles and config location
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
## Troubleshooting
|
|
61
|
+
|
|
62
|
+
If you have multiple Python versions installed and see import errors related
|
|
63
|
+
to ROS2 packages, force pipx to use the system Python explicitly:
|
|
64
|
+
|
|
65
|
+
pipx install ros2-term --python $(which python3)
|
|
66
|
+
|
|
67
|
+
## Features
|
|
68
|
+
|
|
69
|
+
- Tree views for nodes/topics with expandable details
|
|
70
|
+
- **Topic watching** (`w`) with spinning propeller showing publish rate
|
|
71
|
+
- **Topic inspection**: Hz (`h`), echo (`e`), sample (`s`)
|
|
72
|
+
- **Profile system**: Auto-save/load watched topics and views
|
|
73
|
+
- Clickable links between entities (node → topic → type)
|
|
74
|
+
- History navigation (`b`/`f`)
|
|
75
|
+
- Search with regex support
|
|
76
|
+
- Native `rclpy` integration (no subprocess overhead)
|
|
77
|
+
|
|
78
|
+
## Key Components
|
|
79
|
+
|
|
80
|
+
- `ros2_client.py` - Native `rclpy` integration with background executor
|
|
81
|
+
- `topic_watcher.py` - Hz tracking and propeller animation
|
|
82
|
+
- `config.py` - Profile persistence
|
|
83
|
+
- `widgets.py` - In-place tree updates (no flicker)
|
|
84
|
+
|
|
85
|
+
### Keyboard Shortcuts
|
|
86
|
+
|
|
87
|
+
| Key | Action | Visible in Footer |
|
|
88
|
+
|-----|--------|-------------------|
|
|
89
|
+
| `?` | Show help screen | ✓ |
|
|
90
|
+
| `w` | Watch/unwatch topic | ✓ |
|
|
91
|
+
| `h` | Show Hz for topic | ✓ |
|
|
92
|
+
| `e` | Echo topic (continuous) | ✓ |
|
|
93
|
+
| `s` | Sample topic (single msg) | ✓ |
|
|
94
|
+
| `a` | Toggle array visibility | ✓ |
|
|
95
|
+
| `r` | Reload current view | ✓ |
|
|
96
|
+
| `b` | Navigate back in history | |
|
|
97
|
+
| `f` | Navigate forward in history | |
|
|
98
|
+
| `v` | Toggle verbose mode | |
|
|
99
|
+
| `c` | Collapse all tree nodes | |
|
|
100
|
+
| `p` | Save profile | |
|
|
101
|
+
| `1` | View nodes | |
|
|
102
|
+
| `2` | View topics | |
|
|
103
|
+
| `3` | View services | |
|
|
104
|
+
| `4` | View actions | |
|
|
105
|
+
| `5` | View message types | |
|
|
106
|
+
| `q` | Quit | |
|
|
107
|
+
|
|
108
|
+
### Navigation
|
|
109
|
+
|
|
110
|
+
- Arrow keys navigate tree, Enter expands/collapses
|
|
111
|
+
- Click entity names in info panel to navigate
|
|
112
|
+
- `b`/`f` for history navigation
|
|
113
|
+
- Tree state preserved when switching views
|