plexus-python 0.3.0__tar.gz → 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {plexus_python-0.3.0 → plexus_python-0.4.1}/AGENTS.md +5 -20
  2. {plexus_python-0.3.0 → plexus_python-0.4.1}/API.md +16 -14
  3. plexus_python-0.4.1/CHANGELOG.md +110 -0
  4. {plexus_python-0.3.0 → plexus_python-0.4.1}/PKG-INFO +16 -1
  5. {plexus_python-0.3.0 → plexus_python-0.4.1}/README.md +15 -0
  6. {plexus_python-0.3.0 → plexus_python-0.4.1}/SECURITY.md +1 -1
  7. {plexus_python-0.3.0 → plexus_python-0.4.1}/examples/basic.py +1 -1
  8. {plexus_python-0.3.0 → plexus_python-0.4.1}/plexus/__init__.py +1 -1
  9. plexus_python-0.4.1/plexus/cli.py +275 -0
  10. {plexus_python-0.3.0 → plexus_python-0.4.1}/plexus/client.py +15 -1
  11. {plexus_python-0.3.0 → plexus_python-0.4.1}/plexus/config.py +48 -0
  12. {plexus_python-0.3.0 → plexus_python-0.4.1}/plexus/ws.py +31 -2
  13. {plexus_python-0.3.0 → plexus_python-0.4.1}/pyproject.toml +4 -1
  14. {plexus_python-0.3.0 → plexus_python-0.4.1}/scripts/setup.sh +44 -12
  15. {plexus_python-0.3.0 → plexus_python-0.4.1}/tests/test_retry.py +18 -14
  16. {plexus_python-0.3.0 → plexus_python-0.4.1}/tests/test_ws.py +81 -2
  17. plexus_python-0.3.0/CHANGELOG.md +0 -45
  18. {plexus_python-0.3.0 → plexus_python-0.4.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  19. {plexus_python-0.3.0 → plexus_python-0.4.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  20. {plexus_python-0.3.0 → plexus_python-0.4.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  21. {plexus_python-0.3.0 → plexus_python-0.4.1}/.github/workflows/ci.yml +0 -0
  22. {plexus_python-0.3.0 → plexus_python-0.4.1}/.github/workflows/publish.yml +0 -0
  23. {plexus_python-0.3.0 → plexus_python-0.4.1}/.gitignore +0 -0
  24. {plexus_python-0.3.0 → plexus_python-0.4.1}/CODE_OF_CONDUCT.md +0 -0
  25. {plexus_python-0.3.0 → plexus_python-0.4.1}/CONTRIBUTING.md +0 -0
  26. {plexus_python-0.3.0 → plexus_python-0.4.1}/LICENSE +0 -0
  27. {plexus_python-0.3.0 → plexus_python-0.4.1}/examples/README.md +0 -0
  28. {plexus_python-0.3.0 → plexus_python-0.4.1}/examples/can.py +0 -0
  29. {plexus_python-0.3.0 → plexus_python-0.4.1}/examples/i2c_bme280.py +0 -0
  30. {plexus_python-0.3.0 → plexus_python-0.4.1}/examples/mavlink.py +0 -0
  31. {plexus_python-0.3.0 → plexus_python-0.4.1}/examples/mqtt.py +0 -0
  32. {plexus_python-0.3.0 → plexus_python-0.4.1}/plexus/buffer.py +0 -0
  33. {plexus_python-0.3.0 → plexus_python-0.4.1}/scripts/plexus.service +0 -0
  34. {plexus_python-0.3.0 → plexus_python-0.4.1}/scripts/scan_buses.py +0 -0
  35. {plexus_python-0.3.0 → plexus_python-0.4.1}/tests/test_basic.py +0 -0
  36. {plexus_python-0.3.0 → plexus_python-0.4.1}/tests/test_buffer.py +0 -0
  37. {plexus_python-0.3.0 → plexus_python-0.4.1}/tests/test_config.py +0 -0
@@ -4,11 +4,11 @@ Machine-readable interface for AI assistants and automation scripts.
4
4
 
5
5
  ## Environment Variables
6
6
 
7
- | Variable | Description | Default |
8
- | ----------------------- | -------------------------------------------- | -------------------------------- |
9
- | `PLEXUS_API_KEY` | API key for authentication (required) | none |
10
- | `PLEXUS_GATEWAY_URL` | Gateway HTTP ingest URL | `https://plexus-gateway.fly.dev` |
11
- | `PLEXUS_GATEWAY_WS_URL` | Gateway WebSocket URL | `wss://plexus-gateway.fly.dev` |
7
+ | Variable | Description | Default |
8
+ | ----------------------- | ------------------------------------- | -------------------------------- |
9
+ | `PLEXUS_API_KEY` | API key for authentication (required) | none |
10
+ | `PLEXUS_GATEWAY_URL` | Gateway HTTP ingest URL | `https://plexus-gateway.fly.dev` |
11
+ | `PLEXUS_GATEWAY_WS_URL` | Gateway WebSocket URL | `wss://plexus-gateway.fly.dev` |
12
12
 
13
13
  ## CLI Commands
14
14
 
@@ -48,21 +48,6 @@ px.send_batch([
48
48
  px = Plexus(api_key="plx_xxxxx", persistent_buffer=True)
49
49
  ```
50
50
 
51
- ## Project Structure
52
-
53
- ```
54
- plexus/
55
- ├── cli.py # CLI entry point (click commands)
56
- ├── tui.py # Live terminal dashboard (Rich)
57
- ├── client.py # Plexus HTTP client (thin SDK)
58
- ├── connector.py # WebSocket daemon
59
- ├── config.py # Config file + env var management
60
- ├── detect.py # Hardware auto-detection
61
- ├── deps.py # Dependency helpers
62
- ├── sensors/ # I2C sensor drivers + SensorHub
63
- └── adapters/ # Protocol adapters: CAN, MAVLink, MQTT, Modbus, OPC-UA, BLE, Serial
64
- ```
65
-
66
51
  ## Key Conventions
67
52
 
68
53
  - Config lives in `~/.plexus/config.json`
@@ -152,34 +152,36 @@ For real-time UI-controlled streaming, devices connect via WebSocket.
152
152
 
153
153
  ### Device Authentication
154
154
 
155
- Devices authenticate using an API key:
155
+ Devices authenticate using an API key. The `source_id` in the request is the device's *desired* name; the server may return a different, auto-suffixed name in the `authenticated` frame if the desired name is already claimed by another device (see [Device identity](../README.md#device-identity) in the README).
156
156
 
157
157
  ```json
158
158
  // Device → Server
159
159
  {
160
160
  "type": "device_auth",
161
161
  "api_key": "plx_xxxxx",
162
- "source_id": "my-device-001",
163
- "platform": "Linux",
164
- "sensors": [
165
- {
166
- "name": "MPU6050",
167
- "description": "6-axis IMU",
168
- "metrics": ["accel_x", "accel_y", "accel_z", "gyro_x", "gyro_y", "gyro_z"],
169
- "sample_rate": 100,
170
- "prefix": "",
171
- "available": true
172
- }
173
- ]
162
+ "source_id": "drone-01",
163
+ "install_id": "c9f2e0b46f4a4f6a8c3e1d5b0a2e7f91",
164
+ "platform": "python-sdk",
165
+ "agent_version": "0.3.1"
174
166
  }
175
167
 
176
168
  // Server → Device
177
169
  {
178
170
  "type": "authenticated",
179
- "source_id": "my-device-001"
171
+ "source_id": "drone-01"
172
+ }
173
+
174
+ // Server → Device (collision case)
175
+ {
176
+ "type": "authenticated",
177
+ "source_id": "drone-01_2"
180
178
  }
181
179
  ```
182
180
 
181
+ The SDK **adopts** whatever `source_id` the server returns and uses it for all subsequent frames, heartbeats, and reconnects. It also persists the assigned name locally so reconnects go straight to the claimed slot.
182
+
183
+ `install_id` is a stable per-installation UUID, generated on the device's first run and saved to `~/.plexus/config.json`. It lets the server distinguish a rebooting device from a new device trying to claim an existing name. Legacy SDKs that omit `install_id` continue to work as before (the server passes the declared `source_id` through unchanged).
184
+
183
185
  ### Message Types (Dashboard → Device)
184
186
 
185
187
  | Type | Description |
@@ -0,0 +1,110 @@
1
+ # Changelog
2
+
3
+ ## [0.4.1] - 2026-04-27 - CI fixes for 0.4.0
4
+
5
+ ### Fixed
6
+
7
+ - `plexus/cli.py` — drop a stray `f` prefix on a non-interpolated string
8
+ that ruff (`F541`) caught in CI.
9
+ - `tests/test_retry.py::test_concurrent_sends` — move `patch.object` out
10
+ of the per-thread closure. `mock.patch.object` mutates instance
11
+ attributes and is not thread-safe; under 20 concurrent threads the
12
+ state would leak and surface as a spurious `AttributeError` on Python
13
+ 3.8.
14
+
15
+ ## [0.4.0] - 2026-04-27 - Stable device identity + CLI
16
+
17
+ The gateway is now authoritative for a device's `source_id`. The SDK sends a
18
+ locally-generated `install_id` in the auth frame; the gateway atomically
19
+ claims `(org, source_id)` and, if the desired name is already owned by a
20
+ different install, returns an auto-suffixed name (`drone-01` → `drone-01_2`
21
+ → `drone-01_3`…) in the `authenticated` frame. The SDK adopts and persists
22
+ the assigned name so subsequent reconnects are stable.
23
+
24
+ This fixes the silent stream-merging that happened when cloned SD-card
25
+ images shared a hostname or when two operators picked the same name.
26
+
27
+ ### Added
28
+
29
+ - `plexus init` (alias `plexus login`) — fly.io / vercel-style browser auth
30
+ flow. Spins up a localhost listener, opens `${PLEXUS_ENDPOINT}/auth/cli`
31
+ with a state-protected callback, and persists the issued key to
32
+ `~/.plexus/config.json`. Console script registered in `pyproject.toml`
33
+ (`plexus = "plexus.cli:main"`); stdlib-only, no new runtime deps.
34
+ - `plexus.config.get_install_id()` — lazy per-installation UUID, persisted
35
+ to `~/.plexus/config.json`. **Not** written by `setup.sh`: it's minted by
36
+ the SDK on first run so pre-baked images get distinct IDs per boot.
37
+ - `PLEXUS_INSTALL_ID` env var — override for `get_install_id()` so
38
+ ephemeral containers (Fly machines, k8s pods, CI runners) can pin a
39
+ stable identity across restarts when the config filesystem is ephemeral.
40
+ Without this, every redeploy gets a fresh UUID and the gateway
41
+ auto-suffixes the source_id.
42
+ - `plexus.config.set_source_id()` — persist the gateway-assigned name after
43
+ auto-suffix resolution.
44
+ - `WebSocketTransport(install_id=..., on_source_id_assigned=...)` — the
45
+ transport sends `install_id` in the `device_auth` frame and invokes the
46
+ callback whenever the gateway returns a different `source_id` than
47
+ requested.
48
+
49
+ ### Changed
50
+
51
+ - `WebSocketTransport` now reads the `source_id` back from the
52
+ `authenticated` frame and updates `self.source_id` in place if the gateway
53
+ auto-suffixed. The rename is logged at INFO level on first occurrence.
54
+ - `Plexus` wires `install_id` into the transport and persists the assigned
55
+ `source_id` to config on rename.
56
+ - `scripts/setup.sh` — `--name` is **required**. The hostname fallback is
57
+ removed (it was the main source of cloned-image collisions). In a TTY the
58
+ script prompts interactively; in non-TTY it exits with an error. Names are
59
+ validated against `^[a-z0-9][a-z0-9_-]{1,62}$`. Stale `plexus start` /
60
+ `plexus reset` hints were dropped.
61
+
62
+ ### Wire-protocol (compatible)
63
+
64
+ - `device_auth` frame gains an optional `install_id` field. The gateway
65
+ treats a missing `install_id` as legacy pass-through, so older SDKs and
66
+ the C SDK continue to work unchanged.
67
+
68
+ ## [0.3.0] - WebSocket transport
69
+
70
+ Adds a wire-compatible WebSocket transport matching the `plexus-c` SDK. WS is now the default; failed sends transparently fall back to `POST /ingest`.
71
+
72
+ ### Added
73
+
74
+ - `plexus.WebSocketTransport` — connects to `/ws/device` on the gateway. Exchanges the same `device_auth` / `authenticated` / `telemetry` / `heartbeat` / `typed_command` / `command_result` frames as `plexus-c`.
75
+ - `Plexus(transport="ws" | "http")` — defaults to `"ws"`.
76
+ - `Plexus.on_command(name, handler, description=..., params=...)` — register command handlers; automatic `ack`, handler return becomes `result`, exceptions become `error`.
77
+ - `Plexus.close()` — stops the WebSocket thread.
78
+ - Runtime dep: `websocket-client>=1.7`.
79
+ - Tests: `tests/test_ws.py` (auth handshake, telemetry, command roundtrip, error paths).
80
+
81
+ ## [0.2.0] - Thin SDK rewrite
82
+
83
+ Breaking. `plexus-python` is now just the thin client — no agent, adapters, sensors, CLI, or TUI. The package is 886 lines with one runtime dependency (`requests`). Protocol integrations (MAVLink, CAN, MQTT, Modbus, OPC-UA, BLE, I2C sensors) now live as standalone recipes in `examples/`, using the upstream library directly (`pymavlink`, `python-can`, `paho-mqtt`, etc.) plus `px.send()`.
84
+
85
+ ### Added
86
+
87
+ - 5 runnable example scripts: `basic.py`, `mavlink.py`, `can.py`, `mqtt.py`, `i2c_bme280.py`
88
+
89
+ ### Removed
90
+
91
+ - `plexus/adapters/` (MAVLink, CAN, MQTT, Modbus, OPC-UA, BLE, Serial — use the upstream lib directly)
92
+ - `plexus/sensors/` (I2C drivers + auto-detect — use Adafruit CircuitPython or smbus2 directly)
93
+ - `plexus/cameras/` (frame upload — out of scope)
94
+ - `plexus/cli.py`, `plexus/connector.py`, `plexus/streaming.py`, `plexus/detect.py`, `plexus/tui.py`, `plexus/deps.py`
95
+ - `plexus` console script, `python -m plexus`
96
+ - Extras: `[sensors]`, `[system]`, `[tui]`, `[mqtt]`, `[can]`, `[mavlink]`, `[modbus]`, `[opcua]`, `[ble]`, `[serial]`, `[ros]`, `[camera]`, `[picamera]`, `[all]`
97
+ - Runtime deps: `click`, `websockets`
98
+
99
+ ### Changed
100
+
101
+ - Default ingest endpoint points directly at the Plexus gateway (`https://plexus-gateway.fly.dev/ingest`), not the Next.js app proxy
102
+ - Client raises `ValueError` clearly when no API key is available, instead of invoking a login flow
103
+
104
+ ## [0.1.0] - Initial release
105
+
106
+ - `Plexus` thin client for HTTP ingest
107
+ - `plexus start` daemon with WebSocket streaming
108
+ - Protocol adapters: MAVLink, CAN, MQTT, Modbus, OPC-UA, Serial, BLE
109
+ - I2C sensor auto-detection and drivers
110
+ - Store-and-forward buffering (SQLite)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plexus-python
3
- Version: 0.3.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
@@ -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
@@ -20,6 +20,21 @@ px.send("temperature", 72.5)
20
20
 
21
21
  Get an API key at [app.plexus.company](https://app.plexus.company) → Devices → Add Device.
22
22
 
23
+ ## Device identity
24
+
25
+ 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:
26
+
27
+ ```bash
28
+ curl -sL https://app.plexus.company/setup | bash -s -- \
29
+ --key plx_xxx --name drone-01
30
+ ```
31
+
32
+ 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`.
33
+
34
+ **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."
35
+
36
+ In normal code, you usually just pass `source_id=...` explicitly to `Plexus(...)` and never have to think about it.
37
+
23
38
  ## Usage
24
39
 
25
40
  ```python
@@ -22,7 +22,7 @@ Instead, email **support@plexus.company** with:
22
22
  ## Supported Versions
23
23
 
24
24
  | Version | Supported |
25
- |---------|-----------|
25
+ | ------- | --------- |
26
26
  | 0.9.x | Yes |
27
27
  | < 0.9 | No |
28
28
 
@@ -11,7 +11,7 @@ import time
11
11
 
12
12
  from plexus import Plexus
13
13
 
14
- px = Plexus(source_id="demo-device")
14
+ px = Plexus(api_key="plx_123...", source_id="demo-device")
15
15
 
16
16
  while True:
17
17
  px.send("temperature", 20 + random.random() * 5)
@@ -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"]
@@ -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())
@@ -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,
@@ -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()
@@ -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)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "plexus-python"
7
- version = "0.3.0"
7
+ version = "0.4.1"
8
8
  description = "Thin Python SDK for Plexus — send telemetry in one line"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -35,6 +35,9 @@ dependencies = [
35
35
  [project.optional-dependencies]
36
36
  dev = ["pytest", "pytest-cov", "ruff", "websockets>=12"]
37
37
 
38
+ [project.scripts]
39
+ plexus = "plexus.cli:main"
40
+
38
41
  [project.urls]
39
42
  Homepage = "https://plexus.dev"
40
43
  Documentation = "https://docs.plexus.dev"
@@ -43,6 +43,41 @@ while [[ $# -gt 0 ]]; do
43
43
  esac
44
44
  done
45
45
 
46
+ # --name is required so every device lands on a deliberate, unique identifier.
47
+ # Previously we fell back to $(hostname), which silently merged streams on
48
+ # cloned SD-card images where every device shared a hostname. If --name is
49
+ # missing, prompt interactively in a TTY or fail loudly in non-interactive
50
+ # contexts. Validation keeps the name safe for Redis keys, URLs, and logs.
51
+ validate_device_name() {
52
+ local name="$1"
53
+ if [[ ! "$name" =~ ^[a-z0-9][a-z0-9_-]{1,62}$ ]]; then
54
+ echo -e " ${RED}Invalid device name: \"$name\"${NC}"
55
+ echo " Name must start with a letter or digit and contain only"
56
+ echo " lowercase letters, digits, '-', or '_' (2-63 chars total)."
57
+ return 1
58
+ fi
59
+ return 0
60
+ }
61
+
62
+ if [ -z "$DEVICE_NAME" ]; then
63
+ if [ -t 0 ]; then
64
+ echo ""
65
+ echo " Every device needs a unique name (e.g. drone-01, greenhouse-north)."
66
+ while [ -z "$DEVICE_NAME" ]; do
67
+ read -rp " Device name: " DEVICE_NAME
68
+ if [ -n "$DEVICE_NAME" ] && ! validate_device_name "$DEVICE_NAME"; then
69
+ DEVICE_NAME=""
70
+ fi
71
+ done
72
+ else
73
+ echo -e " ${RED}Error: --name is required${NC}" >&2
74
+ echo " Example: curl -sL app.plexus.company/setup | bash -s -- --key plx_... --name drone-01" >&2
75
+ exit 1
76
+ fi
77
+ elif ! validate_device_name "$DEVICE_NAME"; then
78
+ exit 1
79
+ fi
80
+
46
81
  echo ""
47
82
  echo "┌─────────────────────────────────────────┐"
48
83
  echo "│ Plexus Agent Setup │"
@@ -282,14 +317,17 @@ echo ""
282
317
  if [ -n "$API_KEY" ]; then
283
318
  mkdir -p "$HOME/.plexus"
284
319
  ENDPOINT="https://app.plexus.company"
285
- SOURCE_ID="${DEVICE_NAME:-$(hostname)}"
286
- echo "{\"api_key\":\"$API_KEY\",\"endpoint\":\"$ENDPOINT\",\"source_id\":\"$SOURCE_ID\"}" > "$HOME/.plexus/config.json"
320
+ # install_id is intentionally NOT written here. The SDK generates it
321
+ # lazily on first run (plexus.config.get_install_id) so that pre-baked
322
+ # SD-card images get distinct install_ids per boot rather than sharing
323
+ # whatever we'd stamp here.
324
+ echo "{\"api_key\":\"$API_KEY\",\"endpoint\":\"$ENDPOINT\",\"source_id\":\"$DEVICE_NAME\"}" > "$HOME/.plexus/config.json"
287
325
 
288
326
  export PLEXUS_API_KEY="$API_KEY"
289
327
  echo -e " ${GREEN}✓ API key configured${NC}"
290
- if [ -n "$ORG_ID" ]; then
291
- echo -e " ${GREEN}✓ Organization resolved${NC}"
292
- fi
328
+ echo -e " ${GREEN}✓ Device name: ${CYAN}$DEVICE_NAME${NC}"
329
+ echo " (the gateway may auto-suffix this if the name is already taken;"
330
+ echo " the assigned name will be logged on first connect)"
293
331
  echo ""
294
332
  else
295
333
  echo " No API key provided."
@@ -297,9 +335,7 @@ else
297
335
  echo " To authenticate this device:"
298
336
  echo ""
299
337
  echo " 1. Get an API key from ${CYAN}https://app.plexus.company${NC} → Settings → Developer"
300
- echo " 2. Run: ${CYAN}plexus start --key plx_xxxxx${NC}"
301
- echo ""
302
- echo " Run ${CYAN}plexus start${NC} to sign in and connect."
338
+ echo " 2. Re-run this installer with: ${CYAN}--key plx_xxxxx --name $DEVICE_NAME${NC}"
303
339
  echo ""
304
340
  fi
305
341
 
@@ -308,10 +344,6 @@ echo "────────────────────────
308
344
  echo ""
309
345
  echo -e " ${GREEN}Setup complete!${NC}"
310
346
  echo ""
311
- echo " Quick commands:"
312
- echo " plexus start # Set up and stream"
313
- echo " plexus reset # Clear config and start over"
314
- echo ""
315
347
  echo " Dashboard: ${CYAN}https://app.plexus.company${NC}"
316
348
  echo ""
317
349
  echo " To uninstall:"
@@ -329,23 +329,27 @@ class TestThreadSafety:
329
329
 
330
330
  def send_metric(metric_id):
331
331
  try:
332
- with patch.object(client, "_get_session") as mock_session:
333
- mock_session.return_value.post.side_effect = (
334
- requests.exceptions.Timeout()
335
- )
336
- try:
337
- client.send(f"metric_{metric_id}", float(metric_id))
338
- except PlexusError:
339
- pass # Expected
332
+ try:
333
+ client.send(f"metric_{metric_id}", float(metric_id))
334
+ except PlexusError:
335
+ pass # Expected
340
336
  except Exception as e:
341
337
  errors.append(e)
342
338
 
343
- # Launch multiple threads
344
- threads = [threading.Thread(target=send_metric, args=(i,)) for i in range(20)]
345
- for t in threads:
346
- t.start()
347
- for t in threads:
348
- t.join()
339
+ # Patch once outside the threads — patch.object mutates instance
340
+ # attrs and is not thread-safe under contention. We just need every
341
+ # call to fail so the buffer takes the write.
342
+ mock_session = MagicMock()
343
+ mock_session.return_value.post.side_effect = requests.exceptions.Timeout()
344
+
345
+ with patch.object(client, "_get_session", mock_session):
346
+ threads = [
347
+ threading.Thread(target=send_metric, args=(i,)) for i in range(20)
348
+ ]
349
+ for t in threads:
350
+ t.start()
351
+ for t in threads:
352
+ t.join()
349
353
 
350
354
  # No errors should have occurred
351
355
  assert len(errors) == 0
@@ -26,9 +26,13 @@ from plexus.ws import WebSocketTransport # noqa: E402
26
26
  class _StubGateway:
27
27
  """Minimal gateway stub. Records every frame the client sends."""
28
28
 
29
- def __init__(self):
29
+ def __init__(self, assigned_source_id: str | None = None):
30
30
  self.received: List[Dict[str, Any]] = []
31
31
  self.auth_frame: Dict[str, Any] = {}
32
+ # If set, the stub returns this value in the authenticated frame
33
+ # regardless of what the client asked for — used to exercise the
34
+ # auto-suffix path.
35
+ self.assigned_source_id = assigned_source_id
32
36
  self._loop: asyncio.AbstractEventLoop | None = None
33
37
  self._server = None
34
38
  self._thread: threading.Thread | None = None
@@ -53,9 +57,10 @@ class _StubGateway:
53
57
  raw = await ws.recv()
54
58
  msg = json.loads(raw)
55
59
  self.auth_frame = msg
60
+ returned_source_id = self.assigned_source_id or msg.get("source_id")
56
61
  await ws.send(json.dumps({
57
62
  "type": "authenticated",
58
- "source_id": msg.get("source_id"),
63
+ "source_id": returned_source_id,
59
64
  }))
60
65
  try:
61
66
  async for raw in ws:
@@ -120,6 +125,7 @@ def test_auth_handshake_and_telemetry(gateway):
120
125
  api_key="plx_test_abc",
121
126
  source_id="drone-001",
122
127
  ws_url=_url(gateway.port),
128
+ install_id="install-A",
123
129
  agent_version="9.9.9",
124
130
  )
125
131
  t.start()
@@ -130,6 +136,7 @@ def test_auth_handshake_and_telemetry(gateway):
130
136
  assert gateway.auth_frame["type"] == "device_auth"
131
137
  assert gateway.auth_frame["api_key"] == "plx_test_abc"
132
138
  assert gateway.auth_frame["source_id"] == "drone-001"
139
+ assert gateway.auth_frame["install_id"] == "install-A"
133
140
  assert gateway.auth_frame["platform"] == "python-sdk"
134
141
  assert gateway.auth_frame["agent_version"] == "9.9.9"
135
142
  # commands is omitted when none registered
@@ -248,6 +255,78 @@ def test_handler_exception_returns_error(gateway):
248
255
  t.stop()
249
256
 
250
257
 
258
+ def test_install_id_omitted_when_empty():
259
+ # Default install_id="" should not leak an empty install_id field into
260
+ # the auth frame — that keeps the wire shape identical for legacy SDK
261
+ # builds that don't set one.
262
+ g = _StubGateway()
263
+ g.start()
264
+ try:
265
+ t = WebSocketTransport(
266
+ api_key="plx_test_abc",
267
+ source_id="drone-001",
268
+ ws_url=_url(g.port),
269
+ )
270
+ t.start()
271
+ try:
272
+ assert t.wait_authenticated(timeout=3)
273
+ assert "install_id" not in g.auth_frame
274
+ finally:
275
+ t.stop()
276
+ finally:
277
+ g.stop()
278
+
279
+
280
+ def test_server_assigned_source_id_is_adopted():
281
+ # Simulate the auto-suffix path: SDK asks for "drone-01", the gateway
282
+ # returns "drone-01_2" in the authenticated frame. The transport must
283
+ # adopt the assigned name and fire the on_source_id_assigned callback.
284
+ g = _StubGateway(assigned_source_id="drone-01_2")
285
+ g.start()
286
+ try:
287
+ seen: List[str] = []
288
+ t = WebSocketTransport(
289
+ api_key="plx_test_abc",
290
+ source_id="drone-01",
291
+ ws_url=_url(g.port),
292
+ install_id="install-B",
293
+ on_source_id_assigned=lambda s: seen.append(s),
294
+ )
295
+ t.start()
296
+ try:
297
+ assert t.wait_authenticated(timeout=3)
298
+ assert t.source_id == "drone-01_2"
299
+ assert seen == ["drone-01_2"]
300
+ finally:
301
+ t.stop()
302
+ finally:
303
+ g.stop()
304
+
305
+
306
+ def test_same_assigned_source_id_does_not_fire_callback():
307
+ # Happy path — gateway returns the same name. No callback, source_id unchanged.
308
+ g = _StubGateway() # echoes whatever was sent
309
+ g.start()
310
+ try:
311
+ seen: List[str] = []
312
+ t = WebSocketTransport(
313
+ api_key="plx_test_abc",
314
+ source_id="drone-01",
315
+ ws_url=_url(g.port),
316
+ install_id="install-A",
317
+ on_source_id_assigned=lambda s: seen.append(s),
318
+ )
319
+ t.start()
320
+ try:
321
+ assert t.wait_authenticated(timeout=3)
322
+ assert t.source_id == "drone-01"
323
+ assert seen == []
324
+ finally:
325
+ t.stop()
326
+ finally:
327
+ g.stop()
328
+
329
+
251
330
  def test_ensure_device_path():
252
331
  from plexus.ws import _ensure_device_path
253
332
  assert _ensure_device_path("wss://foo") == "wss://foo/ws/device"
@@ -1,45 +0,0 @@
1
- # Changelog
2
-
3
- ## [0.3.0] - WebSocket transport
4
-
5
- Adds a wire-compatible WebSocket transport matching the `plexus-c` SDK. WS is now the default; failed sends transparently fall back to `POST /ingest`.
6
-
7
- ### Added
8
-
9
- - `plexus.WebSocketTransport` — connects to `/ws/device` on the gateway. Exchanges the same `device_auth` / `authenticated` / `telemetry` / `heartbeat` / `typed_command` / `command_result` frames as `plexus-c`.
10
- - `Plexus(transport="ws" | "http")` — defaults to `"ws"`.
11
- - `Plexus.on_command(name, handler, description=..., params=...)` — register command handlers; automatic `ack`, handler return becomes `result`, exceptions become `error`.
12
- - `Plexus.close()` — stops the WebSocket thread.
13
- - Runtime dep: `websocket-client>=1.7`.
14
- - Tests: `tests/test_ws.py` (auth handshake, telemetry, command roundtrip, error paths).
15
-
16
- ## [0.2.0] - Thin SDK rewrite
17
-
18
- Breaking. `plexus-python` is now just the thin client — no agent, adapters, sensors, CLI, or TUI. The package is 886 lines with one runtime dependency (`requests`). Protocol integrations (MAVLink, CAN, MQTT, Modbus, OPC-UA, BLE, I2C sensors) now live as standalone recipes in `examples/`, using the upstream library directly (`pymavlink`, `python-can`, `paho-mqtt`, etc.) plus `px.send()`.
19
-
20
- ### Added
21
-
22
- - 5 runnable example scripts: `basic.py`, `mavlink.py`, `can.py`, `mqtt.py`, `i2c_bme280.py`
23
-
24
- ### Removed
25
-
26
- - `plexus/adapters/` (MAVLink, CAN, MQTT, Modbus, OPC-UA, BLE, Serial — use the upstream lib directly)
27
- - `plexus/sensors/` (I2C drivers + auto-detect — use Adafruit CircuitPython or smbus2 directly)
28
- - `plexus/cameras/` (frame upload — out of scope)
29
- - `plexus/cli.py`, `plexus/connector.py`, `plexus/streaming.py`, `plexus/detect.py`, `plexus/tui.py`, `plexus/deps.py`
30
- - `plexus` console script, `python -m plexus`
31
- - Extras: `[sensors]`, `[system]`, `[tui]`, `[mqtt]`, `[can]`, `[mavlink]`, `[modbus]`, `[opcua]`, `[ble]`, `[serial]`, `[ros]`, `[camera]`, `[picamera]`, `[all]`
32
- - Runtime deps: `click`, `websockets`
33
-
34
- ### Changed
35
-
36
- - Default ingest endpoint points directly at the Plexus gateway (`https://plexus-gateway.fly.dev/ingest`), not the Next.js app proxy
37
- - Client raises `ValueError` clearly when no API key is available, instead of invoking a login flow
38
-
39
- ## [0.1.0] - Initial release
40
-
41
- - `Plexus` thin client for HTTP ingest
42
- - `plexus start` daemon with WebSocket streaming
43
- - Protocol adapters: MAVLink, CAN, MQTT, Modbus, OPC-UA, Serial, BLE
44
- - I2C sensor auto-detection and drivers
45
- - Store-and-forward buffering (SQLite)
File without changes
File without changes