plexus-python 0.9.1__tar.gz → 0.11.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. {plexus_python-0.9.1 → plexus_python-0.11.1}/API.md +87 -3
  2. {plexus_python-0.9.1 → plexus_python-0.11.1}/CHANGELOG.md +105 -0
  3. {plexus_python-0.9.1 → plexus_python-0.11.1}/PKG-INFO +44 -1
  4. {plexus_python-0.9.1 → plexus_python-0.11.1}/README.md +43 -0
  5. plexus_python-0.11.1/plexus/__init__.py +35 -0
  6. plexus_python-0.11.1/plexus/batching.py +262 -0
  7. {plexus_python-0.9.1 → plexus_python-0.11.1}/plexus/cli.py +98 -0
  8. {plexus_python-0.9.1 → plexus_python-0.11.1}/plexus/client.py +305 -2
  9. {plexus_python-0.9.1 → plexus_python-0.11.1}/plexus/ws.py +16 -1
  10. {plexus_python-0.9.1 → plexus_python-0.11.1}/pyproject.toml +9 -1
  11. plexus_python-0.11.1/scripts/verify_skills.py +236 -0
  12. plexus_python-0.11.1/skills/README.md +72 -0
  13. plexus_python-0.11.1/skills/plexus/SKILL.md +228 -0
  14. plexus_python-0.11.1/skills/plexus-dashboard/SKILL.md +204 -0
  15. plexus_python-0.11.1/skills/plexus-firmware/SKILL.md +257 -0
  16. plexus_python-0.11.1/tests/conftest.py +63 -0
  17. plexus_python-0.11.1/tests/test_batching.py +176 -0
  18. plexus_python-0.11.1/tests/test_batching_wire.py +179 -0
  19. plexus_python-0.11.1/tests/test_isolation.py +38 -0
  20. plexus_python-0.11.1/tests/test_rate_limit.py +100 -0
  21. plexus_python-0.11.1/tests/test_runs.py +237 -0
  22. plexus_python-0.11.1/tests/test_skills.py +210 -0
  23. {plexus_python-0.9.1 → plexus_python-0.11.1}/uv.lock +1 -1
  24. plexus_python-0.9.1/plexus/__init__.py +0 -14
  25. {plexus_python-0.9.1 → plexus_python-0.11.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  26. {plexus_python-0.9.1 → plexus_python-0.11.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  27. {plexus_python-0.9.1 → plexus_python-0.11.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  28. {plexus_python-0.9.1 → plexus_python-0.11.1}/.github/workflows/ci.yml +0 -0
  29. {plexus_python-0.9.1 → plexus_python-0.11.1}/.github/workflows/publish.yml +0 -0
  30. {plexus_python-0.9.1 → plexus_python-0.11.1}/.gitignore +0 -0
  31. {plexus_python-0.9.1 → plexus_python-0.11.1}/AGENTS.md +0 -0
  32. {plexus_python-0.9.1 → plexus_python-0.11.1}/CODE_OF_CONDUCT.md +0 -0
  33. {plexus_python-0.9.1 → plexus_python-0.11.1}/CONTRIBUTING.md +0 -0
  34. {plexus_python-0.9.1 → plexus_python-0.11.1}/LICENSE +0 -0
  35. {plexus_python-0.9.1 → plexus_python-0.11.1}/SECURITY.md +0 -0
  36. {plexus_python-0.9.1 → plexus_python-0.11.1}/TODO.md +0 -0
  37. {plexus_python-0.9.1 → plexus_python-0.11.1}/examples/.python-version +0 -0
  38. {plexus_python-0.9.1 → plexus_python-0.11.1}/examples/README.md +0 -0
  39. {plexus_python-0.9.1 → plexus_python-0.11.1}/examples/basic.py +0 -0
  40. {plexus_python-0.9.1 → plexus_python-0.11.1}/examples/can.py +0 -0
  41. {plexus_python-0.9.1 → plexus_python-0.11.1}/examples/i2c_bme280.py +0 -0
  42. {plexus_python-0.9.1 → plexus_python-0.11.1}/examples/mac_metrics.py +0 -0
  43. {plexus_python-0.9.1 → plexus_python-0.11.1}/examples/mavlink.py +0 -0
  44. {plexus_python-0.9.1 → plexus_python-0.11.1}/examples/mqtt.py +0 -0
  45. {plexus_python-0.9.1 → plexus_python-0.11.1}/examples/pyproject.toml +0 -0
  46. {plexus_python-0.9.1 → plexus_python-0.11.1}/examples/thermal_camera.py +0 -0
  47. {plexus_python-0.9.1 → plexus_python-0.11.1}/examples/uv.lock +0 -0
  48. {plexus_python-0.9.1 → plexus_python-0.11.1}/plexus/_log.py +0 -0
  49. {plexus_python-0.9.1 → plexus_python-0.11.1}/plexus/buffer.py +0 -0
  50. {plexus_python-0.9.1 → plexus_python-0.11.1}/plexus/cameras/__init__.py +0 -0
  51. {plexus_python-0.9.1 → plexus_python-0.11.1}/plexus/cameras/thermal.py +0 -0
  52. {plexus_python-0.9.1 → plexus_python-0.11.1}/plexus/config.py +0 -0
  53. {plexus_python-0.9.1 → plexus_python-0.11.1}/scripts/plexus.service +0 -0
  54. {plexus_python-0.9.1 → plexus_python-0.11.1}/scripts/release.sh +0 -0
  55. {plexus_python-0.9.1 → plexus_python-0.11.1}/scripts/scan_buses.py +0 -0
  56. {plexus_python-0.9.1 → plexus_python-0.11.1}/scripts/setup.sh +0 -0
  57. {plexus_python-0.9.1 → plexus_python-0.11.1}/tests/test_basic.py +0 -0
  58. {plexus_python-0.9.1 → plexus_python-0.11.1}/tests/test_buffer.py +0 -0
  59. {plexus_python-0.9.1 → plexus_python-0.11.1}/tests/test_config.py +0 -0
  60. {plexus_python-0.9.1 → plexus_python-0.11.1}/tests/test_retry.py +0 -0
  61. {plexus_python-0.9.1 → plexus_python-0.11.1}/tests/test_thermal.py +0 -0
  62. {plexus_python-0.9.1 → plexus_python-0.11.1}/tests/test_video.py +0 -0
  63. {plexus_python-0.9.1 → plexus_python-0.11.1}/tests/test_ws.py +0 -0
@@ -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
- ### Sessions
114
+ ### Runs
115
115
 
116
- > **Removed / not built.** There is no sessions or runs REST API (`POST /api/sessions`, `POST /api/runs`) no such route exists in the gateway or platform, and the SDK's former `run()` context has been removed. To group a slice of data, use plain `tags` on each point.
116
+ A **run** is a named time window on a sourcea 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 points** - Send up to 100 points per request for HTTP
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,111 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.11.1] - 2026-09-02 - Correct what the agent skills teach
6
+
7
+ ### Fixed
8
+
9
+ - **`plexus-firmware` claimed the SDK batches. It does not.** Of CPython
10
+ targets — naming Jetson and embedded Linux specifically — the skill said
11
+ "stop and use the SDK instead ... It already does batching". `send()` is one
12
+ message per call, the gateway's ceiling counts messages, and the overflow is
13
+ discarded asynchronously after the send returned True. The skills are how a
14
+ coding agent learns this API instead of inventing it, so that line aimed
15
+ agents at silent data loss on exactly the hardware most likely to hit bench
16
+ rates. Both skills now carry the real ceilings and point at `px.batch()`.
17
+
18
+ - **Runs were documented in no skill**, so an agent had no way to know a test
19
+ bench should name its window. The `plexus` skill now carries the contract:
20
+ the app host rather than the gateway, offset-bearing timestamps, the
21
+ operators, and the two semantics that surprise people — every sample must
22
+ satisfy a criterion, and a metric with no data in the window fails rather
23
+ than passes.
24
+
25
+ - **The documented slug rule was wrong** (`^[a-z0-9][a-z0-9_-]{1,62}$`). The
26
+ gateway accepts dots, single characters and 256 bytes, so an agent applying
27
+ the documented rule would reject slugs the platform takes.
28
+
29
+ ## [0.11.0] - 2026-09-01 - Batching, runs, and an end to silent drops
30
+
31
+ ### Added
32
+
33
+ - **`px.batch()`** — a coalescing sender for anything faster than a few
34
+ readings a second. `send()` puts every reading in its own WebSocket frame,
35
+ and the gateway's telemetry ceiling counts *frames*: 500/s on a connection,
36
+ 2000/s per source. Eight channels at 100 Hz sent one at a time is 800
37
+ frames/s, so roughly a third of it was discarded. Batched at 50 ms it is 20
38
+ frames/s. A background thread flushes on the interval, leaving the block
39
+ flushes the remainder, and readings keep the timestamp they were taken at.
40
+ Also the cheaper shape downstream — the loader moves ~400k rows/s at 100
41
+ points per message and ~65k at one.
42
+
43
+ - **Runs** — `px.start_run()`, `px.end_run()` and `with px.run(...)`, against
44
+ the `/api/runs` routes that have existed for a while with nothing reaching
45
+ them. An exception leaving the `with` block closes the run as `aborted`.
46
+ Declared `pass_criteria` are now evaluated when the run closes, so
47
+ `end_run()` hands back a verdict in `test_result`.
48
+
49
+ ### Fixed
50
+
51
+ - **`RATE_LIMITED` is no longer swallowed.** The gateway reports a discarded
52
+ telemetry frame asynchronously, after `send()` has already returned True.
53
+ The entire handling was a `logger.warning`, so a bench could lose a third of
54
+ its data with nothing to show for it. The transport now forwards server
55
+ error frames to the client, which counts them on `px.rate_limited_frames`
56
+ and raises `RateLimitedError` on the following send — after that send's own
57
+ points are away, so reporting the loss never causes more of it.
58
+
59
+ ### Changed
60
+
61
+ - **`API.md` no longer claims runs do not exist.** It said "Removed / not
62
+ built … no such route exists in the gateway or platform" about routes that
63
+ do, which is the first thing anyone integrating would have read.
64
+
65
+ ## [0.10.0] - 2026-08-28 - Agent skills
66
+
67
+ ### Added
68
+
69
+ - **`plexus skills install`** — copies three bundled agent skills into
70
+ `~/.claude/skills` (or `./.claude/skills` with `--project`), so a coding
71
+ agent knows the Plexus API instead of inventing it. `--list` shows what
72
+ ships; `--dir` picks a target. Existing copies are refreshed and reported:
73
+ they are reference docs, and a stale one is the failure this fixes.
74
+
75
+ - **The skills themselves**, in `skills/`: `plexus` (hosts, auth, every
76
+ endpoint, the live stream), `plexus-firmware` (device-side ingest), and
77
+ `plexus-dashboard` (read-API frontend scaffolding). They ship inside the
78
+ wheel at `plexus/_skills`; previously they reached the sdist only, which
79
+ lands in a temp build directory nobody looks in.
80
+
81
+ - **`scripts/verify_skills.py`** — checks every route the skills quote against
82
+ the live OpenAPI spec and every WebSocket route by real handshake, with no
83
+ API key. It also asserts that routes documented as *dead* are still dead:
84
+ the skills name non-existent routes on purpose, because agents invent them
85
+ otherwise, and if one ever ships the warning has become a lie.
86
+
87
+ - **`tests/test_skills.py`** — offline guards on request/response shape, plus
88
+ the CLI. Touches no network, per the `conftest.py` isolation rule.
89
+
90
+ ### Fixed
91
+
92
+ - The skills had drifted badly enough to generate broken code. The firmware
93
+ one keyed the ingest body `metrics` instead of `points`, omitted the
94
+ required `class` on every point, and sent ISO-8601 timestamp strings — every
95
+ template in it would have returned a 400. The dashboard one polled a route
96
+ that 404s and typed the query response as an array of points when it is
97
+ columnar. Both built their live-stream sections on a gateway URL that does
98
+ not exist; the stream is on the data API and authenticates by first message,
99
+ which means a browser can connect directly.
100
+
101
+ - The test suite no longer reaches the production gateway. `plexus/config.py`
102
+ defaults to `wss://gateway.plexus.company`, so every run opened real sockets to
103
+ it and left a burst of `device auth failed / invalid API key` warnings in
104
+ production logs — noise indistinguishable from a customer with a broken key.
105
+ It also read the developer's own `~/.plexus/config.json`, so a real API key
106
+ could have authenticated the suite. `tests/conftest.py` pins all three
107
+ endpoints to loopback and redirects config reads to a tmp dir;
108
+ `tests/test_isolation.py` proves both are in effect.
109
+
5
110
  ## [0.9.1] - 2026-08-27 - Version sync
6
111
 
7
112
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: plexus-python
3
- Version: 0.9.1
3
+ Version: 0.11.1
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.1"
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)