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.
- plexus/__init__.py +31 -0
- plexus/__main__.py +4 -0
- plexus/adapters/__init__.py +122 -0
- plexus/adapters/base.py +409 -0
- plexus/adapters/ble.py +257 -0
- plexus/adapters/can.py +439 -0
- plexus/adapters/can_detect.py +174 -0
- plexus/adapters/mavlink.py +642 -0
- plexus/adapters/mavlink_detect.py +192 -0
- plexus/adapters/modbus.py +622 -0
- plexus/adapters/mqtt.py +350 -0
- plexus/adapters/opcua.py +607 -0
- plexus/adapters/registry.py +206 -0
- plexus/adapters/serial_adapter.py +547 -0
- plexus/buffer.py +257 -0
- plexus/cameras/__init__.py +57 -0
- plexus/cameras/auto.py +239 -0
- plexus/cameras/base.py +189 -0
- plexus/cameras/picamera.py +171 -0
- plexus/cameras/usb.py +143 -0
- plexus/cli.py +783 -0
- plexus/client.py +465 -0
- plexus/config.py +169 -0
- plexus/connector.py +666 -0
- plexus/deps.py +246 -0
- plexus/detect.py +1238 -0
- plexus/importers/__init__.py +25 -0
- plexus/importers/rosbag.py +778 -0
- plexus/sensors/__init__.py +118 -0
- plexus/sensors/ads1115.py +164 -0
- plexus/sensors/adxl345.py +179 -0
- plexus/sensors/auto.py +290 -0
- plexus/sensors/base.py +412 -0
- plexus/sensors/bh1750.py +102 -0
- plexus/sensors/bme280.py +241 -0
- plexus/sensors/gps.py +317 -0
- plexus/sensors/ina219.py +149 -0
- plexus/sensors/magnetometer.py +239 -0
- plexus/sensors/mpu6050.py +162 -0
- plexus/sensors/sht3x.py +139 -0
- plexus/sensors/spi_scan.py +164 -0
- plexus/sensors/system.py +261 -0
- plexus/sensors/vl53l0x.py +109 -0
- plexus/streaming.py +743 -0
- plexus/tui.py +642 -0
- plexus_python-0.1.0.dist-info/METADATA +470 -0
- plexus_python-0.1.0.dist-info/RECORD +50 -0
- plexus_python-0.1.0.dist-info/WHEEL +4 -0
- plexus_python-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|