plexus-python 0.2.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.
- {plexus_python-0.2.0 → plexus_python-0.4.1}/AGENTS.md +5 -20
- {plexus_python-0.2.0 → plexus_python-0.4.1}/API.md +16 -14
- plexus_python-0.4.1/CHANGELOG.md +110 -0
- {plexus_python-0.2.0 → plexus_python-0.4.1}/PKG-INFO +53 -2
- {plexus_python-0.2.0 → plexus_python-0.4.1}/README.md +50 -1
- {plexus_python-0.2.0 → plexus_python-0.4.1}/SECURITY.md +1 -1
- {plexus_python-0.2.0 → plexus_python-0.4.1}/examples/basic.py +1 -1
- {plexus_python-0.2.0 → plexus_python-0.4.1}/plexus/__init__.py +3 -2
- plexus_python-0.4.1/plexus/cli.py +275 -0
- {plexus_python-0.2.0 → plexus_python-0.4.1}/plexus/client.py +85 -5
- {plexus_python-0.2.0 → plexus_python-0.4.1}/plexus/config.py +48 -0
- plexus_python-0.4.1/plexus/ws.py +373 -0
- {plexus_python-0.2.0 → plexus_python-0.4.1}/pyproject.toml +6 -2
- {plexus_python-0.2.0 → plexus_python-0.4.1}/scripts/setup.sh +44 -12
- {plexus_python-0.2.0 → plexus_python-0.4.1}/tests/test_retry.py +18 -14
- plexus_python-0.4.1/tests/test_ws.py +334 -0
- plexus_python-0.2.0/CHANGELOG.md +0 -32
- {plexus_python-0.2.0 → plexus_python-0.4.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {plexus_python-0.2.0 → plexus_python-0.4.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {plexus_python-0.2.0 → plexus_python-0.4.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {plexus_python-0.2.0 → plexus_python-0.4.1}/.github/workflows/ci.yml +0 -0
- {plexus_python-0.2.0 → plexus_python-0.4.1}/.github/workflows/publish.yml +0 -0
- {plexus_python-0.2.0 → plexus_python-0.4.1}/.gitignore +0 -0
- {plexus_python-0.2.0 → plexus_python-0.4.1}/CODE_OF_CONDUCT.md +0 -0
- {plexus_python-0.2.0 → plexus_python-0.4.1}/CONTRIBUTING.md +0 -0
- {plexus_python-0.2.0 → plexus_python-0.4.1}/LICENSE +0 -0
- {plexus_python-0.2.0 → plexus_python-0.4.1}/examples/README.md +0 -0
- {plexus_python-0.2.0 → plexus_python-0.4.1}/examples/can.py +0 -0
- {plexus_python-0.2.0 → plexus_python-0.4.1}/examples/i2c_bme280.py +0 -0
- {plexus_python-0.2.0 → plexus_python-0.4.1}/examples/mavlink.py +0 -0
- {plexus_python-0.2.0 → plexus_python-0.4.1}/examples/mqtt.py +0 -0
- {plexus_python-0.2.0 → plexus_python-0.4.1}/plexus/buffer.py +0 -0
- {plexus_python-0.2.0 → plexus_python-0.4.1}/scripts/plexus.service +0 -0
- {plexus_python-0.2.0 → plexus_python-0.4.1}/scripts/scan_buses.py +0 -0
- {plexus_python-0.2.0 → plexus_python-0.4.1}/tests/test_basic.py +0 -0
- {plexus_python-0.2.0 → plexus_python-0.4.1}/tests/test_buffer.py +0 -0
- {plexus_python-0.2.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
|
|
8
|
-
| ----------------------- |
|
|
9
|
-
| `PLEXUS_API_KEY` | API key for authentication (required)
|
|
10
|
-
| `PLEXUS_GATEWAY_URL` | Gateway HTTP ingest URL
|
|
11
|
-
| `PLEXUS_GATEWAY_WS_URL` | Gateway WebSocket URL
|
|
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": "
|
|
163
|
-
"
|
|
164
|
-
"
|
|
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": "
|
|
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
|
+
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
|
|
|
@@ -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
|
|
@@ -87,13 +102,47 @@ px.buffer_size()
|
|
|
87
102
|
px.flush_buffer()
|
|
88
103
|
```
|
|
89
104
|
|
|
105
|
+
## Transport
|
|
106
|
+
|
|
107
|
+
By default the SDK connects over a **WebSocket** to `/ws/device` on the gateway — same wire protocol as the C SDK. This gives you:
|
|
108
|
+
|
|
109
|
+
- lower-latency streaming of telemetry,
|
|
110
|
+
- live command delivery from the UI / API to the device.
|
|
111
|
+
|
|
112
|
+
If the socket is unavailable, sends transparently fall back to `POST /ingest` so no data is lost.
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
# default — ws with http fallback
|
|
116
|
+
px = Plexus()
|
|
117
|
+
|
|
118
|
+
# force http (legacy)
|
|
119
|
+
px = Plexus(transport="http")
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Handling commands
|
|
123
|
+
|
|
124
|
+
Register a handler before the first `send()` so the command is advertised in the auth frame:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
def reboot(name, params):
|
|
128
|
+
delay = params.get("delay_s", 0)
|
|
129
|
+
# ... reboot logic ...
|
|
130
|
+
return {"ok": True, "delay": delay}
|
|
131
|
+
|
|
132
|
+
px = Plexus()
|
|
133
|
+
px.on_command("reboot", reboot, description="reboot the device")
|
|
134
|
+
px.send("temperature", 72.5) # opens the socket, waits for auth
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
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).
|
|
138
|
+
|
|
90
139
|
## Environment Variables
|
|
91
140
|
|
|
92
141
|
| Variable | Description | Default |
|
|
93
142
|
| ----------------------- | ---------------------------- | -------------------------------- |
|
|
94
143
|
| `PLEXUS_API_KEY` | API key (required) | none |
|
|
95
144
|
| `PLEXUS_GATEWAY_URL` | HTTP ingest URL | `https://plexus-gateway.fly.dev` |
|
|
96
|
-
| `PLEXUS_GATEWAY_WS_URL` | WebSocket URL
|
|
145
|
+
| `PLEXUS_GATEWAY_WS_URL` | WebSocket URL | `wss://plexus-gateway.fly.dev` |
|
|
97
146
|
|
|
98
147
|
## Architecture
|
|
99
148
|
|
|
@@ -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"]
|
|
@@ -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())
|