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/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()