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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lcm = lcm_tools.cli:app
@@ -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
@@ -0,0 +1,3 @@
1
+ """LCM CLI tools - ROS2-like command line tools for LCM middleware."""
2
+
3
+ __version__ = "0.1.0"
lcm_tools/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running lcm_tools as `python -m lcm_tools`."""
2
+
3
+ from lcm_tools.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
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]")