plexus-python 0.2.0__py3-none-any.whl → 0.4.1__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
@@ -8,6 +8,7 @@ Plexus — thin Python SDK for sending telemetry to the Plexus gateway.
8
8
  """
9
9
 
10
10
  from plexus.client import Plexus
11
+ from plexus.ws import WebSocketTransport
11
12
 
12
- __version__ = "0.2.0"
13
- __all__ = ["Plexus"]
13
+ __version__ = "0.4.1"
14
+ __all__ = ["Plexus", "WebSocketTransport"]
plexus/cli.py ADDED
@@ -0,0 +1,275 @@
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_HTML = """<!doctype html>
39
+ <html lang="en">
40
+ <head>
41
+ <meta charset="utf-8" />
42
+ <title>Plexus CLI</title>
43
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
44
+ <style>
45
+ :root { color-scheme: light dark; }
46
+ body {
47
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
48
+ display: flex; align-items: center; justify-content: center;
49
+ min-height: 100vh; margin: 0; background: Canvas; color: CanvasText;
50
+ }
51
+ .card {
52
+ max-width: 360px; padding: 32px; border: 1px solid #8884;
53
+ border-radius: 12px; text-align: center;
54
+ }
55
+ h1 { margin: 0 0 8px; font-size: 18px; }
56
+ p { margin: 0; color: #888; font-size: 14px; }
57
+ </style>
58
+ </head>
59
+ <body>
60
+ <div class="card">
61
+ <h1>You're all set</h1>
62
+ <p>Return to your terminal &mdash; the CLI has your key.</p>
63
+ </div>
64
+ </body>
65
+ </html>""".encode("utf-8")
66
+
67
+ ERROR_HTML = """<!doctype html>
68
+ <html><head><meta charset="utf-8" /><title>Plexus CLI</title></head>
69
+ <body><pre style="font-family:ui-monospace,monospace;padding:24px">
70
+ Plexus CLI authorization failed. Return to your terminal for details.
71
+ </pre></body></html>""".encode("utf-8")
72
+
73
+
74
+ class _CallbackResult:
75
+ key: Optional[str] = None
76
+ state: Optional[str] = None
77
+ error: Optional[str] = None
78
+
79
+
80
+ def _pick_free_port() -> int:
81
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
82
+ s.bind(("127.0.0.1", 0))
83
+ return s.getsockname()[1]
84
+
85
+
86
+ def _make_handler(result: _CallbackResult, expected_state: str, done: threading.Event):
87
+ class Handler(http.server.BaseHTTPRequestHandler):
88
+ # Silence the default request log — we don't want CLI noise.
89
+ def log_message(self, *_args, **_kwargs): # type: ignore[override]
90
+ return
91
+
92
+ def do_GET(self): # type: ignore[override]
93
+ parsed = urllib.parse.urlparse(self.path)
94
+ if parsed.path != "/callback":
95
+ self.send_response(404)
96
+ self.end_headers()
97
+ return
98
+
99
+ params = urllib.parse.parse_qs(parsed.query)
100
+ got_state = (params.get("state") or [""])[0]
101
+ got_key = (params.get("key") or [""])[0]
102
+
103
+ if got_state != expected_state:
104
+ result.error = "state mismatch"
105
+ self.send_response(400)
106
+ self.send_header("Content-Type", "text/html; charset=utf-8")
107
+ self.end_headers()
108
+ self.wfile.write(ERROR_HTML)
109
+ done.set()
110
+ return
111
+
112
+ if not got_key:
113
+ result.error = "no key in callback"
114
+ self.send_response(400)
115
+ self.send_header("Content-Type", "text/html; charset=utf-8")
116
+ self.end_headers()
117
+ self.wfile.write(ERROR_HTML)
118
+ done.set()
119
+ return
120
+
121
+ result.key = got_key
122
+ result.state = got_state
123
+ self.send_response(200)
124
+ self.send_header("Content-Type", "text/html; charset=utf-8")
125
+ self.end_headers()
126
+ self.wfile.write(SUCCESS_HTML)
127
+ done.set()
128
+
129
+ return Handler
130
+
131
+
132
+ def _hostname_label() -> str:
133
+ try:
134
+ host = socket.gethostname() or "device"
135
+ except Exception:
136
+ host = "device"
137
+ # Strip the trailing .local etc. and clean it for display.
138
+ safe = host.split(".")[0].lower().replace(" ", "-")
139
+ return f"cli-{safe}" if safe else "cli"
140
+
141
+
142
+ def cmd_init(args: argparse.Namespace) -> int:
143
+ """Open the browser, capture an API key, save it locally."""
144
+ existing = config.get_api_key()
145
+ if existing and not args.force:
146
+ print(
147
+ "An API key is already configured. "
148
+ "Re-run with --force to replace it.",
149
+ file=sys.stderr,
150
+ )
151
+ return 1
152
+
153
+ endpoint = config.get_endpoint().rstrip("/")
154
+ state = secrets.token_urlsafe(24)
155
+ name = args.name or _hostname_label()
156
+ port = _pick_free_port()
157
+ callback = f"http://127.0.0.1:{port}/callback"
158
+
159
+ auth_url = (
160
+ f"{endpoint}/auth/cli"
161
+ f"?state={urllib.parse.quote(state)}"
162
+ f"&callback={urllib.parse.quote(callback)}"
163
+ f"&name={urllib.parse.quote(name)}"
164
+ )
165
+
166
+ result = _CallbackResult()
167
+ done = threading.Event()
168
+ handler = _make_handler(result, state, done)
169
+
170
+ server = socketserver.TCPServer(("127.0.0.1", port), handler)
171
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
172
+ thread.start()
173
+
174
+ try:
175
+ print(f"Opening {auth_url}")
176
+ try:
177
+ webbrowser.open(auth_url, new=1, autoraise=True)
178
+ except Exception:
179
+ pass # User can copy the URL manually.
180
+
181
+ print("Waiting for browser confirmation...", flush=True)
182
+ finished = done.wait(timeout=args.timeout)
183
+ if not finished:
184
+ print(
185
+ f"Timed out after {args.timeout}s. Re-run `plexus init`.",
186
+ file=sys.stderr,
187
+ )
188
+ return 2
189
+ finally:
190
+ server.shutdown()
191
+ server.server_close()
192
+
193
+ if result.error or not result.key:
194
+ print(
195
+ f"Authorization failed: {result.error or 'no key returned'}",
196
+ file=sys.stderr,
197
+ )
198
+ return 3
199
+
200
+ cfg = config.load_config()
201
+ cfg["api_key"] = result.key
202
+ config.save_config(cfg)
203
+ print(f"✓ Saved API key as {name}.")
204
+ print(" ~/.plexus/config.json")
205
+ return 0
206
+
207
+
208
+ def cmd_logout(_args: argparse.Namespace) -> int:
209
+ """Forget the locally stored API key."""
210
+ cfg = config.load_config()
211
+ if not cfg.get("api_key"):
212
+ print("Nothing to do — no key on file.")
213
+ return 0
214
+ cfg["api_key"] = None
215
+ config.save_config(cfg)
216
+ print("✓ Cleared local API key.")
217
+ return 0
218
+
219
+
220
+ def cmd_whoami(_args: argparse.Namespace) -> int:
221
+ """Print the prefix of the locally stored key + the configured endpoint."""
222
+ key = config.get_api_key()
223
+ endpoint = config.get_endpoint()
224
+ if not key:
225
+ print("Not signed in. Run `plexus init` to authorize this machine.")
226
+ return 1
227
+ masked = f"{key[:8]}…{key[-4:]}" if len(key) > 12 else key
228
+ print(f"key: {masked}")
229
+ print(f"endpoint: {endpoint}")
230
+ return 0
231
+
232
+
233
+ def build_parser() -> argparse.ArgumentParser:
234
+ parser = argparse.ArgumentParser(
235
+ prog="plexus",
236
+ description="Plexus CLI — auth, send, query telemetry from your terminal.",
237
+ )
238
+ sub = parser.add_subparsers(dest="command", required=True)
239
+
240
+ init = sub.add_parser(
241
+ "init",
242
+ help="Authorize this machine and save an API key locally.",
243
+ aliases=["login"],
244
+ )
245
+ init.add_argument("--name", help="Label for the issued key (default: cli-<hostname>).")
246
+ init.add_argument(
247
+ "--timeout",
248
+ type=int,
249
+ default=DEFAULT_TIMEOUT_SECONDS,
250
+ help="Seconds to wait for the browser callback.",
251
+ )
252
+ init.add_argument(
253
+ "--force",
254
+ action="store_true",
255
+ help="Overwrite an existing local key.",
256
+ )
257
+ init.set_defaults(func=cmd_init)
258
+
259
+ logout = sub.add_parser("logout", help="Forget the local API key.")
260
+ logout.set_defaults(func=cmd_logout)
261
+
262
+ whoami = sub.add_parser("whoami", help="Show the local credential summary.")
263
+ whoami.set_defaults(func=cmd_whoami)
264
+
265
+ return parser
266
+
267
+
268
+ def main(argv: Optional[list] = None) -> int:
269
+ parser = build_parser()
270
+ args = parser.parse_args(argv)
271
+ return args.func(args)
272
+
273
+
274
+ if __name__ == "__main__":
275
+ sys.exit(main())
plexus/client.py CHANGED
@@ -48,7 +48,10 @@ from plexus.config import (
48
48
  get_api_key,
49
49
  get_endpoint,
50
50
  get_gateway_url,
51
+ get_gateway_ws_url,
52
+ get_install_id,
51
53
  get_source_id,
54
+ set_source_id,
52
55
  )
53
56
  logger = logging.getLogger(__name__)
54
57
 
@@ -95,6 +98,8 @@ class Plexus:
95
98
  max_buffer_size: int = 10000,
96
99
  persistent_buffer: bool = False,
97
100
  buffer_path: Optional[str] = None,
101
+ transport: str = "ws",
102
+ ws_url: Optional[str] = None,
98
103
  ):
99
104
  self.api_key = api_key or get_api_key()
100
105
  if not self.api_key:
@@ -114,6 +119,12 @@ class Plexus:
114
119
  self._session: Optional[requests.Session] = None
115
120
  self._store_frames: bool = False
116
121
 
122
+ if transport not in ("ws", "http"):
123
+ raise ValueError(f"transport must be 'ws' or 'http', got {transport!r}")
124
+ self.transport = transport
125
+ self._ws_url = (ws_url or get_gateway_ws_url())
126
+ self._ws = None # lazily constructed in _ensure_ws()
127
+
117
128
  # Pluggable buffer backend for failed sends
118
129
  if persistent_buffer:
119
130
  self._buffer: BufferBackend = SqliteBuffer(
@@ -250,16 +261,70 @@ class Plexus:
250
261
  ("position", {"x": 1.0, "y": 2.0}),
251
262
  ])
252
263
  """
253
- ts = timestamp or time.time()
264
+ ts = timestamp if timestamp is not None else time.time()
254
265
  data_points = [self._make_point(m, v, ts, tags) for m, v in points]
255
266
  return self._send_points(data_points)
256
267
 
268
+ def _ensure_ws(self):
269
+ """Lazily construct and start the WebSocket transport."""
270
+ if self._ws is not None:
271
+ return self._ws
272
+ from plexus.ws import WebSocketTransport
273
+ from plexus import __version__
274
+ self._ws = WebSocketTransport(
275
+ api_key=self.api_key,
276
+ source_id=self.source_id,
277
+ ws_url=self._ws_url,
278
+ install_id=get_install_id(),
279
+ agent_version=__version__,
280
+ on_source_id_assigned=self._on_source_id_assigned,
281
+ )
282
+ self._ws.start()
283
+ return self._ws
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
+
295
+ def on_command(
296
+ self,
297
+ name: str,
298
+ handler,
299
+ *,
300
+ description: Optional[str] = None,
301
+ params: Optional[List[Dict[str, Any]]] = None,
302
+ ) -> None:
303
+ """Register a command handler (WebSocket transport only).
304
+
305
+ The handler is called as `handler(command_name, params_dict)` and may
306
+ return a dict (→ `result`) or raise (→ `error`). An `ack` is sent
307
+ automatically before the handler runs.
308
+
309
+ Must be called before the first send() so the command is advertised
310
+ in the auth frame.
311
+ """
312
+ if self.transport != "ws":
313
+ raise PlexusError("on_command requires transport='ws'")
314
+ ws = self._ensure_ws()
315
+ ws.register_command(name, handler, description=description, params=params)
316
+
257
317
  def _send_points(self, points: List[Dict[str, Any]]) -> bool:
258
- """Send data points to the API with retry and buffering.
318
+ """Send data points to the gateway with retry and buffering.
259
319
 
260
- Retry behavior:
261
- - Retries on: Timeout, ConnectionError, HTTP 429 (rate limit), HTTP 5xx
262
- - No retry on: HTTP 401/403 (auth errors), HTTP 400/422 (bad request)
320
+ Path:
321
+ - transport='ws': try the WebSocket first; if not yet authenticated or
322
+ the socket fails, fall through to the HTTP path so points still land.
323
+ - transport='http': always POST /ingest with retries.
324
+
325
+ Retry behavior (HTTP path):
326
+ - Retries on: Timeout, ConnectionError, HTTP 429, HTTP 5xx
327
+ - No retry on: HTTP 401/403 (auth), HTTP 400/422 (bad request)
263
328
  - After max retries: buffers points locally for next send attempt
264
329
  """
265
330
  if not self.api_key:
@@ -270,6 +335,18 @@ class Plexus:
270
335
  # Include any previously buffered points
271
336
  all_points = self._get_buffered_points() + points
272
337
 
338
+ # Preferred path: WebSocket.
339
+ if self.transport == "ws":
340
+ ws = self._ensure_ws()
341
+ # Brief wait on first call so startup races don't dump every point
342
+ # into the HTTP fallback path.
343
+ if not ws.is_authenticated:
344
+ ws.wait_authenticated(timeout=min(self.timeout, 5.0))
345
+ if ws.send_points(all_points):
346
+ self._clear_buffer()
347
+ return True
348
+ # Socket unavailable → fall through to HTTP.
349
+
273
350
  url = f"{self.gateway_url}/ingest"
274
351
  last_error: Optional[Exception] = None
275
352
 
@@ -451,6 +528,9 @@ class Plexus:
451
528
 
452
529
  def close(self):
453
530
  """Close the client and release resources."""
531
+ if self._ws is not None:
532
+ self._ws.stop()
533
+ self._ws = None
454
534
  if self._session:
455
535
  self._session.close()
456
536
  self._session = None
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 ADDED
@@ -0,0 +1,373 @@
1
+ """
2
+ WebSocket transport for the Plexus Python SDK.
3
+
4
+ Wire-compatible with the C SDK (`plexus_ws.c`). Targets the gateway's
5
+ `/ws/device` endpoint and exchanges the same JSON frames:
6
+
7
+ client → {"type": "device_auth", "api_key": ..., "source_id": ...,
8
+ "install_id": ..., "platform": "python-sdk",
9
+ "agent_version": ..., "commands": [...]}
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.
16
+ client → {"type": "telemetry", "points": [...]}
17
+ client → {"type": "heartbeat", "source_id": ..., "agent_version": ...} # every 30s
18
+ server → {"type": "typed_command", "id": ..., "command": ..., "params": {...}}
19
+ client → {"type": "command_result", "id": ..., "command": ..., "event": "ack"}
20
+ client → {"type": "command_result", "id": ..., "command": ...,
21
+ "event": "result" | "error", "result": {...} | "error": "..."}
22
+
23
+ Runs the read loop on a background daemon thread so callers can stay sync.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import logging
30
+ import random
31
+ import threading
32
+ import time
33
+ from dataclasses import dataclass, field
34
+ from typing import Any, Callable, Dict, List, Optional
35
+
36
+ try:
37
+ import websocket # websocket-client
38
+ except ImportError as e: # pragma: no cover - import-time failure is obvious
39
+ raise ImportError(
40
+ "WebSocket transport requires 'websocket-client'. "
41
+ "Install with: pip install websocket-client"
42
+ ) from e
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ AUTH_TIMEOUT_S = 10.0
47
+ HEARTBEAT_INTERVAL_S = 30.0
48
+ BACKOFF_BASE_S = 1.0
49
+ BACKOFF_MAX_S = 60.0
50
+
51
+ CommandHandler = Callable[[str, Dict[str, Any]], Optional[Dict[str, Any]]]
52
+
53
+
54
+ @dataclass
55
+ class _RegisteredCommand:
56
+ name: str
57
+ handler: CommandHandler
58
+ description: Optional[str] = None
59
+ params: List[Dict[str, Any]] = field(default_factory=list)
60
+
61
+ def to_manifest(self) -> Dict[str, Any]:
62
+ m: Dict[str, Any] = {"name": self.name}
63
+ if self.description:
64
+ m["description"] = self.description
65
+ if self.params:
66
+ m["params"] = self.params
67
+ return m
68
+
69
+
70
+ class WebSocketTransport:
71
+ """Background WebSocket connection to the Plexus gateway.
72
+
73
+ Lifecycle:
74
+ t = WebSocketTransport(api_key, source_id, ws_url)
75
+ t.start()
76
+ t.wait_authenticated(timeout=5)
77
+ t.send_points([...])
78
+ t.stop()
79
+ """
80
+
81
+ def __init__(
82
+ self,
83
+ api_key: str,
84
+ source_id: str,
85
+ ws_url: str,
86
+ *,
87
+ install_id: str = "",
88
+ agent_version: str = "0.0.0",
89
+ platform: str = "python-sdk",
90
+ auto_reconnect: bool = True,
91
+ on_source_id_assigned: Optional[Callable[[str], None]] = None,
92
+ ):
93
+ if not api_key:
94
+ raise ValueError("api_key required")
95
+ if not source_id:
96
+ raise ValueError("source_id required")
97
+
98
+ self.api_key = api_key
99
+ self.source_id = source_id
100
+ self.install_id = install_id
101
+ self.ws_url = _ensure_device_path(ws_url)
102
+ self.agent_version = agent_version
103
+ self.platform = platform
104
+ self.auto_reconnect = auto_reconnect
105
+ self._on_source_id_assigned = on_source_id_assigned
106
+
107
+ self._commands: Dict[str, _RegisteredCommand] = {}
108
+ self._ws: Optional[websocket.WebSocket] = None
109
+ self._ws_lock = threading.Lock()
110
+ self._authenticated = threading.Event()
111
+ self._stop = threading.Event()
112
+ self._thread: Optional[threading.Thread] = None
113
+ self._backoff_attempt = 0
114
+
115
+ # ------------------------------------------------------------------ public
116
+
117
+ def register_command(
118
+ self,
119
+ name: str,
120
+ handler: CommandHandler,
121
+ *,
122
+ description: Optional[str] = None,
123
+ params: Optional[List[Dict[str, Any]]] = None,
124
+ ) -> None:
125
+ """Register a command handler. Must be called before start() to be
126
+ advertised in the auth frame."""
127
+ self._commands[name] = _RegisteredCommand(
128
+ name=name, handler=handler, description=description, params=params or []
129
+ )
130
+
131
+ def start(self) -> None:
132
+ if self._thread and self._thread.is_alive():
133
+ return
134
+ self._stop.clear()
135
+ self._thread = threading.Thread(
136
+ target=self._run, name="plexus-ws", daemon=True
137
+ )
138
+ self._thread.start()
139
+
140
+ def stop(self, timeout: float = 2.0) -> None:
141
+ self._stop.set()
142
+ with self._ws_lock:
143
+ ws = self._ws
144
+ if ws is not None:
145
+ try:
146
+ ws.close()
147
+ except Exception: # pragma: no cover
148
+ pass
149
+ if self._thread:
150
+ self._thread.join(timeout=timeout)
151
+
152
+ def wait_authenticated(self, timeout: float = AUTH_TIMEOUT_S) -> bool:
153
+ return self._authenticated.wait(timeout=timeout)
154
+
155
+ @property
156
+ def is_authenticated(self) -> bool:
157
+ return self._authenticated.is_set()
158
+
159
+ def send_points(self, points: List[Dict[str, Any]]) -> bool:
160
+ """Send a telemetry frame. Returns False if the socket is not
161
+ authenticated — caller is expected to fall back to HTTP."""
162
+ if not points:
163
+ return True
164
+ if not self._authenticated.is_set():
165
+ return False
166
+ frame = {"type": "telemetry", "points": points}
167
+ return self._send_frame(frame)
168
+
169
+ # ------------------------------------------------------------------ thread
170
+
171
+ def _run(self) -> None:
172
+ while not self._stop.is_set():
173
+ try:
174
+ self._connect_and_serve()
175
+ except Exception as e:
176
+ logger.warning("plexus ws loop error: %s", e)
177
+ finally:
178
+ self._authenticated.clear()
179
+ with self._ws_lock:
180
+ self._ws = None
181
+
182
+ if not self.auto_reconnect or self._stop.is_set():
183
+ break
184
+
185
+ delay = _backoff_delay(self._backoff_attempt)
186
+ self._backoff_attempt = min(self._backoff_attempt + 1, 10)
187
+ logger.info("plexus ws reconnect in %.1fs", delay)
188
+ if self._stop.wait(timeout=delay):
189
+ break
190
+
191
+ def _connect_and_serve(self) -> None:
192
+ ws = websocket.create_connection(self.ws_url, timeout=AUTH_TIMEOUT_S)
193
+ with self._ws_lock:
194
+ self._ws = ws
195
+
196
+ # 1. Send device_auth
197
+ desired_source_id = self.source_id
198
+ auth = {
199
+ "type": "device_auth",
200
+ "api_key": self.api_key,
201
+ "source_id": desired_source_id,
202
+ "platform": self.platform,
203
+ "agent_version": self.agent_version,
204
+ }
205
+ if self.install_id:
206
+ auth["install_id"] = self.install_id
207
+ if self._commands:
208
+ auth["commands"] = [c.to_manifest() for c in self._commands.values()]
209
+ ws.send(json.dumps(auth))
210
+
211
+ # 2. Wait for authenticated
212
+ ws.settimeout(AUTH_TIMEOUT_S)
213
+ try:
214
+ raw = ws.recv()
215
+ except websocket.WebSocketTimeoutException as e:
216
+ raise TimeoutError("auth timeout") from e
217
+
218
+ msg = _safe_json(raw)
219
+ if msg.get("type") != "authenticated":
220
+ raise RuntimeError(f"auth failed: {msg}")
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
+
238
+ self._authenticated.set()
239
+ self._backoff_attempt = 0
240
+ logger.info("plexus ws authenticated as %s", self.source_id)
241
+
242
+ # 3. Read loop with heartbeat pump
243
+ ws.settimeout(1.0)
244
+ last_heartbeat = time.monotonic()
245
+ while not self._stop.is_set():
246
+ now = time.monotonic()
247
+ if now - last_heartbeat >= HEARTBEAT_INTERVAL_S:
248
+ self._send_frame({
249
+ "type": "heartbeat",
250
+ "source_id": self.source_id,
251
+ "agent_version": self.agent_version,
252
+ })
253
+ last_heartbeat = now
254
+
255
+ try:
256
+ raw = ws.recv()
257
+ except websocket.WebSocketTimeoutException:
258
+ continue
259
+ except (websocket.WebSocketConnectionClosedException, OSError):
260
+ logger.info("plexus ws closed")
261
+ return
262
+
263
+ if not raw:
264
+ continue
265
+ self._dispatch(_safe_json(raw))
266
+
267
+ def _dispatch(self, msg: Dict[str, Any]) -> None:
268
+ mtype = msg.get("type")
269
+ if mtype == "typed_command":
270
+ self._handle_command(msg)
271
+ elif mtype == "error":
272
+ logger.warning("plexus ws server error: %s", msg.get("detail") or msg)
273
+ # ignore unknown types — forward-compat
274
+
275
+ def _handle_command(self, msg: Dict[str, Any]) -> None:
276
+ cmd_id = msg.get("id") or ""
277
+ command = msg.get("command") or ""
278
+ params = msg.get("params") or {}
279
+
280
+ # Ack immediately (matches C SDK: plexus_ws.c:275-280)
281
+ self._send_frame({
282
+ "type": "command_result",
283
+ "id": cmd_id,
284
+ "command": command,
285
+ "event": "ack",
286
+ })
287
+
288
+ reg = self._commands.get(command)
289
+ if reg is None:
290
+ self._send_frame({
291
+ "type": "command_result",
292
+ "id": cmd_id,
293
+ "command": command,
294
+ "event": "error",
295
+ "error": f"unknown command: {command}",
296
+ })
297
+ return
298
+
299
+ # Run the handler off the read-loop thread so a slow handler doesn't
300
+ # block heartbeats or other inbound frames.
301
+ threading.Thread(
302
+ target=self._run_handler,
303
+ args=(reg, cmd_id, command, params),
304
+ daemon=True,
305
+ ).start()
306
+
307
+ def _run_handler(
308
+ self,
309
+ reg: _RegisteredCommand,
310
+ cmd_id: str,
311
+ command: str,
312
+ params: Dict[str, Any],
313
+ ) -> None:
314
+ try:
315
+ result = reg.handler(command, params)
316
+ except Exception as e:
317
+ self._send_frame({
318
+ "type": "command_result",
319
+ "id": cmd_id,
320
+ "command": command,
321
+ "event": "error",
322
+ "error": str(e),
323
+ })
324
+ return
325
+ self._send_frame({
326
+ "type": "command_result",
327
+ "id": cmd_id,
328
+ "command": command,
329
+ "event": "result",
330
+ "result": result if result is not None else {},
331
+ })
332
+
333
+ def _send_frame(self, frame: Dict[str, Any]) -> bool:
334
+ with self._ws_lock:
335
+ ws = self._ws
336
+ if ws is None:
337
+ return False
338
+ try:
339
+ ws.send(json.dumps(frame))
340
+ return True
341
+ except Exception as e:
342
+ logger.debug("plexus ws send failed: %s", e)
343
+ return False
344
+
345
+
346
+ # --------------------------------------------------------------------- helpers
347
+
348
+
349
+ def _ensure_device_path(url: str) -> str:
350
+ url = url.rstrip("/")
351
+ if url.endswith("/ws/device"):
352
+ return url
353
+ return url + "/ws/device"
354
+
355
+
356
+ def _safe_json(raw: Any) -> Dict[str, Any]:
357
+ if isinstance(raw, (bytes, bytearray)):
358
+ raw = raw.decode("utf-8", errors="replace")
359
+ if not isinstance(raw, str):
360
+ return {}
361
+ try:
362
+ obj = json.loads(raw)
363
+ except json.JSONDecodeError:
364
+ return {}
365
+ return obj if isinstance(obj, dict) else {}
366
+
367
+
368
+ def _backoff_delay(attempt: int) -> float:
369
+ """Exponential backoff with ±25% jitter, capped at BACKOFF_MAX_S.
370
+ Matches plexus_ws.c:44-52."""
371
+ base = min(BACKOFF_BASE_S * (2 ** attempt), BACKOFF_MAX_S)
372
+ jitter = base * 0.25 * (2 * random.random() - 1)
373
+ return max(0.1, base + jitter)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plexus-python
3
- Version: 0.2.0
3
+ Version: 0.4.1
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
@@ -24,10 +24,12 @@ Classifier: Topic :: Scientific/Engineering
24
24
  Classifier: Topic :: System :: Hardware
25
25
  Requires-Python: >=3.8
26
26
  Requires-Dist: requests>=2.28.0
27
+ Requires-Dist: websocket-client>=1.7
27
28
  Provides-Extra: dev
28
29
  Requires-Dist: pytest; extra == 'dev'
29
30
  Requires-Dist: pytest-cov; extra == 'dev'
30
31
  Requires-Dist: ruff; extra == 'dev'
32
+ Requires-Dist: websockets>=12; extra == 'dev'
31
33
  Description-Content-Type: text/markdown
32
34
 
33
35
  # plexus-python
@@ -52,6 +54,21 @@ px.send("temperature", 72.5)
52
54
 
53
55
  Get an API key at [app.plexus.company](https://app.plexus.company) → Devices → Add Device.
54
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
+
55
72
  ## Usage
56
73
 
57
74
  ```python
@@ -119,13 +136,47 @@ px.buffer_size()
119
136
  px.flush_buffer()
120
137
  ```
121
138
 
139
+ ## Transport
140
+
141
+ By default the SDK connects over a **WebSocket** to `/ws/device` on the gateway — same wire protocol as the C SDK. This gives you:
142
+
143
+ - lower-latency streaming of telemetry,
144
+ - live command delivery from the UI / API to the device.
145
+
146
+ If the socket is unavailable, sends transparently fall back to `POST /ingest` so no data is lost.
147
+
148
+ ```python
149
+ # default — ws with http fallback
150
+ px = Plexus()
151
+
152
+ # force http (legacy)
153
+ px = Plexus(transport="http")
154
+ ```
155
+
156
+ ### Handling commands
157
+
158
+ Register a handler before the first `send()` so the command is advertised in the auth frame:
159
+
160
+ ```python
161
+ def reboot(name, params):
162
+ delay = params.get("delay_s", 0)
163
+ # ... reboot logic ...
164
+ return {"ok": True, "delay": delay}
165
+
166
+ px = Plexus()
167
+ px.on_command("reboot", reboot, description="reboot the device")
168
+ px.send("temperature", 72.5) # opens the socket, waits for auth
169
+ ```
170
+
171
+ The SDK sends an `ack` frame before invoking the handler, then a `result` frame with whatever the handler returns (or an `error` frame if it raises).
172
+
122
173
  ## Environment Variables
123
174
 
124
175
  | Variable | Description | Default |
125
176
  | ----------------------- | ---------------------------- | -------------------------------- |
126
177
  | `PLEXUS_API_KEY` | API key (required) | none |
127
178
  | `PLEXUS_GATEWAY_URL` | HTTP ingest URL | `https://plexus-gateway.fly.dev` |
128
- | `PLEXUS_GATEWAY_WS_URL` | WebSocket URL (unused in SDK, for compatibility) | `wss://plexus-gateway.fly.dev` |
179
+ | `PLEXUS_GATEWAY_WS_URL` | WebSocket URL | `wss://plexus-gateway.fly.dev` |
129
180
 
130
181
  ## Architecture
131
182
 
@@ -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=FW5NtWAAcXiWTqpn-e_qMIPilWk8N9jJCLywRKHHmUU,8514
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.1.dist-info/METADATA,sha256=wxlE_vaFRHs-MeS1TnP2gi22OvLWN1Dq_Cpi1QRwVqM,6800
8
+ plexus_python-0.4.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ plexus_python-0.4.1.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
10
+ plexus_python-0.4.1.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
11
+ plexus_python-0.4.1.dist-info/RECORD,,
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ plexus = plexus.cli:main
@@ -1,8 +0,0 @@
1
- plexus/__init__.py,sha256=Kal4qSrpQ6S8-AE7F9UqFkJMvbhTD82kzkO59O-ruEw,282
2
- plexus/buffer.py,sha256=3ykybqLs7yMXxQWFajAT8nGe3cs_lW8_6Xvn0vQ69dE,9262
3
- plexus/client.py,sha256=X6gyFYS-CVwcqJt_VzwHuNtsuJhJJzSxH41T7UBv9dE,16212
4
- plexus/config.py,sha256=RNym2Fon6JOCVi1rXPSRWjPFAdT8DSmokY5JPEljQOc,4450
5
- plexus_python-0.2.0.dist-info/METADATA,sha256=51nhbClqTy2w07qn--JyzD8wAOnM7OQQhydvYUgswRI,4528
6
- plexus_python-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
7
- plexus_python-0.2.0.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
8
- plexus_python-0.2.0.dist-info/RECORD,,