plexus-python 0.11.4__py3-none-any.whl → 0.11.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plexus/__init__.py +1 -1
- plexus/_skills/README.md +3 -2
- plexus/_skills/plexus/SKILL.md +21 -17
- plexus/_skills/plexus-dashboard/SKILL.md +6 -6
- plexus/_skills/plexus-firmware/SKILL.md +19 -12
- plexus/batching.py +4 -5
- plexus/client.py +9 -9
- {plexus_python-0.11.4.dist-info → plexus_python-0.11.6.dist-info}/METADATA +30 -8
- plexus_python-0.11.6.dist-info/RECORD +19 -0
- {plexus_python-0.11.4.dist-info → plexus_python-0.11.6.dist-info}/WHEEL +1 -1
- plexus_python-0.11.4.dist-info/RECORD +0 -19
- {plexus_python-0.11.4.dist-info → plexus_python-0.11.6.dist-info}/entry_points.txt +0 -0
- {plexus_python-0.11.4.dist-info → plexus_python-0.11.6.dist-info}/licenses/LICENSE +0 -0
plexus/__init__.py
CHANGED
plexus/_skills/README.md
CHANGED
|
@@ -36,8 +36,9 @@ Then just ask for what you want — "send my ESP32's battery voltage to Plexus",
|
|
|
36
36
|
An agent that has not read these invents a plausible Plexus API and gets it
|
|
37
37
|
wrong in ways that fail quietly. The three that cost the most real time:
|
|
38
38
|
|
|
39
|
-
- The ingest array is **`points`**, not `metrics
|
|
40
|
-
|
|
39
|
+
- The ingest array is **`points`**, not `metrics`. Getting this wrong is a
|
|
40
|
+
400 on every write. Every point should also carry a `class`: HTTP infers
|
|
41
|
+
it when missing, the WebSocket does not.
|
|
41
42
|
- `timestamp` must be a **number**. An ISO-8601 string is rejected outright.
|
|
42
43
|
- The query response is **columnar** — `series[m].avg[i]`, not
|
|
43
44
|
`series[m][i].v`. Guessing wrong yields `undefined` with no error: an empty
|
plexus/_skills/plexus/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: plexus
|
|
3
|
-
description: Integrate with the Plexus telemetry API — send data, query metrics, subscribe to live streams
|
|
3
|
+
description: Integrate with the Plexus telemetry API — send data, query metrics, subscribe to live streams. Use when the user mentions Plexus, plexus.company, gateway.plexus.company, api.plexus.company, or plx_ API keys. ALSO USE when the request involves IoT/hardware telemetry, fleet observability, sending sensor data to a backend, querying device time-series, building a fleet dashboard, monitoring drones/satellites/robots/edge devices, or any phrase like "send my sensor readings somewhere", "store telemetry", "track a fleet", "ingest metrics", or "device observability" — even if "Plexus" is never said.
|
|
4
4
|
tools: Read, Write, Edit, Bash, WebFetch
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -12,8 +12,8 @@ Plexus is a telemetry/observability platform for hardware fleets (drones, satell
|
|
|
12
12
|
|
|
13
13
|
Trigger on any of:
|
|
14
14
|
|
|
15
|
-
- The user mentions "Plexus", "plexus.company", `plx_` keys, `gateway.plexus.company`, or `
|
|
16
|
-
- The user wants to ingest telemetry, query device metrics, stream live points
|
|
15
|
+
- The user mentions "Plexus", "plexus.company", `plx_` keys, `gateway.plexus.company`, or `api.plexus.company`
|
|
16
|
+
- The user wants to ingest telemetry, query device metrics, or stream live points
|
|
17
17
|
- The user is building a dashboard, alert pipeline, or analysis on top of fleet telemetry
|
|
18
18
|
- The user pastes a Plexus curl example and asks for help
|
|
19
19
|
|
|
@@ -27,19 +27,21 @@ Two base URLs. Authenticate with an `x-api-key` header on HTTP.
|
|
|
27
27
|
| Purpose | Host |
|
|
28
28
|
| -------------------------------------------------- | -------------------------------- |
|
|
29
29
|
| Ingest | `https://gateway.plexus.company` |
|
|
30
|
-
| Read API (sources, metrics,
|
|
30
|
+
| Read API (sources, metrics, events, fleet, live stream) | `https://api.plexus.company` |
|
|
31
31
|
|
|
32
32
|
```
|
|
33
33
|
x-api-key: plx_...
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
The OpenAPI spec for the read API lives at `https://
|
|
36
|
+
The OpenAPI spec for the read API lives at `https://api.plexus.company/openapi.json` — fetch it when you need exact schemas. **It does not list the WebSocket endpoints** (FastAPI omits them), so use the Live section below for those.
|
|
37
37
|
|
|
38
38
|
Read keys from env, never hardcode:
|
|
39
39
|
|
|
40
40
|
- `PLEXUS_API_KEY` — server / CLI
|
|
41
41
|
- `NEXT_PUBLIC_PLEXUS_API_KEY` — client-side Next.js (only if the user explicitly accepts the trade-off)
|
|
42
42
|
|
|
43
|
+
Keys are made at `https://app.plexus.company/api`. The optional **Limit to device slug** field binds a key to one `source_id`; the gateway then refuses it for any other source (`403` on `/ingest`). Recommend a bound key per device for hardware that ships to customers, and a fleet key only for servers you control.
|
|
44
|
+
|
|
43
45
|
## Endpoint cheat sheet
|
|
44
46
|
|
|
45
47
|
### Send (gateway)
|
|
@@ -57,13 +59,16 @@ Read keys from env, never hardcode:
|
|
|
57
59
|
|
|
58
60
|
Per point:
|
|
59
61
|
|
|
60
|
-
-
|
|
62
|
+
- **Set `class` on every point**: `"metric"` (the value must be a number) or `"event"` (string, bool, object, array). On HTTP `/ingest` a missing `class` is inferred — numbers become metrics, everything else events — but the WebSocket path rejects a point without it, and an explicit class says what you meant. The Python SDK always sets it.
|
|
61
63
|
- **The array is `points`, not `metrics`.** Sending `metrics: [...]` returns `400 {"error":"'points' array is required"}`. This is the single most common mistake.
|
|
62
64
|
- `metric` (string) and `value` are required.
|
|
63
65
|
- `timestamp` must be a **number** — an ISO-8601 string is rejected with `points[i].timestamp must be a number`. Epoch **milliseconds** is the intended unit; a positive value under `1e12` is interpreted as **seconds** and scaled up automatically.
|
|
64
66
|
- Omitting `timestamp` is safe: the point is stamped with gateway receive time. (This used to land at 1970 and be invisible — fixed, the loader now falls back to `ingested_at`.)
|
|
65
67
|
- `source_id` may be set at the envelope level, per point, or both — per point wins.
|
|
66
68
|
- The gateway creates the source on first write; no registration step.
|
|
69
|
+
- Event limits: a string value up to 256 bytes, an object/array value up to 4,096 bytes of JSON, up to 16 tags per point. Larger is a 400.
|
|
70
|
+
- **There is no log upload endpoint.** Send log lines that matter as event points, e.g. `{"class":"event","metric":"log","value":{"level":"error","msg":"..."}}` (`px.event("log", {...})` in plexus-python).
|
|
71
|
+
- **Free plan:** `/ingest` works. The device WebSocket and video are refused (`streaming_requires_plan`); plexus-python falls back to HTTP by itself. Free keeps 7 days of history and up to 3 devices.
|
|
67
72
|
|
|
68
73
|
`POST /api/v1/write` also exists as a Prometheus/Alloy/OTel/Telegraf remote-write receiver. Do not reach for it unless the user already runs one of those.
|
|
69
74
|
|
|
@@ -78,7 +83,7 @@ Paths are `/v1/sources/...`. `/v1/devices/...` is a deprecated alias — bare `/
|
|
|
78
83
|
- `GET /v1/sources/{id}/metrics` → `string[]` (metric names)
|
|
79
84
|
- `GET /v1/sources/{id}/metrics/latest` → `{ metrics: { [name]: number } }`
|
|
80
85
|
- `GET /v1/sources/{id}/metrics/query?metrics=a,b&last=1h` → columnar, see below. Also takes `start`, `end`, `interval`.
|
|
81
|
-
- `GET /v1/sources/{id}/
|
|
86
|
+
- `GET /v1/sources/{id}/events?last=1h&limit=1000` → `[{ timestamp_ms, metric, value, tags }]`, the `class: "event"` points (also `tail`, `name`, `start`, `end`). Plexus has no separate log type. `/logs` is an old alias for the same route; use `/events`.
|
|
82
87
|
- `GET /v1/fleet/health` → `{ sources_total, sources_online }`
|
|
83
88
|
- `GET /v1/fleet/metrics?metric=X&last=1h` → `{ metric, interval, sources_online, sources_w_metric, sources: [...], truncated }`
|
|
84
89
|
|
|
@@ -117,10 +122,10 @@ const points = s.timestamp_ms.map((t, i) => ({ t, v: s.avg[i] }));
|
|
|
117
122
|
### Live (data API, not the gateway)
|
|
118
123
|
|
|
119
124
|
```
|
|
120
|
-
WS wss://
|
|
125
|
+
WS wss://api.plexus.company/v1/sources/{source_id}/metrics/stream?metrics=a,b
|
|
121
126
|
```
|
|
122
127
|
|
|
123
|
-
Also `/
|
|
128
|
+
Also `/events/stream` (event points) and `/video/stream` under the same source prefix.
|
|
124
129
|
|
|
125
130
|
**Auth is the first message, not a header.** Immediately after connect, send:
|
|
126
131
|
|
|
@@ -141,9 +146,9 @@ You do **not** need to answer application-level pings on this endpoint; keepaliv
|
|
|
141
146
|
|
|
142
147
|
The gateway's own sockets (`/ws/device`, `/ws/browser`) are for the Python SDK and the Plexus app respectively. Don't write third-party clients against them. There is no `/v1/stream` on the gateway.
|
|
143
148
|
|
|
144
|
-
### Control
|
|
149
|
+
### Control
|
|
145
150
|
|
|
146
|
-
`POST /v1/sources/{id}/commands`
|
|
151
|
+
There is no API for sending commands to devices. `POST /v1/sources/{id}/commands` was turned off and returns `410 Gone`. Don't write code that calls it, and don't offer to trigger device behavior through the API.
|
|
147
152
|
|
|
148
153
|
## Standard scaffolding
|
|
149
154
|
|
|
@@ -202,27 +207,26 @@ let the pipeline settle first.
|
|
|
202
207
|
|
|
203
208
|
- **Polling cadences for dashboards**: latest values 5s, charts 10s, fleet health 10s, source list 30s. Use SWR or TanStack Query with `refreshInterval`.
|
|
204
209
|
- **Time ranges**: prefer `last=1h` (relative) over `start`/`end` (absolute) — easier to reason about and less timezone footgun. `start`/`end` are ISO date-times, not epoch ms.
|
|
205
|
-
- **Batching ingest**: the gateway meters **messages, not points** —
|
|
210
|
+
- **Batching ingest**: the gateway meters **messages, not points** — 2,000/s per WebSocket connection and per source (bursts up to 500), up to 10,000 points in one message. Buffer up to 64 points or 5 seconds, whichever first. One send per reading at bench rates exceeds the limit and the overflow is *discarded*, reported asynchronously as `RATE_LIMITED` after the send returned. In plexus-python use `px.batch(interval_ms=50)`; `px.send()` alone is one message per call. On 429 / 5xx, exponential backoff with max 3 attempts.
|
|
206
211
|
- **Source IDs are slugs**: `drone-001`, `sat-alpha-3`, `bench.rig-2`. Must match `^[a-z0-9][a-z0-9._-]*$`, up to 256 characters — dots are legal and a single character is legal. A uuid-shaped slug is rejected: every resolver reads uuid-shaped refs as internal ids, so such a source would be unreachable. Stable, lowercase.
|
|
207
212
|
- **Source IDs are not deduplicated.** The gateway writes whatever `source_id` you declare. Two devices declaring the same name merge into one source.
|
|
208
213
|
- **Metric names are opaque and may be long.** Anything that round-trips a name must use the identical string on both sides or the series and its metadata will not join.
|
|
209
214
|
|
|
210
215
|
## Common pitfalls
|
|
211
216
|
|
|
212
|
-
- **`points`, not `metrics`, on ingest
|
|
217
|
+
- **`points`, not `metrics`, on ingest.** The most common 400. Set `class` on every point too: HTTP infers it when missing, the WebSocket does not, and `class: "metric"` with a non-numeric value is a 400.
|
|
213
218
|
- **Numeric timestamps only.** ISO strings 400. Milliseconds unless the value is under `1e12`, in which case it's read as seconds.
|
|
214
219
|
- **The query response is columnar.** `series[m].avg[i]`, not `series[m][i].v`. Reaching for `.t`/`.v` yields `undefined` and an empty chart with no error.
|
|
215
220
|
- **The live stream is on the data API, and auths by first message.** There is no `/v1/stream` on the gateway.
|
|
216
221
|
- **Telemetry frames are batched** — `points` is an array. Handling one frame as one point silently drops data.
|
|
217
|
-
- `start`/`end` are **ISO date-times on every endpoint** that takes them — `query`, `
|
|
222
|
+
- `start`/`end` are **ISO date-times on every endpoint** that takes them — `query`, `events` and `fleet/metrics` alike. `last=1h` is easier and works on all three.
|
|
218
223
|
- `auto_downsampled: true` in a query response means the bucket size was picked for you — surface it in the UI so users understand what they're looking at.
|
|
219
|
-
- Commands queue on the device; they don't execute synchronously. Don't promise the user "it rebooted" — promise "reboot queued".
|
|
220
224
|
|
|
221
225
|
## When unsure
|
|
222
226
|
|
|
223
|
-
Fetch `https://
|
|
227
|
+
Fetch `https://api.plexus.company/openapi.json` for the authoritative HTTP schema (it will not show WebSocket routes), and check a real response before writing parsing code. `scripts/verify_skills.py` in this repo checks these docs against the live spec — run it if something here looks stale.
|
|
224
228
|
|
|
225
229
|
This cheat sheet has drifted before. Corrected 2026-09-01 against the shipped
|
|
226
230
|
gateway and app (runs API — which an earlier revision of API.md wrongly said did
|
|
227
231
|
not exist; message-not-point metering and `px.batch()`; the real slug rule,
|
|
228
|
-
which permits dots, single characters and 256 bytes). Corrected 2026-08-27 against Data API 0.1.0 (ingest array name, `sources`/`devices` paths, removed per-source health, columnar query) and again 2026-08-28 against gateway + API source (the live-stream host/path/auth/frame shape, the required `class` field, numeric-only timestamps, the redirect chain, and the now-fixed 1970 timestamp behavior).
|
|
232
|
+
which permits dots, single characters and 256 bytes). Corrected 2026-08-27 against Data API 0.1.0 (ingest array name, `sources`/`devices` paths, removed per-source health, columnar query) and again 2026-08-28 against gateway + API source (the live-stream host/path/auth/frame shape, the required `class` field, numeric-only timestamps, the redirect chain, and the now-fixed 1970 timestamp behavior). Corrected 2026-09-22: the read API host is `api.plexus.company`; `class` is inferred on HTTP `/ingest` (not a 400) but required on the WebSocket; key binding to one source; Free-plan WebSocket refusal; event size limits; no log upload.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: plexus-dashboard
|
|
3
|
-
description: Scaffold a working web dashboard against the Plexus telemetry API — device picker, latest-value tiles, time-series charts,
|
|
3
|
+
description: Scaffold a working web dashboard against the Plexus telemetry API — device picker, latest-value tiles, time-series charts, event viewer. Use when the user wants to build a frontend, dashboard, ops UI, mission control, or fleet view on top of Plexus data. Triggering phrases include "show me my drones/satellites/robots", "build a dashboard for my fleet", "vibe code a Plexus frontend", "fleet monitoring UI", "telemetry dashboard", "device status page", "live charts of sensor data", or "ops view for my hardware" — even if "Plexus" is never said, as long as the data source is the Plexus API.
|
|
4
4
|
tools: Read, Write, Edit, Bash, WebFetch
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -37,7 +37,7 @@ Dashboard
|
|
|
37
37
|
└─ Detail pane
|
|
38
38
|
├─ Tile row: every metric from /metrics/latest as a big-number card
|
|
39
39
|
├─ Chart grid: one line chart per metric over the last 1h via /metrics/query
|
|
40
|
-
└─ (Optional)
|
|
40
|
+
└─ (Optional) Event pane: /events (event points: faults, state changes, log lines) in a virtualized list
|
|
41
41
|
```
|
|
42
42
|
|
|
43
43
|
## Polling cadences
|
|
@@ -71,7 +71,7 @@ Ask exactly two questions, no more:
|
|
|
71
71
|
Create `src/lib/plexus.ts`:
|
|
72
72
|
|
|
73
73
|
```ts
|
|
74
|
-
const BASE = "https://
|
|
74
|
+
const BASE = "https://api.plexus.company";
|
|
75
75
|
const KEY = process.env.NEXT_PUBLIC_PLEXUS_API_KEY!;
|
|
76
76
|
|
|
77
77
|
export class PlexusError extends Error {
|
|
@@ -158,7 +158,7 @@ export function subscribeMetrics(
|
|
|
158
158
|
) {
|
|
159
159
|
const qs = metrics.length ? `?metrics=${metrics.join(",")}` : "";
|
|
160
160
|
const ws = new WebSocket(
|
|
161
|
-
`wss://
|
|
161
|
+
`wss://api.plexus.company/v1/sources/${sourceId}/metrics/stream${qs}`,
|
|
162
162
|
);
|
|
163
163
|
|
|
164
164
|
// Auth is the FIRST MESSAGE, not a header. Send it within 10s or the
|
|
@@ -199,6 +199,6 @@ Wire it alongside SWR: keep `useSWR` for initial load (UI hydrates with a value
|
|
|
199
199
|
|
|
200
200
|
## When unsure about endpoint shapes
|
|
201
201
|
|
|
202
|
-
`https://
|
|
202
|
+
`https://api.plexus.company/openapi.json` is the source of truth for HTTP. It does **not** list WebSocket routes — the generic `plexus` skill documents those.
|
|
203
203
|
|
|
204
|
-
Corrected 2026-08-28: `/v1/devices` → `/v1/sources`, removed the non-existent per-source health endpoint, fixed the columnar query type, and replaced the live-stream section (the old `wss://gateway.plexus.company/v1/stream` 404s, and the "browsers can't authenticate" caveat no longer holds).
|
|
204
|
+
Corrected 2026-08-28: `/v1/devices` → `/v1/sources`, removed the non-existent per-source health endpoint, fixed the columnar query type, and replaced the live-stream section (the old `wss://gateway.plexus.company/v1/stream` 404s, and the "browsers can't authenticate" caveat no longer holds). Corrected 2026-09-22: the read API host is `api.plexus.company`.
|
|
@@ -14,7 +14,7 @@ Adds a small, well-behaved Plexus ingest client to device-side code. Optimized f
|
|
|
14
14
|
- Targets include: ESP32 / Arduino / RP2040, Raspberry Pi, NVIDIA Jetson, embedded Linux gateways, drone autopilot companion computers, satellite OBC software
|
|
15
15
|
- Languages: C / C++, MicroPython, CPython, Rust, Go (for edge gateways)
|
|
16
16
|
|
|
17
|
-
**If the target runs CPython (Pi, Jetson, any embedded Linux with room), stop and use the SDK instead** — `pip install plexus-python`, then `Plexus(api_key=..., source_id=...)`. It handles backoff, store-and-forward buffering and the WebSocket transport. Hand-rolling is for targets the SDK can't reach.
|
|
17
|
+
**If the target runs CPython (Pi, Jetson, any embedded Linux with room), stop and use the SDK instead** — `pip install plexus-python`, then `Plexus(api_key=..., source_id=...)`. It handles backoff, store-and-forward buffering and the WebSocket transport, and falls back to HTTP on its own (including on the Free plan, where the gateway refuses the device WebSocket). Hand-rolling is for targets the SDK can't reach.
|
|
18
18
|
|
|
19
19
|
**`send()` does NOT batch.** Every call is its own message on the wire, and the ceiling below counts messages. Above a few readings a second, use `px.batch()`:
|
|
20
20
|
|
|
@@ -56,12 +56,14 @@ Response:
|
|
|
56
56
|
{ "success": true, "count": 2, "source_id": "drone-001" }
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
Three things the gateway will reject you for, and one to always set. Get these right or nothing lands:
|
|
60
60
|
|
|
61
61
|
1. **The array is `points`.** `"metrics": [...]` returns `400 {"error":"'points' array is required"}`.
|
|
62
|
-
2.
|
|
63
|
-
3. **`
|
|
64
|
-
4.
|
|
62
|
+
2. **`timestamp` is a number, never a string.** An ISO-8601 string returns `points[i].timestamp must be a number`. Epoch **milliseconds**; a positive value below `1e12` is read as **seconds** and scaled for you, so either unit works as long as it's numeric.
|
|
63
|
+
3. **`source_id` must match `^[a-z0-9][a-z0-9._-]*$` (max 256 chars)** and is not deduplicated — two devices declaring the same id merge into one source. This is what SD-card clones do when they all boot as `raspberrypi`.
|
|
64
|
+
4. **Set `class` on every point**: `"metric"` for numbers, `"event"` for strings, bools, objects and arrays. HTTP `/ingest` infers it when missing (numbers → metric, anything else → event), but the WebSocket rejects a point without it, and `"class": "metric"` on a non-number is a 400. Set it explicitly.
|
|
65
|
+
|
|
66
|
+
Events are for faults, state changes and log lines. There is no log upload: send the lines that matter as `{"class":"event","metric":"log","value":{"level":"error","msg":"..."}}`. Limits: a string value up to 256 bytes, an object/array value up to 4,096 bytes of JSON, up to 16 tags.
|
|
65
67
|
|
|
66
68
|
`timestamp` is optional. Omit it and the gateway stamps the point with its receive time — which is the right move on a device whose clock has never been NTP-synced. Per-point `tags` (a flat string→string map) are supported and optional.
|
|
67
69
|
|
|
@@ -77,12 +79,12 @@ decides whether you hit a limit:
|
|
|
77
79
|
|
|
78
80
|
| Limit | Value |
|
|
79
81
|
| --- | --- |
|
|
80
|
-
| Telemetry messages per WebSocket connection |
|
|
81
|
-
| Hard ceiling per source (WS and HTTP) |
|
|
82
|
+
| Telemetry messages per WebSocket connection | 2,000/s |
|
|
83
|
+
| Hard ceiling per source (WS and HTTP) | 2,000 messages/s, bursts up to 500 |
|
|
82
84
|
| Points per message | 10,000 |
|
|
83
85
|
| Body size | 1 MB (WS) / 5 MB (HTTP) |
|
|
84
86
|
|
|
85
|
-
|
|
87
|
+
25 channels at 100 Hz sent one at a time is 2,500 messages/s — over the limit,
|
|
86
88
|
and **the overflow is discarded**. Batched every 50 ms it is 20 messages/s.
|
|
87
89
|
|
|
88
90
|
Over the limit the gateway drops the whole message and answers `RATE_LIMITED`,
|
|
@@ -118,6 +120,7 @@ The `source_id` must be stable across reboots **and unique across the fleet**. U
|
|
|
118
120
|
- ESP32 / Arduino: NVS / preferences storage, **not** hardcoded in firmware
|
|
119
121
|
- Raspberry Pi / Linux: `/etc/plexus/key` with mode 0600, or env var
|
|
120
122
|
- Never compile keys into a binary that ships to multiple devices — one leak compromises the fleet
|
|
123
|
+
- For devices in customer hands, give each one its own key made at `https://app.plexus.company/api` with **Limit to device slug** set to its `source_id`. The gateway refuses that key for any other source (`403`), so a key pulled off one unit cannot write as the rest of the fleet
|
|
121
124
|
|
|
122
125
|
### 5. Bound memory
|
|
123
126
|
|
|
@@ -142,7 +145,7 @@ _lock = _thread.allocate_lock()
|
|
|
142
145
|
def emit(metric, value):
|
|
143
146
|
"""Called from sensor-read context. Cheap — just appends to a buffer."""
|
|
144
147
|
with _lock:
|
|
145
|
-
#
|
|
148
|
+
# Always set class. timestamp must be a NUMBER (epoch ms) — omit it
|
|
146
149
|
# entirely if this board has never NTP-synced and the gateway will
|
|
147
150
|
# stamp it on receive.
|
|
148
151
|
_buf.append({
|
|
@@ -203,10 +206,12 @@ Use the SDK — `pip install plexus-python` — unless there's a reason not to:
|
|
|
203
206
|
from plexus import Plexus
|
|
204
207
|
|
|
205
208
|
px = Plexus(api_key=os.environ["PLEXUS_API_KEY"], source_id="pi-fieldunit-03")
|
|
206
|
-
px.send("battery.voltage", 11.8) # class=metric,
|
|
209
|
+
px.send("battery.voltage", 11.8) # class=metric, one message per call
|
|
207
210
|
px.event("fault", "undervoltage lockout") # class=event
|
|
208
211
|
```
|
|
209
212
|
|
|
213
|
+
`send()` does not batch. Above a few readings a second, use `with px.batch(interval_ms=50) as b:` as shown at the top.
|
|
214
|
+
|
|
210
215
|
If you must hand-roll: `requests` with a `Session` for connection pooling, same batching + backoff rules, key from `os.environ["PLEXUS_API_KEY"]`, flush on a background thread on a 5-second tick.
|
|
211
216
|
|
|
212
217
|
### C (ESP-IDF / generic embedded)
|
|
@@ -247,11 +252,13 @@ If they see `{"success":true,"count":1,"source_id":"test-laptop"}`, auth + conne
|
|
|
247
252
|
|
|
248
253
|
```bash
|
|
249
254
|
curl -H "x-api-key: $PLEXUS_API_KEY" \
|
|
250
|
-
https://
|
|
255
|
+
https://api.plexus.company/v1/sources/test-laptop/metrics/latest
|
|
251
256
|
```
|
|
252
257
|
|
|
253
258
|
## When unsure
|
|
254
259
|
|
|
255
|
-
The ingest contract above is the authority for the device side. For reading data back, fetch `https://
|
|
260
|
+
The ingest contract above is the authority for the device side. For reading data back, fetch `https://api.plexus.company/openapi.json` or use the generic `plexus` skill.
|
|
256
261
|
|
|
257
262
|
Corrected 2026-08-28 against gateway source (`ingest.go`, `validate.go`): the array is `points` not `metrics`, `class` is required, timestamps must be numeric, and the response is `{success, count, source_id}`. Every template in the previous version of this file would have 400'd.
|
|
263
|
+
|
|
264
|
+
Corrected 2026-09-22: `class` is inferred on HTTP `/ingest` when missing (it was documented as a 400) but is required on the WebSocket, so set it anyway; per-device keys via "Limit to device slug"; the read API host is `api.plexus.company`; `px.send()` is one message per call, not batched.
|
plexus/batching.py
CHANGED
|
@@ -2,11 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
`px.send()` transmits immediately: every call is its own WebSocket frame. That
|
|
4
4
|
is the right shape for a script sampling a sensor once a second, and the wrong
|
|
5
|
-
one for a test bench. The gateway allows
|
|
6
|
-
connection and
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
close to it. The dropped frames come back as `RATE_LIMITED`, after `send()` has
|
|
5
|
+
one for a test bench. The gateway allows 2,000 telemetry messages per second on
|
|
6
|
+
a connection and per source, and the ceiling counts *messages*, not points — so
|
|
7
|
+
25 channels at 100 Hz is 2,500 frames/s and lands over the limit, while the same
|
|
8
|
+
2,500 readings coalesced into ten frames is not close to it. The dropped frames come back as `RATE_LIMITED`, after `send()` has
|
|
10
9
|
already returned True, which is why this is a batching problem rather than a
|
|
11
10
|
retry one: by the time anyone can react, the points are gone.
|
|
12
11
|
|
plexus/client.py
CHANGED
|
@@ -182,8 +182,8 @@ class RateLimitedError(PlexusError):
|
|
|
182
182
|
# The wire slug rule (gateway validate.go sourceIDPattern, max length =
|
|
183
183
|
# MaxStringLen 256) — the old stricter local regex rejected dots, 1-char and
|
|
184
184
|
# >63-char slugs that the gateway accepts. Uuid-shaped slugs are additionally
|
|
185
|
-
# rejected
|
|
186
|
-
#
|
|
185
|
+
# rejected: the Plexus app resolves uuid-shaped refs as internal ids, which
|
|
186
|
+
# would make such a source unreachable.
|
|
187
187
|
_SOURCE_ID_RE = re.compile(r'^[a-z0-9][a-z0-9._-]*$')
|
|
188
188
|
_SOURCE_ID_UUID_RE = re.compile(
|
|
189
189
|
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
|
@@ -271,7 +271,7 @@ class Plexus:
|
|
|
271
271
|
if not self.api_key:
|
|
272
272
|
raise ValueError(
|
|
273
273
|
"No API key. Pass api_key=... or set PLEXUS_API_KEY. "
|
|
274
|
-
"Get a key at app.plexus.company/
|
|
274
|
+
"Get a key at app.plexus.company/api."
|
|
275
275
|
)
|
|
276
276
|
|
|
277
277
|
self.endpoint = (endpoint or get_endpoint()).rstrip("/")
|
|
@@ -372,9 +372,9 @@ class Plexus:
|
|
|
372
372
|
def _infer_class(value: FlexValue) -> str:
|
|
373
373
|
"""Numbers are metrics; everything else (str/bool/dict/list) is an event.
|
|
374
374
|
|
|
375
|
-
Mirrors the gateway
|
|
376
|
-
(
|
|
377
|
-
|
|
375
|
+
Mirrors the gateway, which infers class the same way on HTTP /ingest
|
|
376
|
+
(ingest.go). bool is a subclass of int in Python, so it must be
|
|
377
|
+
excluded explicitly or True/False would wrongly become metrics.
|
|
378
378
|
"""
|
|
379
379
|
return "metric" if isinstance(value, (int, float)) and not isinstance(value, bool) else "event"
|
|
380
380
|
|
|
@@ -543,9 +543,9 @@ class Plexus:
|
|
|
543
543
|
|
|
544
544
|
Use this whenever readings arrive faster than a few times a second.
|
|
545
545
|
`send()` puts every reading in its own WebSocket frame, and the gateway
|
|
546
|
-
limits *frames*, not points — so
|
|
547
|
-
frames/s against a
|
|
548
|
-
Batched, the same
|
|
546
|
+
limits *frames*, not points — so 25 channels at 100 Hz is 2,500
|
|
547
|
+
frames/s against a 2,000/s ceiling, and the overflow is discarded.
|
|
548
|
+
Batched, the same 2,500 readings/s is 10 frames/s.
|
|
549
549
|
|
|
550
550
|
with px.batch(interval_ms=50) as b:
|
|
551
551
|
while running:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: plexus-python
|
|
3
|
-
Version: 0.11.
|
|
3
|
+
Version: 0.11.6
|
|
4
4
|
Summary: Thin Python SDK for Plexus — send telemetry in one line
|
|
5
5
|
Project-URL: Homepage, https://plexus.company
|
|
6
6
|
Project-URL: Documentation, https://docs.plexus.company
|
|
@@ -55,7 +55,9 @@ px = Plexus(api_key="plx_xxx", source_id="device-001")
|
|
|
55
55
|
px.send("temperature", 72.5)
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
Get an API key at [app.plexus.company](https://app.plexus.company)
|
|
58
|
+
Get an API key at [app.plexus.company/api](https://app.plexus.company/api), or run `plexus init` to authorize the machine in a browser.
|
|
59
|
+
|
|
60
|
+
When you create a key there, you can fill in **Limit to device slug**. That key can then only send as that one `source_id`; the gateway refuses it for any other. Use one of these for every device you put in a customer's hands, so a key pulled off one unit cannot write as the rest of your fleet.
|
|
59
61
|
|
|
60
62
|
## Device identity
|
|
61
63
|
|
|
@@ -66,7 +68,7 @@ curl -sL https://app.plexus.company/setup | bash -s -- \
|
|
|
66
68
|
--key plx_xxx --name drone-01
|
|
67
69
|
```
|
|
68
70
|
|
|
69
|
-
The name must match `^[a-z0-9][a-z0-9._-]*$` (max 256 chars). `
|
|
71
|
+
The name is turned into the device's `source_id`, which must match `^[a-z0-9][a-z0-9._-]*$` (max 256 chars). Pass `--name` every time. Without it, and without `source_id=...` in code, the SDK makes up a random id like `source-1a2b3c4d` on first run and saves it to `~/.plexus/config.json`. Don't use the hostname: cloned SD-card images all boot as `raspberrypi`, and their telemetry merges into one source.
|
|
70
72
|
|
|
71
73
|
**Names are not auto-deduplicated.** The gateway echoes back whatever `source_id` you declare, unchanged — pick a unique name per device (that's what `--name` and `source_id=...` are for). Two devices that declare the same name write into the same source.
|
|
72
74
|
|
|
@@ -115,7 +117,7 @@ px.send_batch([
|
|
|
115
117
|
|
|
116
118
|
### `batch()` — coalesce a fast stream of readings
|
|
117
119
|
|
|
118
|
-
Use this above a few readings per second. Every `send()` is one WebSocket message, and the gateway limits **messages**, not points —
|
|
120
|
+
Use this above a few readings per second. Every `send()` is one WebSocket message, and the gateway limits **messages**, not points — 2,000/s on a connection. 25 channels at 100 Hz sent one at a time is 2,500 messages/s, and the overflow is discarded before it is stored.
|
|
119
121
|
|
|
120
122
|
```python
|
|
121
123
|
with px.batch(interval_ms=50) as b:
|
|
@@ -154,10 +156,24 @@ px.event("sensor_error", {"sensor": "imu", "code": 42}, tags={"motor": "A"})
|
|
|
154
156
|
|
|
155
157
|
The platform displays events as markers overlaid on your telemetry charts, not as time-series lines.
|
|
156
158
|
|
|
159
|
+
Limits per event: a string value up to 256 bytes, a dict or list value up to 4,096 bytes of JSON, and up to 16 tags. The gateway rejects anything larger.
|
|
160
|
+
|
|
161
|
+
### Logs
|
|
162
|
+
|
|
163
|
+
There is no log-file upload and no `logging.Handler` in this package. To get important log lines into Plexus, send them as events:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
px.event("log", {"level": "error", "msg": "IMU read timed out"})
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Forward the lines you would want on the timeline next to your telemetry (errors, warnings, state changes), not every debug line. Each call is one message, and the gateway limits messages (see [`batch()`](#batch--coalesce-a-fast-stream-of-readings)).
|
|
170
|
+
|
|
157
171
|
## Video streaming
|
|
158
172
|
|
|
159
173
|
Two methods depending on whether you control the capture loop or just have a URL.
|
|
160
174
|
|
|
175
|
+
Video needs a paid plan: frames go over the WebSocket, which the gateway refuses on the Free plan. Frames are relayed live to anyone watching. They are stored only when someone presses **Record** in the app, for up to 4 hours per recording.
|
|
176
|
+
|
|
161
177
|
### `send_video_frame(frame, camera_id)` — send frames you capture yourself
|
|
162
178
|
|
|
163
179
|
Use this when your code owns the capture loop — a `picamera2` callback, an OpenCV `VideoCapture` loop, or an FFmpeg pipe you manage. Pass each frame and the SDK ships it to Plexus over WebSocket.
|
|
@@ -219,10 +235,10 @@ See [`examples/`](examples/) for runnable versions of each.
|
|
|
219
235
|
|
|
220
236
|
## Reliability
|
|
221
237
|
|
|
222
|
-
Every send buffers locally before hitting the network, retries with exponential backoff, and keeps your data safe across outages.
|
|
238
|
+
Every send buffers locally before hitting the network, retries with exponential backoff, and keeps your data safe across outages. The buffer is on disk (SQLite) by default, so it survives restarts and power loss. To keep it in memory only:
|
|
223
239
|
|
|
224
240
|
```python
|
|
225
|
-
px = Plexus(persistent_buffer=
|
|
241
|
+
px = Plexus(persistent_buffer=False)
|
|
226
242
|
```
|
|
227
243
|
|
|
228
244
|
Point counts and flush:
|
|
@@ -255,7 +271,7 @@ px.send("temperature", 72.5, timestamp=t) # your timestamp, used as-is, no cor
|
|
|
255
271
|
By default the SDK connects over a **WebSocket** to `/ws/device` on the gateway — the gateway's device wire protocol. This gives you:
|
|
256
272
|
|
|
257
273
|
- lower-latency streaming of telemetry,
|
|
258
|
-
-
|
|
274
|
+
- the channel that will carry actions triggered from a Plexus dashboard.
|
|
259
275
|
|
|
260
276
|
If the socket is unavailable, sends transparently fall back to `POST /ingest` so no data is lost.
|
|
261
277
|
|
|
@@ -266,6 +282,10 @@ px = Plexus()
|
|
|
266
282
|
|
|
267
283
|
There is no transport selector: the SDK always prefers the WebSocket and falls back to `POST /ingest` on its own when the socket is unavailable.
|
|
268
284
|
|
|
285
|
+
Either way, plain `px.send()` is one message per call; it does not batch. `px.send_batch()` sends one list as one message, and `px.batch()` groups a fast stream for you in the background.
|
|
286
|
+
|
|
287
|
+
**On the Free plan** the gateway refuses the device WebSocket (`streaming_requires_plan`). The SDK falls back to HTTP by itself, so `send()`, `send_batch()`, `batch()` and `event()` all still work. Live streaming and video need a paid plan. Free also caps you at 3 devices and 7 days of history.
|
|
288
|
+
|
|
269
289
|
### Handling commands
|
|
270
290
|
|
|
271
291
|
Register a handler before the first `send()` so the command is advertised in the auth frame:
|
|
@@ -283,6 +303,8 @@ px.send("temperature", 72.5) # opens the socket, waits for auth
|
|
|
283
303
|
|
|
284
304
|
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).
|
|
285
305
|
|
|
306
|
+
> **Note:** nothing in Plexus can currently trigger a custom handler. The API route for sending commands was turned off on 2026-09-21. Triggering handlers from a dashboard, with permissions and a record of every run, is being rebuilt.
|
|
307
|
+
|
|
286
308
|
## Environment Variables
|
|
287
309
|
|
|
288
310
|
| Variable | Description | Default |
|
|
@@ -308,7 +330,7 @@ credentials. See [skills/README.md](skills/README.md).
|
|
|
308
330
|
## Architecture
|
|
309
331
|
|
|
310
332
|
```
|
|
311
|
-
Your code ── px.send() ── HTTP POST /ingest ──> plexus-gateway ──> ClickHouse + Dashboard
|
|
333
|
+
Your code ── px.send() ── WebSocket /ws/device (or HTTP POST /ingest) ──> plexus-gateway ──> ClickHouse + Dashboard
|
|
312
334
|
```
|
|
313
335
|
|
|
314
336
|
One thin path. No agent, no daemon, no adapters. If you want the full HardwareOps platform — dashboards, alerts, RCA, fleet views — that's the web UI at app.plexus.company. This package gets your data there.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
plexus/__init__.py,sha256=71KGed-vePAvaK-OaC6gPUX4JDY_OUbtxwbvvk8EKv8,808
|
|
2
|
+
plexus/_log.py,sha256=3fjXrHFZghQ_17umMcvDUjjTH6aTQB3J4SpVDBiH03w,335
|
|
3
|
+
plexus/batching.py,sha256=s0ipaQTMPIW9d6VOwSLpmMSvbYF1o5WIJ06jlFezVfk,10432
|
|
4
|
+
plexus/buffer.py,sha256=UNv_jEcrDwbkjJ6uhCehb7uBI2EuFEwO40waDpZn_5I,9579
|
|
5
|
+
plexus/cli.py,sha256=YFkptze8LRc6mBmDgbsTeU8J6i8MMsdTWvtm3jCINiI,21773
|
|
6
|
+
plexus/client.py,sha256=agPH8_nBdfL4SA6OUKJcP8RnBNHSTSm2aqt2GW95UGo,53834
|
|
7
|
+
plexus/config.py,sha256=RuDh5UdVGdVQld5kQlXZO6CVXO4tS0HBalyaoAlXNvc,4416
|
|
8
|
+
plexus/ws.py,sha256=xQhJCOizX-V34tF3-r7Zpzs_wnBbNSCeEoZeFFXWH7E,19763
|
|
9
|
+
plexus/cameras/__init__.py,sha256=AVu1vE1xfYk9lz1cprHlTfi0ieHf84UhQh9Z92E05b8,490
|
|
10
|
+
plexus/cameras/thermal.py,sha256=-klCEJQG5GlLtELaowWOPYomwMsLK8O5pmmX0PqUrTA,12022
|
|
11
|
+
plexus/_skills/README.md,sha256=yOJCcwIjzwk3g6EJkzkkTaSU7l1axOaqHj0UYRyzdg4,3306
|
|
12
|
+
plexus/_skills/plexus/SKILL.md,sha256=E-csIYsMMiipqK-5pYWRgFtxzY6MZBPfNgXmrtpBRRo,15088
|
|
13
|
+
plexus/_skills/plexus-dashboard/SKILL.md,sha256=qJJVsFbkZe1LqnsfaNPxMFS1BWqm_VqztLv33g7tZ3M,9613
|
|
14
|
+
plexus/_skills/plexus-firmware/SKILL.md,sha256=jx9noI4P6mY9pXMKLNBIw6tVuAVipeU_w2L1vwXBdRM,13420
|
|
15
|
+
plexus_python-0.11.6.dist-info/METADATA,sha256=37ryJgYdD1rz4CyQbQyGkjbVgtQtTBATBOwrHHb5vhY,15667
|
|
16
|
+
plexus_python-0.11.6.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
17
|
+
plexus_python-0.11.6.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
|
|
18
|
+
plexus_python-0.11.6.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
|
|
19
|
+
plexus_python-0.11.6.dist-info/RECORD,,
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
plexus/__init__.py,sha256=3108jW9HO6oLnblXhpp7gpqyL2ZAE7uaMC5F18gwQ5c,808
|
|
2
|
-
plexus/_log.py,sha256=3fjXrHFZghQ_17umMcvDUjjTH6aTQB3J4SpVDBiH03w,335
|
|
3
|
-
plexus/batching.py,sha256=mPMa3m9xK-DCwRmuyL_aqaTltimIOtwoDzz4PfKWyh8,10452
|
|
4
|
-
plexus/buffer.py,sha256=UNv_jEcrDwbkjJ6uhCehb7uBI2EuFEwO40waDpZn_5I,9579
|
|
5
|
-
plexus/cli.py,sha256=YFkptze8LRc6mBmDgbsTeU8J6i8MMsdTWvtm3jCINiI,21773
|
|
6
|
-
plexus/client.py,sha256=H69DR30pj3X8PiURZsViGTDHsIsQ2Kn4muHNECsioKw,53865
|
|
7
|
-
plexus/config.py,sha256=RuDh5UdVGdVQld5kQlXZO6CVXO4tS0HBalyaoAlXNvc,4416
|
|
8
|
-
plexus/ws.py,sha256=xQhJCOizX-V34tF3-r7Zpzs_wnBbNSCeEoZeFFXWH7E,19763
|
|
9
|
-
plexus/cameras/__init__.py,sha256=AVu1vE1xfYk9lz1cprHlTfi0ieHf84UhQh9Z92E05b8,490
|
|
10
|
-
plexus/cameras/thermal.py,sha256=-klCEJQG5GlLtELaowWOPYomwMsLK8O5pmmX0PqUrTA,12022
|
|
11
|
-
plexus/_skills/README.md,sha256=9TNo9mssmBiBPkrYBiA2JJHWOIAEw6DvCWCYAvfzmjM,3243
|
|
12
|
-
plexus/_skills/plexus/SKILL.md,sha256=Q1ZYe9YH-gOhMcGYuMG5H9DkHMfQOr3OazCtlxtP9f8,13617
|
|
13
|
-
plexus/_skills/plexus-dashboard/SKILL.md,sha256=8xjdvI3orM31XHTLv1fSAWstCGMwt1HnEbo0SlpTxV4,9508
|
|
14
|
-
plexus/_skills/plexus-firmware/SKILL.md,sha256=C5tOGu0_LOb76n8US2SLpWSdZdeHQIa4e_5fvkX4DGU,12093
|
|
15
|
-
plexus_python-0.11.4.dist-info/METADATA,sha256=wu8laMoqY4e-3NtQbbma-LQFg4C0C6f0-1MwVzskasY,13532
|
|
16
|
-
plexus_python-0.11.4.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
17
|
-
plexus_python-0.11.4.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
|
|
18
|
-
plexus_python-0.11.4.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
|
|
19
|
-
plexus_python-0.11.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|