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,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)