plexus-python 0.3.0__py3-none-any.whl → 0.4.2__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 CHANGED
@@ -10,5 +10,5 @@ Plexus — thin Python SDK for sending telemetry to the Plexus gateway.
10
10
  from plexus.client import Plexus
11
11
  from plexus.ws import WebSocketTransport
12
12
 
13
- __version__ = "0.3.0"
13
+ __version__ = "0.4.1"
14
14
  __all__ = ["Plexus", "WebSocketTransport"]
plexus/cli.py ADDED
@@ -0,0 +1,486 @@
1
+ """
2
+ Plexus CLI — `plexus init` style auth, plus a few sibling commands.
3
+
4
+ Designed to feel like fly.io / vercel CLIs:
5
+ $ pip install plexus
6
+ $ plexus init
7
+ Opening browser to https://app.plexus.company/auth/cli...
8
+ ✓ Saved API key as cli-<host>. You're set up.
9
+
10
+ Implementation:
11
+ - Spin up a local HTTP listener on a random free port.
12
+ - Open the browser to /auth/cli with the callback URL embedded.
13
+ - Block until the browser POSTs (well — redirects with key) to /callback.
14
+ - Verify the `state` parameter matches what we generated.
15
+ - Persist the key via plexus.config.save_config; the SDK already reads
16
+ `~/.plexus/config.json` for `PLEXUS_API_KEY`.
17
+
18
+ Stdlib only — keep dependency footprint minimal.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import http.server
25
+ import secrets
26
+ import socket
27
+ import socketserver
28
+ import sys
29
+ import threading
30
+ import urllib.parse
31
+ import webbrowser
32
+ from typing import Optional
33
+
34
+ from . import config
35
+
36
+
37
+ DEFAULT_TIMEOUT_SECONDS = 300
38
+ SUCCESS_REDIRECT_SECONDS = 10
39
+ SUCCESS_HTML_TEMPLATE = """<!doctype html>
40
+ <html lang="en">
41
+ <head>
42
+ <meta charset="utf-8" />
43
+ <title>Plexus CLI</title>
44
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
45
+ <meta http-equiv="refresh" content="{seconds};url={target}" />
46
+ <style>
47
+ :root {{ color-scheme: dark; }}
48
+ * {{ box-sizing: border-box; }}
49
+ html, body {{ height: 100%; }}
50
+ body {{
51
+ margin: 0;
52
+ font-family: -apple-system, BlinkMacSystemFont, 'Inter',
53
+ 'Segoe UI', system-ui, sans-serif;
54
+ background: #000;
55
+ color: #fafafa;
56
+ display: flex;
57
+ align-items: center;
58
+ justify-content: center;
59
+ padding: 24px;
60
+ -webkit-font-smoothing: antialiased;
61
+ }}
62
+ .shell {{
63
+ width: 100%;
64
+ max-width: 380px;
65
+ display: flex;
66
+ flex-direction: column;
67
+ align-items: center;
68
+ gap: 24px;
69
+ }}
70
+ .brand {{
71
+ display: flex;
72
+ align-items: center;
73
+ gap: 10px;
74
+ color: #e4e4e7;
75
+ font-size: 14px;
76
+ font-weight: 500;
77
+ letter-spacing: -0.01em;
78
+ }}
79
+ .brand .mark {{
80
+ width: 22px;
81
+ height: 22px;
82
+ border-radius: 6px;
83
+ background: linear-gradient(135deg, #fafafa 0%, #71717a 100%);
84
+ display: inline-block;
85
+ }}
86
+ .card {{
87
+ width: 100%;
88
+ background: #09090b;
89
+ border: 1px solid #27272a;
90
+ border-radius: 12px;
91
+ padding: 28px 28px 24px;
92
+ text-align: center;
93
+ }}
94
+ .check {{
95
+ width: 36px;
96
+ height: 36px;
97
+ border-radius: 999px;
98
+ background: rgba(34, 197, 94, 0.12);
99
+ color: #4ade80;
100
+ display: inline-flex;
101
+ align-items: center;
102
+ justify-content: center;
103
+ margin: 0 auto 16px;
104
+ }}
105
+ .check svg {{ width: 18px; height: 18px; }}
106
+ h1 {{
107
+ margin: 0 0 6px;
108
+ font-size: 16px;
109
+ font-weight: 600;
110
+ color: #fafafa;
111
+ letter-spacing: -0.01em;
112
+ }}
113
+ .lede {{
114
+ margin: 0 0 20px;
115
+ color: #a1a1aa;
116
+ font-size: 13px;
117
+ line-height: 1.5;
118
+ }}
119
+ .meta {{
120
+ margin-top: 20px;
121
+ padding-top: 16px;
122
+ border-top: 1px solid #18181b;
123
+ color: #71717a;
124
+ font-size: 12px;
125
+ }}
126
+ .meta a {{
127
+ color: #a1a1aa;
128
+ text-decoration: none;
129
+ font-family: ui-monospace, SFMono-Regular, 'SF Mono',
130
+ Menlo, Consolas, monospace;
131
+ }}
132
+ .meta a:hover {{ color: #fafafa; }}
133
+ #countdown {{
134
+ font-variant-numeric: tabular-nums;
135
+ color: #e4e4e7;
136
+ }}
137
+ </style>
138
+ </head>
139
+ <body>
140
+ <div class="shell">
141
+ <div class="brand">
142
+ <span class="mark" aria-hidden="true"></span>
143
+ <span>Plexus</span>
144
+ </div>
145
+ <div class="card">
146
+ <div class="check" aria-hidden="true">
147
+ <svg viewBox="0 0 20 20" fill="none"
148
+ stroke="currentColor" stroke-width="2.5"
149
+ stroke-linecap="round" stroke-linejoin="round">
150
+ <polyline points="5 10.5 8.5 14 15 7" />
151
+ </svg>
152
+ </div>
153
+ <h1>You&rsquo;re all set</h1>
154
+ <p class="lede">
155
+ Return to your terminal &mdash; the CLI has your key.
156
+ </p>
157
+ <div class="meta">
158
+ Opening <a href="{target}">{target_label}</a> in
159
+ <span id="countdown">{seconds}</span>s&hellip;
160
+ </div>
161
+ </div>
162
+ </div>
163
+ <script>
164
+ (function () {{
165
+ var n = {seconds};
166
+ var el = document.getElementById('countdown');
167
+ var t = setInterval(function () {{
168
+ n -= 1;
169
+ if (el) el.textContent = n;
170
+ if (n <= 0) {{
171
+ clearInterval(t);
172
+ window.location.replace({target_js!s});
173
+ }}
174
+ }}, 1000);
175
+ }})();
176
+ </script>
177
+ </body>
178
+ </html>"""
179
+
180
+
181
+ def _success_html(target: str) -> bytes:
182
+ label = target.replace("https://", "").replace("http://", "").rstrip("/")
183
+ return SUCCESS_HTML_TEMPLATE.format(
184
+ seconds=SUCCESS_REDIRECT_SECONDS,
185
+ target=target,
186
+ target_label=label,
187
+ target_js=repr(target),
188
+ ).encode("utf-8")
189
+
190
+ ERROR_HTML = """<!doctype html>
191
+ <html lang="en">
192
+ <head>
193
+ <meta charset="utf-8" />
194
+ <title>Plexus CLI</title>
195
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
196
+ <style>
197
+ :root { color-scheme: dark; }
198
+ * { box-sizing: border-box; }
199
+ html, body { height: 100%; }
200
+ body {
201
+ margin: 0;
202
+ font-family: -apple-system, BlinkMacSystemFont, 'Inter',
203
+ 'Segoe UI', system-ui, sans-serif;
204
+ background: #000;
205
+ color: #fafafa;
206
+ display: flex;
207
+ align-items: center;
208
+ justify-content: center;
209
+ padding: 24px;
210
+ -webkit-font-smoothing: antialiased;
211
+ }
212
+ .shell {
213
+ width: 100%;
214
+ max-width: 380px;
215
+ display: flex;
216
+ flex-direction: column;
217
+ align-items: center;
218
+ gap: 24px;
219
+ }
220
+ .brand {
221
+ display: flex; align-items: center; gap: 10px;
222
+ color: #e4e4e7; font-size: 14px; font-weight: 500;
223
+ letter-spacing: -0.01em;
224
+ }
225
+ .brand .mark {
226
+ width: 22px; height: 22px; border-radius: 6px;
227
+ background: linear-gradient(135deg, #fafafa 0%, #71717a 100%);
228
+ display: inline-block;
229
+ }
230
+ .card {
231
+ width: 100%;
232
+ background: #09090b;
233
+ border: 1px solid #27272a;
234
+ border-radius: 12px;
235
+ padding: 28px;
236
+ text-align: center;
237
+ }
238
+ .icon {
239
+ width: 36px; height: 36px; border-radius: 999px;
240
+ background: rgba(239, 68, 68, 0.12);
241
+ color: #f87171;
242
+ display: inline-flex; align-items: center; justify-content: center;
243
+ margin: 0 auto 16px;
244
+ }
245
+ .icon svg { width: 18px; height: 18px; }
246
+ h1 {
247
+ margin: 0 0 6px; font-size: 16px; font-weight: 600;
248
+ color: #fafafa; letter-spacing: -0.01em;
249
+ }
250
+ p {
251
+ margin: 0; color: #a1a1aa; font-size: 13px; line-height: 1.5;
252
+ }
253
+ </style>
254
+ </head>
255
+ <body>
256
+ <div class="shell">
257
+ <div class="brand">
258
+ <span class="mark" aria-hidden="true"></span>
259
+ <span>Plexus</span>
260
+ </div>
261
+ <div class="card">
262
+ <div class="icon" aria-hidden="true">
263
+ <svg viewBox="0 0 20 20" fill="none"
264
+ stroke="currentColor" stroke-width="2.5"
265
+ stroke-linecap="round" stroke-linejoin="round">
266
+ <line x1="6" y1="6" x2="14" y2="14" />
267
+ <line x1="14" y1="6" x2="6" y2="14" />
268
+ </svg>
269
+ </div>
270
+ <h1>Authorization failed</h1>
271
+ <p>Return to your terminal for details.</p>
272
+ </div>
273
+ </div>
274
+ </body>
275
+ </html>""".encode("utf-8")
276
+
277
+
278
+ class _CallbackResult:
279
+ key: Optional[str] = None
280
+ state: Optional[str] = None
281
+ error: Optional[str] = None
282
+
283
+
284
+ def _pick_free_port() -> int:
285
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
286
+ s.bind(("127.0.0.1", 0))
287
+ return s.getsockname()[1]
288
+
289
+
290
+ def _make_handler(
291
+ result: _CallbackResult,
292
+ expected_state: str,
293
+ done: threading.Event,
294
+ redirect_target: str,
295
+ ):
296
+ success_html = _success_html(redirect_target)
297
+
298
+ class Handler(http.server.BaseHTTPRequestHandler):
299
+ # Silence the default request log — we don't want CLI noise.
300
+ def log_message(self, *_args, **_kwargs): # type: ignore[override]
301
+ return
302
+
303
+ def do_GET(self): # type: ignore[override]
304
+ parsed = urllib.parse.urlparse(self.path)
305
+ if parsed.path != "/callback":
306
+ self.send_response(404)
307
+ self.end_headers()
308
+ return
309
+
310
+ params = urllib.parse.parse_qs(parsed.query)
311
+ got_state = (params.get("state") or [""])[0]
312
+ got_key = (params.get("key") or [""])[0]
313
+
314
+ if got_state != expected_state:
315
+ result.error = "state mismatch"
316
+ self.send_response(400)
317
+ self.send_header("Content-Type", "text/html; charset=utf-8")
318
+ self.end_headers()
319
+ self.wfile.write(ERROR_HTML)
320
+ done.set()
321
+ return
322
+
323
+ if not got_key:
324
+ result.error = "no key in callback"
325
+ self.send_response(400)
326
+ self.send_header("Content-Type", "text/html; charset=utf-8")
327
+ self.end_headers()
328
+ self.wfile.write(ERROR_HTML)
329
+ done.set()
330
+ return
331
+
332
+ result.key = got_key
333
+ result.state = got_state
334
+ self.send_response(200)
335
+ self.send_header("Content-Type", "text/html; charset=utf-8")
336
+ self.end_headers()
337
+ self.wfile.write(success_html)
338
+ done.set()
339
+
340
+ return Handler
341
+
342
+
343
+ def _hostname_label() -> str:
344
+ try:
345
+ host = socket.gethostname() or "device"
346
+ except Exception:
347
+ host = "device"
348
+ # Strip the trailing .local etc. and clean it for display.
349
+ safe = host.split(".")[0].lower().replace(" ", "-")
350
+ return f"cli-{safe}" if safe else "cli"
351
+
352
+
353
+ def cmd_init(args: argparse.Namespace) -> int:
354
+ """Open the browser, capture an API key, save it locally."""
355
+ existing = config.get_api_key()
356
+ if existing and not args.force:
357
+ print(
358
+ "An API key is already configured. "
359
+ "Re-run with --force to replace it.",
360
+ file=sys.stderr,
361
+ )
362
+ return 1
363
+
364
+ endpoint = config.get_endpoint().rstrip("/")
365
+ state = secrets.token_urlsafe(24)
366
+ name = args.name or _hostname_label()
367
+ port = _pick_free_port()
368
+ callback = f"http://127.0.0.1:{port}/callback"
369
+
370
+ auth_url = (
371
+ f"{endpoint}/auth/cli"
372
+ f"?state={urllib.parse.quote(state)}"
373
+ f"&callback={urllib.parse.quote(callback)}"
374
+ f"&name={urllib.parse.quote(name)}"
375
+ )
376
+
377
+ result = _CallbackResult()
378
+ done = threading.Event()
379
+ handler = _make_handler(result, state, done, redirect_target=endpoint)
380
+
381
+ server = socketserver.TCPServer(("127.0.0.1", port), handler)
382
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
383
+ thread.start()
384
+
385
+ try:
386
+ print(f"Opening {auth_url}")
387
+ try:
388
+ webbrowser.open(auth_url, new=1, autoraise=True)
389
+ except Exception:
390
+ pass # User can copy the URL manually.
391
+
392
+ print("Waiting for browser confirmation...", flush=True)
393
+ finished = done.wait(timeout=args.timeout)
394
+ if not finished:
395
+ print(
396
+ f"Timed out after {args.timeout}s. Re-run `plexus init`.",
397
+ file=sys.stderr,
398
+ )
399
+ return 2
400
+ finally:
401
+ server.shutdown()
402
+ server.server_close()
403
+
404
+ if result.error or not result.key:
405
+ print(
406
+ f"Authorization failed: {result.error or 'no key returned'}",
407
+ file=sys.stderr,
408
+ )
409
+ return 3
410
+
411
+ cfg = config.load_config()
412
+ cfg["api_key"] = result.key
413
+ config.save_config(cfg)
414
+ print(f"✓ Saved API key as {name}.")
415
+ print(" ~/.plexus/config.json")
416
+ return 0
417
+
418
+
419
+ def cmd_logout(_args: argparse.Namespace) -> int:
420
+ """Forget the locally stored API key."""
421
+ cfg = config.load_config()
422
+ if not cfg.get("api_key"):
423
+ print("Nothing to do — no key on file.")
424
+ return 0
425
+ cfg["api_key"] = None
426
+ config.save_config(cfg)
427
+ print("✓ Cleared local API key.")
428
+ return 0
429
+
430
+
431
+ def cmd_whoami(_args: argparse.Namespace) -> int:
432
+ """Print the prefix of the locally stored key + the configured endpoint."""
433
+ key = config.get_api_key()
434
+ endpoint = config.get_endpoint()
435
+ if not key:
436
+ print("Not signed in. Run `plexus init` to authorize this machine.")
437
+ return 1
438
+ masked = f"{key[:8]}…{key[-4:]}" if len(key) > 12 else key
439
+ print(f"key: {masked}")
440
+ print(f"endpoint: {endpoint}")
441
+ return 0
442
+
443
+
444
+ def build_parser() -> argparse.ArgumentParser:
445
+ parser = argparse.ArgumentParser(
446
+ prog="plexus",
447
+ description="Plexus CLI — auth, send, query telemetry from your terminal.",
448
+ )
449
+ sub = parser.add_subparsers(dest="command", required=True)
450
+
451
+ init = sub.add_parser(
452
+ "init",
453
+ help="Authorize this machine and save an API key locally.",
454
+ aliases=["login"],
455
+ )
456
+ init.add_argument("--name", help="Label for the issued key (default: cli-<hostname>).")
457
+ init.add_argument(
458
+ "--timeout",
459
+ type=int,
460
+ default=DEFAULT_TIMEOUT_SECONDS,
461
+ help="Seconds to wait for the browser callback.",
462
+ )
463
+ init.add_argument(
464
+ "--force",
465
+ action="store_true",
466
+ help="Overwrite an existing local key.",
467
+ )
468
+ init.set_defaults(func=cmd_init)
469
+
470
+ logout = sub.add_parser("logout", help="Forget the local API key.")
471
+ logout.set_defaults(func=cmd_logout)
472
+
473
+ whoami = sub.add_parser("whoami", help="Show the local credential summary.")
474
+ whoami.set_defaults(func=cmd_whoami)
475
+
476
+ return parser
477
+
478
+
479
+ def main(argv: Optional[list] = None) -> int:
480
+ parser = build_parser()
481
+ args = parser.parse_args(argv)
482
+ return args.func(args)
483
+
484
+
485
+ if __name__ == "__main__":
486
+ sys.exit(main())
plexus/client.py CHANGED
@@ -49,7 +49,9 @@ from plexus.config import (
49
49
  get_endpoint,
50
50
  get_gateway_url,
51
51
  get_gateway_ws_url,
52
+ get_install_id,
52
53
  get_source_id,
54
+ set_source_id,
53
55
  )
54
56
  logger = logging.getLogger(__name__)
55
57
 
@@ -259,7 +261,7 @@ class Plexus:
259
261
  ("position", {"x": 1.0, "y": 2.0}),
260
262
  ])
261
263
  """
262
- ts = timestamp or time.time()
264
+ ts = timestamp if timestamp is not None else time.time()
263
265
  data_points = [self._make_point(m, v, ts, tags) for m, v in points]
264
266
  return self._send_points(data_points)
265
267
 
@@ -273,11 +275,23 @@ class Plexus:
273
275
  api_key=self.api_key,
274
276
  source_id=self.source_id,
275
277
  ws_url=self._ws_url,
278
+ install_id=get_install_id(),
276
279
  agent_version=__version__,
280
+ on_source_id_assigned=self._on_source_id_assigned,
277
281
  )
278
282
  self._ws.start()
279
283
  return self._ws
280
284
 
285
+ def _on_source_id_assigned(self, assigned: str) -> None:
286
+ """Callback from WebSocketTransport when the gateway returns an
287
+ auto-suffixed source_id. Persists it so subsequent runs (and the HTTP
288
+ fallback path in this process) use the assigned name directly."""
289
+ self.source_id = assigned
290
+ try:
291
+ set_source_id(assigned)
292
+ except Exception as e: # pragma: no cover - persistence failure is non-fatal
293
+ logger.debug("failed to persist assigned source_id: %s", e)
294
+
281
295
  def on_command(
282
296
  self,
283
297
  name: str,
plexus/config.py CHANGED
@@ -145,6 +145,54 @@ def get_source_id() -> Optional[str]:
145
145
  return source_id
146
146
 
147
147
 
148
+ def get_install_id() -> str:
149
+ """Get the device install ID, generating one if not set.
150
+
151
+ The install_id is a stable per-installation UUID. It is generated lazily
152
+ on first run (NOT at image-build time) so that cloned SD-card images
153
+ naturally get distinct install_ids on their first boot. The gateway uses
154
+ it to tell "same device reconnecting" from "different device claiming the
155
+ same name" when resolving source_id collisions.
156
+
157
+ Resolution order:
158
+ 1. ``PLEXUS_INSTALL_ID`` env var — lets ephemeral containers (Fly
159
+ machines, CI runners, Kubernetes pods) pin a stable identity
160
+ across restarts when the config filesystem is ephemeral. Without
161
+ this, every redeploy generates a new install_id and the gateway
162
+ auto-suffixes the source_id to avoid a collision with the prior
163
+ install ("gw-001" → "gw-001_2" → "gw-001_3"…).
164
+ 2. ``install_id`` in the on-disk config.
165
+ 3. Newly-generated UUID, persisted to config.
166
+ """
167
+ env_id = os.environ.get("PLEXUS_INSTALL_ID", "").strip()
168
+ if env_id:
169
+ return env_id
170
+
171
+ config = load_config()
172
+ install_id = config.get("install_id")
173
+
174
+ if not install_id:
175
+ import uuid
176
+ install_id = uuid.uuid4().hex
177
+ config["install_id"] = install_id
178
+ save_config(config)
179
+
180
+ return install_id
181
+
182
+
183
+ def set_source_id(source_id: str) -> None:
184
+ """Persist an updated source_id to the config file.
185
+
186
+ Called by the SDK when the gateway returns an auto-suffixed name so the
187
+ assigned name is stable across reconnects.
188
+ """
189
+ config = load_config()
190
+ if config.get("source_id") == source_id:
191
+ return
192
+ config["source_id"] = source_id
193
+ save_config(config)
194
+
195
+
148
196
  def get_persistent_buffer() -> bool:
149
197
  """Get persistent buffer setting. Default True (store-and-forward enabled)."""
150
198
  config = load_config()
plexus/ws.py CHANGED
@@ -5,8 +5,14 @@ Wire-compatible with the C SDK (`plexus_ws.c`). Targets the gateway's
5
5
  `/ws/device` endpoint and exchanges the same JSON frames:
6
6
 
7
7
  client → {"type": "device_auth", "api_key": ..., "source_id": ...,
8
- "platform": "python-sdk", "agent_version": ..., "commands": [...]}
8
+ "install_id": ..., "platform": "python-sdk",
9
+ "agent_version": ..., "commands": [...]}
9
10
  server → {"type": "authenticated", "source_id": ...}
11
+
12
+ The server-returned `source_id` in the `authenticated` frame is
13
+ authoritative: if the gateway auto-suffixed on a collision (e.g. the
14
+ desired name was already claimed by a different install_id), the
15
+ client's `source_id` is updated in place to match.
10
16
  client → {"type": "telemetry", "points": [...]}
11
17
  client → {"type": "heartbeat", "source_id": ..., "agent_version": ...} # every 30s
12
18
  server → {"type": "typed_command", "id": ..., "command": ..., "params": {...}}
@@ -78,9 +84,11 @@ class WebSocketTransport:
78
84
  source_id: str,
79
85
  ws_url: str,
80
86
  *,
87
+ install_id: str = "",
81
88
  agent_version: str = "0.0.0",
82
89
  platform: str = "python-sdk",
83
90
  auto_reconnect: bool = True,
91
+ on_source_id_assigned: Optional[Callable[[str], None]] = None,
84
92
  ):
85
93
  if not api_key:
86
94
  raise ValueError("api_key required")
@@ -89,10 +97,12 @@ class WebSocketTransport:
89
97
 
90
98
  self.api_key = api_key
91
99
  self.source_id = source_id
100
+ self.install_id = install_id
92
101
  self.ws_url = _ensure_device_path(ws_url)
93
102
  self.agent_version = agent_version
94
103
  self.platform = platform
95
104
  self.auto_reconnect = auto_reconnect
105
+ self._on_source_id_assigned = on_source_id_assigned
96
106
 
97
107
  self._commands: Dict[str, _RegisteredCommand] = {}
98
108
  self._ws: Optional[websocket.WebSocket] = None
@@ -184,13 +194,16 @@ class WebSocketTransport:
184
194
  self._ws = ws
185
195
 
186
196
  # 1. Send device_auth
197
+ desired_source_id = self.source_id
187
198
  auth = {
188
199
  "type": "device_auth",
189
200
  "api_key": self.api_key,
190
- "source_id": self.source_id,
201
+ "source_id": desired_source_id,
191
202
  "platform": self.platform,
192
203
  "agent_version": self.agent_version,
193
204
  }
205
+ if self.install_id:
206
+ auth["install_id"] = self.install_id
194
207
  if self._commands:
195
208
  auth["commands"] = [c.to_manifest() for c in self._commands.values()]
196
209
  ws.send(json.dumps(auth))
@@ -206,6 +219,22 @@ class WebSocketTransport:
206
219
  if msg.get("type") != "authenticated":
207
220
  raise RuntimeError(f"auth failed: {msg}")
208
221
 
222
+ # The gateway may return a different source_id if the desired name
223
+ # was already claimed by another install — adopt the assigned value
224
+ # so all subsequent frames (heartbeats, future reconnects) use it.
225
+ assigned = msg.get("source_id")
226
+ if isinstance(assigned, str) and assigned and assigned != self.source_id:
227
+ logger.info(
228
+ "plexus ws source_id auto-suffixed: requested=%s assigned=%s",
229
+ desired_source_id, assigned,
230
+ )
231
+ self.source_id = assigned
232
+ if self._on_source_id_assigned is not None:
233
+ try:
234
+ self._on_source_id_assigned(assigned)
235
+ except Exception as e: # pragma: no cover - callback errors must not break auth
236
+ logger.debug("on_source_id_assigned callback raised: %s", e)
237
+
209
238
  self._authenticated.set()
210
239
  self._backoff_attempt = 0
211
240
  logger.info("plexus ws authenticated as %s", self.source_id)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plexus-python
3
- Version: 0.3.0
3
+ Version: 0.4.2
4
4
  Summary: Thin Python SDK for Plexus — send telemetry in one line
5
5
  Project-URL: Homepage, https://plexus.dev
6
6
  Project-URL: Documentation, https://docs.plexus.dev
@@ -54,6 +54,21 @@ px.send("temperature", 72.5)
54
54
 
55
55
  Get an API key at [app.plexus.company](https://app.plexus.company) → Devices → Add Device.
56
56
 
57
+ ## Device identity
58
+
59
+ Every device needs a unique `source_id`. The recommended way to set one on a real host is the bootstrap script, which requires a device name up front:
60
+
61
+ ```bash
62
+ curl -sL https://app.plexus.company/setup | bash -s -- \
63
+ --key plx_xxx --name drone-01
64
+ ```
65
+
66
+ The name must match `^[a-z0-9][a-z0-9_-]{1,62}$`. `setup.sh` refuses to run without `--name` (or without a TTY to prompt for one) — this is deliberate, because the previous `hostname` fallback silently merged telemetry from cloned SD-card images that all booted as `raspberrypi`.
67
+
68
+ **If two devices end up requesting the same name**, the gateway auto-suffixes: the first connection gets `drone-01`, the second gets `drone-01_2`, the third `drone-01_3`, and so on. The SDK logs the rename at INFO and persists the assigned name to `~/.plexus/config.json` so the device keeps its identity across reboots. Under the hood, a per-installation UUID (`install_id`, lazily generated on first run) is what lets the gateway tell "same device reconnecting" from "different device claiming the same name."
69
+
70
+ In normal code, you usually just pass `source_id=...` explicitly to `Plexus(...)` and never have to think about it.
71
+
57
72
  ## Usage
58
73
 
59
74
  ```python
@@ -0,0 +1,11 @@
1
+ plexus/__init__.py,sha256=sIeMuUgTaztA5jYnSxh6T-2lBBjRR7TXQiVHXut5SXI,345
2
+ plexus/buffer.py,sha256=3ykybqLs7yMXxQWFajAT8nGe3cs_lW8_6Xvn0vQ69dE,9262
3
+ plexus/cli.py,sha256=-2wvHXQzobx3_tDGTXpaE2PlHv884y93Mu29kZE8qZE,14214
4
+ plexus/client.py,sha256=Hp-qUdLkZ83OQeF_3d2FH5kCZXK9iJOSmO7o0opOR8U,19395
5
+ plexus/config.py,sha256=wsG6lhNLmKe3JRlVycyRUKQeywnPUPPfrWkXFxYwELE,6179
6
+ plexus/ws.py,sha256=upQ9SpekDYa7MltUW5ZDEuCm_E8hEVpxC0QFNP_jT1g,12581
7
+ plexus_python-0.4.2.dist-info/METADATA,sha256=1hIhHwH2CtEf-CN8iC6DBNyknKE-NkmaF7pfM1bEiFQ,6800
8
+ plexus_python-0.4.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ plexus_python-0.4.2.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
10
+ plexus_python-0.4.2.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
11
+ plexus_python-0.4.2.dist-info/RECORD,,
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ plexus = plexus.cli:main
@@ -1,9 +0,0 @@
1
- plexus/__init__.py,sha256=pWOj-JeHkHxRS6r5bbXFLWjk3Iv36TjsHdhvWHNRe98,345
2
- plexus/buffer.py,sha256=3ykybqLs7yMXxQWFajAT8nGe3cs_lW8_6Xvn0vQ69dE,9262
3
- plexus/client.py,sha256=Pv9xmZMlHoNIr3SjFw8iLwu06QPUO4qEDaAGz3gx-f4,18698
4
- plexus/config.py,sha256=RNym2Fon6JOCVi1rXPSRWjPFAdT8DSmokY5JPEljQOc,4450
5
- plexus/ws.py,sha256=U8FtIxzTDIOgjpfSUtCnqN738uIX8b07MytGHwHGqWE,11112
6
- plexus_python-0.3.0.dist-info/METADATA,sha256=Fk-PyYEGSE_b5LY78p7bRMDRnD702VyXG3pbVIV7-mY,5613
7
- plexus_python-0.3.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
- plexus_python-0.3.0.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
9
- plexus_python-0.3.0.dist-info/RECORD,,