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/cli.py
ADDED
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface for Plexus Agent.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
plexus start # Set up and stream
|
|
6
|
+
plexus reset # Clear config and start over
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import getpass
|
|
10
|
+
import logging
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
import threading
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
|
|
18
|
+
from plexus import __version__
|
|
19
|
+
|
|
20
|
+
from plexus.config import (
|
|
21
|
+
load_config,
|
|
22
|
+
save_config,
|
|
23
|
+
get_api_key,
|
|
24
|
+
get_endpoint,
|
|
25
|
+
get_source_id,
|
|
26
|
+
get_config_path,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
33
|
+
# Console Styling
|
|
34
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
class Style:
|
|
37
|
+
"""Consistent styling for CLI output."""
|
|
38
|
+
|
|
39
|
+
# Colors
|
|
40
|
+
SUCCESS = "green"
|
|
41
|
+
ERROR = "red"
|
|
42
|
+
WARNING = "yellow"
|
|
43
|
+
INFO = "cyan"
|
|
44
|
+
DIM = "bright_black"
|
|
45
|
+
|
|
46
|
+
# Symbols
|
|
47
|
+
CHECK = "✓"
|
|
48
|
+
CROSS = "✗"
|
|
49
|
+
BULLET = "•"
|
|
50
|
+
ARROW = "→"
|
|
51
|
+
|
|
52
|
+
# Layout
|
|
53
|
+
WIDTH = 45
|
|
54
|
+
INDENT = " "
|
|
55
|
+
|
|
56
|
+
# Spinner frames
|
|
57
|
+
SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def header(title: str):
|
|
61
|
+
"""Print a styled header box."""
|
|
62
|
+
click.echo()
|
|
63
|
+
click.secho(f" ┌{'─' * (Style.WIDTH - 2)}┐", fg=Style.DIM)
|
|
64
|
+
click.secho(f" │ {title:<{Style.WIDTH - 6}}│", fg=Style.DIM)
|
|
65
|
+
click.secho(f" └{'─' * (Style.WIDTH - 2)}┘", fg=Style.DIM)
|
|
66
|
+
click.echo()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def divider():
|
|
70
|
+
"""Print a subtle divider."""
|
|
71
|
+
click.secho(f" {'─' * (Style.WIDTH - 2)}", fg=Style.DIM)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def success(msg: str):
|
|
75
|
+
"""Print a success message."""
|
|
76
|
+
click.secho(f" {Style.CHECK} {msg}", fg=Style.SUCCESS)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def error(msg: str):
|
|
80
|
+
"""Print an error message."""
|
|
81
|
+
click.secho(f" {Style.CROSS} {msg}", fg=Style.ERROR)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def warning(msg: str):
|
|
85
|
+
"""Print a warning message."""
|
|
86
|
+
click.secho(f" {Style.BULLET} {msg}", fg=Style.WARNING)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def info(msg: str):
|
|
90
|
+
"""Print an info message."""
|
|
91
|
+
click.echo(f" {msg}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def dim(msg: str):
|
|
95
|
+
"""Print dimmed text."""
|
|
96
|
+
click.secho(f" {msg}", fg=Style.DIM)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _pip_install_cmd(package: str) -> str:
|
|
100
|
+
"""Return the right install command for the user's environment."""
|
|
101
|
+
import shutil
|
|
102
|
+
if shutil.which("pipx") and ".local/share/pipx" in (sys.prefix or ""):
|
|
103
|
+
return f"pipx inject plexus-python {package}"
|
|
104
|
+
return f"pip install {package}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def label(key: str, value: str, key_width: int = 12):
|
|
108
|
+
"""Print a key-value pair."""
|
|
109
|
+
click.echo(f" {key:<{key_width}} {value}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def hint(msg: str):
|
|
113
|
+
"""Print a hint/help message."""
|
|
114
|
+
click.secho(f" {msg}", fg=Style.INFO)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Spinner:
|
|
118
|
+
"""Animated spinner for long-running operations."""
|
|
119
|
+
|
|
120
|
+
def __init__(self, message: str):
|
|
121
|
+
self.message = message
|
|
122
|
+
self.running = False
|
|
123
|
+
self.thread: Optional[threading.Thread] = None
|
|
124
|
+
self.frame = 0
|
|
125
|
+
|
|
126
|
+
def _spin(self):
|
|
127
|
+
while self.running:
|
|
128
|
+
frame = Style.SPINNER[self.frame % len(Style.SPINNER)]
|
|
129
|
+
click.echo(f"\r {frame} {self.message}", nl=False)
|
|
130
|
+
self.frame += 1
|
|
131
|
+
time.sleep(0.08)
|
|
132
|
+
|
|
133
|
+
def start(self):
|
|
134
|
+
self.running = True
|
|
135
|
+
self.thread = threading.Thread(target=self._spin, daemon=True)
|
|
136
|
+
self.thread.start()
|
|
137
|
+
|
|
138
|
+
def stop(self, final_message: str = None, success_status: bool = True):
|
|
139
|
+
self.running = False
|
|
140
|
+
if self.thread:
|
|
141
|
+
self.thread.join(timeout=0.2)
|
|
142
|
+
# Clear the line
|
|
143
|
+
click.echo(f"\r{' ' * (Style.WIDTH + 10)}\r", nl=False)
|
|
144
|
+
if final_message:
|
|
145
|
+
if success_status:
|
|
146
|
+
success(final_message)
|
|
147
|
+
else:
|
|
148
|
+
error(final_message)
|
|
149
|
+
|
|
150
|
+
def update(self, message: str):
|
|
151
|
+
self.message = message
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def status_line(msg: str):
|
|
155
|
+
"""Print a timestamped status line."""
|
|
156
|
+
timestamp = time.strftime("%H:%M:%S")
|
|
157
|
+
click.secho(f" {timestamp}", fg=Style.DIM, nl=False)
|
|
158
|
+
click.echo(f" {msg}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _print_status_block(device_name: str, sensors: list, endpoint: str):
|
|
162
|
+
"""Print the compact post-connect status block."""
|
|
163
|
+
click.echo()
|
|
164
|
+
success(f"{device_name} connected")
|
|
165
|
+
click.echo()
|
|
166
|
+
|
|
167
|
+
# Metrics preview — first 3 names + "N more"
|
|
168
|
+
all_metrics = []
|
|
169
|
+
for s in sensors:
|
|
170
|
+
m = getattr(s, 'metrics', None) or (
|
|
171
|
+
getattr(s.driver, 'metrics', None) if hasattr(s, 'driver') else None
|
|
172
|
+
)
|
|
173
|
+
if m:
|
|
174
|
+
all_metrics.extend(m)
|
|
175
|
+
|
|
176
|
+
if all_metrics:
|
|
177
|
+
preview = ", ".join(all_metrics[:3])
|
|
178
|
+
remaining = len(all_metrics) - 3
|
|
179
|
+
metrics_str = preview + (f" + {remaining} more" if remaining > 0 else "")
|
|
180
|
+
else:
|
|
181
|
+
metrics_str = "none"
|
|
182
|
+
|
|
183
|
+
label("Metrics", metrics_str)
|
|
184
|
+
label("Mode", "Live (record from dashboard)")
|
|
185
|
+
label("Dashboard", endpoint)
|
|
186
|
+
click.echo()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
190
|
+
# Helpers
|
|
191
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
def _validate_api_key(api_key: str, endpoint: str) -> bool:
|
|
194
|
+
"""Make a lightweight request to verify the API key is valid.
|
|
195
|
+
|
|
196
|
+
Returns True if the key is accepted, False otherwise.
|
|
197
|
+
"""
|
|
198
|
+
from plexus.config import get_gateway_url
|
|
199
|
+
try:
|
|
200
|
+
import requests
|
|
201
|
+
resp = requests.post(
|
|
202
|
+
f"{get_gateway_url()}/ingest",
|
|
203
|
+
headers={"x-api-key": api_key, "Content-Type": "application/json"},
|
|
204
|
+
json={"points": []},
|
|
205
|
+
timeout=10,
|
|
206
|
+
)
|
|
207
|
+
return resp.status_code < 400
|
|
208
|
+
except Exception:
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _detect_device_type() -> str:
|
|
213
|
+
"""Detect the type of device we're running on."""
|
|
214
|
+
import platform
|
|
215
|
+
|
|
216
|
+
system = platform.system().lower()
|
|
217
|
+
machine = platform.machine().lower()
|
|
218
|
+
|
|
219
|
+
# Check for Raspberry Pi
|
|
220
|
+
try:
|
|
221
|
+
with open("/proc/device-tree/model", "r") as f:
|
|
222
|
+
model = f.read().strip()
|
|
223
|
+
if "raspberry pi" in model.lower():
|
|
224
|
+
return model
|
|
225
|
+
except (FileNotFoundError, PermissionError):
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
# Check for Jetson
|
|
229
|
+
try:
|
|
230
|
+
with open("/proc/device-tree/model", "r") as f:
|
|
231
|
+
model = f.read().strip()
|
|
232
|
+
if "jetson" in model.lower():
|
|
233
|
+
return model
|
|
234
|
+
except (FileNotFoundError, PermissionError):
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
if system == "darwin":
|
|
238
|
+
mac_model = platform.machine()
|
|
239
|
+
return f"macOS ({mac_model})"
|
|
240
|
+
elif system == "linux":
|
|
241
|
+
if "aarch64" in machine or "arm" in machine:
|
|
242
|
+
return f"Linux ({machine})"
|
|
243
|
+
return f"Linux ({machine})"
|
|
244
|
+
elif system == "windows":
|
|
245
|
+
return f"Windows ({machine})"
|
|
246
|
+
|
|
247
|
+
return f"{platform.system()} ({machine})"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _mask_key(key: str) -> str:
|
|
251
|
+
"""Mask an API key for display: plx_a1b2...c3d4"""
|
|
252
|
+
if len(key) <= 12:
|
|
253
|
+
return "****"
|
|
254
|
+
return f"{key[:8]}...{key[-4:]}"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
258
|
+
# Terminal Auth
|
|
259
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
def _select(label: str, options: list, default: int = 0) -> int:
|
|
262
|
+
"""Arrow-key selector. Returns the chosen index."""
|
|
263
|
+
import tty
|
|
264
|
+
import termios
|
|
265
|
+
|
|
266
|
+
selected = default
|
|
267
|
+
fd = sys.stdin.fileno()
|
|
268
|
+
old = termios.tcgetattr(fd)
|
|
269
|
+
|
|
270
|
+
def _render():
|
|
271
|
+
# Move cursor up to overwrite previous render (except first time)
|
|
272
|
+
for i, opt in enumerate(options):
|
|
273
|
+
prefix = click.style(" ›", fg=Style.SUCCESS) if i == selected else " "
|
|
274
|
+
text = click.style(f" {opt}", bold=(i == selected))
|
|
275
|
+
click.echo(f"\r{prefix}{text} ") # trailing spaces clear leftover chars
|
|
276
|
+
|
|
277
|
+
click.echo(click.style(f" {label}", fg=Style.INFO))
|
|
278
|
+
click.echo()
|
|
279
|
+
_render()
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
tty.setraw(fd)
|
|
283
|
+
while True:
|
|
284
|
+
ch = sys.stdin.read(1)
|
|
285
|
+
if ch == "\r" or ch == "\n":
|
|
286
|
+
break
|
|
287
|
+
if ch == "\x03": # Ctrl-C
|
|
288
|
+
raise KeyboardInterrupt
|
|
289
|
+
if ch == "\x1b": # Escape sequence
|
|
290
|
+
seq = sys.stdin.read(2)
|
|
291
|
+
if seq == "[A": # Up arrow
|
|
292
|
+
selected = (selected - 1) % len(options)
|
|
293
|
+
elif seq == "[B": # Down arrow
|
|
294
|
+
selected = (selected + 1) % len(options)
|
|
295
|
+
# Move cursor up to re-render
|
|
296
|
+
click.echo(f"\x1b[{len(options)}A", nl=False)
|
|
297
|
+
_render()
|
|
298
|
+
finally:
|
|
299
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
300
|
+
|
|
301
|
+
click.echo()
|
|
302
|
+
return selected
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _terminal_auth(endpoint: str) -> str:
|
|
306
|
+
"""Interactive sign-up / sign-in flow entirely in the terminal.
|
|
307
|
+
|
|
308
|
+
Returns the API key on success or exits on failure.
|
|
309
|
+
"""
|
|
310
|
+
import requests
|
|
311
|
+
|
|
312
|
+
click.echo()
|
|
313
|
+
choice = _select("New to Plexus?", ["Sign up", "Sign in"], default=0)
|
|
314
|
+
mode = "signup" if choice == 0 else "signin"
|
|
315
|
+
|
|
316
|
+
email = click.prompt(
|
|
317
|
+
click.style(" Email", fg=Style.INFO),
|
|
318
|
+
type=str,
|
|
319
|
+
).strip()
|
|
320
|
+
|
|
321
|
+
password = getpass.getpass(
|
|
322
|
+
click.style(" Password: ", fg=Style.INFO),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
if mode == "signup":
|
|
326
|
+
first_name = click.prompt(
|
|
327
|
+
click.style(" First name", fg=Style.INFO)
|
|
328
|
+
+ click.style(" (optional)", fg=Style.DIM),
|
|
329
|
+
default="",
|
|
330
|
+
show_default=False,
|
|
331
|
+
).strip() or None
|
|
332
|
+
|
|
333
|
+
spinner = Spinner("Creating account...")
|
|
334
|
+
spinner.start()
|
|
335
|
+
try:
|
|
336
|
+
resp = requests.post(
|
|
337
|
+
f"{endpoint}/api/auth/cli/signup",
|
|
338
|
+
json={"email": email, "password": password, "first_name": first_name},
|
|
339
|
+
timeout=30,
|
|
340
|
+
)
|
|
341
|
+
except Exception as e:
|
|
342
|
+
spinner.stop(f"Connection failed: {e}", success_status=False)
|
|
343
|
+
sys.exit(1)
|
|
344
|
+
|
|
345
|
+
if resp.status_code == 409:
|
|
346
|
+
# Account already exists — fall through to sign-in
|
|
347
|
+
spinner.stop()
|
|
348
|
+
hint("Account exists, signing in instead...")
|
|
349
|
+
mode = "signin"
|
|
350
|
+
elif not resp.ok:
|
|
351
|
+
data = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
|
|
352
|
+
spinner.stop(data.get("message", data.get("error", "Sign-up failed")), success_status=False)
|
|
353
|
+
sys.exit(1)
|
|
354
|
+
else:
|
|
355
|
+
result = resp.json()
|
|
356
|
+
spinner.stop("Welcome to Plexus!", success_status=True)
|
|
357
|
+
|
|
358
|
+
api_key = result["api_key"]
|
|
359
|
+
config = load_config()
|
|
360
|
+
config["api_key"] = api_key
|
|
361
|
+
config["org_id"] = result.get("org_id")
|
|
362
|
+
save_config(config)
|
|
363
|
+
click.echo()
|
|
364
|
+
return api_key
|
|
365
|
+
|
|
366
|
+
# Sign-in flow (also handles signup → signin fallback)
|
|
367
|
+
if mode == "signin":
|
|
368
|
+
spinner = Spinner("Signing in...")
|
|
369
|
+
spinner.start()
|
|
370
|
+
try:
|
|
371
|
+
resp = requests.post(
|
|
372
|
+
f"{endpoint}/api/auth/cli/signin",
|
|
373
|
+
json={"email": email, "password": password},
|
|
374
|
+
timeout=30,
|
|
375
|
+
)
|
|
376
|
+
except Exception as e:
|
|
377
|
+
spinner.stop(f"Connection failed: {e}", success_status=False)
|
|
378
|
+
sys.exit(1)
|
|
379
|
+
|
|
380
|
+
if not resp.ok:
|
|
381
|
+
data = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
|
|
382
|
+
err_code = data.get("error", "")
|
|
383
|
+
if err_code == "no_account":
|
|
384
|
+
spinner.stop("No account found. Try signup instead.", success_status=False)
|
|
385
|
+
elif err_code == "invalid_credentials":
|
|
386
|
+
spinner.stop("Wrong password", success_status=False)
|
|
387
|
+
else:
|
|
388
|
+
spinner.stop(data.get("message", "Sign-in failed"), success_status=False)
|
|
389
|
+
sys.exit(1)
|
|
390
|
+
|
|
391
|
+
result = resp.json()
|
|
392
|
+
spinner.stop("Welcome back!", success_status=True)
|
|
393
|
+
|
|
394
|
+
api_key = result["api_key"]
|
|
395
|
+
config = load_config()
|
|
396
|
+
config["api_key"] = api_key
|
|
397
|
+
config["org_id"] = result.get("org_id")
|
|
398
|
+
save_config(config)
|
|
399
|
+
click.echo()
|
|
400
|
+
return api_key
|
|
401
|
+
|
|
402
|
+
# Should not reach here
|
|
403
|
+
error("Authentication failed")
|
|
404
|
+
sys.exit(1)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _startup_wizard(endpoint: str) -> str:
|
|
408
|
+
"""Interactive first-time setup. Returns API key."""
|
|
409
|
+
import socket
|
|
410
|
+
|
|
411
|
+
# ── Welcome box ──
|
|
412
|
+
device_type = _detect_device_type()
|
|
413
|
+
click.echo()
|
|
414
|
+
click.secho(f" ┌{'─' * (Style.WIDTH - 2)}┐", fg=Style.DIM)
|
|
415
|
+
click.secho(f" │{'Welcome to Plexus':^{Style.WIDTH - 2}}│", fg="white", bold=True)
|
|
416
|
+
click.secho(f" │{f'Device: {device_type}':^{Style.WIDTH - 2}}│", fg=Style.DIM)
|
|
417
|
+
click.secho(f" │{f'Version: {__version__}':^{Style.WIDTH - 2}}│", fg=Style.DIM)
|
|
418
|
+
click.secho(f" └{'─' * (Style.WIDTH - 2)}┘", fg=Style.DIM)
|
|
419
|
+
click.echo()
|
|
420
|
+
|
|
421
|
+
# ── Device name ──
|
|
422
|
+
default_name = socket.gethostname().lower().replace(" ", "-")
|
|
423
|
+
device_name = click.prompt(
|
|
424
|
+
click.style(" Device name", fg=Style.INFO),
|
|
425
|
+
default=default_name,
|
|
426
|
+
).strip().lower().replace(" ", "-")
|
|
427
|
+
|
|
428
|
+
config = load_config()
|
|
429
|
+
config["source_name"] = device_name
|
|
430
|
+
config["source_id"] = device_name
|
|
431
|
+
save_config(config)
|
|
432
|
+
success(f"Device: {device_name}")
|
|
433
|
+
click.echo()
|
|
434
|
+
|
|
435
|
+
# ── Auth ──
|
|
436
|
+
api_key = _terminal_auth(endpoint)
|
|
437
|
+
return api_key
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _start_metric_readout(sensor_hub):
|
|
441
|
+
"""Show live metric values in the terminal."""
|
|
442
|
+
num_metrics = 0
|
|
443
|
+
|
|
444
|
+
def _readout():
|
|
445
|
+
nonlocal num_metrics
|
|
446
|
+
while True:
|
|
447
|
+
try:
|
|
448
|
+
readings = sensor_hub.read_all()
|
|
449
|
+
if not readings:
|
|
450
|
+
time.sleep(2)
|
|
451
|
+
continue
|
|
452
|
+
|
|
453
|
+
# Move cursor up to overwrite previous readout
|
|
454
|
+
if num_metrics > 0:
|
|
455
|
+
click.echo(f"\x1b[{num_metrics}A", nl=False)
|
|
456
|
+
|
|
457
|
+
num_metrics = len(readings)
|
|
458
|
+
for r in readings:
|
|
459
|
+
name = r.metric.replace("_", " ").replace(".", " > ")
|
|
460
|
+
if isinstance(r.value, float):
|
|
461
|
+
val = f"{r.value:.1f}"
|
|
462
|
+
else:
|
|
463
|
+
val = str(r.value)
|
|
464
|
+
|
|
465
|
+
line = (
|
|
466
|
+
click.style(f" {name:<24}", fg=Style.DIM)
|
|
467
|
+
+ click.style(val, bold=True)
|
|
468
|
+
)
|
|
469
|
+
# Pad to clear previous content
|
|
470
|
+
click.echo(f"{line:<60}")
|
|
471
|
+
|
|
472
|
+
except Exception:
|
|
473
|
+
pass
|
|
474
|
+
time.sleep(2)
|
|
475
|
+
|
|
476
|
+
thread = threading.Thread(target=_readout, daemon=True)
|
|
477
|
+
thread.start()
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
481
|
+
# CLI Commands
|
|
482
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
483
|
+
|
|
484
|
+
@click.group()
|
|
485
|
+
@click.version_option(version=__version__, prog_name="plexus")
|
|
486
|
+
def main():
|
|
487
|
+
"""
|
|
488
|
+
Plexus Agent - Connect your hardware to Plexus.
|
|
489
|
+
|
|
490
|
+
\b
|
|
491
|
+
Quick start:
|
|
492
|
+
plexus start # Interactive setup + stream
|
|
493
|
+
plexus start --key plx_xxx # Use an API key directly
|
|
494
|
+
|
|
495
|
+
\b
|
|
496
|
+
Other commands:
|
|
497
|
+
plexus reset # Clear config and start over
|
|
498
|
+
"""
|
|
499
|
+
pass
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
503
|
+
# plexus start
|
|
504
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
505
|
+
|
|
506
|
+
def _should_use_tui(headless: bool) -> bool:
|
|
507
|
+
"""Determine if TUI should be used based on flags and environment."""
|
|
508
|
+
if headless or not sys.stdout.isatty():
|
|
509
|
+
return False
|
|
510
|
+
try:
|
|
511
|
+
import rich # noqa: F401
|
|
512
|
+
return True
|
|
513
|
+
except ImportError:
|
|
514
|
+
return False
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _quiet_status_line(msg: str, _state={"connected": False}):
|
|
518
|
+
"""Status callback that suppresses initial connect noise."""
|
|
519
|
+
if not _state["connected"]:
|
|
520
|
+
# Suppress Connecting/Authenticating/Connected during first connect
|
|
521
|
+
if msg.startswith("Connecting to") or msg == "Authenticating...":
|
|
522
|
+
return
|
|
523
|
+
if msg.startswith("Connected as"):
|
|
524
|
+
_state["connected"] = True
|
|
525
|
+
return
|
|
526
|
+
status_line(msg)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _run_connector(
|
|
530
|
+
*,
|
|
531
|
+
api_key: str,
|
|
532
|
+
endpoint: str,
|
|
533
|
+
use_tui: bool,
|
|
534
|
+
source_name: Optional[str] = None,
|
|
535
|
+
sensor_hub,
|
|
536
|
+
camera_hub,
|
|
537
|
+
can_adapters,
|
|
538
|
+
):
|
|
539
|
+
"""Single launch site for the connector (TUI or plain)."""
|
|
540
|
+
if use_tui:
|
|
541
|
+
try:
|
|
542
|
+
from plexus.tui import LiveDashboard
|
|
543
|
+
dashboard = LiveDashboard(sensor_hub=sensor_hub)
|
|
544
|
+
|
|
545
|
+
def _connector_fn():
|
|
546
|
+
from plexus.connector import run_connector
|
|
547
|
+
run_connector(
|
|
548
|
+
api_key=api_key,
|
|
549
|
+
endpoint=endpoint,
|
|
550
|
+
source_name=source_name,
|
|
551
|
+
on_status=dashboard.wrap_status_callback(status_line),
|
|
552
|
+
sensor_hub=sensor_hub,
|
|
553
|
+
camera_hub=camera_hub,
|
|
554
|
+
can_adapters=can_adapters,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
dashboard.run(_connector_fn)
|
|
558
|
+
except ImportError as e:
|
|
559
|
+
warning(str(e).strip())
|
|
560
|
+
hint("Install with: pip install plexus-python[tui]")
|
|
561
|
+
_run_connector(
|
|
562
|
+
api_key=api_key,
|
|
563
|
+
endpoint=endpoint,
|
|
564
|
+
use_tui=False,
|
|
565
|
+
source_name=source_name,
|
|
566
|
+
sensor_hub=sensor_hub,
|
|
567
|
+
camera_hub=camera_hub,
|
|
568
|
+
can_adapters=can_adapters,
|
|
569
|
+
)
|
|
570
|
+
except KeyboardInterrupt:
|
|
571
|
+
pass
|
|
572
|
+
else:
|
|
573
|
+
from plexus.connector import run_connector
|
|
574
|
+
try:
|
|
575
|
+
run_connector(
|
|
576
|
+
api_key=api_key,
|
|
577
|
+
endpoint=endpoint,
|
|
578
|
+
source_name=source_name,
|
|
579
|
+
on_status=_quiet_status_line,
|
|
580
|
+
sensor_hub=sensor_hub,
|
|
581
|
+
camera_hub=camera_hub,
|
|
582
|
+
can_adapters=can_adapters,
|
|
583
|
+
)
|
|
584
|
+
except KeyboardInterrupt:
|
|
585
|
+
click.echo()
|
|
586
|
+
status_line("Disconnected")
|
|
587
|
+
click.echo()
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
@main.command()
|
|
591
|
+
@click.option("--key", "-k", help="API key from dashboard")
|
|
592
|
+
@click.option("--device-id", help="Device ID from dashboard")
|
|
593
|
+
@click.option("--scan", is_flag=True, help="Re-detect hardware and update config")
|
|
594
|
+
def start(key: Optional[str], device_id: Optional[str], scan: bool):
|
|
595
|
+
"""
|
|
596
|
+
Set up and start streaming.
|
|
597
|
+
|
|
598
|
+
Handles auth, hardware detection, and streaming. Sensors are detected
|
|
599
|
+
on first run and saved to config. Use --scan to re-detect.
|
|
600
|
+
|
|
601
|
+
\b
|
|
602
|
+
Examples:
|
|
603
|
+
plexus start # Interactive setup
|
|
604
|
+
plexus start --key plx_xxx # Use an API key directly
|
|
605
|
+
"""
|
|
606
|
+
from plexus.detect import (
|
|
607
|
+
detect_sensors, detect_cameras, detect_can,
|
|
608
|
+
detect_named_sensors, sensors_to_config, load_sensors_from_config,
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
slug = device_id
|
|
612
|
+
|
|
613
|
+
# ── TUI mode detection ────────────────────────────────────────────────
|
|
614
|
+
# Auto-detect: use TUI if interactive terminal, headless otherwise
|
|
615
|
+
headless = not sys.stdout.isatty()
|
|
616
|
+
use_tui = _should_use_tui(headless)
|
|
617
|
+
|
|
618
|
+
# ── Welcome ───────────────────────────────────────────────────────────
|
|
619
|
+
header(f"Plexus Agent v{__version__}")
|
|
620
|
+
|
|
621
|
+
# ── Auth ──────────────────────────────────────────────────────────────
|
|
622
|
+
api_key = get_api_key()
|
|
623
|
+
endpoint = get_endpoint()
|
|
624
|
+
|
|
625
|
+
# ── Startup wizard for first-time users ──────────────────────────────
|
|
626
|
+
if not api_key and not headless:
|
|
627
|
+
api_key = _startup_wizard(endpoint)
|
|
628
|
+
|
|
629
|
+
if key:
|
|
630
|
+
# --key flag: save and use
|
|
631
|
+
config = load_config()
|
|
632
|
+
config["api_key"] = key
|
|
633
|
+
save_config(config)
|
|
634
|
+
api_key = key
|
|
635
|
+
elif not api_key:
|
|
636
|
+
# Terminal sign-up / sign-in flow
|
|
637
|
+
api_key = _terminal_auth(endpoint)
|
|
638
|
+
|
|
639
|
+
# Validate key
|
|
640
|
+
if not _validate_api_key(api_key, endpoint):
|
|
641
|
+
error("API key invalid or server unreachable")
|
|
642
|
+
hint("Check your key at app.plexus.company/devices")
|
|
643
|
+
click.echo()
|
|
644
|
+
sys.exit(1)
|
|
645
|
+
|
|
646
|
+
# ── Device ID ──────────────────────────────────────────────────────────
|
|
647
|
+
if slug:
|
|
648
|
+
config = load_config()
|
|
649
|
+
config["source_id"] = slug
|
|
650
|
+
save_config(config)
|
|
651
|
+
|
|
652
|
+
# ── Hardware ──────────────────────────────────────────────────────────
|
|
653
|
+
cfg = load_config()
|
|
654
|
+
saved_sensors = cfg.get("sensors")
|
|
655
|
+
sensor_hub = None
|
|
656
|
+
sensors = []
|
|
657
|
+
|
|
658
|
+
if saved_sensors is not None and not scan:
|
|
659
|
+
# ── Load from config (no prompts, no scanning) ──
|
|
660
|
+
sensor_hub, sensors = load_sensors_from_config(saved_sensors)
|
|
661
|
+
if not sensors and saved_sensors:
|
|
662
|
+
warning("No configured sensors responding (try: plexus start --scan)")
|
|
663
|
+
else:
|
|
664
|
+
# ── First run or --scan: detect and save ──
|
|
665
|
+
info("Scanning hardware...")
|
|
666
|
+
click.echo()
|
|
667
|
+
|
|
668
|
+
hw_detected = False
|
|
669
|
+
try:
|
|
670
|
+
sensor_hub, sensors = detect_sensors()
|
|
671
|
+
hw_detected = bool(sensors)
|
|
672
|
+
except PermissionError:
|
|
673
|
+
warning("I2C permission denied (run: sudo usermod -aG i2c $USER)")
|
|
674
|
+
except ImportError:
|
|
675
|
+
warning("I2C sensor support not installed")
|
|
676
|
+
dim(f"Install with: {_pip_install_cmd('smbus2')}")
|
|
677
|
+
except OSError as e:
|
|
678
|
+
warning(f"I2C bus error: {e}")
|
|
679
|
+
except Exception as e:
|
|
680
|
+
warning(f"Sensor detection failed: {e}")
|
|
681
|
+
|
|
682
|
+
# Fallback to system metrics if nothing found
|
|
683
|
+
if not sensors:
|
|
684
|
+
try:
|
|
685
|
+
sensor_hub, sensors = detect_named_sensors(["system"])
|
|
686
|
+
except Exception:
|
|
687
|
+
warning("Could not enable system metrics (pip install psutil)")
|
|
688
|
+
|
|
689
|
+
# Only save to config if we found real hardware sensors —
|
|
690
|
+
# don't persist fallback-only so next run re-scans
|
|
691
|
+
if hw_detected:
|
|
692
|
+
cfg["sensors"] = sensors_to_config(sensors)
|
|
693
|
+
save_config(cfg)
|
|
694
|
+
|
|
695
|
+
# Print what was detected
|
|
696
|
+
if sensors:
|
|
697
|
+
for s in sensors:
|
|
698
|
+
s_metrics = getattr(s, 'metrics', None) or (
|
|
699
|
+
getattr(s.driver, 'metrics', None) if hasattr(s, 'driver') else None
|
|
700
|
+
)
|
|
701
|
+
metrics_str = ", ".join(s_metrics) if s_metrics else ""
|
|
702
|
+
click.echo(
|
|
703
|
+
f" {Style.CHECK} "
|
|
704
|
+
+ click.style(f"{s.name:<12}", fg=Style.SUCCESS)
|
|
705
|
+
+ click.style(metrics_str, fg=Style.DIM)
|
|
706
|
+
)
|
|
707
|
+
dim(f"Saved to config ({get_config_path()})")
|
|
708
|
+
dim("Re-detect with: plexus start --scan")
|
|
709
|
+
click.echo()
|
|
710
|
+
|
|
711
|
+
# Cameras
|
|
712
|
+
camera_hub = None
|
|
713
|
+
cameras = []
|
|
714
|
+
try:
|
|
715
|
+
camera_hub, cameras = detect_cameras()
|
|
716
|
+
except ImportError:
|
|
717
|
+
warning("Camera support not installed")
|
|
718
|
+
dim(f"Install with: {_pip_install_cmd('picamera2')}")
|
|
719
|
+
dim(f" or: {_pip_install_cmd('opencv-python')}")
|
|
720
|
+
except Exception as e:
|
|
721
|
+
warning(f"Camera detection failed: {e}")
|
|
722
|
+
|
|
723
|
+
# CAN
|
|
724
|
+
can_adapters, up_can, down_can = detect_can()
|
|
725
|
+
|
|
726
|
+
# ── Status block ────────────────────────────────────────────────────
|
|
727
|
+
source_id = get_source_id()
|
|
728
|
+
cfg = load_config()
|
|
729
|
+
device_name = cfg.get("source_name") or source_id
|
|
730
|
+
|
|
731
|
+
_print_status_block(device_name, sensors, endpoint)
|
|
732
|
+
|
|
733
|
+
# ── Live metric readout (background) ────────────────────────────────
|
|
734
|
+
if sensor_hub and not use_tui:
|
|
735
|
+
_start_metric_readout(sensor_hub)
|
|
736
|
+
|
|
737
|
+
# ── Start connector ───────────────────────────────────────────────────
|
|
738
|
+
_run_connector(
|
|
739
|
+
api_key=api_key,
|
|
740
|
+
endpoint=endpoint,
|
|
741
|
+
use_tui=use_tui,
|
|
742
|
+
source_name=device_name,
|
|
743
|
+
sensor_hub=sensor_hub,
|
|
744
|
+
camera_hub=camera_hub,
|
|
745
|
+
can_adapters=can_adapters,
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
750
|
+
# plexus reset
|
|
751
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
752
|
+
|
|
753
|
+
@main.command()
|
|
754
|
+
def reset():
|
|
755
|
+
"""
|
|
756
|
+
Clear all configuration and start over.
|
|
757
|
+
|
|
758
|
+
Removes saved API key, device ID, and all other settings.
|
|
759
|
+
Run 'plexus start' again to set up from scratch.
|
|
760
|
+
"""
|
|
761
|
+
config_path = get_config_path()
|
|
762
|
+
|
|
763
|
+
if not config_path.exists():
|
|
764
|
+
info("Nothing to reset — no config file found.")
|
|
765
|
+
click.echo()
|
|
766
|
+
return
|
|
767
|
+
|
|
768
|
+
click.echo()
|
|
769
|
+
if click.confirm(click.style(" Remove all Plexus configuration?", fg=Style.WARNING), default=False):
|
|
770
|
+
config_path.unlink()
|
|
771
|
+
click.echo()
|
|
772
|
+
success("Configuration cleared")
|
|
773
|
+
click.echo()
|
|
774
|
+
hint("Run 'plexus start' to set up again")
|
|
775
|
+
click.echo()
|
|
776
|
+
else:
|
|
777
|
+
click.echo()
|
|
778
|
+
dim(" Cancelled")
|
|
779
|
+
click.echo()
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
if __name__ == "__main__":
|
|
783
|
+
main()
|