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,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
@@ -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."""