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.
- {plexus_python-0.3.0 → plexus_python-0.4.1}/AGENTS.md +5 -20
- {plexus_python-0.3.0 → plexus_python-0.4.1}/API.md +16 -14
- plexus_python-0.4.1/CHANGELOG.md +110 -0
- {plexus_python-0.3.0 → plexus_python-0.4.1}/PKG-INFO +16 -1
- {plexus_python-0.3.0 → plexus_python-0.4.1}/README.md +15 -0
- {plexus_python-0.3.0 → plexus_python-0.4.1}/SECURITY.md +1 -1
- {plexus_python-0.3.0 → plexus_python-0.4.1}/examples/basic.py +1 -1
- {plexus_python-0.3.0 → plexus_python-0.4.1}/plexus/__init__.py +1 -1
- plexus_python-0.4.1/plexus/cli.py +275 -0
- {plexus_python-0.3.0 → plexus_python-0.4.1}/plexus/client.py +15 -1
- {plexus_python-0.3.0 → plexus_python-0.4.1}/plexus/config.py +48 -0
- {plexus_python-0.3.0 → plexus_python-0.4.1}/plexus/ws.py +31 -2
- {plexus_python-0.3.0 → plexus_python-0.4.1}/pyproject.toml +4 -1
- {plexus_python-0.3.0 → plexus_python-0.4.1}/scripts/setup.sh +44 -12
- {plexus_python-0.3.0 → plexus_python-0.4.1}/tests/test_retry.py +18 -14
- {plexus_python-0.3.0 → plexus_python-0.4.1}/tests/test_ws.py +81 -2
- plexus_python-0.3.0/CHANGELOG.md +0 -45
- {plexus_python-0.3.0 → plexus_python-0.4.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {plexus_python-0.3.0 → plexus_python-0.4.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {plexus_python-0.3.0 → plexus_python-0.4.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {plexus_python-0.3.0 → plexus_python-0.4.1}/.github/workflows/ci.yml +0 -0
- {plexus_python-0.3.0 → plexus_python-0.4.1}/.github/workflows/publish.yml +0 -0
- {plexus_python-0.3.0 → plexus_python-0.4.1}/.gitignore +0 -0
- {plexus_python-0.3.0 → plexus_python-0.4.1}/CODE_OF_CONDUCT.md +0 -0
- {plexus_python-0.3.0 → plexus_python-0.4.1}/CONTRIBUTING.md +0 -0
- {plexus_python-0.3.0 → plexus_python-0.4.1}/LICENSE +0 -0
- {plexus_python-0.3.0 → plexus_python-0.4.1}/examples/README.md +0 -0
- {plexus_python-0.3.0 → plexus_python-0.4.1}/examples/can.py +0 -0
- {plexus_python-0.3.0 → plexus_python-0.4.1}/examples/i2c_bme280.py +0 -0
- {plexus_python-0.3.0 → plexus_python-0.4.1}/examples/mavlink.py +0 -0
- {plexus_python-0.3.0 → plexus_python-0.4.1}/examples/mqtt.py +0 -0
- {plexus_python-0.3.0 → plexus_python-0.4.1}/plexus/buffer.py +0 -0
- {plexus_python-0.3.0 → plexus_python-0.4.1}/scripts/plexus.service +0 -0
- {plexus_python-0.3.0 → plexus_python-0.4.1}/scripts/scan_buses.py +0 -0
- {plexus_python-0.3.0 → plexus_python-0.4.1}/tests/test_basic.py +0 -0
- {plexus_python-0.3.0 → plexus_python-0.4.1}/tests/test_buffer.py +0 -0
- {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
|
|
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
|
|
@@ -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
|
|
@@ -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())
|
|
@@ -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
|
|
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",
|
|
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":
|
|
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.
|
|
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
|
-
|
|
286
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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.
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
#
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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":
|
|
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"
|
plexus_python-0.3.0/CHANGELOG.md
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|