plexus-python 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.
Files changed (50) hide show
  1. plexus/__init__.py +31 -0
  2. plexus/__main__.py +4 -0
  3. plexus/adapters/__init__.py +122 -0
  4. plexus/adapters/base.py +409 -0
  5. plexus/adapters/ble.py +257 -0
  6. plexus/adapters/can.py +439 -0
  7. plexus/adapters/can_detect.py +174 -0
  8. plexus/adapters/mavlink.py +642 -0
  9. plexus/adapters/mavlink_detect.py +192 -0
  10. plexus/adapters/modbus.py +622 -0
  11. plexus/adapters/mqtt.py +350 -0
  12. plexus/adapters/opcua.py +607 -0
  13. plexus/adapters/registry.py +206 -0
  14. plexus/adapters/serial_adapter.py +547 -0
  15. plexus/buffer.py +257 -0
  16. plexus/cameras/__init__.py +57 -0
  17. plexus/cameras/auto.py +239 -0
  18. plexus/cameras/base.py +189 -0
  19. plexus/cameras/picamera.py +171 -0
  20. plexus/cameras/usb.py +143 -0
  21. plexus/cli.py +783 -0
  22. plexus/client.py +465 -0
  23. plexus/config.py +169 -0
  24. plexus/connector.py +666 -0
  25. plexus/deps.py +246 -0
  26. plexus/detect.py +1238 -0
  27. plexus/importers/__init__.py +25 -0
  28. plexus/importers/rosbag.py +778 -0
  29. plexus/sensors/__init__.py +118 -0
  30. plexus/sensors/ads1115.py +164 -0
  31. plexus/sensors/adxl345.py +179 -0
  32. plexus/sensors/auto.py +290 -0
  33. plexus/sensors/base.py +412 -0
  34. plexus/sensors/bh1750.py +102 -0
  35. plexus/sensors/bme280.py +241 -0
  36. plexus/sensors/gps.py +317 -0
  37. plexus/sensors/ina219.py +149 -0
  38. plexus/sensors/magnetometer.py +239 -0
  39. plexus/sensors/mpu6050.py +162 -0
  40. plexus/sensors/sht3x.py +139 -0
  41. plexus/sensors/spi_scan.py +164 -0
  42. plexus/sensors/system.py +261 -0
  43. plexus/sensors/vl53l0x.py +109 -0
  44. plexus/streaming.py +743 -0
  45. plexus/tui.py +642 -0
  46. plexus_python-0.1.0.dist-info/METADATA +470 -0
  47. plexus_python-0.1.0.dist-info/RECORD +50 -0
  48. plexus_python-0.1.0.dist-info/WHEEL +4 -0
  49. plexus_python-0.1.0.dist-info/entry_points.txt +2 -0
  50. plexus_python-0.1.0.dist-info/licenses/LICENSE +190 -0
plexus/tui.py ADDED
@@ -0,0 +1,642 @@
1
+ """
2
+ Live terminal dashboard for Plexus agent.
3
+
4
+ Full-screen, keyboard-driven TUI for monitoring device telemetry.
5
+ Braille charts, color gradients, metric cards. Like htop for hardware.
6
+
7
+ Usage:
8
+ plexus start (TUI is the default when Rich is available)
9
+ plexus start --headless (disable TUI)
10
+
11
+ Optional: pip install plexus-python[tui] (installs 'rich' for TUI)
12
+ """
13
+
14
+ import time
15
+ import threading
16
+ from collections import deque
17
+ from dataclasses import dataclass, field
18
+ from typing import Dict, List, Optional, Callable
19
+
20
+ # Rich is optional — imported lazily
21
+ _rich_available = False
22
+ try:
23
+ from rich.console import Console, Group
24
+ from rich.live import Live
25
+ from rich.text import Text
26
+ from rich.panel import Panel
27
+ from rich.style import Style
28
+ from rich.columns import Columns
29
+ from rich import box
30
+ _rich_available = True
31
+ except ImportError:
32
+ pass
33
+
34
+
35
+ # ─────────────────────────────────────────────────────────────────────────────
36
+ # Braille Chart (ported from ONCE's chart.go)
37
+ # ─────────────────────────────────────────────────────────────────────────────
38
+
39
+ BRAILLE_BASE = 0x2800
40
+ LEFT_DOTS = [0x40, 0x04, 0x02, 0x01]
41
+ RIGHT_DOTS = [0x80, 0x20, 0x10, 0x08]
42
+
43
+ # Green → amber gradient (8 steps)
44
+ GRADIENT = [
45
+ "#50fa7b", "#6ee867", "#8ed654", "#aac443",
46
+ "#c4b235", "#dba02a", "#ef8e22", "#f5a623",
47
+ ]
48
+
49
+ BORDER_COLOR = "#4a4a5a"
50
+ MUTED_COLOR = "#6a6a7a"
51
+ DIM_COLOR = "#3a3a4a"
52
+
53
+
54
+ def _braille_column(height: int, row_bottom: int, dots: list) -> int:
55
+ if height <= row_bottom:
56
+ return 0
57
+ bits = 0
58
+ for i in range(min(height - row_bottom, 4)):
59
+ bits |= dots[i]
60
+ return bits
61
+
62
+
63
+ def braille_chart(
64
+ data: List[float], width: int, height: int,
65
+ scale_min: Optional[float] = None, scale_max: Optional[float] = None,
66
+ ) -> List[Text]:
67
+ """Render a braille chart with auto-scaling that shows variation.
68
+
69
+ Uses a tight min/max window around the data so even near-constant
70
+ values show meaningful waveforms instead of flat blocks.
71
+ """
72
+ if width <= 0 or height <= 0:
73
+ return [Text("")]
74
+
75
+ data_points = width * 2
76
+ padded = [0.0] * data_points
77
+ src_start = max(0, len(data) - data_points)
78
+ dst_start = max(0, data_points - len(data))
79
+ for i, v in enumerate(data[src_start:]):
80
+ padded[dst_start + i] = v
81
+
82
+ # Auto-scale: use data range with 10% padding so values aren't all
83
+ # pinned to the top. This reveals variation in near-constant metrics.
84
+ if scale_max is not None and scale_min is not None:
85
+ lo, hi = scale_min, scale_max
86
+ else:
87
+ actual_vals = [v for v in padded if v != 0] or padded
88
+ lo = min(actual_vals)
89
+ hi = max(actual_vals)
90
+ spread = hi - lo
91
+ if spread == 0:
92
+ spread = abs(hi) * 0.1 or 1.0
93
+ # Add 10% padding
94
+ lo = lo - spread * 0.1
95
+ hi = hi + spread * 0.1
96
+
97
+ rng = hi - lo
98
+ if rng == 0:
99
+ rng = 1.0
100
+
101
+ dots_height = height * 4
102
+
103
+ heights = []
104
+ for v in padded:
105
+ if v == 0 and lo > 0:
106
+ heights.append(0)
107
+ else:
108
+ normalized = max(0.0, min(1.0, (v - lo) / rng))
109
+ h = int(normalized * dots_height)
110
+ if v > lo and h == 0:
111
+ h = 1
112
+ heights.append(h)
113
+
114
+ lines = []
115
+ for row in range(height):
116
+ row_bottom = (height - 1 - row) * 4
117
+ chars = []
118
+ for col in range(width):
119
+ left_idx = col * 2
120
+ right_idx = col * 2 + 1
121
+ char = BRAILLE_BASE
122
+ if left_idx < len(heights):
123
+ char |= _braille_column(heights[left_idx], row_bottom, LEFT_DOTS)
124
+ if right_idx < len(heights):
125
+ char |= _braille_column(heights[right_idx], row_bottom, RIGHT_DOTS)
126
+ chars.append(chr(char))
127
+
128
+ # Gradient: bottom → green, top → amber
129
+ t = (height - 1 - row) / max(height - 1, 1)
130
+ color = GRADIENT[min(int(t * (len(GRADIENT) - 1)), len(GRADIENT) - 1)]
131
+ lines.append(Text("".join(chars), style=Style(color=color)))
132
+
133
+ return lines
134
+
135
+
136
+ # ─────────────────────────────────────────────────────────────────────────────
137
+ # Logo
138
+ # ─────────────────────────────────────────────────────────────────────────────
139
+
140
+ LOGO_LINES = [
141
+ " ┌─┐ ",
142
+ " ───┤ ├─── ",
143
+ " └─┘ ",
144
+ " plexus ",
145
+ ]
146
+
147
+
148
+ def render_logo(tick: int) -> Text:
149
+ """Render the logo with a diagonal shine animation."""
150
+ result = Text(justify="center")
151
+ for row, line in enumerate(LOGO_LINES):
152
+ if row > 0:
153
+ result.append("\n")
154
+ for col, ch in enumerate(line):
155
+ diag = col - row
156
+ shine_hit = 0 <= tick and (tick - 3) <= diag <= tick
157
+ if shine_hit:
158
+ result.append(ch, style=Style(color="#f5a623", bold=True))
159
+ elif row == 3: # brand name row
160
+ result.append(ch, style=Style(color="#8a8a9a", bold=True))
161
+ else:
162
+ result.append(ch, style=Style(color=MUTED_COLOR))
163
+ return result
164
+
165
+
166
+ # ─────────────────────────────────────────────────────────────────────────────
167
+ # State
168
+ # ─────────────────────────────────────────────────────────────────────────────
169
+
170
+ HISTORY_LEN = 120
171
+
172
+
173
+ @dataclass
174
+ class MetricState:
175
+ name: str
176
+ value: str = ""
177
+ raw_value: float = 0.0
178
+ rate_hz: float = 0.0
179
+ last_update: float = 0.0
180
+ update_count: int = 0
181
+ _timestamps: List[float] = field(default_factory=list)
182
+ _history: deque = field(default_factory=lambda: deque(maxlen=HISTORY_LEN))
183
+
184
+ def update(self, value, timestamp: Optional[float] = None):
185
+ now = timestamp or time.time()
186
+ self.raw_value = value if isinstance(value, (int, float)) else 0.0
187
+ self.value = _format_value(value)
188
+ self.last_update = now
189
+ self.update_count += 1
190
+ if isinstance(value, (int, float)):
191
+ self._history.append(float(value))
192
+ self._timestamps.append(now)
193
+ cutoff = now - 2.0
194
+ self._timestamps = [t for t in self._timestamps if t > cutoff]
195
+ if len(self._timestamps) >= 2:
196
+ span = self._timestamps[-1] - self._timestamps[0]
197
+ if span > 0:
198
+ self.rate_hz = (len(self._timestamps) - 1) / span
199
+
200
+
201
+ @dataclass
202
+ class DashboardState:
203
+ metrics: Dict[str, MetricState] = field(default_factory=dict)
204
+ connection_status: str = "connecting"
205
+ total_sent: int = 0
206
+ total_errors: int = 0
207
+ buffer_size: int = 0
208
+ start_time: float = field(default_factory=time.time)
209
+ sort_mode: str = "name"
210
+ paused: bool = False
211
+ shine_tick: int = -1
212
+ _lock: threading.Lock = field(default_factory=threading.Lock)
213
+
214
+ def update_metric(self, name: str, value, timestamp: Optional[float] = None):
215
+ with self._lock:
216
+ if name not in self.metrics:
217
+ self.metrics[name] = MetricState(name=name)
218
+ self.metrics[name].update(value, timestamp)
219
+ self.total_sent += 1
220
+
221
+ def set_status(self, status: str):
222
+ with self._lock:
223
+ self.connection_status = status
224
+
225
+ def set_buffer(self, size: int):
226
+ with self._lock:
227
+ self.buffer_size = size
228
+
229
+ def increment_errors(self):
230
+ with self._lock:
231
+ self.total_errors += 1
232
+
233
+ def cycle_sort(self):
234
+ modes = ["name", "rate", "recent"]
235
+ idx = modes.index(self.sort_mode)
236
+ self.sort_mode = modes[(idx + 1) % len(modes)]
237
+
238
+ def toggle_pause(self):
239
+ self.paused = not self.paused
240
+
241
+ @property
242
+ def uptime(self) -> str:
243
+ elapsed = int(time.time() - self.start_time)
244
+ if elapsed < 60:
245
+ return f"{elapsed}s"
246
+ elif elapsed < 3600:
247
+ return f"{elapsed // 60}m {elapsed % 60}s"
248
+ else:
249
+ return f"{elapsed // 3600}h {(elapsed % 3600) // 60}m"
250
+
251
+ @property
252
+ def points_per_min(self) -> float:
253
+ elapsed = time.time() - self.start_time
254
+ if elapsed < 1:
255
+ return 0
256
+ return self.total_sent / elapsed * 60
257
+
258
+
259
+ # ─────────────────────────────────────────────────────────────────────────────
260
+ # Formatting
261
+ # ─────────────────────────────────────────────────────────────────────────────
262
+
263
+ def _format_value(value) -> str:
264
+ if isinstance(value, float):
265
+ if abs(value) < 0.001 and value != 0:
266
+ return f"{value:.4e}"
267
+ elif abs(value) < 10:
268
+ return f"{value:.2f}"
269
+ elif abs(value) < 1000:
270
+ return f"{value:.1f}"
271
+ else:
272
+ return f"{value:,.0f}"
273
+ elif isinstance(value, bool):
274
+ return "true" if value else "false"
275
+ elif isinstance(value, int):
276
+ return str(value)
277
+ elif isinstance(value, str):
278
+ return value[:32]
279
+ elif isinstance(value, (dict, list)):
280
+ return str(value)[:40]
281
+ return str(value)[:32]
282
+
283
+
284
+ def _compact_value(value: float) -> str:
285
+ """Format a number compactly for scale labels."""
286
+ if value >= 1_000_000:
287
+ return f"{value / 1_000_000:.1f}M"
288
+ if value >= 1_000:
289
+ return f"{value / 1_000:.1f}K"
290
+ if value >= 100:
291
+ return f"{value:.0f}"
292
+ if value >= 10:
293
+ return f"{value:.1f}"
294
+ return f"{value:.2f}"
295
+
296
+
297
+ def _format_rate(hz: float) -> str:
298
+ if hz == 0:
299
+ return ""
300
+ elif hz < 1:
301
+ return f"{hz:.1f} Hz"
302
+ else:
303
+ return f"{hz:.0f} Hz"
304
+
305
+
306
+ # ─────────────────────────────────────────────────────────────────────────────
307
+ # Metric Card
308
+ # ─────────────────────────────────────────────────────────────────────────────
309
+
310
+ def build_metric_card(metric: MetricState, width: int, chart_height: int = 4) -> Panel:
311
+ """Build a single metric card with braille chart, scale labels, and value."""
312
+ history = list(metric._history)
313
+ label_width = 6
314
+ chart_width = max(width - 4 - label_width - 1, 4)
315
+
316
+ # Compute scale from history
317
+ actual_vals = [v for v in history if v != 0] or history or [0.0]
318
+ lo = min(actual_vals)
319
+ hi = max(actual_vals)
320
+ spread = hi - lo
321
+ if spread == 0:
322
+ spread = abs(hi) * 0.1 or 1.0
323
+ scale_lo = lo - spread * 0.1
324
+ scale_hi = hi + spread * 0.1
325
+
326
+ # Chart lines
327
+ chart_lines = braille_chart(history, chart_width, chart_height, scale_lo, scale_hi)
328
+
329
+ # Status dot
330
+ age = time.time() - metric.last_update if metric.last_update > 0 else 999
331
+ dot_color = "green" if age < 5 else ("yellow" if age < 30 else "red")
332
+
333
+ # Assemble content: scale label | chart, per row
334
+ content = Text()
335
+ for i, chart_line in enumerate(chart_lines):
336
+ # Scale labels: max on first row, min on last row
337
+ if i == 0:
338
+ label = _compact_value(scale_hi)
339
+ elif i == len(chart_lines) - 1:
340
+ label = _compact_value(max(scale_lo, 0))
341
+ else:
342
+ label = ""
343
+ padded_label = label.rjust(label_width)
344
+ content.append(padded_label, style=Style(color=MUTED_COLOR))
345
+ content.append(" ")
346
+ content.append_text(chart_line)
347
+ content.append("\n")
348
+
349
+ # Value line
350
+ value_line = Text()
351
+ value_line.append(" " * label_width + " ")
352
+ value_line.append(metric.value, style=Style(bold=True))
353
+ rate_str = _format_rate(metric.rate_hz)
354
+ if rate_str:
355
+ value_line.append(f" {rate_str}", style=Style(color=MUTED_COLOR))
356
+ value_line.append(" ●", style=Style(color=dot_color))
357
+ content.append_text(value_line)
358
+
359
+ display_name = metric.name.replace("_", " ").replace(".", " ")
360
+
361
+ return Panel(
362
+ content,
363
+ title=f"[{MUTED_COLOR}]{display_name}[/{MUTED_COLOR}]",
364
+ title_align="left",
365
+ border_style=Style(color=BORDER_COLOR),
366
+ box=box.ROUNDED,
367
+ width=width,
368
+ padding=(0, 1),
369
+ )
370
+
371
+
372
+ # ─────────────────────────────────────────────────────────────────────────────
373
+ # Dashboard Builder
374
+ # ─────────────────────────────────────────────────────────────────────────────
375
+
376
+ def build_dashboard(state: DashboardState, term_width: int = 0) -> Group:
377
+ """Build the full-screen dashboard layout."""
378
+ parts = []
379
+
380
+ # ── Logo ──
381
+ parts.append(Text(""))
382
+ parts.append(render_logo(state.shine_tick))
383
+ parts.append(Text(""))
384
+
385
+ # ── Status line ──
386
+ status_map = {
387
+ "connecting": ("#e5c07b", "connecting..."),
388
+ "connected": ("#50fa7b", "streaming"),
389
+ "authenticated": ("#50fa7b", "streaming"),
390
+ "disconnected": ("#ff5555", "disconnected"),
391
+ "buffering": ("#e5c07b", "buffering"),
392
+ "error": ("#ff5555", "error"),
393
+ }
394
+ color, label = status_map.get(
395
+ state.connection_status, (MUTED_COLOR, state.connection_status)
396
+ )
397
+
398
+ status_line = Text(justify="center")
399
+ status_line.append("● ", style=Style(color=color, bold=True))
400
+ status_line.append(label, style=Style(color=color))
401
+ status_line.append(" ")
402
+ status_line.append(state.uptime, style=Style(color=MUTED_COLOR))
403
+ pts = f"{state.points_per_min:.0f}" if state.points_per_min > 0 else "0"
404
+ status_line.append(" ")
405
+ status_line.append(pts, style=Style(bold=True))
406
+ status_line.append(" pts/min", style=Style(color=MUTED_COLOR))
407
+ if state.buffer_size > 0:
408
+ status_line.append(" ")
409
+ status_line.append(f"{state.buffer_size:,}", style=Style(bold=True))
410
+ status_line.append(" buffered", style=Style(color="#e5c07b"))
411
+ if state.total_errors > 0:
412
+ status_line.append(f" {state.total_errors} err", style=Style(color="#ff5555"))
413
+ if state.paused:
414
+ status_line.append(" PAUSED", style=Style(color="#e5c07b", bold=True))
415
+
416
+ parts.append(status_line)
417
+ parts.append(Text(""))
418
+
419
+ # ── Metric cards ──
420
+ with state._lock:
421
+ if not state.metrics:
422
+ waiting = Text(
423
+ " waiting for first reading...",
424
+ style=Style(color=MUTED_COLOR, italic=True),
425
+ justify="center",
426
+ )
427
+ parts.append(waiting)
428
+ else:
429
+ if state.sort_mode == "rate":
430
+ sorted_metrics = sorted(state.metrics.values(), key=lambda m: m.rate_hz, reverse=True)
431
+ elif state.sort_mode == "recent":
432
+ sorted_metrics = sorted(state.metrics.values(), key=lambda m: m.last_update, reverse=True)
433
+ else:
434
+ sorted_metrics = sorted(state.metrics.values(), key=lambda m: m.name)
435
+
436
+ n = len(sorted_metrics)
437
+ usable = term_width if term_width > 0 else 100
438
+
439
+ # Responsive layout: side-by-side if they fit, stacked if not
440
+ if n <= 3 and usable >= n * 34 + (n - 1) * 1:
441
+ # Side by side
442
+ card_width = min((usable - (n - 1)) // n, 42)
443
+ chart_height = 6
444
+ cards = []
445
+ for metric in sorted_metrics:
446
+ cards.append(build_metric_card(metric, card_width, chart_height))
447
+ parts.append(Columns(cards, padding=(0, 1), expand=False))
448
+ elif n <= 6 and usable >= 2 * 34 + 1:
449
+ # Two per row
450
+ card_width = min((usable - 1) // 2, 42)
451
+ chart_height = 5
452
+ row = []
453
+ for i, metric in enumerate(sorted_metrics):
454
+ row.append(build_metric_card(metric, card_width, chart_height))
455
+ if len(row) == 2 or i == n - 1:
456
+ parts.append(Columns(row, padding=(0, 1), expand=False))
457
+ row = []
458
+ else:
459
+ # Stacked
460
+ card_width = min(usable, 60)
461
+ chart_height = 4
462
+ for metric in sorted_metrics:
463
+ parts.append(build_metric_card(metric, card_width, chart_height))
464
+
465
+ parts.append(Text(""))
466
+
467
+ # ── Footer ──
468
+ footer = Text(justify="center")
469
+ for key, label in [("q", "quit"), ("p", "pause"), ("s", "sort")]:
470
+ footer.append(f" {key}", style=Style(bold=True))
471
+ footer.append(f" {label} ", style=Style(color=MUTED_COLOR))
472
+ parts.append(footer)
473
+
474
+ return Group(*parts)
475
+
476
+
477
+ # ─────────────────────────────────────────────────────────────────────────────
478
+ # Keyboard
479
+ # ─────────────────────────────────────────────────────────────────────────────
480
+
481
+ class _KeyReader:
482
+ def __init__(self, state: DashboardState, stop_event: threading.Event):
483
+ self.state = state
484
+ self.stop_event = stop_event
485
+ self._thread: Optional[threading.Thread] = None
486
+
487
+ def start(self):
488
+ self._thread = threading.Thread(target=self._run, daemon=True)
489
+ self._thread.start()
490
+
491
+ def _run(self):
492
+ import sys
493
+ try:
494
+ import termios
495
+ import tty
496
+ except ImportError:
497
+ return
498
+
499
+ fd = sys.stdin.fileno()
500
+ try:
501
+ old = termios.tcgetattr(fd)
502
+ except termios.error:
503
+ return
504
+
505
+ try:
506
+ tty.setcbreak(fd)
507
+ while not self.stop_event.is_set():
508
+ import select
509
+ if select.select([sys.stdin], [], [], 0.1)[0]:
510
+ ch = sys.stdin.read(1)
511
+ if ch == "q":
512
+ self.stop_event.set()
513
+ elif ch == "p":
514
+ self.state.toggle_pause()
515
+ elif ch == "s":
516
+ self.state.cycle_sort()
517
+ finally:
518
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
519
+
520
+
521
+ # ─────────────────────────────────────────────────────────────────────────────
522
+ # Dashboard
523
+ # ─────────────────────────────────────────────────────────────────────────────
524
+
525
+ SHINE_INTERVAL = 10.0
526
+ SHINE_MAX = 20
527
+
528
+
529
+ class LiveDashboard:
530
+ """Full-screen live terminal dashboard."""
531
+
532
+ def __init__(self, sensor_hub=None):
533
+ if not _rich_available:
534
+ raise ImportError(
535
+ "\n"
536
+ " Live dashboard requires 'rich'.\n"
537
+ "\n"
538
+ " Install with:\n"
539
+ " pip install plexus-python[tui]\n"
540
+ "\n"
541
+ " Or: pip install rich\n"
542
+ )
543
+ self.state = DashboardState()
544
+ self.console = Console()
545
+ self.sensor_hub = sensor_hub
546
+ self._live: Optional[Live] = None
547
+ self._stop_event = threading.Event()
548
+ self._key_reader: Optional[_KeyReader] = None
549
+
550
+ def on_status(self, msg: str):
551
+ lower = msg.lower()
552
+ if "connected as" in lower or "authenticated" in lower:
553
+ self.state.set_status("connected")
554
+ elif "connecting" in lower:
555
+ self.state.set_status("connecting")
556
+ elif "disconnected" in lower or "error" in lower:
557
+ self.state.set_status("disconnected")
558
+ elif "reconnecting" in lower:
559
+ self.state.set_status("connecting")
560
+
561
+ # Parse buffer status from backlog drain messages
562
+ if "buffered points" in lower:
563
+ # "Draining 12,340 buffered points..."
564
+ import re
565
+ m = re.search(r"([\d,]+)\s+buffered", msg)
566
+ if m:
567
+ self.state.set_buffer(int(m.group(1).replace(",", "")))
568
+ elif "backlog drained" in lower or "backlog: " in lower:
569
+ # Update remaining count from drain progress
570
+ import re
571
+ m = re.search(r"([\d,]+)\s+remaining", msg)
572
+ if m:
573
+ self.state.set_buffer(int(m.group(1).replace(",", "")))
574
+ elif "drained" in lower:
575
+ self.state.set_buffer(0)
576
+
577
+ def on_metric(self, name: str, value, timestamp: Optional[float] = None):
578
+ self.state.update_metric(name, value, timestamp)
579
+
580
+ def wrap_status_callback(self, original_callback: Optional[Callable] = None) -> Callable:
581
+ def wrapped(msg: str):
582
+ self.on_status(msg)
583
+ if original_callback:
584
+ original_callback(msg)
585
+ return wrapped
586
+
587
+ def _sensor_read_loop(self):
588
+ while not self._stop_event.is_set():
589
+ try:
590
+ readings = self.sensor_hub.read_all()
591
+ for r in readings:
592
+ self.on_metric(r.metric, r.value)
593
+ except Exception:
594
+ pass
595
+ time.sleep(1)
596
+
597
+ def _shine_loop(self):
598
+ time.sleep(2.0)
599
+ while not self._stop_event.is_set():
600
+ for tick in range(SHINE_MAX):
601
+ if self._stop_event.is_set():
602
+ return
603
+ self.state.shine_tick = tick
604
+ time.sleep(0.05)
605
+ self.state.shine_tick = -1
606
+ for _ in range(int(SHINE_INTERVAL * 10)):
607
+ if self._stop_event.is_set():
608
+ return
609
+ time.sleep(0.1)
610
+
611
+ def run(self, connector_fn: Callable):
612
+ self.state = DashboardState()
613
+ self._stop_event.clear()
614
+
615
+ connector_thread = threading.Thread(target=connector_fn, daemon=True)
616
+ connector_thread.start()
617
+
618
+ if self.sensor_hub:
619
+ threading.Thread(target=self._sensor_read_loop, daemon=True).start()
620
+
621
+ self._key_reader = _KeyReader(self.state, self._stop_event)
622
+ self._key_reader.start()
623
+
624
+ threading.Thread(target=self._shine_loop, daemon=True).start()
625
+
626
+ try:
627
+ with Live(
628
+ build_dashboard(self.state, self.console.width),
629
+ console=self.console,
630
+ refresh_per_second=4,
631
+ screen=True,
632
+ ) as live:
633
+ self._live = live
634
+ while connector_thread.is_alive() and not self._stop_event.is_set():
635
+ if not self.state.paused:
636
+ live.update(build_dashboard(self.state, self.console.width))
637
+ time.sleep(0.25)
638
+ except KeyboardInterrupt:
639
+ pass
640
+ finally:
641
+ self._stop_event.set()
642
+ self._live = None