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,69 @@
|
|
|
1
|
+
"""``lcm topic list`` — discover and list active LCM channels.
|
|
2
|
+
|
|
3
|
+
Joins the LCM multicast group, listens for a configurable duration,
|
|
4
|
+
and prints a table of all observed channels with their message counts,
|
|
5
|
+
total sizes, and publisher addresses.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
|
|
17
|
+
from lcm_tools.core.discovery import ChannelDiscovery
|
|
18
|
+
from lcm_tools.display.stats_display import build_channel_table
|
|
19
|
+
from lcm_tools.listener import run_listener
|
|
20
|
+
from lcm_tools.protocol import DEFAULT_MC_ADDR, DEFAULT_MC_PORT
|
|
21
|
+
|
|
22
|
+
_console = Console()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def list_channels(
|
|
26
|
+
duration: float = typer.Option(
|
|
27
|
+
5.0,
|
|
28
|
+
"--duration",
|
|
29
|
+
"-d",
|
|
30
|
+
help="How many seconds to listen for channel activity.",
|
|
31
|
+
),
|
|
32
|
+
lcm_url: str = typer.Option(
|
|
33
|
+
DEFAULT_MC_ADDR,
|
|
34
|
+
"--lcm-url",
|
|
35
|
+
help="LCM multicast address.",
|
|
36
|
+
),
|
|
37
|
+
lcm_port: int = typer.Option(
|
|
38
|
+
DEFAULT_MC_PORT,
|
|
39
|
+
"--lcm-port",
|
|
40
|
+
help="LCM multicast port.",
|
|
41
|
+
),
|
|
42
|
+
) -> None:
|
|
43
|
+
"""List active LCM channels (like ``ros2 topic list``)."""
|
|
44
|
+
discovery = ChannelDiscovery()
|
|
45
|
+
|
|
46
|
+
_console.print(
|
|
47
|
+
f"[bold]Discovering channels for {duration}s ...[/bold] "
|
|
48
|
+
f"(multicast: {lcm_url}:{lcm_port})"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
stop_event = run_listener(discovery.on_packet, mc_addr=lcm_url, mc_port=lcm_port)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
time.sleep(duration)
|
|
55
|
+
except KeyboardInterrupt:
|
|
56
|
+
pass
|
|
57
|
+
finally:
|
|
58
|
+
stop_event.set()
|
|
59
|
+
|
|
60
|
+
channels = discovery.get_active_channels(stale_after=duration + 2.0)
|
|
61
|
+
if not channels:
|
|
62
|
+
_console.print("[yellow]No active channels found.[/yellow]")
|
|
63
|
+
_console.print(
|
|
64
|
+
"[dim]Hint: make sure a publisher is running and your "
|
|
65
|
+
"multicast routing is configured.[/dim]"
|
|
66
|
+
)
|
|
67
|
+
raise typer.Exit(code=0)
|
|
68
|
+
|
|
69
|
+
_console.print(build_channel_table(channels))
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""``lcm topic stats`` — real-time per-channel statistics monitor.
|
|
2
|
+
|
|
3
|
+
Joins the LCM multicast group and displays a continuously updating
|
|
4
|
+
table of message frequency, bandwidth, message sizes, and cumulative
|
|
5
|
+
data transfer for each observed channel.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.live import Live
|
|
17
|
+
|
|
18
|
+
from lcm_tools.core.stats import StatsCollector
|
|
19
|
+
from lcm_tools.display.stats_display import build_stats_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
|
+
|
|
26
|
+
def stats(
|
|
27
|
+
channel: Optional[str] = typer.Argument(
|
|
28
|
+
None,
|
|
29
|
+
help="Only monitor channels whose name contains this string. "
|
|
30
|
+
"Leave empty to monitor all channels.",
|
|
31
|
+
),
|
|
32
|
+
duration: Optional[float] = typer.Option(
|
|
33
|
+
None,
|
|
34
|
+
"--duration",
|
|
35
|
+
"-d",
|
|
36
|
+
help="Stop after this many seconds. "
|
|
37
|
+
"Default: run until Ctrl+C.",
|
|
38
|
+
),
|
|
39
|
+
lcm_url: str = typer.Option(
|
|
40
|
+
DEFAULT_MC_ADDR,
|
|
41
|
+
"--lcm-url",
|
|
42
|
+
help="LCM multicast address.",
|
|
43
|
+
),
|
|
44
|
+
lcm_port: int = typer.Option(
|
|
45
|
+
DEFAULT_MC_PORT,
|
|
46
|
+
"--lcm-port",
|
|
47
|
+
help="LCM multicast port.",
|
|
48
|
+
),
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Show real-time channel statistics (like ``ros2 topic hz``)."""
|
|
51
|
+
collector = StatsCollector(channel_filter=channel)
|
|
52
|
+
|
|
53
|
+
filter_label = f"matching '{channel}'" if channel else "all"
|
|
54
|
+
_console.print(
|
|
55
|
+
f"[bold]Collecting stats for {filter_label} channels ...[/bold] "
|
|
56
|
+
f"(multicast: {lcm_url}:{lcm_port}, Ctrl+C to stop)"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
stop_event = run_listener(
|
|
60
|
+
collector.on_packet,
|
|
61
|
+
mc_addr=lcm_url,
|
|
62
|
+
mc_port=lcm_port,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
start_time = time.monotonic()
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
with Live(
|
|
69
|
+
build_stats_table(collector.snapshot()),
|
|
70
|
+
console=_console,
|
|
71
|
+
refresh_per_second=2,
|
|
72
|
+
) as live:
|
|
73
|
+
while True:
|
|
74
|
+
time.sleep(0.5)
|
|
75
|
+
live.update(build_stats_table(collector.snapshot()))
|
|
76
|
+
|
|
77
|
+
if duration and (time.monotonic() - start_time) >= duration:
|
|
78
|
+
break
|
|
79
|
+
|
|
80
|
+
except KeyboardInterrupt:
|
|
81
|
+
pass
|
|
82
|
+
finally:
|
|
83
|
+
stop_event.set()
|
|
84
|
+
|
|
85
|
+
# Print final snapshot
|
|
86
|
+
_console.print("\n[bold]Final Statistics:[/bold]")
|
|
87
|
+
_console.print(build_stats_table(collector.snapshot()))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""LCM core modules package."""
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Channel and node discovery by sniffing LCM multicast traffic.
|
|
2
|
+
|
|
3
|
+
LCM has no centralized registry or daemon, so there is no built-in
|
|
4
|
+
mechanism to discover active channels or publishers. This module
|
|
5
|
+
implements passive discovery by listening to all multicast traffic
|
|
6
|
+
and recording metadata about each observed channel and sender.
|
|
7
|
+
|
|
8
|
+
Nodes are identified by their UDP source address (IP:port), since
|
|
9
|
+
LCM does not have a native "node name" concept like ROS2.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from collections import defaultdict
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
19
|
+
|
|
20
|
+
from lcm_tools.protocol import PacketInfo, extract_fingerprint
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ChannelInfo:
|
|
25
|
+
"""Metadata about a single observed LCM channel."""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
first_seen: float = 0.0
|
|
29
|
+
last_seen: float = 0.0
|
|
30
|
+
msg_count: int = 0
|
|
31
|
+
total_bytes: int = 0
|
|
32
|
+
fingerprint: Optional[int] = None
|
|
33
|
+
publishers: Set[str] = field(default_factory=set)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def avg_msg_size(self) -> float:
|
|
37
|
+
return self.total_bytes / self.msg_count if self.msg_count > 0 else 0.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class NodeInfo:
|
|
42
|
+
"""A publisher node identified by its UDP source address."""
|
|
43
|
+
|
|
44
|
+
address: str # "ip:port"
|
|
45
|
+
channels: Set[str] = field(default_factory=set)
|
|
46
|
+
msg_count: int = 0
|
|
47
|
+
total_bytes: int = 0
|
|
48
|
+
first_seen: float = 0.0
|
|
49
|
+
last_seen: float = 0.0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ChannelDiscovery:
|
|
53
|
+
"""Passive discovery of LCM channels and publisher nodes.
|
|
54
|
+
|
|
55
|
+
Thread-safe: can be fed packets from a listener thread while
|
|
56
|
+
being queried from the main thread.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self) -> None:
|
|
60
|
+
self._lock = threading.Lock()
|
|
61
|
+
self._channels: Dict[str, ChannelInfo] = {}
|
|
62
|
+
self._nodes: Dict[str, NodeInfo] = {} # key = "ip:port"
|
|
63
|
+
|
|
64
|
+
def on_packet(self, pkt: PacketInfo) -> None:
|
|
65
|
+
"""Process a received LCM packet for discovery purposes."""
|
|
66
|
+
now = time.time()
|
|
67
|
+
sender_key = f"{pkt.sender_addr[0]}:{pkt.sender_addr[1]}"
|
|
68
|
+
|
|
69
|
+
with self._lock:
|
|
70
|
+
# Update node info
|
|
71
|
+
if sender_key not in self._nodes:
|
|
72
|
+
self._nodes[sender_key] = NodeInfo(
|
|
73
|
+
address=sender_key, first_seen=now
|
|
74
|
+
)
|
|
75
|
+
node = self._nodes[sender_key]
|
|
76
|
+
node.msg_count += 1
|
|
77
|
+
node.total_bytes += pkt.packet_size
|
|
78
|
+
node.last_seen = now
|
|
79
|
+
|
|
80
|
+
# Only process packets that have a channel (skip mid-fragments)
|
|
81
|
+
if not pkt.has_channel:
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
channel = pkt.channel
|
|
85
|
+
assert channel is not None
|
|
86
|
+
node.channels.add(channel)
|
|
87
|
+
|
|
88
|
+
# Update channel info
|
|
89
|
+
if channel not in self._channels:
|
|
90
|
+
self._channels[channel] = ChannelInfo(
|
|
91
|
+
name=channel, first_seen=now
|
|
92
|
+
)
|
|
93
|
+
ch_info = self._channels[channel]
|
|
94
|
+
ch_info.last_seen = now
|
|
95
|
+
ch_info.msg_count += 1
|
|
96
|
+
ch_info.total_bytes += pkt.packet_size
|
|
97
|
+
ch_info.publishers.add(sender_key)
|
|
98
|
+
|
|
99
|
+
# Try to extract fingerprint from first message
|
|
100
|
+
if ch_info.fingerprint is None and pkt.payload:
|
|
101
|
+
fp = extract_fingerprint(pkt.payload)
|
|
102
|
+
if fp is not None:
|
|
103
|
+
ch_info.fingerprint = fp
|
|
104
|
+
|
|
105
|
+
def get_active_channels(
|
|
106
|
+
self, stale_after: float = 10.0
|
|
107
|
+
) -> List[ChannelInfo]:
|
|
108
|
+
"""Return channels that have been active within *stale_after* seconds."""
|
|
109
|
+
now = time.time()
|
|
110
|
+
with self._lock:
|
|
111
|
+
return [
|
|
112
|
+
ch
|
|
113
|
+
for ch in sorted(self._channels.values(), key=lambda c: c.name)
|
|
114
|
+
if now - ch.last_seen < stale_after
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
def get_all_channels(self) -> List[ChannelInfo]:
|
|
118
|
+
"""Return all ever-observed channels."""
|
|
119
|
+
with self._lock:
|
|
120
|
+
return sorted(self._channels.values(), key=lambda c: c.name)
|
|
121
|
+
|
|
122
|
+
def get_nodes(self, stale_after: float = 10.0) -> List[NodeInfo]:
|
|
123
|
+
"""Return nodes that have been active within *stale_after* seconds."""
|
|
124
|
+
now = time.time()
|
|
125
|
+
with self._lock:
|
|
126
|
+
return [
|
|
127
|
+
node
|
|
128
|
+
for node in sorted(self._nodes.values(), key=lambda n: n.address)
|
|
129
|
+
if now - node.last_seen < stale_after
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
def get_all_nodes(self) -> List[NodeInfo]:
|
|
133
|
+
"""Return all ever-observed nodes."""
|
|
134
|
+
with self._lock:
|
|
135
|
+
return sorted(self._nodes.values(), key=lambda n: n.address)
|