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,515 @@
|
|
|
1
|
+
"""Pure-Python parser for LCM type definition files (.lcm).
|
|
2
|
+
|
|
3
|
+
Parses .lcm files into an AST (LcmStruct, LcmMember, etc.) and computes
|
|
4
|
+
the LCM fingerprint (hash) compatible with lcm-gen's algorithm.
|
|
5
|
+
|
|
6
|
+
Reference: lcm_ref/lcmgen/lcmgen.c
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import List, Optional
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# AST data structures
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
# Primitive types recognised by LCM
|
|
21
|
+
PRIMITIVE_TYPES = frozenset({
|
|
22
|
+
"int8_t", "int16_t", "int32_t", "int64_t",
|
|
23
|
+
"byte", "float", "double", "string", "boolean",
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
# Types that can be used as array dimension sizes
|
|
27
|
+
ARRAY_DIM_TYPES = frozenset({"int8_t", "int16_t", "int32_t", "int64_t"})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class LcmDimension:
|
|
32
|
+
"""One dimension of an array member."""
|
|
33
|
+
mode: str # "const" (literal number) or "var" (runtime variable)
|
|
34
|
+
size: str # numeric string or member variable name
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class LcmConstant:
|
|
39
|
+
"""A constant declared inside a struct."""
|
|
40
|
+
type_name: str # e.g. "int32_t", "double"
|
|
41
|
+
name: str
|
|
42
|
+
value_str: str # raw value string from the .lcm file
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class LcmMember:
|
|
47
|
+
"""A single member (field) of a struct."""
|
|
48
|
+
type_name: str # fully-qualified, e.g. "exlcm.point_t"
|
|
49
|
+
member_name: str
|
|
50
|
+
dimensions: List[LcmDimension] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class LcmStruct:
|
|
55
|
+
"""A complete struct definition parsed from a .lcm file."""
|
|
56
|
+
full_name: str # e.g. "exlcm.example_t"
|
|
57
|
+
package: str # e.g. "exlcm"
|
|
58
|
+
short_name: str # e.g. "example_t"
|
|
59
|
+
members: List[LcmMember] = field(default_factory=list)
|
|
60
|
+
constants: List[LcmConstant] = field(default_factory=list)
|
|
61
|
+
hash_value: int = 0 # LCM fingerprint (computed after parsing)
|
|
62
|
+
base_hash: int = 0 # Non-recursive hash (before compute_fingerprints)
|
|
63
|
+
source_file: str = "" # path to the .lcm file
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Tokenizer
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
# Token types
|
|
71
|
+
_TOK_EOF = "EOF"
|
|
72
|
+
_TOK_IDENT = "IDENT"
|
|
73
|
+
_TOK_NUMBER = "NUMBER"
|
|
74
|
+
_TOK_PUNCT = "PUNCT"
|
|
75
|
+
_TOK_COMMENT = "COMMENT"
|
|
76
|
+
|
|
77
|
+
# Regex patterns (order matters)
|
|
78
|
+
_TOKEN_PATTERNS = [
|
|
79
|
+
("SKIP", re.compile(r"[ \t\r]+")),
|
|
80
|
+
("NEWLINE", re.compile(r"\n")),
|
|
81
|
+
("LINE_COMMENT", re.compile(r"//[^\n]*")),
|
|
82
|
+
("BLOCK_COMMENT", re.compile(r"/\*[\s\S]*?\*/")),
|
|
83
|
+
("HEX_NUMBER", re.compile(r"0[xX][0-9a-fA-F]+")),
|
|
84
|
+
("FLOAT_NUMBER", re.compile(r"\d+\.\d*([eE][+-]?\d+)?|\.\d+([eE][+-]?\d+)?|\d+[eE][+-]?\d+")),
|
|
85
|
+
("INT_NUMBER", re.compile(r"\d+")),
|
|
86
|
+
("IDENT", re.compile(r"[a-zA-Z_][a-zA-Z0-9_]*")),
|
|
87
|
+
("PUNCT", re.compile(r"[{}[\]();,.=]")),
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class _Token:
|
|
93
|
+
type: str
|
|
94
|
+
value: str
|
|
95
|
+
line: int
|
|
96
|
+
col: int
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _Tokenizer:
|
|
100
|
+
"""Simple tokenizer for .lcm files."""
|
|
101
|
+
|
|
102
|
+
def __init__(self, text: str, path: str = "<string>") -> None:
|
|
103
|
+
self._text = text
|
|
104
|
+
self._path = path
|
|
105
|
+
self._pos = 0
|
|
106
|
+
self._line = 1
|
|
107
|
+
self._col = 1
|
|
108
|
+
self._tokens: List[_Token] = []
|
|
109
|
+
self._idx = 0
|
|
110
|
+
self._tokenize()
|
|
111
|
+
|
|
112
|
+
def _tokenize(self) -> None:
|
|
113
|
+
text = self._text
|
|
114
|
+
pos = 0
|
|
115
|
+
line = 1
|
|
116
|
+
col = 1
|
|
117
|
+
|
|
118
|
+
while pos < len(text):
|
|
119
|
+
matched = False
|
|
120
|
+
for name, pattern in _TOKEN_PATTERNS:
|
|
121
|
+
m = pattern.match(text, pos)
|
|
122
|
+
if m:
|
|
123
|
+
value = m.group(0)
|
|
124
|
+
if name in ("LINE_COMMENT", "BLOCK_COMMENT"):
|
|
125
|
+
# Count newlines inside block comments
|
|
126
|
+
for ch in value:
|
|
127
|
+
if ch == "\n":
|
|
128
|
+
line += 1
|
|
129
|
+
col = 1
|
|
130
|
+
else:
|
|
131
|
+
col += 1
|
|
132
|
+
elif name == "NEWLINE":
|
|
133
|
+
line += 1
|
|
134
|
+
col = 1
|
|
135
|
+
elif name == "SKIP":
|
|
136
|
+
col += len(value)
|
|
137
|
+
elif name == "HEX_NUMBER":
|
|
138
|
+
self._tokens.append(_Token(_TOK_NUMBER, value, line, col))
|
|
139
|
+
col += len(value)
|
|
140
|
+
elif name == "INT_NUMBER" or name == "FLOAT_NUMBER":
|
|
141
|
+
self._tokens.append(_Token(_TOK_NUMBER, value, line, col))
|
|
142
|
+
col += len(value)
|
|
143
|
+
elif name == "IDENT":
|
|
144
|
+
self._tokens.append(_Token(_TOK_IDENT, value, line, col))
|
|
145
|
+
col += len(value)
|
|
146
|
+
elif name == "PUNCT":
|
|
147
|
+
self._tokens.append(_Token(_TOK_PUNCT, value, line, col))
|
|
148
|
+
col += len(value)
|
|
149
|
+
pos = m.end()
|
|
150
|
+
matched = True
|
|
151
|
+
break
|
|
152
|
+
if not matched:
|
|
153
|
+
raise LcmParseError(
|
|
154
|
+
f"{self._path}:{line}:{col}: unexpected character {text[pos]!r}"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
self._tokens.append(_Token(_TOK_EOF, "", line, col))
|
|
158
|
+
|
|
159
|
+
def peek(self) -> _Token:
|
|
160
|
+
return self._tokens[self._idx]
|
|
161
|
+
|
|
162
|
+
def next(self) -> _Token:
|
|
163
|
+
tok = self._tokens[self._idx]
|
|
164
|
+
if tok.type != _TOK_EOF:
|
|
165
|
+
self._idx += 1
|
|
166
|
+
return tok
|
|
167
|
+
|
|
168
|
+
def expect(self, type_: str, value: Optional[str] = None) -> _Token:
|
|
169
|
+
tok = self.next()
|
|
170
|
+
if tok.type != type_:
|
|
171
|
+
raise LcmParseError(
|
|
172
|
+
f"{self._path}:{tok.line}:{tok.col}: expected {type_} "
|
|
173
|
+
f"{'(' + value + ')' if value else ''}, got {tok.type} {tok.value!r}"
|
|
174
|
+
)
|
|
175
|
+
if value is not None and tok.value != value:
|
|
176
|
+
raise LcmParseError(
|
|
177
|
+
f"{self._path}:{tok.line}:{tok.col}: expected {value!r}, "
|
|
178
|
+
f"got {tok.value!r}"
|
|
179
|
+
)
|
|
180
|
+
return tok
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class LcmParseError(Exception):
|
|
184
|
+
"""Raised when .lcm file parsing fails."""
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
# Parser
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def _is_primitive(type_name: str) -> bool:
|
|
192
|
+
return type_name in PRIMITIVE_TYPES
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _parse_typename(tok: _Tokenizer, current_package: str) -> str:
|
|
196
|
+
"""Read a type name (possibly dotted), return fully-qualified name."""
|
|
197
|
+
name_tok = tok.expect(_TOK_IDENT)
|
|
198
|
+
name = name_tok.value
|
|
199
|
+
|
|
200
|
+
# Handle dotted names like "exlcm.node_t"
|
|
201
|
+
while tok.peek().type == _TOK_PUNCT and tok.peek().value == ".":
|
|
202
|
+
tok.next() # consume "."
|
|
203
|
+
part = tok.expect(_TOK_IDENT)
|
|
204
|
+
name = f"{name}.{part.value}"
|
|
205
|
+
|
|
206
|
+
# If no package in the name and it's not a primitive, prepend current package
|
|
207
|
+
if not _is_primitive(name) and "." not in name and current_package:
|
|
208
|
+
name = f"{current_package}.{name}"
|
|
209
|
+
|
|
210
|
+
return name
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _parse_dimensions(tok: _Tokenizer) -> List[LcmDimension]:
|
|
214
|
+
"""Parse zero or more array dimension brackets: [3], [n], etc."""
|
|
215
|
+
dims: List[LcmDimension] = []
|
|
216
|
+
while tok.peek().type == _TOK_PUNCT and tok.peek().value == "[":
|
|
217
|
+
tok.next() # consume "["
|
|
218
|
+
size_tok = tok.next()
|
|
219
|
+
if size_tok.type == _TOK_NUMBER:
|
|
220
|
+
dims.append(LcmDimension(mode="const", size=size_tok.value))
|
|
221
|
+
elif size_tok.type == _TOK_IDENT:
|
|
222
|
+
dims.append(LcmDimension(mode="var", size=size_tok.value))
|
|
223
|
+
else:
|
|
224
|
+
raise LcmParseError(
|
|
225
|
+
f"{size_tok.line}:{size_tok.col}: expected array size, "
|
|
226
|
+
f"got {size_tok.value!r}"
|
|
227
|
+
)
|
|
228
|
+
tok.expect(_TOK_PUNCT, "]")
|
|
229
|
+
return dims
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _parse_const(tok: _Tokenizer, struct: LcmStruct) -> None:
|
|
233
|
+
"""Parse: const <type> NAME = VALUE [, NAME = VALUE]* ;"""
|
|
234
|
+
type_tok = tok.expect(_TOK_IDENT)
|
|
235
|
+
const_type = type_tok.value
|
|
236
|
+
|
|
237
|
+
while True:
|
|
238
|
+
name_tok = tok.expect(_TOK_IDENT)
|
|
239
|
+
tok.expect(_TOK_PUNCT, "=")
|
|
240
|
+
val_tok = tok.next()
|
|
241
|
+
# Value can be a number or a negative number (handled as "- number")
|
|
242
|
+
if val_tok.type == _TOK_PUNCT and val_tok.value == "-":
|
|
243
|
+
num_tok = tok.next()
|
|
244
|
+
value_str = f"-{num_tok.value}"
|
|
245
|
+
else:
|
|
246
|
+
value_str = val_tok.value
|
|
247
|
+
|
|
248
|
+
struct.constants.append(LcmConstant(
|
|
249
|
+
type_name=const_type,
|
|
250
|
+
name=name_tok.value,
|
|
251
|
+
value_str=value_str,
|
|
252
|
+
))
|
|
253
|
+
|
|
254
|
+
# Check for comma (another constant) or semicolon (end)
|
|
255
|
+
nxt = tok.peek()
|
|
256
|
+
if nxt.type == _TOK_PUNCT and nxt.value == ",":
|
|
257
|
+
tok.next()
|
|
258
|
+
continue
|
|
259
|
+
break
|
|
260
|
+
|
|
261
|
+
tok.expect(_TOK_PUNCT, ";")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _parse_struct(tok: _Tokenizer, current_package: str, source_file: str) -> LcmStruct:
|
|
265
|
+
"""Parse: struct <name> { ... }"""
|
|
266
|
+
name_tok = tok.expect(_TOK_IDENT)
|
|
267
|
+
short_name = name_tok.value
|
|
268
|
+
|
|
269
|
+
if current_package:
|
|
270
|
+
full_name = f"{current_package}.{short_name}"
|
|
271
|
+
else:
|
|
272
|
+
full_name = short_name
|
|
273
|
+
|
|
274
|
+
struct = LcmStruct(
|
|
275
|
+
full_name=full_name,
|
|
276
|
+
package=current_package,
|
|
277
|
+
short_name=short_name,
|
|
278
|
+
source_file=source_file,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
tok.expect(_TOK_PUNCT, "{")
|
|
282
|
+
|
|
283
|
+
while True:
|
|
284
|
+
nxt = tok.peek()
|
|
285
|
+
if nxt.type == _TOK_EOF:
|
|
286
|
+
raise LcmParseError(f"{source_file}: unexpected EOF inside struct {short_name}")
|
|
287
|
+
if nxt.type == _TOK_PUNCT and nxt.value == "}":
|
|
288
|
+
tok.next()
|
|
289
|
+
break
|
|
290
|
+
|
|
291
|
+
# Check for "const" keyword
|
|
292
|
+
if nxt.type == _TOK_IDENT and nxt.value == "const":
|
|
293
|
+
tok.next() # consume "const"
|
|
294
|
+
_parse_const(tok, struct)
|
|
295
|
+
continue
|
|
296
|
+
|
|
297
|
+
# Otherwise it's a member declaration
|
|
298
|
+
type_name = _parse_typename(tok, current_package)
|
|
299
|
+
|
|
300
|
+
# One or more members can be declared on the same line
|
|
301
|
+
while True:
|
|
302
|
+
member_name_tok = tok.expect(_TOK_IDENT)
|
|
303
|
+
dims = _parse_dimensions(tok)
|
|
304
|
+
|
|
305
|
+
struct.members.append(LcmMember(
|
|
306
|
+
type_name=type_name,
|
|
307
|
+
member_name=member_name_tok.value,
|
|
308
|
+
dimensions=dims,
|
|
309
|
+
))
|
|
310
|
+
|
|
311
|
+
nxt2 = tok.peek()
|
|
312
|
+
if nxt2.type == _TOK_PUNCT and nxt2.value == ",":
|
|
313
|
+
tok.next() # consume "," and read next member
|
|
314
|
+
continue
|
|
315
|
+
break
|
|
316
|
+
|
|
317
|
+
tok.expect(_TOK_PUNCT, ";")
|
|
318
|
+
|
|
319
|
+
return struct
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def parse_lcm_string(text: str, source_file: str = "<string>") -> List[LcmStruct]:
|
|
323
|
+
"""Parse .lcm file content and return a list of LcmStruct definitions.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
text: Content of the .lcm file.
|
|
327
|
+
source_file: File path (for error messages).
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
List of parsed struct definitions (hash not yet computed).
|
|
331
|
+
"""
|
|
332
|
+
tok = _Tokenizer(text, source_file)
|
|
333
|
+
structs: List[LcmStruct] = []
|
|
334
|
+
current_package = ""
|
|
335
|
+
|
|
336
|
+
while tok.peek().type != _TOK_EOF:
|
|
337
|
+
nxt = tok.peek()
|
|
338
|
+
|
|
339
|
+
if nxt.type == _TOK_IDENT and nxt.value == "package":
|
|
340
|
+
tok.next()
|
|
341
|
+
pkg_tok = tok.expect(_TOK_IDENT)
|
|
342
|
+
# Package names can be dotted
|
|
343
|
+
pkg = pkg_tok.value
|
|
344
|
+
while tok.peek().type == _TOK_PUNCT and tok.peek().value == ".":
|
|
345
|
+
tok.next()
|
|
346
|
+
part = tok.expect(_TOK_IDENT)
|
|
347
|
+
pkg = f"{pkg}.{part.value}"
|
|
348
|
+
tok.expect(_TOK_PUNCT, ";")
|
|
349
|
+
current_package = pkg
|
|
350
|
+
continue
|
|
351
|
+
|
|
352
|
+
if nxt.type == _TOK_IDENT and nxt.value == "struct":
|
|
353
|
+
tok.next() # consume "struct"
|
|
354
|
+
s = _parse_struct(tok, current_package, source_file)
|
|
355
|
+
structs.append(s)
|
|
356
|
+
continue
|
|
357
|
+
|
|
358
|
+
raise LcmParseError(
|
|
359
|
+
f"{source_file}:{nxt.line}:{nxt.col}: unexpected token {nxt.value!r}, "
|
|
360
|
+
f"expected 'package' or 'struct'"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
return structs
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def parse_lcm_file(path: str | Path) -> List[LcmStruct]:
|
|
367
|
+
"""Parse a .lcm file from disk.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
path: Path to the .lcm file.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
List of parsed struct definitions.
|
|
374
|
+
"""
|
|
375
|
+
p = Path(path)
|
|
376
|
+
text = p.read_text(encoding="utf-8")
|
|
377
|
+
return parse_lcm_string(text, source_file=str(p))
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# ---------------------------------------------------------------------------
|
|
381
|
+
# Hash / Fingerprint computation (must match lcmgen.c exactly)
|
|
382
|
+
# ---------------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
_MASK64 = 0xFFFFFFFFFFFFFFFF
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _hash_update(v: int, c: int) -> int:
|
|
388
|
+
"""Replicate lcmgen.c hash_update() with arithmetic right shift.
|
|
389
|
+
|
|
390
|
+
C int64_t >> 55 is an arithmetic (sign-extending) shift.
|
|
391
|
+
Python >> on positive ints is logical (zero-extending).
|
|
392
|
+
We must simulate the signed behaviour.
|
|
393
|
+
"""
|
|
394
|
+
# Sign-extend c to 64-bit if needed (C char is signed on most platforms)
|
|
395
|
+
if c > 127:
|
|
396
|
+
c -= 256
|
|
397
|
+
# Convert to signed 64-bit for arithmetic right shift
|
|
398
|
+
sv = v if v < (1 << 63) else v - (1 << 64)
|
|
399
|
+
ashift = (sv >> 55) & _MASK64 # arithmetic shift, masked to unsigned 64-bit
|
|
400
|
+
v = (((v << 8) ^ ashift) + c) & _MASK64
|
|
401
|
+
return v
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _hash_string_update(v: int, s: str) -> int:
|
|
405
|
+
"""Replicate lcmgen.c hash_string_update()."""
|
|
406
|
+
v = _hash_update(v, len(s))
|
|
407
|
+
for ch in s:
|
|
408
|
+
v = _hash_update(v, ord(ch))
|
|
409
|
+
return v
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _lcm_struct_hash(struct: LcmStruct) -> int:
|
|
413
|
+
"""Compute the LCM fingerprint for a single struct (non-recursive part).
|
|
414
|
+
|
|
415
|
+
This replicates lcm_struct_hash() from lcmgen.c:
|
|
416
|
+
- Hashes member names, primitive type names, and dimension info.
|
|
417
|
+
- Does NOT include the struct's own name in the hash.
|
|
418
|
+
- For compound (non-primitive) member types, does NOT hash the type name
|
|
419
|
+
here; that's handled in the recursive fingerprint.
|
|
420
|
+
"""
|
|
421
|
+
v = 0x12345678
|
|
422
|
+
|
|
423
|
+
for member in struct.members:
|
|
424
|
+
# Hash member name
|
|
425
|
+
v = _hash_string_update(v, member.member_name)
|
|
426
|
+
|
|
427
|
+
# Hash primitive type name (but NOT compound type names)
|
|
428
|
+
if _is_primitive(member.type_name):
|
|
429
|
+
v = _hash_string_update(v, member.type_name)
|
|
430
|
+
|
|
431
|
+
# Hash dimensionality
|
|
432
|
+
ndim = len(member.dimensions)
|
|
433
|
+
v = _hash_update(v, ndim)
|
|
434
|
+
for dim in member.dimensions:
|
|
435
|
+
mode_val = 0 if dim.mode == "const" else 1 # LCM_CONST=0, LCM_VAR=1
|
|
436
|
+
v = _hash_update(v, mode_val)
|
|
437
|
+
v = _hash_string_update(v, dim.size)
|
|
438
|
+
|
|
439
|
+
return v
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def compute_fingerprints(structs: List[LcmStruct]) -> None:
|
|
443
|
+
"""Compute fingerprints for all structs, resolving cross-references.
|
|
444
|
+
|
|
445
|
+
The final fingerprint (_get_hash_recursive) includes:
|
|
446
|
+
1. The struct's own hash (from member names, primitive types, dimensions)
|
|
447
|
+
2. Recursively, the hash of any compound member types
|
|
448
|
+
|
|
449
|
+
This function sets struct.hash_value for each struct in the list.
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
structs: List of all known struct definitions (may span multiple files).
|
|
453
|
+
"""
|
|
454
|
+
# Build lookup table: full_name -> LcmStruct
|
|
455
|
+
by_name: dict[str, LcmStruct] = {}
|
|
456
|
+
for s in structs:
|
|
457
|
+
by_name[s.full_name] = s
|
|
458
|
+
# Also index by short name for same-package references
|
|
459
|
+
if s.short_name not in by_name:
|
|
460
|
+
by_name[s.short_name] = s
|
|
461
|
+
|
|
462
|
+
# First compute base hashes (non-recursive part)
|
|
463
|
+
for s in structs:
|
|
464
|
+
base = _lcm_struct_hash(s)
|
|
465
|
+
s.base_hash = base
|
|
466
|
+
s.hash_value = base
|
|
467
|
+
|
|
468
|
+
# Now compute recursive fingerprints
|
|
469
|
+
# Cache: full_name -> final recursive hash
|
|
470
|
+
cache: dict[str, int] = {}
|
|
471
|
+
|
|
472
|
+
def _recursive_hash(struct: LcmStruct, parents: list[str]) -> int:
|
|
473
|
+
if struct.full_name in parents:
|
|
474
|
+
return 0 # Break recursion cycle
|
|
475
|
+
|
|
476
|
+
if struct.full_name in cache:
|
|
477
|
+
return cache[struct.full_name]
|
|
478
|
+
|
|
479
|
+
new_parents = parents + [struct.full_name]
|
|
480
|
+
tmphash = struct.hash_value
|
|
481
|
+
|
|
482
|
+
for member in struct.members:
|
|
483
|
+
if not _is_primitive(member.type_name):
|
|
484
|
+
# Resolve the compound type
|
|
485
|
+
ref_struct = _resolve_type(member.type_name, struct.package, by_name)
|
|
486
|
+
if ref_struct is not None:
|
|
487
|
+
tmphash = (tmphash + _recursive_hash(ref_struct, new_parents)) & _MASK64
|
|
488
|
+
|
|
489
|
+
# Rotate left by 1 (logical right shift — matches lcm-gen Python output)
|
|
490
|
+
tmphash = (((tmphash << 1) & _MASK64) + (tmphash >> 63)) & _MASK64
|
|
491
|
+
|
|
492
|
+
cache[struct.full_name] = tmphash
|
|
493
|
+
return tmphash
|
|
494
|
+
|
|
495
|
+
for s in structs:
|
|
496
|
+
s.hash_value = _recursive_hash(s, [])
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _resolve_type(
|
|
500
|
+
type_name: str,
|
|
501
|
+
current_package: str,
|
|
502
|
+
by_name: dict[str, LcmStruct],
|
|
503
|
+
) -> Optional[LcmStruct]:
|
|
504
|
+
"""Resolve a type name to its LcmStruct definition."""
|
|
505
|
+
# Try exact match first
|
|
506
|
+
if type_name in by_name:
|
|
507
|
+
return by_name[type_name]
|
|
508
|
+
|
|
509
|
+
# Try with current package prefix
|
|
510
|
+
if current_package and "." not in type_name:
|
|
511
|
+
qualified = f"{current_package}.{type_name}"
|
|
512
|
+
if qualified in by_name:
|
|
513
|
+
return by_name[qualified]
|
|
514
|
+
|
|
515
|
+
return None
|
lcm_tools/core/stats.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Real-time statistics collection for LCM channels.
|
|
2
|
+
|
|
3
|
+
Provides a thread-safe, memory-bounded statistics collector that
|
|
4
|
+
tracks per-channel message frequency, bandwidth, message sizes,
|
|
5
|
+
and cumulative data transfer.
|
|
6
|
+
|
|
7
|
+
Frequency and bandwidth are computed using a sliding window over
|
|
8
|
+
the most recent timestamps, giving a smoothed, up-to-date rate
|
|
9
|
+
without storing unbounded history.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from collections import deque
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
from lcm_tools.protocol import PacketInfo
|
|
21
|
+
|
|
22
|
+
# Default sliding-window capacity (number of samples kept per channel)
|
|
23
|
+
_DEFAULT_WINDOW: int = 2000
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ChannelStats:
|
|
28
|
+
"""Per-channel statistics with a sliding time window."""
|
|
29
|
+
|
|
30
|
+
channel: str
|
|
31
|
+
msg_count: int = 0
|
|
32
|
+
total_bytes: int = 0
|
|
33
|
+
_timestamps: deque = field(default_factory=lambda: deque(maxlen=_DEFAULT_WINDOW))
|
|
34
|
+
_sizes: deque = field(default_factory=lambda: deque(maxlen=_DEFAULT_WINDOW))
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def frequency_hz(self) -> float:
|
|
38
|
+
"""Message rate in Hz over the current window."""
|
|
39
|
+
n = len(self._timestamps)
|
|
40
|
+
if n < 2:
|
|
41
|
+
return 0.0
|
|
42
|
+
dt = self._timestamps[-1] - self._timestamps[0]
|
|
43
|
+
return (n - 1) / dt if dt > 0.0 else 0.0
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def bandwidth_kbps(self) -> float:
|
|
47
|
+
"""Bandwidth in KB/s over the current window."""
|
|
48
|
+
n = len(self._timestamps)
|
|
49
|
+
if n < 2:
|
|
50
|
+
return 0.0
|
|
51
|
+
dt = self._timestamps[-1] - self._timestamps[0]
|
|
52
|
+
if dt <= 0.0:
|
|
53
|
+
return 0.0
|
|
54
|
+
total = sum(self._sizes)
|
|
55
|
+
return (total / 1024.0) / dt
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def avg_msg_size(self) -> float:
|
|
59
|
+
"""Average message size in bytes over the current window."""
|
|
60
|
+
if not self._sizes:
|
|
61
|
+
return 0.0
|
|
62
|
+
return sum(self._sizes) / len(self._sizes)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def total_kb(self) -> float:
|
|
66
|
+
"""Total data transferred in KB."""
|
|
67
|
+
return self.total_bytes / 1024.0
|
|
68
|
+
|
|
69
|
+
def record(self, size: int, ts: Optional[float] = None) -> None:
|
|
70
|
+
"""Record a single message."""
|
|
71
|
+
if ts is None:
|
|
72
|
+
ts = time.monotonic()
|
|
73
|
+
self.msg_count += 1
|
|
74
|
+
self.total_bytes += size
|
|
75
|
+
self._timestamps.append(ts)
|
|
76
|
+
self._sizes.append(size)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class StatsCollector:
|
|
80
|
+
"""Thread-safe multi-channel statistics aggregator.
|
|
81
|
+
|
|
82
|
+
Safe to call ``on_packet`` from a listener thread while
|
|
83
|
+
reading stats from the main thread.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, channel_filter: Optional[str] = None) -> None:
|
|
87
|
+
"""
|
|
88
|
+
Args:
|
|
89
|
+
channel_filter: If not None, only collect stats for channels
|
|
90
|
+
whose name contains this substring.
|
|
91
|
+
"""
|
|
92
|
+
self._lock = threading.Lock()
|
|
93
|
+
self._stats: Dict[str, ChannelStats] = {}
|
|
94
|
+
self._filter = channel_filter
|
|
95
|
+
|
|
96
|
+
def on_packet(self, pkt: PacketInfo) -> None:
|
|
97
|
+
"""Process an incoming LCM packet for statistics."""
|
|
98
|
+
# Skip mid-fragments (no channel info)
|
|
99
|
+
if not pkt.has_channel:
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
channel = pkt.channel
|
|
103
|
+
assert channel is not None
|
|
104
|
+
|
|
105
|
+
# Apply filter
|
|
106
|
+
if self._filter and self._filter not in channel:
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
with self._lock:
|
|
110
|
+
if channel not in self._stats:
|
|
111
|
+
self._stats[channel] = ChannelStats(channel=channel)
|
|
112
|
+
self._stats[channel].record(pkt.packet_size)
|
|
113
|
+
|
|
114
|
+
def get_stats(self) -> List[ChannelStats]:
|
|
115
|
+
"""Return a snapshot of all channel statistics, sorted by name."""
|
|
116
|
+
with self._lock:
|
|
117
|
+
return sorted(self._stats.values(), key=lambda s: s.channel)
|
|
118
|
+
|
|
119
|
+
def get_channel_stats(self, channel: str) -> Optional[ChannelStats]:
|
|
120
|
+
"""Return stats for a specific channel, or None."""
|
|
121
|
+
with self._lock:
|
|
122
|
+
return self._stats.get(channel)
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def total_channels(self) -> int:
|
|
126
|
+
with self._lock:
|
|
127
|
+
return len(self._stats)
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def total_messages(self) -> int:
|
|
131
|
+
with self._lock:
|
|
132
|
+
return sum(s.msg_count for s in self._stats.values())
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def total_bytes(self) -> int:
|
|
136
|
+
with self._lock:
|
|
137
|
+
return sum(s.total_bytes for s in self._stats.values())
|
|
138
|
+
|
|
139
|
+
def snapshot(self) -> "StatsSnapshot":
|
|
140
|
+
"""Return an immutable point-in-time snapshot of all stats."""
|
|
141
|
+
with self._lock:
|
|
142
|
+
channels = [
|
|
143
|
+
_ChannelSnapshot(
|
|
144
|
+
channel=s.channel,
|
|
145
|
+
msg_count=s.msg_count,
|
|
146
|
+
total_bytes=s.total_bytes,
|
|
147
|
+
frequency_hz=s.frequency_hz,
|
|
148
|
+
bandwidth_kbps=s.bandwidth_kbps,
|
|
149
|
+
avg_msg_size=s.avg_msg_size,
|
|
150
|
+
)
|
|
151
|
+
for s in sorted(self._stats.values(), key=lambda x: x.channel)
|
|
152
|
+
]
|
|
153
|
+
return StatsSnapshot(
|
|
154
|
+
channels=channels,
|
|
155
|
+
total_channels=len(channels),
|
|
156
|
+
total_messages=sum(c.msg_count for c in channels),
|
|
157
|
+
total_bytes=sum(c.total_bytes for c in channels),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass(frozen=True)
|
|
162
|
+
class _ChannelSnapshot:
|
|
163
|
+
channel: str
|
|
164
|
+
msg_count: int
|
|
165
|
+
total_bytes: int
|
|
166
|
+
frequency_hz: float
|
|
167
|
+
bandwidth_kbps: float
|
|
168
|
+
avg_msg_size: float
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True)
|
|
172
|
+
class StatsSnapshot:
|
|
173
|
+
"""Immutable point-in-time snapshot of all channel statistics."""
|
|
174
|
+
|
|
175
|
+
channels: List[_ChannelSnapshot]
|
|
176
|
+
total_channels: int
|
|
177
|
+
total_messages: int
|
|
178
|
+
total_bytes: int
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def total_bandwidth_kbps(self) -> float:
|
|
182
|
+
return sum(c.bandwidth_kbps for c in self.channels)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""LCM display modules package."""
|