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 +3 -2
- plexus/cli.py +275 -0
- plexus/client.py +85 -5
- plexus/config.py +48 -0
- plexus/ws.py +373 -0
- {plexus_python-0.2.0.dist-info → plexus_python-0.4.1.dist-info}/METADATA +53 -2
- plexus_python-0.4.1.dist-info/RECORD +11 -0
- plexus_python-0.4.1.dist-info/entry_points.txt +2 -0
- plexus_python-0.2.0.dist-info/RECORD +0 -8
- {plexus_python-0.2.0.dist-info → plexus_python-0.4.1.dist-info}/WHEEL +0 -0
- {plexus_python-0.2.0.dist-info → plexus_python-0.4.1.dist-info}/licenses/LICENSE +0 -0
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.
|
|
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 — 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
|
|
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
|
|
318
|
+
"""Send data points to the gateway with retry and buffering.
|
|
259
319
|
|
|
260
|
-
|
|
261
|
-
-
|
|
262
|
-
|
|
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.
|
|
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
|
|
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,,
|
|
@@ -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,,
|
|
File without changes
|
|
File without changes
|