plexus-python 0.9.0__tar.gz → 0.11.0__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.9.0 → plexus_python-0.11.0}/.github/workflows/publish.yml +17 -3
- {plexus_python-0.9.0 → plexus_python-0.11.0}/API.md +87 -3
- {plexus_python-0.9.0 → plexus_python-0.11.0}/CHANGELOG.md +92 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/PKG-INFO +44 -1
- {plexus_python-0.9.0 → plexus_python-0.11.0}/README.md +43 -0
- plexus_python-0.11.0/plexus/__init__.py +35 -0
- plexus_python-0.11.0/plexus/batching.py +262 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/plexus/cli.py +98 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/plexus/client.py +305 -2
- {plexus_python-0.9.0 → plexus_python-0.11.0}/plexus/ws.py +16 -1
- {plexus_python-0.9.0 → plexus_python-0.11.0}/pyproject.toml +9 -1
- {plexus_python-0.9.0 → plexus_python-0.11.0}/scripts/release.sh +13 -0
- plexus_python-0.11.0/scripts/verify_skills.py +236 -0
- plexus_python-0.11.0/skills/README.md +72 -0
- plexus_python-0.11.0/skills/plexus/SKILL.md +183 -0
- plexus_python-0.11.0/skills/plexus-dashboard/SKILL.md +204 -0
- plexus_python-0.11.0/skills/plexus-firmware/SKILL.md +220 -0
- plexus_python-0.11.0/tests/conftest.py +63 -0
- plexus_python-0.11.0/tests/test_batching.py +176 -0
- plexus_python-0.11.0/tests/test_batching_wire.py +179 -0
- plexus_python-0.11.0/tests/test_isolation.py +38 -0
- plexus_python-0.11.0/tests/test_rate_limit.py +100 -0
- plexus_python-0.11.0/tests/test_runs.py +237 -0
- plexus_python-0.11.0/tests/test_skills.py +210 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/uv.lock +1 -1
- plexus_python-0.9.0/plexus/__init__.py +0 -14
- {plexus_python-0.9.0 → plexus_python-0.11.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/.github/workflows/ci.yml +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/.gitignore +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/AGENTS.md +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/CODE_OF_CONDUCT.md +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/CONTRIBUTING.md +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/LICENSE +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/SECURITY.md +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/TODO.md +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/examples/.python-version +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/examples/README.md +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/examples/basic.py +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/examples/can.py +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/examples/i2c_bme280.py +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/examples/mac_metrics.py +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/examples/mavlink.py +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/examples/mqtt.py +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/examples/pyproject.toml +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/examples/thermal_camera.py +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/examples/uv.lock +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/plexus/_log.py +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/plexus/buffer.py +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/plexus/cameras/__init__.py +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/plexus/cameras/thermal.py +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/plexus/config.py +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/scripts/plexus.service +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/scripts/scan_buses.py +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/scripts/setup.sh +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/tests/test_basic.py +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/tests/test_buffer.py +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/tests/test_config.py +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/tests/test_retry.py +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/tests/test_thermal.py +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/tests/test_video.py +0 -0
- {plexus_python-0.9.0 → plexus_python-0.11.0}/tests/test_ws.py +0 -0
|
@@ -30,10 +30,24 @@ jobs:
|
|
|
30
30
|
match = re.search(r'^version\s*=\s*\"(.+?)\"', f.read(), re.MULTILINE)
|
|
31
31
|
print(match.group(1))
|
|
32
32
|
")
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
INIT_VERSION=$(python -c "
|
|
34
|
+
import re
|
|
35
|
+
with open('plexus/__init__.py') as f:
|
|
36
|
+
match = re.search(r'^__version__\s*=\s*\"(.+?)\"', f.read(), re.MULTILINE)
|
|
37
|
+
print(match.group(1))
|
|
38
|
+
")
|
|
39
|
+
echo "Git tag version: $TAG"
|
|
40
|
+
echo "pyproject.toml: $CODE_VERSION"
|
|
41
|
+
echo "plexus/__init__.py: $INIT_VERSION"
|
|
35
42
|
if [ "$TAG" != "$CODE_VERSION" ]; then
|
|
36
|
-
echo "::error::Tag v$TAG does not match
|
|
43
|
+
echo "::error::Tag v$TAG does not match pyproject.toml version $CODE_VERSION"
|
|
44
|
+
exit 1
|
|
45
|
+
fi
|
|
46
|
+
# 0.9.0 shipped to PyPI with __version__ still reading 0.8.0: the CI
|
|
47
|
+
# job that checks this does not gate publish, and this job only read
|
|
48
|
+
# pyproject.toml. Check it here, where it can actually stop an upload.
|
|
49
|
+
if [ "$TAG" != "$INIT_VERSION" ]; then
|
|
50
|
+
echo "::error::Tag v$TAG does not match plexus/__init__.py version $INIT_VERSION"
|
|
37
51
|
exit 1
|
|
38
52
|
fi
|
|
39
53
|
|
|
@@ -111,9 +111,48 @@ x-api-key: plx_xxxxx
|
|
|
111
111
|
| object | `{"x": 1.2, "y": 3.4, "z": 5.6}` | Vector data, structured readings |
|
|
112
112
|
| array | `[1.0, 2.0, 3.0, 4.0]` | Waveforms, multiple values |
|
|
113
113
|
|
|
114
|
-
###
|
|
114
|
+
### Runs
|
|
115
115
|
|
|
116
|
-
|
|
116
|
+
A **run** is a named time window on a source — a hot-fire, a bench sequence, a
|
|
117
|
+
flight. Runs are recalled on `/runs`, compared against each other aligned at
|
|
118
|
+
T+0, and evaluated against declared pass criteria when they close.
|
|
119
|
+
|
|
120
|
+
The SDK opens and closes them, so the software driving the bench owns the
|
|
121
|
+
window rather than someone remembering to drag a time-range picker afterwards:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
with px.run("hotfire-03", pass_criteria=[
|
|
125
|
+
{"metric": "motor.temp_c", "operator": "<", "value": 85, "label": "motor stays cool"},
|
|
126
|
+
{"metric": "frames.dropped", "operator": "=", "value": 0},
|
|
127
|
+
]) as run:
|
|
128
|
+
bench.execute()
|
|
129
|
+
|
|
130
|
+
print(run["id"])
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Leaving the block closes the run as `completed`; an exception closes it as
|
|
134
|
+
`aborted` and re-raises. For a bench that starts and stops from different
|
|
135
|
+
places, call the two halves directly:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
run = px.start_run("hotfire-03", tags={"build": "a41f"})
|
|
139
|
+
...
|
|
140
|
+
result = px.end_run(run) # or px.end_run(run, status="aborted")
|
|
141
|
+
print(result["test_result"]) # {"passed": bool, "criteria_results": [...]}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`start_run()` defaults `source_id` to the client's source; pass
|
|
145
|
+
`source_id=None` for an org-wide run. Underneath these are `POST /api/runs`
|
|
146
|
+
and `PATCH /api/runs/{id}` on the app API (not the gateway), authenticated
|
|
147
|
+
with the same `x-api-key`.
|
|
148
|
+
|
|
149
|
+
**Pass criteria.** Each is `{metric, operator, value, label?}` with operator
|
|
150
|
+
one of `> >= < <= = !=`. On close, every point for that metric inside the run
|
|
151
|
+
window is checked; the criterion passes only if all of them satisfy it, and a
|
|
152
|
+
criterion whose metric has no data in the window fails. The verdict is stored
|
|
153
|
+
on the run's `test_result` and shown on `/runs`.
|
|
154
|
+
|
|
155
|
+
To group data without a window, plain `tags` on each point still work.
|
|
117
156
|
|
|
118
157
|
## WebSocket API
|
|
119
158
|
|
|
@@ -367,6 +406,51 @@ while True:
|
|
|
367
406
|
| 404 | Resource not found |
|
|
368
407
|
| 410 | Resource expired |
|
|
369
408
|
|
|
409
|
+
## Rate limits and batching
|
|
410
|
+
|
|
411
|
+
The gateway meters **messages**, not points. One `px.send()` call is one
|
|
412
|
+
message, whatever it carries:
|
|
413
|
+
|
|
414
|
+
| Limit | Value |
|
|
415
|
+
| ---------------------------------- | ---------------- |
|
|
416
|
+
| Telemetry messages per WS connection | 500/s sustained, 2000 burst |
|
|
417
|
+
| Hard ceiling per source (WS + HTTP) | 2000 messages/s |
|
|
418
|
+
| Points per message | 10,000 |
|
|
419
|
+
| Message size | 1 MB |
|
|
420
|
+
|
|
421
|
+
Because the ceiling counts messages, the shape of your sends decides whether
|
|
422
|
+
you hit it. Eight channels at 100 Hz sent one at a time is 800 messages/s —
|
|
423
|
+
over the limit. The same 800 readings/s batched every 100 ms is 10 messages/s,
|
|
424
|
+
and the batches are also several times cheaper to store.
|
|
425
|
+
|
|
426
|
+
**Over the limit, the gateway discards the whole message.** It replies with a
|
|
427
|
+
`RATE_LIMITED` error frame, but that arrives after `send()` has already
|
|
428
|
+
returned — those points are gone and cannot be resent. The SDK counts the
|
|
429
|
+
notices (`px.rate_limited_frames`) and raises `RateLimitedError` on the next
|
|
430
|
+
send so the loss cannot pass unnoticed, but the only real fix is to send
|
|
431
|
+
fewer, larger messages.
|
|
432
|
+
|
|
433
|
+
Use `px.batch()` for anything above a few readings per second:
|
|
434
|
+
|
|
435
|
+
```python
|
|
436
|
+
with px.batch(interval_ms=50) as b:
|
|
437
|
+
while running:
|
|
438
|
+
b.send("att.pos_x", att.x)
|
|
439
|
+
b.send("att.rate_x", gyro.x)
|
|
440
|
+
b.send("frames.captured", grabber.count)
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
`b.send()` takes the same arguments as `px.send()` and queues the reading; a
|
|
444
|
+
background thread flushes the queue on the interval, and leaving the block
|
|
445
|
+
flushes what is left. A failed flush leaves the points in the local
|
|
446
|
+
store-and-forward buffer, so nothing is dropped for a transient outage — the
|
|
447
|
+
one bounded exception is `max_pending` (default 200,000 points), reached only
|
|
448
|
+
when the gateway has been unreachable for a long time, and counted on
|
|
449
|
+
`BatchSender.dropped`.
|
|
450
|
+
|
|
451
|
+
If you are already accumulating readings yourself, `px.send_batch([...])`
|
|
452
|
+
sends a list in one message without the background thread.
|
|
453
|
+
|
|
370
454
|
## Clock correction
|
|
371
455
|
|
|
372
456
|
Embedded devices commonly boot with a wrong system clock — no hardware RTC, NTP unreachable on first boot, or a fresh OS image whose filesystem timestamp is months in the past. Without correction, all telemetry lands at the wrong place on the timeline.
|
|
@@ -401,7 +485,7 @@ px.send("temperature", 72.5, timestamp=t) # your timestamp → used as-is
|
|
|
401
485
|
|
|
402
486
|
## Best Practices
|
|
403
487
|
|
|
404
|
-
- **Batch
|
|
488
|
+
- **Batch above a few readings per second** - `px.batch()` coalesces into one message per interval; the gateway's ceiling counts messages, not points (see [Rate limits and batching](#rate-limits-and-batching))
|
|
405
489
|
- **Omit timestamp when unsure** - The Python SDK applies server-synced clock correction when `timestamp` is omitted over WebSocket; only pass an explicit timestamp when you have a reliable wall-clock source
|
|
406
490
|
- **Consistent source_id** - Use the same ID for each physical device/source
|
|
407
491
|
- **Use tags** - Label data for filtering and grouping (e.g., `{"location": "lab"}`)
|
|
@@ -2,6 +2,98 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.11.0] - 2026-09-01 - Batching, runs, and an end to silent drops
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`px.batch()`** — a coalescing sender for anything faster than a few
|
|
10
|
+
readings a second. `send()` puts every reading in its own WebSocket frame,
|
|
11
|
+
and the gateway's telemetry ceiling counts *frames*: 500/s on a connection,
|
|
12
|
+
2000/s per source. Eight channels at 100 Hz sent one at a time is 800
|
|
13
|
+
frames/s, so roughly a third of it was discarded. Batched at 50 ms it is 20
|
|
14
|
+
frames/s. A background thread flushes on the interval, leaving the block
|
|
15
|
+
flushes the remainder, and readings keep the timestamp they were taken at.
|
|
16
|
+
Also the cheaper shape downstream — the loader moves ~400k rows/s at 100
|
|
17
|
+
points per message and ~65k at one.
|
|
18
|
+
|
|
19
|
+
- **Runs** — `px.start_run()`, `px.end_run()` and `with px.run(...)`, against
|
|
20
|
+
the `/api/runs` routes that have existed for a while with nothing reaching
|
|
21
|
+
them. An exception leaving the `with` block closes the run as `aborted`.
|
|
22
|
+
Declared `pass_criteria` are now evaluated when the run closes, so
|
|
23
|
+
`end_run()` hands back a verdict in `test_result`.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **`RATE_LIMITED` is no longer swallowed.** The gateway reports a discarded
|
|
28
|
+
telemetry frame asynchronously, after `send()` has already returned True.
|
|
29
|
+
The entire handling was a `logger.warning`, so a bench could lose a third of
|
|
30
|
+
its data with nothing to show for it. The transport now forwards server
|
|
31
|
+
error frames to the client, which counts them on `px.rate_limited_frames`
|
|
32
|
+
and raises `RateLimitedError` on the following send — after that send's own
|
|
33
|
+
points are away, so reporting the loss never causes more of it.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- **`API.md` no longer claims runs do not exist.** It said "Removed / not
|
|
38
|
+
built … no such route exists in the gateway or platform" about routes that
|
|
39
|
+
do, which is the first thing anyone integrating would have read.
|
|
40
|
+
|
|
41
|
+
## [0.10.0] - 2026-08-28 - Agent skills
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- **`plexus skills install`** — copies three bundled agent skills into
|
|
46
|
+
`~/.claude/skills` (or `./.claude/skills` with `--project`), so a coding
|
|
47
|
+
agent knows the Plexus API instead of inventing it. `--list` shows what
|
|
48
|
+
ships; `--dir` picks a target. Existing copies are refreshed and reported:
|
|
49
|
+
they are reference docs, and a stale one is the failure this fixes.
|
|
50
|
+
|
|
51
|
+
- **The skills themselves**, in `skills/`: `plexus` (hosts, auth, every
|
|
52
|
+
endpoint, the live stream), `plexus-firmware` (device-side ingest), and
|
|
53
|
+
`plexus-dashboard` (read-API frontend scaffolding). They ship inside the
|
|
54
|
+
wheel at `plexus/_skills`; previously they reached the sdist only, which
|
|
55
|
+
lands in a temp build directory nobody looks in.
|
|
56
|
+
|
|
57
|
+
- **`scripts/verify_skills.py`** — checks every route the skills quote against
|
|
58
|
+
the live OpenAPI spec and every WebSocket route by real handshake, with no
|
|
59
|
+
API key. It also asserts that routes documented as *dead* are still dead:
|
|
60
|
+
the skills name non-existent routes on purpose, because agents invent them
|
|
61
|
+
otherwise, and if one ever ships the warning has become a lie.
|
|
62
|
+
|
|
63
|
+
- **`tests/test_skills.py`** — offline guards on request/response shape, plus
|
|
64
|
+
the CLI. Touches no network, per the `conftest.py` isolation rule.
|
|
65
|
+
|
|
66
|
+
### Fixed
|
|
67
|
+
|
|
68
|
+
- The skills had drifted badly enough to generate broken code. The firmware
|
|
69
|
+
one keyed the ingest body `metrics` instead of `points`, omitted the
|
|
70
|
+
required `class` on every point, and sent ISO-8601 timestamp strings — every
|
|
71
|
+
template in it would have returned a 400. The dashboard one polled a route
|
|
72
|
+
that 404s and typed the query response as an array of points when it is
|
|
73
|
+
columnar. Both built their live-stream sections on a gateway URL that does
|
|
74
|
+
not exist; the stream is on the data API and authenticates by first message,
|
|
75
|
+
which means a browser can connect directly.
|
|
76
|
+
|
|
77
|
+
- The test suite no longer reaches the production gateway. `plexus/config.py`
|
|
78
|
+
defaults to `wss://gateway.plexus.company`, so every run opened real sockets to
|
|
79
|
+
it and left a burst of `device auth failed / invalid API key` warnings in
|
|
80
|
+
production logs — noise indistinguishable from a customer with a broken key.
|
|
81
|
+
It also read the developer's own `~/.plexus/config.json`, so a real API key
|
|
82
|
+
could have authenticated the suite. `tests/conftest.py` pins all three
|
|
83
|
+
endpoints to loopback and redirects config reads to a tmp dir;
|
|
84
|
+
`tests/test_isolation.py` proves both are in effect.
|
|
85
|
+
|
|
86
|
+
## [0.9.1] - 2026-08-27 - Version sync
|
|
87
|
+
|
|
88
|
+
### Fixed
|
|
89
|
+
|
|
90
|
+
- `plexus.__version__` reported `0.8.0` in the 0.9.0 release. The release script
|
|
91
|
+
and the publish workflow both validated `pyproject.toml` only; the CI job that
|
|
92
|
+
compares the two version sites does not gate publishing, so it failed after the
|
|
93
|
+
upload had already gone out. Both gates now check `plexus/__init__.py` too.
|
|
94
|
+
0.9.0 is functionally identical to this release — only the reported version
|
|
95
|
+
string was wrong.
|
|
96
|
+
|
|
5
97
|
## [0.9.0] - 2026-08-27 - Flexible values and the real slug rule
|
|
6
98
|
|
|
7
99
|
Two fixes for data that looked like it was sent but never arrived, plus the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: plexus-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
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
|
|
@@ -113,6 +113,35 @@ px.send_batch([
|
|
|
113
113
|
|
|
114
114
|
`points` is a list of `(metric, value)` tuples, or `(metric, value, timestamp)` 3-tuples when you need a per-point timestamp. Points without their own timestamp share the batch timestamp (now, unless you pass `timestamp=t`).
|
|
115
115
|
|
|
116
|
+
### `batch()` — coalesce a fast stream of readings
|
|
117
|
+
|
|
118
|
+
Use this above a few readings per second. Every `send()` is one WebSocket message, and the gateway limits **messages**, not points — 500/s on a connection. Eight channels at 100 Hz sent one at a time is 800 messages/s, and the overflow is discarded before it is stored.
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
with px.batch(interval_ms=50) as b:
|
|
122
|
+
while running:
|
|
123
|
+
b.send("att.pos_x", att.x)
|
|
124
|
+
b.send("att.rate_x", gyro.x)
|
|
125
|
+
b.send("frames.captured", grabber.count)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`b.send()` takes the same arguments as `px.send()`. A background thread flushes the queue every `interval_ms`, and leaving the block flushes what is left, so nothing is stranded. Readings keep the timestamp they were taken at, not the one they were flushed at.
|
|
129
|
+
|
|
130
|
+
If the gateway does discard frames it reports `RATE_LIMITED`; the SDK counts those on `px.rate_limited_frames` and raises `RateLimitedError` on the next send rather than letting the loss pass unnoticed.
|
|
131
|
+
|
|
132
|
+
### `run(name)` — mark a test run
|
|
133
|
+
|
|
134
|
+
A run is a named window on a source. Runs are recalled on `/runs`, compared against each other aligned at T+0, and checked against their pass criteria when they close.
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
with px.run("hotfire-03", pass_criteria=[
|
|
138
|
+
{"metric": "motor.temp_c", "operator": "<", "value": 85},
|
|
139
|
+
]) as run:
|
|
140
|
+
bench.execute()
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Leaving the block closes the run as `completed`; an exception closes it as `aborted` and re-raises. Use `px.start_run()` / `px.end_run()` when the two halves happen in different places — `end_run()` returns the run with its verdict in `test_result`.
|
|
144
|
+
|
|
116
145
|
### `event(name, data)` — record a discrete occurrence
|
|
117
146
|
|
|
118
147
|
Use `event()` for things that *happen* rather than things you *measure continuously*. Faults, state transitions, operator actions, log entries — anything you'd put on a timeline as a marker rather than plot as a graph.
|
|
@@ -262,6 +291,20 @@ The SDK sends an `ack` frame before invoking the handler, then a `result` frame
|
|
|
262
291
|
| `PLEXUS_GATEWAY_URL` | HTTP ingest URL | `https://gateway.plexus.company` |
|
|
263
292
|
| `PLEXUS_GATEWAY_WS_URL` | WebSocket URL | `wss://gateway.plexus.company` |
|
|
264
293
|
|
|
294
|
+
## Agent skills
|
|
295
|
+
|
|
296
|
+
Three skills ship with the package and teach a coding agent the Plexus API —
|
|
297
|
+
the endpoints, the live stream, and the mistakes that produce a silent 400.
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
plexus skills install # -> ~/.claude/skills
|
|
301
|
+
plexus skills install --project # -> ./.claude/skills, travels with the repo
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Then ask for what you want in plain language: *"send my ESP32's battery voltage
|
|
305
|
+
to Plexus"*, *"build me a fleet dashboard"*. Plain Markdown, no install, no
|
|
306
|
+
credentials. See [skills/README.md](skills/README.md).
|
|
307
|
+
|
|
265
308
|
## Architecture
|
|
266
309
|
|
|
267
310
|
```
|
|
@@ -76,6 +76,35 @@ px.send_batch([
|
|
|
76
76
|
|
|
77
77
|
`points` is a list of `(metric, value)` tuples, or `(metric, value, timestamp)` 3-tuples when you need a per-point timestamp. Points without their own timestamp share the batch timestamp (now, unless you pass `timestamp=t`).
|
|
78
78
|
|
|
79
|
+
### `batch()` — coalesce a fast stream of readings
|
|
80
|
+
|
|
81
|
+
Use this above a few readings per second. Every `send()` is one WebSocket message, and the gateway limits **messages**, not points — 500/s on a connection. Eight channels at 100 Hz sent one at a time is 800 messages/s, and the overflow is discarded before it is stored.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
with px.batch(interval_ms=50) as b:
|
|
85
|
+
while running:
|
|
86
|
+
b.send("att.pos_x", att.x)
|
|
87
|
+
b.send("att.rate_x", gyro.x)
|
|
88
|
+
b.send("frames.captured", grabber.count)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`b.send()` takes the same arguments as `px.send()`. A background thread flushes the queue every `interval_ms`, and leaving the block flushes what is left, so nothing is stranded. Readings keep the timestamp they were taken at, not the one they were flushed at.
|
|
92
|
+
|
|
93
|
+
If the gateway does discard frames it reports `RATE_LIMITED`; the SDK counts those on `px.rate_limited_frames` and raises `RateLimitedError` on the next send rather than letting the loss pass unnoticed.
|
|
94
|
+
|
|
95
|
+
### `run(name)` — mark a test run
|
|
96
|
+
|
|
97
|
+
A run is a named window on a source. Runs are recalled on `/runs`, compared against each other aligned at T+0, and checked against their pass criteria when they close.
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
with px.run("hotfire-03", pass_criteria=[
|
|
101
|
+
{"metric": "motor.temp_c", "operator": "<", "value": 85},
|
|
102
|
+
]) as run:
|
|
103
|
+
bench.execute()
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Leaving the block closes the run as `completed`; an exception closes it as `aborted` and re-raises. Use `px.start_run()` / `px.end_run()` when the two halves happen in different places — `end_run()` returns the run with its verdict in `test_result`.
|
|
107
|
+
|
|
79
108
|
### `event(name, data)` — record a discrete occurrence
|
|
80
109
|
|
|
81
110
|
Use `event()` for things that *happen* rather than things you *measure continuously*. Faults, state transitions, operator actions, log entries — anything you'd put on a timeline as a marker rather than plot as a graph.
|
|
@@ -225,6 +254,20 @@ The SDK sends an `ack` frame before invoking the handler, then a `result` frame
|
|
|
225
254
|
| `PLEXUS_GATEWAY_URL` | HTTP ingest URL | `https://gateway.plexus.company` |
|
|
226
255
|
| `PLEXUS_GATEWAY_WS_URL` | WebSocket URL | `wss://gateway.plexus.company` |
|
|
227
256
|
|
|
257
|
+
## Agent skills
|
|
258
|
+
|
|
259
|
+
Three skills ship with the package and teach a coding agent the Plexus API —
|
|
260
|
+
the endpoints, the live stream, and the mistakes that produce a silent 400.
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
plexus skills install # -> ~/.claude/skills
|
|
264
|
+
plexus skills install --project # -> ./.claude/skills, travels with the repo
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Then ask for what you want in plain language: *"send my ESP32's battery voltage
|
|
268
|
+
to Plexus"*, *"build me a fleet dashboard"*. Plain Markdown, no install, no
|
|
269
|
+
credentials. See [skills/README.md](skills/README.md).
|
|
270
|
+
|
|
228
271
|
## Architecture
|
|
229
272
|
|
|
230
273
|
```
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Plexus — thin Python SDK for sending telemetry to the Plexus gateway.
|
|
3
|
+
|
|
4
|
+
from plexus import Plexus
|
|
5
|
+
|
|
6
|
+
px = Plexus(api_key="plx_xxx", source_id="device-001")
|
|
7
|
+
px.send("temperature", 72.5)
|
|
8
|
+
|
|
9
|
+
At bench rates, batch — `send()` is one WebSocket frame per call and the
|
|
10
|
+
gateway limits frames, not points:
|
|
11
|
+
|
|
12
|
+
with px.run("hotfire-03"), px.batch(interval_ms=50) as b:
|
|
13
|
+
b.send("att.rate_x", gyro.x)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from plexus.batching import BatchSender
|
|
17
|
+
from plexus.client import (
|
|
18
|
+
AuthenticationError,
|
|
19
|
+
Plexus,
|
|
20
|
+
PlexusError,
|
|
21
|
+
RateLimitedError,
|
|
22
|
+
read_mjpeg_frames,
|
|
23
|
+
)
|
|
24
|
+
from plexus.config import RetryConfig
|
|
25
|
+
|
|
26
|
+
__version__ = "0.11.0"
|
|
27
|
+
__all__ = [
|
|
28
|
+
"AuthenticationError",
|
|
29
|
+
"BatchSender",
|
|
30
|
+
"Plexus",
|
|
31
|
+
"PlexusError",
|
|
32
|
+
"RateLimitedError",
|
|
33
|
+
"RetryConfig",
|
|
34
|
+
"read_mjpeg_frames",
|
|
35
|
+
]
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Coalescing sender — one frame per interval instead of one per reading.
|
|
2
|
+
|
|
3
|
+
`px.send()` transmits immediately: every call is its own WebSocket frame. That
|
|
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 500 telemetry messages per second on a
|
|
6
|
+
connection and hard-drops a source above 2000/s, and both ceilings count
|
|
7
|
+
*messages*, not points — so eight channels at 100 Hz is 800 frames/s and lands
|
|
8
|
+
over the limit, while the same 800 readings coalesced into ten frames is not
|
|
9
|
+
close to it. The dropped frames come back as `RATE_LIMITED`, after `send()` has
|
|
10
|
+
already returned True, which is why this is a batching problem rather than a
|
|
11
|
+
retry one: by the time anyone can react, the points are gone.
|
|
12
|
+
|
|
13
|
+
Downstream the same shape decides throughput. The ClickHouse loader measures
|
|
14
|
+
~400k rows/s at 100 points per message and ~65k at one point per message — a 6x
|
|
15
|
+
penalty paid entirely at the producer.
|
|
16
|
+
|
|
17
|
+
with px.batch(interval_ms=50) as b:
|
|
18
|
+
while running:
|
|
19
|
+
b.send("att.rate_x", gyro.x)
|
|
20
|
+
b.send("att.rate_y", gyro.y)
|
|
21
|
+
b.send("frames.captured", grabber.count)
|
|
22
|
+
|
|
23
|
+
Points accumulate in memory and a background thread flushes them on the
|
|
24
|
+
interval. `close()` (and so the `with` block) flushes what is left before
|
|
25
|
+
returning, so exiting normally never strands a reading.
|
|
26
|
+
|
|
27
|
+
Delivery semantics are the client's, unchanged: a failed flush leaves the
|
|
28
|
+
points in the client's local store-and-forward buffer to go out with the next
|
|
29
|
+
send. Nothing here drops data on the floor — the one bounded exception is
|
|
30
|
+
`max_pending`, which exists so a permanently unreachable gateway cannot grow
|
|
31
|
+
the process's memory without limit, and which reports every point it evicts.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import logging
|
|
37
|
+
import threading
|
|
38
|
+
import time
|
|
39
|
+
from typing import TYPE_CHECKING, Any
|
|
40
|
+
|
|
41
|
+
from plexus._log import _say
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
44
|
+
from plexus.client import FlexValue, Plexus
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
# Frames per second implied by the default interval: 10. Two orders of
|
|
49
|
+
# magnitude under the gateway's per-connection ceiling, so a caller has room
|
|
50
|
+
# to shorten it without having to know what the ceiling is.
|
|
51
|
+
DEFAULT_INTERVAL_MS = 100.0
|
|
52
|
+
|
|
53
|
+
# Hard floor on the flush interval. Below ~2ms the flush thread spends more
|
|
54
|
+
# time waking up than sending, and 500 frames/s is the gateway's limit anyway.
|
|
55
|
+
MIN_INTERVAL_MS = 2.0
|
|
56
|
+
|
|
57
|
+
# Frames a periodic flush may send in one cycle. At the default interval and
|
|
58
|
+
# `max_points` this is 200k points/s of drain capacity while holding the frame
|
|
59
|
+
# rate at 40/s — an order of magnitude under the ceiling even while catching up.
|
|
60
|
+
_FRAMES_PER_FLUSH = 4
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class BatchSender:
|
|
64
|
+
"""Accumulates points and flushes them on an interval.
|
|
65
|
+
|
|
66
|
+
Construct via `Plexus.batch()` rather than directly — the client owns the
|
|
67
|
+
connection, the buffer and the retry policy, and this only decides when a
|
|
68
|
+
frame leaves.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
client: "Plexus",
|
|
74
|
+
interval_ms: float = DEFAULT_INTERVAL_MS,
|
|
75
|
+
max_points: int = 5000,
|
|
76
|
+
max_pending: int = 200_000,
|
|
77
|
+
):
|
|
78
|
+
if interval_ms < MIN_INTERVAL_MS:
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f"interval_ms must be >= {MIN_INTERVAL_MS} "
|
|
81
|
+
f"({1000 / MIN_INTERVAL_MS:.0f} flushes/s is already past the "
|
|
82
|
+
"gateway's per-connection ceiling)"
|
|
83
|
+
)
|
|
84
|
+
if max_points < 1:
|
|
85
|
+
raise ValueError("max_points must be >= 1")
|
|
86
|
+
if max_pending < max_points:
|
|
87
|
+
raise ValueError("max_pending must be >= max_points")
|
|
88
|
+
|
|
89
|
+
self._client = client
|
|
90
|
+
self._interval_s = interval_ms / 1000.0
|
|
91
|
+
self._max_points = max_points
|
|
92
|
+
self._max_pending = max_pending
|
|
93
|
+
|
|
94
|
+
self._pending: list[dict[str, Any]] = []
|
|
95
|
+
self._lock = threading.Lock()
|
|
96
|
+
self._stop = threading.Event()
|
|
97
|
+
self._thread: threading.Thread | None = None
|
|
98
|
+
self._dropped = 0
|
|
99
|
+
self._announced_overflow = False
|
|
100
|
+
self._closed = False
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------ public
|
|
103
|
+
|
|
104
|
+
def send(
|
|
105
|
+
self,
|
|
106
|
+
metric: str,
|
|
107
|
+
value: "FlexValue",
|
|
108
|
+
timestamp: float | None = None,
|
|
109
|
+
tags: dict[str, str] | None = None,
|
|
110
|
+
data_class: str | None = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Queue one reading. Same arguments as `Plexus.send()`.
|
|
113
|
+
|
|
114
|
+
Returns None rather than bool: the point has not been sent yet, and a
|
|
115
|
+
True here would mean the same thing `send()`'s True means, which it
|
|
116
|
+
does not. Value-type errors still raise immediately — that is a bug in
|
|
117
|
+
the caller's code and should surface at the call site, not on a
|
|
118
|
+
background thread a hundred milliseconds later.
|
|
119
|
+
"""
|
|
120
|
+
point = self._client._make_point(metric, value, timestamp, tags, data_class)
|
|
121
|
+
self._append([point])
|
|
122
|
+
|
|
123
|
+
def send_batch(
|
|
124
|
+
self,
|
|
125
|
+
points: list[tuple[str, "FlexValue"] | tuple[str, "FlexValue", float]],
|
|
126
|
+
timestamp: float | None = None,
|
|
127
|
+
tags: dict[str, str] | None = None,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Queue several readings at once. Same arguments as `Plexus.send_batch()`."""
|
|
130
|
+
default_ts_ms = self._client._normalize_ts_ms(timestamp)
|
|
131
|
+
built: list[dict[str, Any]] = []
|
|
132
|
+
for p in points:
|
|
133
|
+
if len(p) == 3:
|
|
134
|
+
m, v, t = p
|
|
135
|
+
built.append(
|
|
136
|
+
self._client._make_point(m, v, self._client._normalize_ts_ms(t), tags)
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
m, v = p
|
|
140
|
+
built.append(self._client._make_point(m, v, default_ts_ms, tags))
|
|
141
|
+
self._append(built)
|
|
142
|
+
|
|
143
|
+
def flush(self) -> None:
|
|
144
|
+
"""Send everything queued right now. Blocks until the send returns.
|
|
145
|
+
|
|
146
|
+
Raises whatever `Plexus.send()` raises. The points are already in the
|
|
147
|
+
client's local buffer by then, so a raise here means "not delivered
|
|
148
|
+
yet", not "lost".
|
|
149
|
+
"""
|
|
150
|
+
self._flush_once(raise_on_error=True)
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def pending(self) -> int:
|
|
154
|
+
"""Points queued but not yet handed to the client."""
|
|
155
|
+
with self._lock:
|
|
156
|
+
return len(self._pending)
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def dropped(self) -> int:
|
|
160
|
+
"""Points evicted because the queue hit `max_pending`."""
|
|
161
|
+
return self._dropped
|
|
162
|
+
|
|
163
|
+
def start(self) -> "BatchSender":
|
|
164
|
+
if self._thread is not None:
|
|
165
|
+
return self
|
|
166
|
+
self._thread = threading.Thread(
|
|
167
|
+
target=self._run, name="plexus-batch", daemon=True
|
|
168
|
+
)
|
|
169
|
+
self._thread.start()
|
|
170
|
+
return self
|
|
171
|
+
|
|
172
|
+
def close(self) -> None:
|
|
173
|
+
"""Stop the flush thread and send what is left.
|
|
174
|
+
|
|
175
|
+
Idempotent. The final flush is best-effort: anything it cannot deliver
|
|
176
|
+
stays in the client's buffer for the next send or for `close()` on the
|
|
177
|
+
client itself, which flushes again.
|
|
178
|
+
"""
|
|
179
|
+
if self._closed:
|
|
180
|
+
return
|
|
181
|
+
self._closed = True
|
|
182
|
+
self._stop.set()
|
|
183
|
+
thread, self._thread = self._thread, None
|
|
184
|
+
if thread is not None:
|
|
185
|
+
thread.join(timeout=max(5.0, self._interval_s * 10))
|
|
186
|
+
self._flush_once(raise_on_error=False)
|
|
187
|
+
|
|
188
|
+
def __enter__(self) -> "BatchSender":
|
|
189
|
+
return self.start()
|
|
190
|
+
|
|
191
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
|
|
192
|
+
self.close()
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
# ----------------------------------------------------------------- internal
|
|
196
|
+
|
|
197
|
+
def _append(self, points: list[dict[str, Any]]) -> None:
|
|
198
|
+
if self._closed:
|
|
199
|
+
raise RuntimeError("BatchSender is closed")
|
|
200
|
+
overflow = 0
|
|
201
|
+
with self._lock:
|
|
202
|
+
self._pending.extend(points)
|
|
203
|
+
excess = len(self._pending) - self._max_pending
|
|
204
|
+
if excess > 0:
|
|
205
|
+
# Drop oldest. A bench cares more about the reading it just
|
|
206
|
+
# took than one from several minutes of unreachable gateway.
|
|
207
|
+
del self._pending[:excess]
|
|
208
|
+
overflow = excess
|
|
209
|
+
self._dropped += excess
|
|
210
|
+
if overflow:
|
|
211
|
+
self._report_overflow(overflow)
|
|
212
|
+
|
|
213
|
+
def _report_overflow(self, dropped: int) -> None:
|
|
214
|
+
if not self._announced_overflow:
|
|
215
|
+
_say(
|
|
216
|
+
f"⚠ batch queue full at {self._max_pending} points — dropping "
|
|
217
|
+
f"oldest ({dropped} so far). The gateway is not keeping up."
|
|
218
|
+
)
|
|
219
|
+
self._announced_overflow = True
|
|
220
|
+
logger.warning("plexus batch overflow: dropped %d oldest points", dropped)
|
|
221
|
+
|
|
222
|
+
def _take(self) -> list[dict[str, Any]]:
|
|
223
|
+
with self._lock:
|
|
224
|
+
if not self._pending:
|
|
225
|
+
return []
|
|
226
|
+
batch = self._pending[: self._max_points]
|
|
227
|
+
del self._pending[: len(batch)]
|
|
228
|
+
return batch
|
|
229
|
+
|
|
230
|
+
def _flush_once(self, raise_on_error: bool, max_frames: int | None = None) -> None:
|
|
231
|
+
frames = 0
|
|
232
|
+
while max_frames is None or frames < max_frames:
|
|
233
|
+
batch = self._take()
|
|
234
|
+
if not batch:
|
|
235
|
+
return
|
|
236
|
+
frames += 1
|
|
237
|
+
try:
|
|
238
|
+
self._client._send_points(batch)
|
|
239
|
+
except Exception as e:
|
|
240
|
+
# _send_points has already returned the points to the client's
|
|
241
|
+
# buffer, so they are not lost — but there is no point spinning
|
|
242
|
+
# through the rest of the queue into the same failure.
|
|
243
|
+
logger.debug("plexus batch flush failed: %s", e)
|
|
244
|
+
if raise_on_error:
|
|
245
|
+
raise
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
def _run(self) -> None:
|
|
249
|
+
# Pace off a fixed schedule rather than sleeping `interval` *after*
|
|
250
|
+
# each flush: a flush that runs long would otherwise stretch the
|
|
251
|
+
# effective interval, and a queue that is filling faster than it
|
|
252
|
+
# drains would stretch it further each cycle.
|
|
253
|
+
next_flush = time.monotonic() + self._interval_s
|
|
254
|
+
while not self._stop.is_set():
|
|
255
|
+
delay = next_flush - time.monotonic()
|
|
256
|
+
if delay > 0 and self._stop.wait(delay):
|
|
257
|
+
break
|
|
258
|
+
# Bounded per cycle so a large backlog drains at a predictable
|
|
259
|
+
# frame rate instead of bursting straight back into the gateway's
|
|
260
|
+
# per-connection limit — the thing this class exists to avoid.
|
|
261
|
+
self._flush_once(raise_on_error=False, max_frames=_FRAMES_PER_FLUSH)
|
|
262
|
+
next_flush = max(time.monotonic(), next_flush + self._interval_s)
|