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,233 @@
|
|
|
1
|
+
"""Rich-based real-time echo display for ``lcm topic echo``.
|
|
2
|
+
|
|
3
|
+
Supports three display modes:
|
|
4
|
+
1. Default — Rich Panel with channel, seq, size, fingerprint, hex dump
|
|
5
|
+
2. Raw — compact plain-text output for piping/scripting
|
|
6
|
+
3. Decoded — attempt to decode payload via an lcm-gen generated class
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
import time
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.panel import Panel
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
|
|
20
|
+
from lcm_tools.protocol import PacketInfo, extract_fingerprint, fingerprint_to_hex
|
|
21
|
+
|
|
22
|
+
# TYPE_CHECKING import for TypeRegistry (avoid circular imports)
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from lcm_tools.core.lcm_type_builder import TypeRegistry
|
|
27
|
+
|
|
28
|
+
_console = Console()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def echo_packet_default(pkt: PacketInfo, msg_index: int) -> None:
|
|
32
|
+
"""Display a single packet using Rich Panel (default mode)."""
|
|
33
|
+
fp = extract_fingerprint(pkt.payload)
|
|
34
|
+
fp_str = fingerprint_to_hex(fp) if fp is not None else "N/A"
|
|
35
|
+
|
|
36
|
+
ts_str = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
|
37
|
+
|
|
38
|
+
# Build payload preview (first 128 bytes as hex, wrapped)
|
|
39
|
+
preview_bytes = pkt.payload[:128]
|
|
40
|
+
hex_lines = [
|
|
41
|
+
preview_bytes[i : i + 16].hex(" ")
|
|
42
|
+
for i in range(0, len(preview_bytes), 16)
|
|
43
|
+
]
|
|
44
|
+
hex_preview = "\n".join(hex_lines)
|
|
45
|
+
if len(pkt.payload) > 128:
|
|
46
|
+
hex_preview += f"\n... ({len(pkt.payload) - 128} more bytes)"
|
|
47
|
+
|
|
48
|
+
body = Text.from_markup(
|
|
49
|
+
f"[bold]channel:[/bold] [cyan]{pkt.channel}[/cyan]\n"
|
|
50
|
+
f"[bold]seq:[/bold] {pkt.seqno}\n"
|
|
51
|
+
f"[bold]size:[/bold] {len(pkt.payload)} bytes "
|
|
52
|
+
f"(packet: {pkt.packet_size} B)\n"
|
|
53
|
+
f"[bold]time:[/bold] {ts_str}\n"
|
|
54
|
+
f"[bold]fingerprint:[/bold] {fp_str}\n"
|
|
55
|
+
f"[bold]sender:[/bold] {pkt.sender_addr[0]}:{pkt.sender_addr[1]}\n"
|
|
56
|
+
f"\n[dim]payload (hex):[/dim]\n{hex_preview}"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
panel = Panel(
|
|
60
|
+
body,
|
|
61
|
+
title=f"[bold green]Message #{msg_index}[/bold green]",
|
|
62
|
+
border_style="blue",
|
|
63
|
+
expand=False,
|
|
64
|
+
)
|
|
65
|
+
_console.print(panel)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def echo_packet_raw(pkt: PacketInfo, msg_index: int) -> None:
|
|
69
|
+
"""Display a packet in compact raw text mode."""
|
|
70
|
+
ts = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
|
71
|
+
fp = extract_fingerprint(pkt.payload)
|
|
72
|
+
fp_str = fingerprint_to_hex(fp) if fp is not None else "N/A"
|
|
73
|
+
_console.print(
|
|
74
|
+
f"[{ts}] #{msg_index} {pkt.channel} "
|
|
75
|
+
f"seq={pkt.seqno} size={len(pkt.payload)}B "
|
|
76
|
+
f"fp={fp_str} from={pkt.sender_addr[0]}:{pkt.sender_addr[1]}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _format_value(value: Any, indent: int = 1) -> str:
|
|
81
|
+
"""Recursively format a field value, expanding nested LCM structs.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
value: The field value to format.
|
|
85
|
+
indent: Current indentation level (each level = 2 spaces).
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
A formatted string representation of the value.
|
|
89
|
+
"""
|
|
90
|
+
prefix = " " * indent
|
|
91
|
+
|
|
92
|
+
# Handle nested LCM struct objects (have __slots__ or custom attributes)
|
|
93
|
+
if hasattr(value, "__slots__") or (
|
|
94
|
+
hasattr(value, "__dict__")
|
|
95
|
+
and not isinstance(value, (list, tuple, dict, str, bytes))
|
|
96
|
+
and not hasattr(value, "__len__")
|
|
97
|
+
):
|
|
98
|
+
nested_fields = _extract_fields(value)
|
|
99
|
+
if nested_fields:
|
|
100
|
+
lines = []
|
|
101
|
+
for k, v in nested_fields:
|
|
102
|
+
formatted_v = _format_value(v, indent + 1)
|
|
103
|
+
lines.append(f"{prefix} {k}: {formatted_v}")
|
|
104
|
+
return "\n" + "\n".join(lines)
|
|
105
|
+
|
|
106
|
+
# Handle lists / tuples (e.g., arrays of structs or primitives)
|
|
107
|
+
if isinstance(value, (list, tuple)):
|
|
108
|
+
if not value:
|
|
109
|
+
return "[]"
|
|
110
|
+
# Check if elements are nested structs
|
|
111
|
+
first = value[0]
|
|
112
|
+
if hasattr(first, "__slots__") or (
|
|
113
|
+
hasattr(first, "__dict__")
|
|
114
|
+
and not isinstance(first, (list, tuple, dict, str, bytes))
|
|
115
|
+
and not hasattr(first, "__len__")
|
|
116
|
+
):
|
|
117
|
+
lines = []
|
|
118
|
+
for i, item in enumerate(value):
|
|
119
|
+
nested_fields = _extract_fields(item)
|
|
120
|
+
if nested_fields:
|
|
121
|
+
lines.append(f"{prefix} [{i}]:")
|
|
122
|
+
for k, v in nested_fields:
|
|
123
|
+
formatted_v = _format_value(v, indent + 2)
|
|
124
|
+
lines.append(f"{prefix} {k}: {formatted_v}")
|
|
125
|
+
else:
|
|
126
|
+
lines.append(f"{prefix} [{i}]: {item}")
|
|
127
|
+
return "\n" + "\n".join(lines)
|
|
128
|
+
return repr(value)
|
|
129
|
+
|
|
130
|
+
# Handle dicts
|
|
131
|
+
if isinstance(value, dict):
|
|
132
|
+
if not value:
|
|
133
|
+
return "{}"
|
|
134
|
+
lines = []
|
|
135
|
+
for k, v in value.items():
|
|
136
|
+
formatted_v = _format_value(v, indent + 1)
|
|
137
|
+
lines.append(f"{prefix} {k}: {formatted_v}")
|
|
138
|
+
return "\n" + "\n".join(lines)
|
|
139
|
+
|
|
140
|
+
# Primitive types
|
|
141
|
+
return repr(value)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _extract_fields(obj: Any) -> list[tuple[str, Any]]:
|
|
145
|
+
"""Extract (name, value) pairs from an LCM struct-like object."""
|
|
146
|
+
# Prefer __slots__ if available (lcm-gen generated classes use __slots__)
|
|
147
|
+
if hasattr(obj, "__slots__"):
|
|
148
|
+
return [
|
|
149
|
+
(k, getattr(obj, k))
|
|
150
|
+
for k in obj.__slots__
|
|
151
|
+
if not k.startswith("_")
|
|
152
|
+
]
|
|
153
|
+
# Fall back to dir(), filtering out callables and private attrs
|
|
154
|
+
return [
|
|
155
|
+
(k, getattr(obj, k))
|
|
156
|
+
for k in dir(obj)
|
|
157
|
+
if not k.startswith("_") and not callable(getattr(obj, k))
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def echo_packet_decoded(
|
|
162
|
+
pkt: PacketInfo, msg_index: int, decode_cls: Any
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Display a decoded LCM message with recursive nested struct expansion."""
|
|
165
|
+
ts = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
|
166
|
+
try:
|
|
167
|
+
msg = decode_cls.decode(pkt.payload)
|
|
168
|
+
fields = _extract_fields(msg)
|
|
169
|
+
body_lines = []
|
|
170
|
+
for k, v in fields:
|
|
171
|
+
formatted_v = _format_value(v, indent=1)
|
|
172
|
+
body_lines.append(f" {k}: {formatted_v}")
|
|
173
|
+
body = "\n".join(body_lines)
|
|
174
|
+
except Exception as exc:
|
|
175
|
+
body = f"[decode error: {exc}]\nRaw hex: {pkt.payload[:64].hex(' ')}"
|
|
176
|
+
|
|
177
|
+
_console.print(
|
|
178
|
+
Panel(
|
|
179
|
+
body,
|
|
180
|
+
title=f"[bold green]#{msg_index}[/bold green] "
|
|
181
|
+
f"[cyan]{pkt.channel}[/cyan] [{ts}]",
|
|
182
|
+
border_style="blue",
|
|
183
|
+
expand=False,
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def echo_packet_auto_decode(
|
|
189
|
+
pkt: PacketInfo, msg_index: int, registry: "TypeRegistry"
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Auto-decode a packet using fingerprint matching from a TypeRegistry.
|
|
192
|
+
|
|
193
|
+
If the fingerprint matches a registered type, decode and display.
|
|
194
|
+
Otherwise, fall back to default display with a hint.
|
|
195
|
+
"""
|
|
196
|
+
fp = extract_fingerprint(pkt.payload)
|
|
197
|
+
decode_cls = None
|
|
198
|
+
if fp is not None:
|
|
199
|
+
decode_cls = registry.find_by_fingerprint(fp)
|
|
200
|
+
|
|
201
|
+
if decode_cls is not None:
|
|
202
|
+
echo_packet_decoded(pkt, msg_index, decode_cls)
|
|
203
|
+
else:
|
|
204
|
+
# Fall back to default display with fingerprint info
|
|
205
|
+
echo_packet_default(pkt, msg_index)
|
|
206
|
+
if fp is not None:
|
|
207
|
+
_console.print(
|
|
208
|
+
f" [dim](no matching type for fingerprint "
|
|
209
|
+
f"{fingerprint_to_hex(fp)})[/dim]"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def load_decode_class(type_path: str) -> Any:
|
|
214
|
+
"""Dynamically import an lcm-gen generated class.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
type_path: Dotted path like ``exlcm.example_t``.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
The class object (must have a ``decode(data)`` classmethod).
|
|
221
|
+
|
|
222
|
+
Raises:
|
|
223
|
+
ImportError: If the module cannot be imported.
|
|
224
|
+
AttributeError: If the class is not found in the module.
|
|
225
|
+
"""
|
|
226
|
+
parts = type_path.rsplit(".", 1)
|
|
227
|
+
if len(parts) != 2:
|
|
228
|
+
raise ValueError(
|
|
229
|
+
f"Expected 'module.ClassName' format, got: {type_path!r}"
|
|
230
|
+
)
|
|
231
|
+
module_path, class_name = parts
|
|
232
|
+
mod = importlib.import_module(module_path)
|
|
233
|
+
return getattr(mod, class_name)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Rich Live table display for ``lcm topic stats`` and ``lcm topic list``.
|
|
2
|
+
|
|
3
|
+
Uses ``rich.live.Live`` to render a continuously updating table of
|
|
4
|
+
per-channel statistics in the terminal.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import List
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.live import Live
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from lcm_tools.core.discovery import ChannelInfo, NodeInfo
|
|
16
|
+
from lcm_tools.core.stats import StatsSnapshot, _ChannelSnapshot
|
|
17
|
+
|
|
18
|
+
_console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_stats_table(snap: StatsSnapshot) -> Table:
|
|
22
|
+
"""Build a Rich Table from a stats snapshot."""
|
|
23
|
+
table = Table(
|
|
24
|
+
title="LCM Channel Statistics",
|
|
25
|
+
show_lines=False,
|
|
26
|
+
show_header=True,
|
|
27
|
+
header_style="bold magenta",
|
|
28
|
+
)
|
|
29
|
+
table.add_column("Channel", style="cyan", min_width=18)
|
|
30
|
+
table.add_column("Messages", justify="right", style="bold")
|
|
31
|
+
table.add_column("Rate (Hz)", justify="right", style="green")
|
|
32
|
+
table.add_column("BW (KB/s)", justify="right", style="yellow")
|
|
33
|
+
table.add_column("Avg Size (B)", justify="right")
|
|
34
|
+
table.add_column("Total (KB)", justify="right", style="blue")
|
|
35
|
+
|
|
36
|
+
for ch in snap.channels:
|
|
37
|
+
table.add_row(
|
|
38
|
+
ch.channel,
|
|
39
|
+
str(ch.msg_count),
|
|
40
|
+
f"{ch.frequency_hz:.1f}",
|
|
41
|
+
f"{ch.bandwidth_kbps:.2f}",
|
|
42
|
+
f"{ch.avg_msg_size:.0f}",
|
|
43
|
+
f"{ch.total_bytes / 1024:.1f}",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Summary row
|
|
47
|
+
table.add_section()
|
|
48
|
+
table.add_row(
|
|
49
|
+
f"[bold]{snap.total_channels} channels[/bold]",
|
|
50
|
+
f"[bold]{snap.total_messages}[/bold]",
|
|
51
|
+
"",
|
|
52
|
+
f"[bold]{snap.total_bandwidth_kbps:.2f}[/bold]",
|
|
53
|
+
"",
|
|
54
|
+
f"[bold]{snap.total_bytes / 1024:.1f}[/bold]",
|
|
55
|
+
)
|
|
56
|
+
return table
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_channel_table(channels: List[ChannelInfo]) -> Table:
|
|
60
|
+
"""Build a Rich Table listing discovered channels."""
|
|
61
|
+
table = Table(
|
|
62
|
+
title=f"Active LCM Channels ({len(channels)} found)",
|
|
63
|
+
show_lines=False,
|
|
64
|
+
show_header=True,
|
|
65
|
+
header_style="bold magenta",
|
|
66
|
+
)
|
|
67
|
+
table.add_column("Channel", style="cyan", min_width=18)
|
|
68
|
+
table.add_column("Messages", justify="right")
|
|
69
|
+
table.add_column("Total Size", justify="right", style="blue")
|
|
70
|
+
table.add_column("Publishers", justify="right", style="dim")
|
|
71
|
+
|
|
72
|
+
for ch in channels:
|
|
73
|
+
table.add_row(
|
|
74
|
+
ch.name,
|
|
75
|
+
str(ch.msg_count),
|
|
76
|
+
f"{ch.total_bytes / 1024:.1f} KB",
|
|
77
|
+
", ".join(sorted(ch.publishers)) if ch.publishers else "-",
|
|
78
|
+
)
|
|
79
|
+
return table
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def build_node_table(nodes: List[NodeInfo]) -> Table:
|
|
83
|
+
"""Build a Rich Table listing discovered publisher nodes."""
|
|
84
|
+
table = Table(
|
|
85
|
+
title=f"LCM Publisher Nodes ({len(nodes)} found)",
|
|
86
|
+
show_lines=False,
|
|
87
|
+
show_header=True,
|
|
88
|
+
header_style="bold magenta",
|
|
89
|
+
)
|
|
90
|
+
table.add_column("Node (IP:port)", style="cyan", min_width=22)
|
|
91
|
+
table.add_column("Channels", style="green")
|
|
92
|
+
table.add_column("Messages", justify="right")
|
|
93
|
+
table.add_column("Total Size", justify="right", style="blue")
|
|
94
|
+
|
|
95
|
+
for node in nodes:
|
|
96
|
+
channels_str = ", ".join(sorted(node.channels)) or "-"
|
|
97
|
+
table.add_row(
|
|
98
|
+
node.address,
|
|
99
|
+
channels_str,
|
|
100
|
+
str(node.msg_count),
|
|
101
|
+
f"{node.total_bytes / 1024:.1f} KB",
|
|
102
|
+
)
|
|
103
|
+
return table
|
lcm_tools/listener.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""UDP multicast socket management for LCM traffic capture.
|
|
2
|
+
|
|
3
|
+
Creates and manages a UDP socket that joins the LCM multicast group,
|
|
4
|
+
receives raw datagrams, parses them via the protocol module, and
|
|
5
|
+
dispatches PacketInfo objects to registered callbacks.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import select
|
|
11
|
+
import socket
|
|
12
|
+
import struct
|
|
13
|
+
import sys
|
|
14
|
+
import threading
|
|
15
|
+
from typing import Callable, Optional
|
|
16
|
+
|
|
17
|
+
from lcm_tools.protocol import (
|
|
18
|
+
DEFAULT_MC_ADDR,
|
|
19
|
+
DEFAULT_MC_PORT,
|
|
20
|
+
PacketInfo,
|
|
21
|
+
parse_lcm_packet,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# 4 MB receive buffer — helps avoid packet loss under burst traffic
|
|
25
|
+
_RCVBUF_SIZE: int = 4 * 1024 * 1024
|
|
26
|
+
|
|
27
|
+
PacketCallback = Callable[[PacketInfo], None]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def create_multicast_socket(
|
|
31
|
+
mc_addr: str = DEFAULT_MC_ADDR,
|
|
32
|
+
mc_port: int = DEFAULT_MC_PORT,
|
|
33
|
+
interface: Optional[str] = None,
|
|
34
|
+
) -> socket.socket:
|
|
35
|
+
"""Create a UDP socket and join the given multicast group.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
mc_addr: Multicast group address (e.g. "239.255.76.67").
|
|
39
|
+
mc_port: Multicast port (e.g. 7667).
|
|
40
|
+
interface: Local interface IP to bind to. ``None`` means
|
|
41
|
+
``INADDR_ANY`` (let the OS choose).
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
A configured, non-blocking UDP socket ready to receive
|
|
45
|
+
multicast datagrams.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
OSError: If the socket cannot be created or the group cannot
|
|
49
|
+
be joined (e.g. no route to multicast address).
|
|
50
|
+
"""
|
|
51
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
|
52
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
53
|
+
|
|
54
|
+
# macOS / FreeBSD require SO_REUSEPORT in addition to SO_REUSEADDR
|
|
55
|
+
if sys.platform == "darwin" or "freebsd" in sys.platform:
|
|
56
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
|
57
|
+
|
|
58
|
+
sock.bind(("", mc_port))
|
|
59
|
+
|
|
60
|
+
# Build the IP_ADD_MEMBERSHIP request
|
|
61
|
+
group_bin = socket.inet_aton(mc_addr)
|
|
62
|
+
if interface:
|
|
63
|
+
iface_bin = socket.inet_aton(interface)
|
|
64
|
+
else:
|
|
65
|
+
iface_bin = struct.pack("=I", socket.INADDR_ANY)
|
|
66
|
+
|
|
67
|
+
mreq = group_bin + iface_bin
|
|
68
|
+
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
|
|
69
|
+
|
|
70
|
+
# Enlarge receive buffer
|
|
71
|
+
try:
|
|
72
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, _RCVBUF_SIZE)
|
|
73
|
+
except OSError:
|
|
74
|
+
pass # OS may refuse; proceed with default
|
|
75
|
+
|
|
76
|
+
sock.setblocking(False)
|
|
77
|
+
return sock
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def drop_multicast_membership(
|
|
81
|
+
sock: socket.socket,
|
|
82
|
+
mc_addr: str = DEFAULT_MC_ADDR,
|
|
83
|
+
interface: Optional[str] = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Leave the multicast group before closing the socket."""
|
|
86
|
+
group_bin = socket.inet_aton(mc_addr)
|
|
87
|
+
iface_bin = (
|
|
88
|
+
socket.inet_aton(interface)
|
|
89
|
+
if interface
|
|
90
|
+
else struct.pack("=I", socket.INADDR_ANY)
|
|
91
|
+
)
|
|
92
|
+
mreq = group_bin + iface_bin
|
|
93
|
+
try:
|
|
94
|
+
sock.setsockopt(socket.IPPROTO_IP, socket.IP_DROP_MEMBERSHIP, mreq)
|
|
95
|
+
except OSError:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def listen_packets(
|
|
100
|
+
sock: socket.socket,
|
|
101
|
+
callback: PacketCallback,
|
|
102
|
+
stop_event: threading.Event,
|
|
103
|
+
timeout: float = 0.5,
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Read packets from *sock* and invoke *callback* until *stop_event* is set.
|
|
106
|
+
|
|
107
|
+
This function is designed to run in a dedicated thread.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
sock: A non-blocking multicast socket from :func:`create_multicast_socket`.
|
|
111
|
+
callback: Called for every successfully parsed LCM packet.
|
|
112
|
+
stop_event: Set this event to request a clean shutdown.
|
|
113
|
+
timeout: ``select`` timeout in seconds; controls how quickly the
|
|
114
|
+
function checks *stop_event*.
|
|
115
|
+
"""
|
|
116
|
+
while not stop_event.is_set():
|
|
117
|
+
readable, _, _ = select.select([sock], [], [], timeout)
|
|
118
|
+
if not readable:
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
data, sender_addr = sock.recvfrom(65536)
|
|
123
|
+
except OSError:
|
|
124
|
+
# Socket may have been closed during shutdown
|
|
125
|
+
break
|
|
126
|
+
|
|
127
|
+
pkt = parse_lcm_packet(data, sender_addr)
|
|
128
|
+
if pkt is not None:
|
|
129
|
+
callback(pkt)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def run_listener(
|
|
133
|
+
callback: PacketCallback,
|
|
134
|
+
mc_addr: str = DEFAULT_MC_ADDR,
|
|
135
|
+
mc_port: int = DEFAULT_MC_PORT,
|
|
136
|
+
interface: Optional[str] = None,
|
|
137
|
+
stop_event: Optional[threading.Event] = None,
|
|
138
|
+
) -> threading.Event:
|
|
139
|
+
"""Convenience: start a background listener thread.
|
|
140
|
+
|
|
141
|
+
Returns the ``threading.Event`` that can be set to stop the listener.
|
|
142
|
+
"""
|
|
143
|
+
if stop_event is None:
|
|
144
|
+
stop_event = threading.Event()
|
|
145
|
+
|
|
146
|
+
sock = create_multicast_socket(mc_addr, mc_port, interface)
|
|
147
|
+
|
|
148
|
+
def _worker() -> None:
|
|
149
|
+
try:
|
|
150
|
+
listen_packets(sock, callback, stop_event)
|
|
151
|
+
finally:
|
|
152
|
+
drop_multicast_membership(sock, mc_addr, interface)
|
|
153
|
+
sock.close()
|
|
154
|
+
|
|
155
|
+
t = threading.Thread(target=_worker, daemon=True, name="lcm-listener")
|
|
156
|
+
t.start()
|
|
157
|
+
return stop_event
|
lcm_tools/protocol.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""LCM UDP Multicast wire protocol parser.
|
|
2
|
+
|
|
3
|
+
Reference: https://lcm-proj.github.io/lcm/content/udp-multicast-protocol.html
|
|
4
|
+
|
|
5
|
+
Two packet formats:
|
|
6
|
+
- Small message (short header, 8 bytes): magic=0x4c433032 + seqno + channel\0 + payload
|
|
7
|
+
- Fragmented message (long header, 20 bytes): magic=0x4c433033 + seqno + payload_size
|
|
8
|
+
+ fragment_offset + fragment_no(2B) + n_fragments(2B) + [if frag==0: channel\0] + payload
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import struct
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Optional, Tuple
|
|
16
|
+
|
|
17
|
+
# LCM protocol constants
|
|
18
|
+
LCM2_MAGIC_SHORT: int = 0x4C433032 # "LC02" - small single-packet messages
|
|
19
|
+
LCM2_MAGIC_LONG: int = 0x4C433033 # "LC03" - fragmented messages
|
|
20
|
+
|
|
21
|
+
DEFAULT_MC_ADDR: str = "239.255.76.67"
|
|
22
|
+
DEFAULT_MC_PORT: int = 7667
|
|
23
|
+
|
|
24
|
+
# Maximum channel name length (sanity check)
|
|
25
|
+
_MAX_CHANNEL_LEN: int = 256
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class PacketInfo:
|
|
30
|
+
"""Parsed information from a single LCM UDP datagram."""
|
|
31
|
+
|
|
32
|
+
channel: Optional[str] = None
|
|
33
|
+
seqno: int = 0
|
|
34
|
+
payload: bytes = b""
|
|
35
|
+
fragment_no: int = 0
|
|
36
|
+
n_fragments: int = 1
|
|
37
|
+
packet_size: int = 0
|
|
38
|
+
sender_addr: Tuple[str, int] = ("", 0)
|
|
39
|
+
is_fragment: bool = False
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def is_first_fragment(self) -> bool:
|
|
43
|
+
return self.is_fragment and self.fragment_no == 0
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def has_channel(self) -> bool:
|
|
47
|
+
"""True if this packet contains a channel name."""
|
|
48
|
+
return self.channel is not None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_lcm_packet(
|
|
52
|
+
data: bytes, sender_addr: Tuple[str, int] = ("", 0)
|
|
53
|
+
) -> Optional[PacketInfo]:
|
|
54
|
+
"""Parse a raw UDP datagram as an LCM packet.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
data: Raw bytes received from the UDP socket.
|
|
58
|
+
sender_addr: (ip, port) of the sender.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
PacketInfo on success, None if the packet is malformed or unrecognised.
|
|
62
|
+
"""
|
|
63
|
+
if len(data) < 8:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
magic, seqno = struct.unpack("!II", data[:8])
|
|
67
|
+
|
|
68
|
+
if magic == LCM2_MAGIC_SHORT:
|
|
69
|
+
return _parse_short_message(data, seqno, sender_addr)
|
|
70
|
+
elif magic == LCM2_MAGIC_LONG:
|
|
71
|
+
return _parse_fragmented_message(data, seqno, sender_addr)
|
|
72
|
+
else:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _parse_short_message(
|
|
77
|
+
data: bytes, seqno: int, sender_addr: Tuple[str, int]
|
|
78
|
+
) -> Optional[PacketInfo]:
|
|
79
|
+
"""Parse a small (non-fragmented) LCM message."""
|
|
80
|
+
# Header: 8 bytes already read; channel name starts at offset 8
|
|
81
|
+
null_pos = data.find(b"\x00", 8)
|
|
82
|
+
if null_pos == -1 or null_pos - 8 > _MAX_CHANNEL_LEN:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
channel = data[8:null_pos].decode("utf-8")
|
|
87
|
+
except UnicodeDecodeError:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
payload = data[null_pos + 1 :]
|
|
91
|
+
|
|
92
|
+
return PacketInfo(
|
|
93
|
+
channel=channel,
|
|
94
|
+
seqno=seqno,
|
|
95
|
+
payload=payload,
|
|
96
|
+
fragment_no=0,
|
|
97
|
+
n_fragments=1,
|
|
98
|
+
packet_size=len(data),
|
|
99
|
+
sender_addr=sender_addr,
|
|
100
|
+
is_fragment=False,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _parse_fragmented_message(
|
|
105
|
+
data: bytes, seqno: int, sender_addr: Tuple[str, int]
|
|
106
|
+
) -> Optional[PacketInfo]:
|
|
107
|
+
"""Parse a fragmented LCM message (first fragment has channel name)."""
|
|
108
|
+
if len(data) < 20:
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
_payload_size, _frag_offset, frag_no, n_frags = struct.unpack(
|
|
112
|
+
"!IIHH", data[8:20]
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if frag_no == 0:
|
|
116
|
+
# First fragment: contains channel name
|
|
117
|
+
null_pos = data.find(b"\x00", 20)
|
|
118
|
+
if null_pos == -1 or null_pos - 20 > _MAX_CHANNEL_LEN:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
channel = data[20:null_pos].decode("utf-8")
|
|
123
|
+
except UnicodeDecodeError:
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
payload = data[null_pos + 1 :]
|
|
127
|
+
|
|
128
|
+
return PacketInfo(
|
|
129
|
+
channel=channel,
|
|
130
|
+
seqno=seqno,
|
|
131
|
+
payload=payload,
|
|
132
|
+
fragment_no=0,
|
|
133
|
+
n_fragments=n_frags,
|
|
134
|
+
packet_size=len(data),
|
|
135
|
+
sender_addr=sender_addr,
|
|
136
|
+
is_fragment=True,
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
# Subsequent fragments: no channel name, payload only
|
|
140
|
+
payload = data[20:]
|
|
141
|
+
return PacketInfo(
|
|
142
|
+
channel=None,
|
|
143
|
+
seqno=seqno,
|
|
144
|
+
payload=payload,
|
|
145
|
+
fragment_no=frag_no,
|
|
146
|
+
n_fragments=n_frags,
|
|
147
|
+
packet_size=len(data),
|
|
148
|
+
sender_addr=sender_addr,
|
|
149
|
+
is_fragment=True,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def extract_fingerprint(payload: bytes) -> Optional[int]:
|
|
154
|
+
"""Extract the LCM type fingerprint from a message payload.
|
|
155
|
+
|
|
156
|
+
The fingerprint is the first 8 bytes of the payload, encoded as a
|
|
157
|
+
big-endian unsigned 64-bit integer.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
payload: The message payload bytes.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
The fingerprint as an integer, or None if the payload is too short.
|
|
164
|
+
"""
|
|
165
|
+
if len(payload) < 8:
|
|
166
|
+
return None
|
|
167
|
+
return struct.unpack(">Q", payload[:8])[0]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def fingerprint_to_hex(fp: int) -> str:
|
|
171
|
+
"""Format a fingerprint integer as a hex string."""
|
|
172
|
+
return f"0x{fp:016x}"
|