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,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}"