lcm-cli 0.1.0__py3-none-any.whl
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.
- lcm_cli-0.1.0.dist-info/METADATA +254 -0
- lcm_cli-0.1.0.dist-info/RECORD +23 -0
- lcm_cli-0.1.0.dist-info/WHEEL +4 -0
- lcm_cli-0.1.0.dist-info/entry_points.txt +2 -0
- lcm_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- lcm_tools/__init__.py +3 -0
- lcm_tools/__main__.py +6 -0
- lcm_tools/cli.py +52 -0
- lcm_tools/commands/__init__.py +1 -0
- lcm_tools/commands/node_list.py +82 -0
- lcm_tools/commands/topic_echo.py +188 -0
- lcm_tools/commands/topic_list.py +69 -0
- lcm_tools/commands/topic_stats.py +87 -0
- lcm_tools/core/__init__.py +1 -0
- lcm_tools/core/discovery.py +135 -0
- lcm_tools/core/lcm_type_builder.py +577 -0
- lcm_tools/core/lcm_type_parser.py +515 -0
- lcm_tools/core/stats.py +182 -0
- lcm_tools/display/__init__.py +1 -0
- lcm_tools/display/echo_display.py +233 -0
- lcm_tools/display/stats_display.py +103 -0
- lcm_tools/listener.py +157 -0
- lcm_tools/protocol.py +172 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lcm-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: ROS2-like command line tools for LCM (Lightweight Communications and Marshalling)
|
|
5
|
+
Project-URL: Homepage, https://github.com/lcm-tools/lcm-tools
|
|
6
|
+
Project-URL: Repository, https://github.com/lcm-tools/lcm-tools
|
|
7
|
+
Project-URL: Issues, https://github.com/lcm-tools/lcm-tools/issues
|
|
8
|
+
Author: lcm-tools contributors
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: cli,debugging,lcm,middleware,multicast,robotics,ros2
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: MacOS
|
|
18
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
26
|
+
Classifier: Topic :: Scientific/Engineering
|
|
27
|
+
Classifier: Topic :: Software Development :: Embedded Systems
|
|
28
|
+
Classifier: Topic :: System :: Networking
|
|
29
|
+
Classifier: Topic :: Utilities
|
|
30
|
+
Requires-Python: >=3.9
|
|
31
|
+
Requires-Dist: rich>=13.0.0
|
|
32
|
+
Requires-Dist: typer>=0.12.0
|
|
33
|
+
Provides-Extra: decode
|
|
34
|
+
Requires-Dist: lcm>=1.5.0; extra == 'decode'
|
|
35
|
+
Provides-Extra: dev
|
|
36
|
+
Requires-Dist: pytest-timeout>=2.1; extra == 'dev'
|
|
37
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
|
|
40
|
+
# LCM CLI Tools
|
|
41
|
+
|
|
42
|
+
**[English](README.md)** | [中文](README_zh.md)
|
|
43
|
+
|
|
44
|
+
> ROS2-style command line tools for monitoring and debugging LCM (Lightweight Communications and Marshalling) networks.
|
|
45
|
+
|
|
46
|
+
## Features
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
lcm topic echo <channel> — View real-time topic data (like ros2 topic echo)
|
|
50
|
+
lcm topic list — List active topics/channels (like ros2 topic list)
|
|
51
|
+
lcm topic stats — Real-time topic stats: rate, bandwidth, msg count (like ros2 topic hz)
|
|
52
|
+
lcm node list — List discovered publisher nodes (like ros2 node list)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Highlight**: Built-in pure-Python `.lcm` file parser — no `lcm-gen` or `PYTHONPATH` needed. Just point to your `.lcm` files and messages are decoded automatically.
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# From PyPI
|
|
61
|
+
pip install lcm-tools
|
|
62
|
+
|
|
63
|
+
# From source (development)
|
|
64
|
+
git clone https://github.com/your-username/lcm-tools.git
|
|
65
|
+
cd lcm-tools
|
|
66
|
+
pip install -e .
|
|
67
|
+
|
|
68
|
+
# Optional: traditional lcm-gen Python package decode support
|
|
69
|
+
pip install lcm-tools[decode]
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**Requirements**: Python >= 3.9, `typer`, `rich` (`lcm` package is optional, only for legacy `--type module.Class` decoding).
|
|
73
|
+
|
|
74
|
+
## Quick Start
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# Show all subcommands
|
|
78
|
+
lcm --help
|
|
79
|
+
|
|
80
|
+
# List active channels (listens for 5 seconds)
|
|
81
|
+
lcm topic list
|
|
82
|
+
|
|
83
|
+
# View messages on a channel (raw hex format)
|
|
84
|
+
lcm topic echo EXAMPLE
|
|
85
|
+
|
|
86
|
+
# Receive only 10 messages
|
|
87
|
+
lcm topic echo EXAMPLE -n 10
|
|
88
|
+
|
|
89
|
+
# Match multiple channels with regex
|
|
90
|
+
lcm topic echo "CAM.*"
|
|
91
|
+
|
|
92
|
+
# Monitor real-time statistics for all channels
|
|
93
|
+
lcm topic stats
|
|
94
|
+
|
|
95
|
+
# Monitor a specific channel only
|
|
96
|
+
lcm topic stats CAMERA
|
|
97
|
+
|
|
98
|
+
# List discovered publisher nodes
|
|
99
|
+
lcm node list
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Message Decoding
|
|
103
|
+
|
|
104
|
+
### Method 1: Specify `.lcm` files directly (recommended)
|
|
105
|
+
|
|
106
|
+
No `lcm-gen` installation, no `PYTHONPATH` configuration. The tool includes a built-in pure-Python parser:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# Specify a single .lcm file — auto-matches message type by fingerprint
|
|
110
|
+
lcm topic echo EXAMPLE --lcm-file types/example_t.lcm
|
|
111
|
+
|
|
112
|
+
# Specify a directory (recursively scans all .lcm files)
|
|
113
|
+
lcm topic echo EXAMPLE -f types/
|
|
114
|
+
|
|
115
|
+
# Specify multiple paths
|
|
116
|
+
lcm topic echo EXAMPLE -f types/ -f extra_types/
|
|
117
|
+
|
|
118
|
+
# Specify a concrete type name (when .lcm files contain multiple structs)
|
|
119
|
+
lcm topic echo EXAMPLE -f types/ --type example_t
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Supports the complete LCM type system:
|
|
123
|
+
- All primitive types (`int8_t` ~ `int64_t`, `float`, `double`, `string`, `boolean`, `byte`)
|
|
124
|
+
- Fixed-length and variable-length arrays (`double position[3]`, `int16_t ranges[num_ranges]`)
|
|
125
|
+
- Multi-dimensional arrays (`int32_t data[size_a][size_b][size_c]`)
|
|
126
|
+
- Nested structs and cross-file type references
|
|
127
|
+
- Recursive types (e.g., `node_t children[n]` in a linked-list `node_t`)
|
|
128
|
+
- Constant declarations (`const int32_t MAX_SIZE = 100`)
|
|
129
|
+
|
|
130
|
+
**How it works**: Parses `.lcm` files → builds decode classes in memory (`type()` dynamic creation) → auto-matches by the first 8-byte fingerprint of the payload → decodes and recursively expands nested structs. No files are generated at any point.
|
|
131
|
+
|
|
132
|
+
### Method 2: Traditional `lcm-gen` generated files
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
# Install the lcm Python package
|
|
136
|
+
pip install lcm-tools[decode]
|
|
137
|
+
|
|
138
|
+
# Generate Python files with lcm-gen, then configure PYTHONPATH
|
|
139
|
+
lcm-gen --python -d types/ types/example_t.lcm
|
|
140
|
+
export PYTHONPATH=types:$PYTHONPATH
|
|
141
|
+
|
|
142
|
+
# Use --type to specify the decode class (module.Class format)
|
|
143
|
+
lcm topic echo EXAMPLE --type exlcm.example_t
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Custom Multicast Address
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
lcm topic list --lcm-url 239.255.76.68 --lcm-port 7668
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Statistics
|
|
153
|
+
|
|
154
|
+
| Metric | Description |
|
|
155
|
+
|--------|-------------|
|
|
156
|
+
| Rate (Hz) | Message frequency within a sliding window (last 2000 messages) |
|
|
157
|
+
| BW (KB/s) | Bandwidth within the sliding window |
|
|
158
|
+
| Avg Size (B) | Average bytes per message |
|
|
159
|
+
| Total (KB) | Cumulative total transferred |
|
|
160
|
+
|
|
161
|
+
## Architecture
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
┌───────────────────────────────────────────────────┐
|
|
165
|
+
│ CLI Layer (Typer) │
|
|
166
|
+
│ topic echo │ topic list │ topic stats │ node │
|
|
167
|
+
├───────────────────────────────────────────────────┤
|
|
168
|
+
│ Display Layer (Rich Panel) │
|
|
169
|
+
│ recursive nesting │ hex dump │ stats table │
|
|
170
|
+
├───────────────────────────────────────────────────┤
|
|
171
|
+
│ Type Parsing Layer (Pure Python) │
|
|
172
|
+
│ .lcm parse → AST → fingerprint → dynamic class │
|
|
173
|
+
├───────────────────────────────────────────────────┤
|
|
174
|
+
│ Protocol Layer (Raw UDP Socket) │
|
|
175
|
+
│ LCM Wire Protocol parsing (zero deps) │
|
|
176
|
+
├───────────────────────────────────────────────────┤
|
|
177
|
+
│ UDP Multicast (239.255.76.67) │
|
|
178
|
+
└───────────────────────────────────────────────────┘
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
- **Zero external LCM dependency**: Core functionality directly parses the LCM wire protocol from UDP multicast packets
|
|
182
|
+
- **Built-in type parsing**: Pure-Python `.lcm` file parser + runtime decode class generator
|
|
183
|
+
- **Node discovery**: Infers different publishers from UDP packet source IP:port
|
|
184
|
+
- **Legacy decode compatible**: Optional `lcm` Python package for `--type module.Class` decoding
|
|
185
|
+
|
|
186
|
+
## LCM Protocol
|
|
187
|
+
|
|
188
|
+
LCM uses UDP multicast for communication (default `239.255.76.67:7667`).
|
|
189
|
+
|
|
190
|
+
**Short messages** (< 64KB): 8-byte header (magic=0x4c433032 + seqno) + channel name (null-terminated) + payload
|
|
191
|
+
|
|
192
|
+
**Fragmented messages**: 20-byte header (magic=0x4c433033 + seqno + payload_size + fragment_offset + fragment_no + n_fragments)
|
|
193
|
+
|
|
194
|
+
Reference: [LCM UDP Multicast Protocol](https://lcm-proj.github.io/lcm/content/udp-multicast-protocol.html)
|
|
195
|
+
|
|
196
|
+
## LCM vs ROS2 Concepts
|
|
197
|
+
|
|
198
|
+
| LCM Concept | ROS2 Equivalent | Description |
|
|
199
|
+
|-------------|----------------|-------------|
|
|
200
|
+
| Channel | Topic | Message publish/subscribe conduit |
|
|
201
|
+
| UDP (IP:port) | Node | LCM has no native node concept; inferred from publisher address |
|
|
202
|
+
| Fingerprint | Message Type Hash | Unique identifier for a message type |
|
|
203
|
+
|
|
204
|
+
## Project Structure
|
|
205
|
+
|
|
206
|
+
```
|
|
207
|
+
src/lcm_tools/
|
|
208
|
+
├── cli.py # Typer entry point, registers subcommands
|
|
209
|
+
├── commands/
|
|
210
|
+
│ ├── topic_echo.py # lcm topic echo
|
|
211
|
+
│ ├── topic_list.py # lcm topic list
|
|
212
|
+
│ ├── topic_stats.py # lcm topic stats
|
|
213
|
+
│ └── node_list.py # lcm node list
|
|
214
|
+
├── core/
|
|
215
|
+
│ ├── discovery.py # Passive channel/node discovery
|
|
216
|
+
│ ├── stats.py # Real-time statistics (rate, bandwidth)
|
|
217
|
+
│ ├── lcm_type_parser.py # .lcm file parser + fingerprint algorithm
|
|
218
|
+
│ └── lcm_type_builder.py # Runtime decode class generation + TypeRegistry
|
|
219
|
+
├── display/
|
|
220
|
+
│ ├── echo_display.py # Rich panel display (with recursive nesting)
|
|
221
|
+
│ └── stats_display.py # Statistics table display
|
|
222
|
+
├── listener.py # UDP multicast listener thread
|
|
223
|
+
└── protocol.py # LCM Wire Protocol parser
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Testing
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
pip install -e ".[dev]"
|
|
230
|
+
pytest tests/ -v
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## Network Configuration
|
|
234
|
+
|
|
235
|
+
If you can't receive messages, check your multicast routing:
|
|
236
|
+
|
|
237
|
+
**macOS:**
|
|
238
|
+
```bash
|
|
239
|
+
# View multicast routes
|
|
240
|
+
netstat -rn | grep 239
|
|
241
|
+
|
|
242
|
+
# Add route if needed
|
|
243
|
+
sudo route add -net 239.255.76.0/24 -interface en0
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
**Linux:**
|
|
247
|
+
```bash
|
|
248
|
+
# Add route
|
|
249
|
+
sudo ip route add 239.255.76.0/24 dev eth0
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## License
|
|
253
|
+
|
|
254
|
+
MIT
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
lcm_tools/__init__.py,sha256=MJTza6au1FnWncO_lPNRfJUkY4zLBxoD0gr0PEqh4y4,94
|
|
2
|
+
lcm_tools/__main__.py,sha256=rL0qCqIX2Z8dOUd11dtX7zP-z_RWpbMQQUvLuI_1kcY,125
|
|
3
|
+
lcm_tools/cli.py,sha256=3Y-fYc1pTyXEF6dBodxE4-GV78qwxVed5KDnT51Pe2U,1860
|
|
4
|
+
lcm_tools/listener.py,sha256=t7jKX2VNQL5XKcHrJ6EqFqGr2wwIXFjX_j0GUx8uQuE,4710
|
|
5
|
+
lcm_tools/protocol.py,sha256=ULXe0e5UchGV6uLJCIIuLmu76pPPj1AAmP5TCswMMaM,4833
|
|
6
|
+
lcm_tools/commands/__init__.py,sha256=OKM15hJi770MsQWgSzoHam09SmQlma-q5e5-hZsnzDc,32
|
|
7
|
+
lcm_tools/commands/node_list.py,sha256=A8Oi9VwbDUxRENx_f0wBE_9N-AAD-T6AudBVO97hlvc,2362
|
|
8
|
+
lcm_tools/commands/topic_echo.py,sha256=Xxi4e9uCOad6zBFSqB--sr_PxvMg13cWPzBdEHbWRPU,5822
|
|
9
|
+
lcm_tools/commands/topic_list.py,sha256=AZzSRPjSAkyA8xSRfMBM4QEDhknnLqOJ-7-7WkiS9fk,1922
|
|
10
|
+
lcm_tools/commands/topic_stats.py,sha256=6sFzzT_R5OjvKTDfJVEIe0ZdUQbXiFK04ocf2TmxUo4,2430
|
|
11
|
+
lcm_tools/core/__init__.py,sha256=SSWlrq864vGN21KxHwIffre1Fa7jEoN-tCJfV0C7syI,32
|
|
12
|
+
lcm_tools/core/discovery.py,sha256=lxTOuAoZCHeV0TKXQTtw2y8yElJGyotPdiK70RVwXQg,4537
|
|
13
|
+
lcm_tools/core/lcm_type_builder.py,sha256=TXs-NV-8_CKSPpIeHT8ZRwBk4c6yu7Fngay29EyNmNw,19811
|
|
14
|
+
lcm_tools/core/lcm_type_parser.py,sha256=xGlObzmrLoD00dbnoJe5BSIMWZgHbKfQYZaEgNAHVKI,16924
|
|
15
|
+
lcm_tools/core/stats.py,sha256=2xxXhIEqR-omfRvQWU05FsFl8HC22HOK5p67U5oHel0,5622
|
|
16
|
+
lcm_tools/display/__init__.py,sha256=eAGGFoWZ9yeriISW8mu8ocAB5hfS00-MNPwNVo7QxZQ,35
|
|
17
|
+
lcm_tools/display/echo_display.py,sha256=8muEjvXcyGuetrm71-TX6ReY5fEBm3oHn1fj8SejtDQ,7823
|
|
18
|
+
lcm_tools/display/stats_display.py,sha256=Km142prR5qO6ZhOBs43lf4VPN8P0obhFQaVfryZRSb4,3307
|
|
19
|
+
lcm_cli-0.1.0.dist-info/METADATA,sha256=0LzvX5XC-WBlmYNmSC8Qxq1foERSGgkTxo2BPS6J_50,9490
|
|
20
|
+
lcm_cli-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
21
|
+
lcm_cli-0.1.0.dist-info/entry_points.txt,sha256=r3ZNR7GWTY7gm7zd4kUlyQvz3rwAyZgcowufTWbnglQ,42
|
|
22
|
+
lcm_cli-0.1.0.dist-info/licenses/LICENSE,sha256=fzMYOaWB2zTO3LgLDAcKCA_ATJE8qGROhvCSUOXShsw,1079
|
|
23
|
+
lcm_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 lcm-tools contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
lcm_tools/__init__.py
ADDED
lcm_tools/__main__.py
ADDED
lcm_tools/cli.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""LCM CLI - ROS2-like command line tools for LCM middleware.
|
|
2
|
+
|
|
3
|
+
Provides the ``lcm`` command with subcommands:
|
|
4
|
+
lcm topic echo <channel> — View real-time topic data
|
|
5
|
+
lcm topic list — List active topics (channels)
|
|
6
|
+
lcm topic stats — Monitor topic statistics
|
|
7
|
+
lcm node list — List discovered publisher nodes
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from lcm_tools.commands.node_list import node_app
|
|
17
|
+
from lcm_tools.commands.topic_echo import echo
|
|
18
|
+
from lcm_tools.commands.topic_list import list_channels
|
|
19
|
+
from lcm_tools.commands.topic_stats import stats
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Root application
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
app = typer.Typer(
|
|
25
|
+
name="lcm",
|
|
26
|
+
help="LCM command line tools — inspect and monitor LCM networks.\n\n"
|
|
27
|
+
"Similar to ROS2 CLI tools (ros2 topic echo, ros2 node list, etc.) "
|
|
28
|
+
"but for LCM (Lightweight Communications and Marshalling).",
|
|
29
|
+
no_args_is_help=True,
|
|
30
|
+
add_completion=False,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Topic subcommand group
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
topic_app = typer.Typer(
|
|
37
|
+
help="Inspect and monitor LCM topics (channels).",
|
|
38
|
+
no_args_is_help=True,
|
|
39
|
+
)
|
|
40
|
+
app.add_typer(topic_app, name="topic")
|
|
41
|
+
|
|
42
|
+
# Register topic subcommands
|
|
43
|
+
topic_app.command(name="echo", help="Echo messages on a channel.")(echo)
|
|
44
|
+
topic_app.command(name="list", help="List active channels.")(list_channels)
|
|
45
|
+
topic_app.command(name="stats", help="Show real-time channel statistics.")(stats)
|
|
46
|
+
|
|
47
|
+
# Node subcommand group is imported as a Typer app and attached directly
|
|
48
|
+
app.add_typer(node_app, name="node")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
app()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""LCM CLI commands package."""
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""``lcm node list`` — discover and list publisher nodes.
|
|
2
|
+
|
|
3
|
+
LCM has no native "node name" concept (unlike ROS2). This command
|
|
4
|
+
identifies publisher processes by their UDP source address (IP:port),
|
|
5
|
+
grouped with the set of channels each has published to.
|
|
6
|
+
|
|
7
|
+
This is a best-effort inference based on the multicast traffic that
|
|
8
|
+
reaches this host within a configurable listening window.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
|
|
18
|
+
from lcm_tools.core.discovery import ChannelDiscovery
|
|
19
|
+
from lcm_tools.display.stats_display import build_node_table
|
|
20
|
+
from lcm_tools.listener import run_listener
|
|
21
|
+
from lcm_tools.protocol import DEFAULT_MC_ADDR, DEFAULT_MC_PORT
|
|
22
|
+
|
|
23
|
+
_console = Console()
|
|
24
|
+
|
|
25
|
+
# Typer sub-app for `lcm node`
|
|
26
|
+
node_app = typer.Typer(
|
|
27
|
+
help="Inspect LCM publisher nodes (inferred from UDP source address).",
|
|
28
|
+
no_args_is_help=True,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@node_app.command(name="list")
|
|
33
|
+
def list_nodes(
|
|
34
|
+
duration: float = typer.Option(
|
|
35
|
+
5.0,
|
|
36
|
+
"--duration",
|
|
37
|
+
"-d",
|
|
38
|
+
help="How many seconds to listen for node activity.",
|
|
39
|
+
),
|
|
40
|
+
lcm_url: str = typer.Option(
|
|
41
|
+
DEFAULT_MC_ADDR,
|
|
42
|
+
"--lcm-url",
|
|
43
|
+
help="LCM multicast address.",
|
|
44
|
+
),
|
|
45
|
+
lcm_port: int = typer.Option(
|
|
46
|
+
DEFAULT_MC_PORT,
|
|
47
|
+
"--lcm-port",
|
|
48
|
+
help="LCM multicast port.",
|
|
49
|
+
),
|
|
50
|
+
) -> None:
|
|
51
|
+
"""List discovered publisher nodes (like ``ros2 node list``).
|
|
52
|
+
|
|
53
|
+
Note: LCM does not have a native "node name" concept. Nodes are
|
|
54
|
+
identified here by their UDP source IP:port, which corresponds to
|
|
55
|
+
the publisher's network interface.
|
|
56
|
+
"""
|
|
57
|
+
discovery = ChannelDiscovery()
|
|
58
|
+
|
|
59
|
+
_console.print(
|
|
60
|
+
f"[bold]Discovering nodes for {duration}s ...[/bold] "
|
|
61
|
+
f"(multicast: {lcm_url}:{lcm_port})"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
stop_event = run_listener(discovery.on_packet, mc_addr=lcm_url, mc_port=lcm_port)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
time.sleep(duration)
|
|
68
|
+
except KeyboardInterrupt:
|
|
69
|
+
pass
|
|
70
|
+
finally:
|
|
71
|
+
stop_event.set()
|
|
72
|
+
|
|
73
|
+
nodes = discovery.get_nodes(stale_after=duration + 2.0)
|
|
74
|
+
if not nodes:
|
|
75
|
+
_console.print("[yellow]No publisher nodes found.[/yellow]")
|
|
76
|
+
_console.print(
|
|
77
|
+
"[dim]Hint: make sure a publisher is running and your "
|
|
78
|
+
"multicast routing is configured.[/dim]"
|
|
79
|
+
)
|
|
80
|
+
raise typer.Exit(code=0)
|
|
81
|
+
|
|
82
|
+
_console.print(build_node_table(nodes))
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""``lcm topic echo`` — echo messages received on a channel.
|
|
2
|
+
|
|
3
|
+
Listens on the LCM multicast group and prints every message matching
|
|
4
|
+
the given channel name (or pattern).
|
|
5
|
+
|
|
6
|
+
Supports four display modes:
|
|
7
|
+
- **default**: Rich panel with hex dump and metadata
|
|
8
|
+
- **--raw**: compact one-line-per-message text
|
|
9
|
+
- **--type module.Class**: decode payload with an lcm-gen generated class
|
|
10
|
+
- **--lcm-file path.lcm**: auto-decode from .lcm file definitions
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import queue
|
|
16
|
+
import re
|
|
17
|
+
import signal
|
|
18
|
+
import sys
|
|
19
|
+
import threading
|
|
20
|
+
from typing import Any, List, Optional
|
|
21
|
+
|
|
22
|
+
import typer
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
|
|
25
|
+
from lcm_tools.display.echo_display import (
|
|
26
|
+
echo_packet_auto_decode,
|
|
27
|
+
echo_packet_decoded,
|
|
28
|
+
echo_packet_default,
|
|
29
|
+
echo_packet_raw,
|
|
30
|
+
load_decode_class,
|
|
31
|
+
)
|
|
32
|
+
from lcm_tools.listener import run_listener
|
|
33
|
+
from lcm_tools.protocol import DEFAULT_MC_ADDR, DEFAULT_MC_PORT, PacketInfo
|
|
34
|
+
|
|
35
|
+
_console = Console()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def echo(
|
|
39
|
+
channel: str = typer.Argument(
|
|
40
|
+
...,
|
|
41
|
+
help="Channel name to listen on. Use a regex pattern to match "
|
|
42
|
+
"multiple channels (e.g. 'CAM.*').",
|
|
43
|
+
),
|
|
44
|
+
count: Optional[int] = typer.Option(
|
|
45
|
+
None,
|
|
46
|
+
"--count",
|
|
47
|
+
"-n",
|
|
48
|
+
help="Stop after receiving this many messages.",
|
|
49
|
+
),
|
|
50
|
+
timeout: Optional[float] = typer.Option(
|
|
51
|
+
None,
|
|
52
|
+
"--timeout",
|
|
53
|
+
"-t",
|
|
54
|
+
help="Stop after this many seconds with no matching messages.",
|
|
55
|
+
),
|
|
56
|
+
raw: bool = typer.Option(
|
|
57
|
+
False,
|
|
58
|
+
"--raw",
|
|
59
|
+
help="Compact raw-text output (suitable for piping).",
|
|
60
|
+
),
|
|
61
|
+
type_path: Optional[str] = typer.Option(
|
|
62
|
+
None,
|
|
63
|
+
"--type",
|
|
64
|
+
help="lcm-gen type for decoding, e.g. 'exlcm.example_t'. "
|
|
65
|
+
"With --lcm-file, use just the struct name (e.g. 'example_t').",
|
|
66
|
+
),
|
|
67
|
+
lcm_files: Optional[List[str]] = typer.Option(
|
|
68
|
+
None,
|
|
69
|
+
"--lcm-file",
|
|
70
|
+
"-f",
|
|
71
|
+
help="Path to .lcm file or directory containing .lcm files. "
|
|
72
|
+
"Can be specified multiple times. Enables auto-decode without lcm-gen.",
|
|
73
|
+
),
|
|
74
|
+
lcm_url: str = typer.Option(
|
|
75
|
+
DEFAULT_MC_ADDR,
|
|
76
|
+
"--lcm-url",
|
|
77
|
+
help="LCM multicast address.",
|
|
78
|
+
),
|
|
79
|
+
lcm_port: int = typer.Option(
|
|
80
|
+
DEFAULT_MC_PORT,
|
|
81
|
+
"--lcm-port",
|
|
82
|
+
help="LCM multicast port.",
|
|
83
|
+
),
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Echo messages on an LCM channel (like ``ros2 topic echo``)."""
|
|
86
|
+
# Compile channel filter
|
|
87
|
+
try:
|
|
88
|
+
pattern = re.compile(channel)
|
|
89
|
+
except re.error as exc:
|
|
90
|
+
_console.print(f"[red]Invalid regex pattern:[/red] {exc}")
|
|
91
|
+
raise typer.Exit(code=1)
|
|
92
|
+
|
|
93
|
+
# Build TypeRegistry from --lcm-file if provided
|
|
94
|
+
type_registry: Any = None
|
|
95
|
+
if lcm_files:
|
|
96
|
+
try:
|
|
97
|
+
from lcm_tools.core.lcm_type_builder import TypeRegistry
|
|
98
|
+
|
|
99
|
+
type_registry = TypeRegistry()
|
|
100
|
+
type_registry.register_paths(lcm_files)
|
|
101
|
+
n_types = len(type_registry.all_types)
|
|
102
|
+
_console.print(
|
|
103
|
+
f"[green]Loaded {n_types} type(s) from "
|
|
104
|
+
f"{len(lcm_files)} LCM file path(s).[/green]"
|
|
105
|
+
)
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
_console.print(f"[red]Failed to load LCM files:[/red] {exc}")
|
|
108
|
+
raise typer.Exit(code=1)
|
|
109
|
+
|
|
110
|
+
# Resolve decode class
|
|
111
|
+
decode_cls: Any = None
|
|
112
|
+
if type_path:
|
|
113
|
+
if type_registry is not None:
|
|
114
|
+
# Look up from registry (--lcm-file + --type)
|
|
115
|
+
decode_cls = type_registry.find_by_name(type_path)
|
|
116
|
+
if decode_cls is None:
|
|
117
|
+
available = ", ".join(sorted(type_registry.all_types.keys()))
|
|
118
|
+
_console.print(
|
|
119
|
+
f"[red]Type '{type_path}' not found in LCM files.[/red]\n"
|
|
120
|
+
f"Available: {available}"
|
|
121
|
+
)
|
|
122
|
+
raise typer.Exit(code=1)
|
|
123
|
+
else:
|
|
124
|
+
# Traditional: import from PYTHONPATH
|
|
125
|
+
try:
|
|
126
|
+
decode_cls = load_decode_class(type_path)
|
|
127
|
+
except Exception as exc:
|
|
128
|
+
_console.print(f"[red]Failed to load type '{type_path}':[/red] {exc}")
|
|
129
|
+
raise typer.Exit(code=1)
|
|
130
|
+
|
|
131
|
+
# Thread-safe queue bridging the listener thread → main display thread
|
|
132
|
+
pkt_queue: "queue.Queue[Optional[PacketInfo]]" = queue.Queue(maxsize=5000)
|
|
133
|
+
|
|
134
|
+
def _on_packet(pkt: PacketInfo) -> None:
|
|
135
|
+
if pkt.has_channel and pattern.search(pkt.channel): # type: ignore[arg-type]
|
|
136
|
+
try:
|
|
137
|
+
pkt_queue.put_nowait(pkt)
|
|
138
|
+
except queue.Full:
|
|
139
|
+
pass # drop oldest if producer outpaces display
|
|
140
|
+
|
|
141
|
+
stop_event = run_listener(
|
|
142
|
+
_on_packet,
|
|
143
|
+
mc_addr=lcm_url,
|
|
144
|
+
mc_port=lcm_port,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
_console.print(
|
|
148
|
+
f"[bold]Listening on '{channel}' ...[/bold] "
|
|
149
|
+
f"(multicast: {lcm_url}:{lcm_port}, Ctrl+C to stop)"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
received = 0
|
|
153
|
+
import time
|
|
154
|
+
|
|
155
|
+
last_match_time = time.monotonic()
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
while True:
|
|
159
|
+
try:
|
|
160
|
+
pkt = pkt_queue.get(timeout=0.3)
|
|
161
|
+
except queue.Empty:
|
|
162
|
+
if timeout and (time.monotonic() - last_match_time) >= timeout:
|
|
163
|
+
break
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
if pkt is None:
|
|
167
|
+
break
|
|
168
|
+
|
|
169
|
+
received += 1
|
|
170
|
+
last_match_time = time.monotonic()
|
|
171
|
+
|
|
172
|
+
if raw:
|
|
173
|
+
echo_packet_raw(pkt, received)
|
|
174
|
+
elif decode_cls:
|
|
175
|
+
echo_packet_decoded(pkt, received, decode_cls)
|
|
176
|
+
elif type_registry is not None:
|
|
177
|
+
echo_packet_auto_decode(pkt, received, type_registry)
|
|
178
|
+
else:
|
|
179
|
+
echo_packet_default(pkt, received)
|
|
180
|
+
|
|
181
|
+
if count and received >= count:
|
|
182
|
+
break
|
|
183
|
+
|
|
184
|
+
except KeyboardInterrupt:
|
|
185
|
+
pass
|
|
186
|
+
finally:
|
|
187
|
+
stop_event.set()
|
|
188
|
+
_console.print(f"\n[dim]Received {received} message(s).[/dim]")
|