plexus-python 0.7.1__tar.gz → 0.8.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.7.1 → plexus_python-0.8.0}/AGENTS.md +2 -2
- {plexus_python-0.7.1 → plexus_python-0.8.0}/API.md +17 -16
- {plexus_python-0.7.1 → plexus_python-0.8.0}/CHANGELOG.md +13 -2
- {plexus_python-0.7.1 → plexus_python-0.8.0}/PKG-INFO +6 -6
- {plexus_python-0.7.1 → plexus_python-0.8.0}/README.md +2 -2
- {plexus_python-0.7.1 → plexus_python-0.8.0}/plexus/__init__.py +1 -1
- {plexus_python-0.7.1 → plexus_python-0.8.0}/plexus/client.py +11 -1
- {plexus_python-0.7.1 → plexus_python-0.8.0}/plexus/config.py +2 -2
- {plexus_python-0.7.1 → plexus_python-0.8.0}/plexus/ws.py +60 -7
- {plexus_python-0.7.1 → plexus_python-0.8.0}/pyproject.toml +4 -4
- {plexus_python-0.7.1 → plexus_python-0.8.0}/tests/test_ws.py +129 -0
- plexus_python-0.7.1/skills/plexus/SKILL.md +0 -189
- plexus_python-0.7.1/skills/plexus/references/api.md +0 -331
- plexus_python-0.7.1/skills/plexus/references/sdk.md +0 -227
- {plexus_python-0.7.1 → plexus_python-0.8.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/.github/workflows/ci.yml +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/.github/workflows/publish.yml +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/.gitignore +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/CODE_OF_CONDUCT.md +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/CONTRIBUTING.md +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/LICENSE +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/SECURITY.md +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/TODO.md +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/examples/.python-version +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/examples/README.md +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/examples/basic.py +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/examples/can.py +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/examples/i2c_bme280.py +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/examples/mac_metrics.py +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/examples/mavlink.py +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/examples/mqtt.py +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/examples/pyproject.toml +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/examples/thermal_camera.py +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/examples/uv.lock +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/plexus/_log.py +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/plexus/buffer.py +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/plexus/cameras/__init__.py +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/plexus/cameras/thermal.py +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/plexus/cli.py +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/scripts/plexus.service +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/scripts/release.sh +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/scripts/scan_buses.py +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/scripts/setup.sh +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/tests/test_basic.py +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/tests/test_buffer.py +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/tests/test_config.py +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/tests/test_retry.py +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/tests/test_thermal.py +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/tests/test_video.py +0 -0
- {plexus_python-0.7.1 → plexus_python-0.8.0}/uv.lock +0 -0
|
@@ -7,8 +7,8 @@ Machine-readable interface for AI assistants and automation scripts.
|
|
|
7
7
|
| Variable | Description | Default |
|
|
8
8
|
| ----------------------- | ------------------------------------- | -------------------------------- |
|
|
9
9
|
| `PLEXUS_API_KEY` | API key for authentication (required) | none |
|
|
10
|
-
| `PLEXUS_GATEWAY_URL` | Gateway HTTP ingest URL | `https://
|
|
11
|
-
| `PLEXUS_GATEWAY_WS_URL` | Gateway WebSocket URL | `wss://
|
|
10
|
+
| `PLEXUS_GATEWAY_URL` | Gateway HTTP ingest URL | `https://gateway.plexus.company` |
|
|
11
|
+
| `PLEXUS_GATEWAY_WS_URL` | Gateway WebSocket URL | `wss://gateway.plexus.company` |
|
|
12
12
|
|
|
13
13
|
## CLI Commands
|
|
14
14
|
|
|
@@ -30,7 +30,7 @@ Then control streaming, recording, and configuration from [app.plexus.company/de
|
|
|
30
30
|
Send data directly via HTTP:
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
|
-
curl -X POST https://
|
|
33
|
+
curl -X POST https://gateway.plexus.company/ingest \
|
|
34
34
|
-H "x-api-key: YOUR_API_KEY" \
|
|
35
35
|
-H "Content-Type: application/json" \
|
|
36
36
|
-d '{
|
|
@@ -94,14 +94,14 @@ x-api-key: plx_xxxxx
|
|
|
94
94
|
}
|
|
95
95
|
```
|
|
96
96
|
|
|
97
|
-
| Field | Type | Required | Description
|
|
98
|
-
| ------------ | ------ | -------- |
|
|
99
|
-
| `metric` | string | Yes | Metric name (e.g., `temperature`, `motor.rpm`)
|
|
100
|
-
| `value` | any | Yes | See supported value types below
|
|
97
|
+
| Field | Type | Required | Description |
|
|
98
|
+
| ------------ | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
99
|
+
| `metric` | string | Yes | Metric name (e.g., `temperature`, `motor.rpm`) |
|
|
100
|
+
| `value` | any | Yes | See supported value types below |
|
|
101
101
|
| `timestamp` | float | No | Unix timestamp in seconds (or ms if ≥ 1e12). Omit to use device time. Over WebSocket, the Python SDK applies a server-synced clock correction when omitted — see [Clock correction](#clock-correction). |
|
|
102
|
-
| `source_id` | string | Yes | Your source identifier
|
|
103
|
-
| `tags` | object | No | Key-value labels
|
|
104
|
-
| `session_id` | string | No | Group data into sessions
|
|
102
|
+
| `source_id` | string | Yes | Your source identifier |
|
|
103
|
+
| `tags` | object | No | Key-value labels |
|
|
104
|
+
| `session_id` | string | No | Group data into sessions |
|
|
105
105
|
|
|
106
106
|
### Supported Value Types
|
|
107
107
|
|
|
@@ -152,7 +152,7 @@ For real-time UI-controlled streaming, devices connect via WebSocket.
|
|
|
152
152
|
|
|
153
153
|
### Device Authentication
|
|
154
154
|
|
|
155
|
-
Devices authenticate using an API key. The `source_id` in the request is the device's
|
|
155
|
+
Devices authenticate using an API key. The `source_id` in the request is the device's _desired_ name; the server may return a different, auto-suffixed name in the `authenticated` frame if the desired name is already claimed by another device (see [Device identity](../README.md#device-identity) in the README).
|
|
156
156
|
|
|
157
157
|
```json
|
|
158
158
|
// Device → Server
|
|
@@ -286,7 +286,7 @@ import requests
|
|
|
286
286
|
import time
|
|
287
287
|
|
|
288
288
|
requests.post(
|
|
289
|
-
"https://
|
|
289
|
+
"https://gateway.plexus.company/ingest",
|
|
290
290
|
headers={"x-api-key": "plx_xxxxx"},
|
|
291
291
|
json={
|
|
292
292
|
"points": [{
|
|
@@ -302,7 +302,7 @@ requests.post(
|
|
|
302
302
|
### JavaScript
|
|
303
303
|
|
|
304
304
|
```javascript
|
|
305
|
-
await fetch("https://
|
|
305
|
+
await fetch("https://gateway.plexus.company/ingest", {
|
|
306
306
|
method: "POST",
|
|
307
307
|
headers: {
|
|
308
308
|
"x-api-key": "plx_xxxxx",
|
|
@@ -344,7 +344,7 @@ func main() {
|
|
|
344
344
|
}
|
|
345
345
|
|
|
346
346
|
body, _ := json.Marshal(points)
|
|
347
|
-
req, _ := http.NewRequest("POST", "https://
|
|
347
|
+
req, _ := http.NewRequest("POST", "https://gateway.plexus.company/ingest", bytes.NewBuffer(body))
|
|
348
348
|
req.Header.Set("x-api-key", "plx_xxxxx")
|
|
349
349
|
req.Header.Set("Content-Type", "application/json")
|
|
350
350
|
|
|
@@ -363,7 +363,7 @@ func main() {
|
|
|
363
363
|
// field entirely if you cannot guarantee NTP sync at send time.
|
|
364
364
|
void sendToPlexus(const char* metric, float value) {
|
|
365
365
|
HTTPClient http;
|
|
366
|
-
http.begin("https://
|
|
366
|
+
http.begin("https://gateway.plexus.company/ingest");
|
|
367
367
|
http.addHeader("Content-Type", "application/json");
|
|
368
368
|
http.addHeader("x-api-key", "plx_xxxxx");
|
|
369
369
|
|
|
@@ -386,7 +386,7 @@ void sendToPlexus(const char* metric, float value) {
|
|
|
386
386
|
API_KEY="plx_xxxxx"
|
|
387
387
|
SOURCE_ID="sensor-001"
|
|
388
388
|
|
|
389
|
-
curl -X POST https://
|
|
389
|
+
curl -X POST https://gateway.plexus.company/ingest \
|
|
390
390
|
-H "x-api-key: $API_KEY" \
|
|
391
391
|
-H "Content-Type: application/json" \
|
|
392
392
|
-d "{
|
|
@@ -430,8 +430,6 @@ while True:
|
|
|
430
430
|
px.send("attitude.pitch", msg.pitch)
|
|
431
431
|
```
|
|
432
432
|
|
|
433
|
-
See [docs.plexus.dev/recipes](https://docs.plexus.dev/recipes) for more.
|
|
434
|
-
|
|
435
433
|
## Python SDK with Sensor Drivers
|
|
436
434
|
|
|
437
435
|
For Raspberry Pi and other Linux devices, the Python SDK includes sensor drivers:
|
|
@@ -492,15 +490,18 @@ px.send("temperature", 72.5, timestamp=t) # your timestamp → used as-is
|
|
|
492
490
|
```
|
|
493
491
|
|
|
494
492
|
**When to pass an explicit timestamp:**
|
|
493
|
+
|
|
495
494
|
- You have a reliable wall-clock source (GPS, trusted hardware RTC, host NTP)
|
|
496
495
|
- You are replaying or backfilling historical data
|
|
497
496
|
- Your sensor provides its own wall-clock timestamp
|
|
498
497
|
|
|
499
498
|
**When to omit timestamp:**
|
|
499
|
+
|
|
500
500
|
- The device may have booted without NTP (Raspberry Pi, Jetson, field robots without network on first boot)
|
|
501
501
|
- You have no reliable external time source
|
|
502
502
|
|
|
503
503
|
**Known limitations:**
|
|
504
|
+
|
|
504
505
|
- The clock offset refreshes only on WebSocket reconnect. A device with a drifting RTC that stays connected for many days will accumulate uncorrected drift between reconnects proportional to the drift rate.
|
|
505
506
|
- HTTP transport (`transport="http"`) does not receive clock sync — timestamps default to the device clock uncorrected.
|
|
506
507
|
- `send_batch()` takes one shared `timestamp` for the whole batch, not per-point. For per-point timestamps, call `send()` in a loop.
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.8.0] - 2026-07-02 - Command concurrency control
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `concurrency` option on `on_command()` / `register_command()`. Defaults to `"accept"`
|
|
8
|
+
(unchanged behavior — overlapping invocations run concurrently). Set `"reject"` to refuse a
|
|
9
|
+
new invocation with an error result (`command already in progress: <name>`) while a previous
|
|
10
|
+
invocation of the same command is still running. Use it for handlers that drive exclusive
|
|
11
|
+
hardware (e.g. a pump init) so a client retry or double-click can't start two at once.
|
|
12
|
+
Protection is per command name; the immediate ack is still sent before the handler runs.
|
|
13
|
+
|
|
3
14
|
## [0.7.1] - 2026-06-02 - Remove install_id / source_id auto-suffix
|
|
4
15
|
|
|
5
16
|
### Changed
|
|
@@ -184,7 +195,7 @@
|
|
|
184
195
|
|
|
185
196
|
### Changed
|
|
186
197
|
|
|
187
|
-
- Gateway WebSocket URL (`wss://
|
|
198
|
+
- Gateway WebSocket URL (`wss://gateway.plexus.company`) is now the SDK
|
|
188
199
|
default — no need to pass `ws_url` explicitly.
|
|
189
200
|
- Removed the `[plexus] endpoint: …` line from the connection printout.
|
|
190
201
|
|
|
@@ -342,7 +353,7 @@ Breaking. `plexus-python` is now just the thin client — no agent, adapters, se
|
|
|
342
353
|
|
|
343
354
|
### Changed
|
|
344
355
|
|
|
345
|
-
- Default ingest endpoint points directly at the Plexus gateway (`https://
|
|
356
|
+
- Default ingest endpoint points directly at the Plexus gateway (`https://gateway.plexus.company/ingest`), not the Next.js app proxy
|
|
346
357
|
- Client raises `ValueError` clearly when no API key is available, instead of invoking a login flow
|
|
347
358
|
|
|
348
359
|
## [0.1.0] - Initial release
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plexus-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Thin Python SDK for Plexus — send telemetry in one line
|
|
5
|
-
Project-URL: Homepage, https://plexus.
|
|
6
|
-
Project-URL: Documentation, https://docs.plexus.
|
|
5
|
+
Project-URL: Homepage, https://plexus.company
|
|
6
|
+
Project-URL: Documentation, https://docs.plexus.company
|
|
7
7
|
Project-URL: Repository, https://github.com/plexus-oss/plexus-python
|
|
8
8
|
Project-URL: Issues, https://github.com/plexus-oss/plexus-python/issues
|
|
9
|
-
Author-email: Plexus <
|
|
9
|
+
Author-email: Plexus <info@plexus.company>
|
|
10
10
|
License-Expression: Apache-2.0
|
|
11
11
|
License-File: LICENSE
|
|
12
12
|
Keywords: fleet,hardware,iot,monitoring,observability,telemetry
|
|
@@ -270,8 +270,8 @@ The SDK sends an `ack` frame before invoking the handler, then a `result` frame
|
|
|
270
270
|
| Variable | Description | Default |
|
|
271
271
|
| ----------------------- | ---------------------------- | -------------------------------- |
|
|
272
272
|
| `PLEXUS_API_KEY` | API key (required) | none |
|
|
273
|
-
| `PLEXUS_GATEWAY_URL` | HTTP ingest URL | `https://
|
|
274
|
-
| `PLEXUS_GATEWAY_WS_URL` | WebSocket URL | `wss://
|
|
273
|
+
| `PLEXUS_GATEWAY_URL` | HTTP ingest URL | `https://gateway.plexus.company` |
|
|
274
|
+
| `PLEXUS_GATEWAY_WS_URL` | WebSocket URL | `wss://gateway.plexus.company` |
|
|
275
275
|
|
|
276
276
|
## Architecture
|
|
277
277
|
|
|
@@ -233,8 +233,8 @@ The SDK sends an `ack` frame before invoking the handler, then a `result` frame
|
|
|
233
233
|
| Variable | Description | Default |
|
|
234
234
|
| ----------------------- | ---------------------------- | -------------------------------- |
|
|
235
235
|
| `PLEXUS_API_KEY` | API key (required) | none |
|
|
236
|
-
| `PLEXUS_GATEWAY_URL` | HTTP ingest URL | `https://
|
|
237
|
-
| `PLEXUS_GATEWAY_WS_URL` | WebSocket URL | `wss://
|
|
236
|
+
| `PLEXUS_GATEWAY_URL` | HTTP ingest URL | `https://gateway.plexus.company` |
|
|
237
|
+
| `PLEXUS_GATEWAY_WS_URL` | WebSocket URL | `wss://gateway.plexus.company` |
|
|
238
238
|
|
|
239
239
|
## Architecture
|
|
240
240
|
|
|
@@ -10,5 +10,5 @@ Plexus — thin Python SDK for sending telemetry to the Plexus gateway.
|
|
|
10
10
|
from plexus.client import Plexus, PlexusError, AuthenticationError, read_mjpeg_frames
|
|
11
11
|
from plexus.config import RetryConfig
|
|
12
12
|
|
|
13
|
-
__version__ = "0.
|
|
13
|
+
__version__ = "0.8.0"
|
|
14
14
|
__all__ = ["Plexus", "PlexusError", "AuthenticationError", "RetryConfig", "read_mjpeg_frames"]
|
|
@@ -686,6 +686,7 @@ class Plexus:
|
|
|
686
686
|
*,
|
|
687
687
|
description: Optional[str] = None,
|
|
688
688
|
params: Optional[List[Dict[str, Any]]] = None,
|
|
689
|
+
concurrency: str = "accept",
|
|
689
690
|
) -> None:
|
|
690
691
|
"""Register a command handler (WebSocket transport only).
|
|
691
692
|
|
|
@@ -695,6 +696,12 @@ class Plexus:
|
|
|
695
696
|
|
|
696
697
|
Must be called before the first send() so the command is advertised
|
|
697
698
|
in the auth frame.
|
|
699
|
+
|
|
700
|
+
concurrency: "accept" (default) runs overlapping invocations of the
|
|
701
|
+
same command concurrently; "reject" refuses a new invocation with
|
|
702
|
+
an error result while a previous one is still running. Use
|
|
703
|
+
"reject" for handlers that drive exclusive hardware (e.g. a pump
|
|
704
|
+
init) so a retry or double-click can't start two at once.
|
|
698
705
|
"""
|
|
699
706
|
ws = self._ensure_ws()
|
|
700
707
|
if ws.is_authenticated:
|
|
@@ -704,7 +711,10 @@ class Plexus:
|
|
|
704
711
|
"Call on_command() before the first send().",
|
|
705
712
|
name,
|
|
706
713
|
)
|
|
707
|
-
ws.register_command(
|
|
714
|
+
ws.register_command(
|
|
715
|
+
name, handler, description=description, params=params,
|
|
716
|
+
concurrency=concurrency,
|
|
717
|
+
)
|
|
708
718
|
|
|
709
719
|
def _send_points(self, points: List[Dict[str, Any]]) -> bool:
|
|
710
720
|
"""Send data points to the gateway with retry and buffering.
|
|
@@ -49,8 +49,8 @@ CONFIG_DIR = Path.home() / ".plexus"
|
|
|
49
49
|
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
50
50
|
|
|
51
51
|
PLEXUS_ENDPOINT = "https://app.plexus.company"
|
|
52
|
-
PLEXUS_GATEWAY_URL = "https://
|
|
53
|
-
PLEXUS_GATEWAY_WS_URL = "wss://
|
|
52
|
+
PLEXUS_GATEWAY_URL = "https://gateway.plexus.company"
|
|
53
|
+
PLEXUS_GATEWAY_WS_URL = "wss://gateway.plexus.company"
|
|
54
54
|
|
|
55
55
|
DEFAULT_CONFIG = {
|
|
56
56
|
"api_key": None,
|
|
@@ -62,6 +62,11 @@ class _RegisteredCommand:
|
|
|
62
62
|
handler: CommandHandler
|
|
63
63
|
description: Optional[str] = None
|
|
64
64
|
params: List[Dict[str, Any]] = field(default_factory=list)
|
|
65
|
+
# "accept" (default): run overlapping invocations concurrently.
|
|
66
|
+
# "reject": refuse a new invocation with an error result while a
|
|
67
|
+
# previous one of the same command is still running.
|
|
68
|
+
concurrency: str = "accept"
|
|
69
|
+
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
|
65
70
|
|
|
66
71
|
def to_manifest(self) -> Dict[str, Any]:
|
|
67
72
|
m: Dict[str, Any] = {"name": self.name}
|
|
@@ -127,11 +132,24 @@ class WebSocketTransport:
|
|
|
127
132
|
*,
|
|
128
133
|
description: Optional[str] = None,
|
|
129
134
|
params: Optional[List[Dict[str, Any]]] = None,
|
|
135
|
+
concurrency: str = "accept",
|
|
130
136
|
) -> None:
|
|
131
137
|
"""Register a command handler. Must be called before start() to be
|
|
132
|
-
advertised in the auth frame.
|
|
138
|
+
advertised in the auth frame.
|
|
139
|
+
|
|
140
|
+
concurrency controls what happens when a command arrives while a
|
|
141
|
+
previous invocation of the *same* command is still running:
|
|
142
|
+
"accept" (default) — run it concurrently on a new thread.
|
|
143
|
+
"reject" — refuse it with an error result until the
|
|
144
|
+
in-flight invocation finishes.
|
|
145
|
+
"""
|
|
146
|
+
if concurrency not in ("accept", "reject"):
|
|
147
|
+
raise ValueError(
|
|
148
|
+
f"concurrency must be 'accept' or 'reject', got {concurrency!r}"
|
|
149
|
+
)
|
|
133
150
|
self._commands[name] = _RegisteredCommand(
|
|
134
|
-
name=name, handler=handler, description=description,
|
|
151
|
+
name=name, handler=handler, description=description,
|
|
152
|
+
params=params or [], concurrency=concurrency,
|
|
135
153
|
)
|
|
136
154
|
|
|
137
155
|
def start(self) -> None:
|
|
@@ -371,13 +389,42 @@ class WebSocketTransport:
|
|
|
371
389
|
})
|
|
372
390
|
return
|
|
373
391
|
|
|
392
|
+
# concurrency="reject": if an invocation is already running, refuse
|
|
393
|
+
# this one immediately rather than starting a second in parallel.
|
|
394
|
+
holds_lock = False
|
|
395
|
+
if reg.concurrency == "reject":
|
|
396
|
+
if not reg._lock.acquire(blocking=False):
|
|
397
|
+
self._send_frame({
|
|
398
|
+
"type": "command_result",
|
|
399
|
+
"id": cmd_id,
|
|
400
|
+
"command": command,
|
|
401
|
+
"event": "error",
|
|
402
|
+
"error": f"command already in progress: {command}",
|
|
403
|
+
})
|
|
404
|
+
return
|
|
405
|
+
holds_lock = True
|
|
406
|
+
|
|
374
407
|
# Run the handler off the read-loop thread so a slow handler doesn't
|
|
375
408
|
# block heartbeats or other inbound frames.
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
409
|
+
try:
|
|
410
|
+
threading.Thread(
|
|
411
|
+
target=self._run_handler,
|
|
412
|
+
args=(reg, cmd_id, command, params, holds_lock),
|
|
413
|
+
daemon=True,
|
|
414
|
+
).start()
|
|
415
|
+
except Exception as e:
|
|
416
|
+
# Thread creation failed (e.g. resource exhaustion). Release the
|
|
417
|
+
# concurrency lock so the command isn't wedged as "in progress",
|
|
418
|
+
# and report the failure rather than leaving it silently un-acked.
|
|
419
|
+
if holds_lock:
|
|
420
|
+
reg._lock.release()
|
|
421
|
+
self._send_frame({
|
|
422
|
+
"type": "command_result",
|
|
423
|
+
"id": cmd_id,
|
|
424
|
+
"command": command,
|
|
425
|
+
"event": "error",
|
|
426
|
+
"error": f"failed to start command handler: {e}",
|
|
427
|
+
})
|
|
381
428
|
|
|
382
429
|
def _run_handler(
|
|
383
430
|
self,
|
|
@@ -385,6 +432,7 @@ class WebSocketTransport:
|
|
|
385
432
|
cmd_id: str,
|
|
386
433
|
command: str,
|
|
387
434
|
params: Dict[str, Any],
|
|
435
|
+
holds_lock: bool = False,
|
|
388
436
|
) -> None:
|
|
389
437
|
try:
|
|
390
438
|
result = reg.handler(command, params)
|
|
@@ -397,6 +445,11 @@ class WebSocketTransport:
|
|
|
397
445
|
"error": str(e),
|
|
398
446
|
})
|
|
399
447
|
return
|
|
448
|
+
finally:
|
|
449
|
+
# Release the concurrency lock (if held) as soon as the handler
|
|
450
|
+
# returns, regardless of success or failure.
|
|
451
|
+
if holds_lock:
|
|
452
|
+
reg._lock.release()
|
|
400
453
|
self._send_frame({
|
|
401
454
|
"type": "command_result",
|
|
402
455
|
"id": cmd_id,
|
|
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "plexus-python"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.8.0"
|
|
8
8
|
description = "Thin Python SDK for Plexus — send telemetry in one line"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
11
11
|
requires-python = ">=3.10"
|
|
12
12
|
authors = [
|
|
13
|
-
{ name = "Plexus", email = "
|
|
13
|
+
{ name = "Plexus", email = "info@plexus.company" }
|
|
14
14
|
]
|
|
15
15
|
keywords = ["telemetry", "iot", "hardware", "observability", "fleet", "monitoring"]
|
|
16
16
|
classifiers = [
|
|
@@ -41,8 +41,8 @@ dev = ["pytest>=9.0.3", "pytest-cov", "ruff", "websockets>=12", "opencv-python-h
|
|
|
41
41
|
plexus = "plexus.cli:main"
|
|
42
42
|
|
|
43
43
|
[project.urls]
|
|
44
|
-
Homepage = "https://plexus.
|
|
45
|
-
Documentation = "https://docs.plexus.
|
|
44
|
+
Homepage = "https://plexus.company"
|
|
45
|
+
Documentation = "https://docs.plexus.company"
|
|
46
46
|
Repository = "https://github.com/plexus-oss/plexus-python"
|
|
47
47
|
Issues = "https://github.com/plexus-oss/plexus-python/issues"
|
|
48
48
|
|
|
@@ -255,6 +255,135 @@ def test_handler_exception_returns_error(gateway):
|
|
|
255
255
|
t.stop()
|
|
256
256
|
|
|
257
257
|
|
|
258
|
+
def test_reject_concurrency_refuses_overlap(gateway):
|
|
259
|
+
release = threading.Event()
|
|
260
|
+
started = threading.Event()
|
|
261
|
+
|
|
262
|
+
def slow(name, params):
|
|
263
|
+
started.set()
|
|
264
|
+
release.wait(timeout=3)
|
|
265
|
+
return {"done": True}
|
|
266
|
+
|
|
267
|
+
t = WebSocketTransport(
|
|
268
|
+
api_key="plx_test_abc",
|
|
269
|
+
source_id="drone-001",
|
|
270
|
+
ws_url=_url(gateway.port),
|
|
271
|
+
)
|
|
272
|
+
t.register_command("init", slow, concurrency="reject")
|
|
273
|
+
t.start()
|
|
274
|
+
try:
|
|
275
|
+
assert t.wait_authenticated(timeout=3)
|
|
276
|
+
|
|
277
|
+
# First invocation starts and blocks inside the handler.
|
|
278
|
+
gateway.send_command_sync("cmd-1", "init", {})
|
|
279
|
+
assert started.wait(timeout=3)
|
|
280
|
+
|
|
281
|
+
# Second invocation arrives while the first is still running.
|
|
282
|
+
gateway.send_command_sync("cmd-2", "init", {})
|
|
283
|
+
|
|
284
|
+
# cmd-2 is acked, then rejected with an error — the handler never runs
|
|
285
|
+
# a second time.
|
|
286
|
+
assert _wait_until(lambda: any(
|
|
287
|
+
m.get("type") == "command_result"
|
|
288
|
+
and m.get("id") == "cmd-2"
|
|
289
|
+
and m.get("event") == "error"
|
|
290
|
+
for m in gateway.received
|
|
291
|
+
))
|
|
292
|
+
err = next(
|
|
293
|
+
m for m in gateway.received
|
|
294
|
+
if m.get("type") == "command_result"
|
|
295
|
+
and m.get("id") == "cmd-2" and m.get("event") == "error"
|
|
296
|
+
)
|
|
297
|
+
assert "already in progress" in err["error"]
|
|
298
|
+
|
|
299
|
+
# Let the first finish; it still returns its result normally.
|
|
300
|
+
release.set()
|
|
301
|
+
assert _wait_until(lambda: any(
|
|
302
|
+
m.get("type") == "command_result"
|
|
303
|
+
and m.get("id") == "cmd-1" and m.get("event") == "result"
|
|
304
|
+
for m in gateway.received
|
|
305
|
+
))
|
|
306
|
+
|
|
307
|
+
# A fresh invocation after the first completes is accepted again.
|
|
308
|
+
gateway.send_command_sync("cmd-3", "init", {})
|
|
309
|
+
assert _wait_until(lambda: any(
|
|
310
|
+
m.get("type") == "command_result"
|
|
311
|
+
and m.get("id") == "cmd-3" and m.get("event") == "result"
|
|
312
|
+
for m in gateway.received
|
|
313
|
+
))
|
|
314
|
+
finally:
|
|
315
|
+
release.set()
|
|
316
|
+
t.stop()
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def test_accept_concurrency_allows_overlap(gateway):
|
|
320
|
+
release = threading.Event()
|
|
321
|
+
active = []
|
|
322
|
+
lock = threading.Lock()
|
|
323
|
+
|
|
324
|
+
def slow(name, params):
|
|
325
|
+
with lock:
|
|
326
|
+
active.append(1)
|
|
327
|
+
release.wait(timeout=3)
|
|
328
|
+
return {"done": True}
|
|
329
|
+
|
|
330
|
+
t = WebSocketTransport(
|
|
331
|
+
api_key="plx_test_abc",
|
|
332
|
+
source_id="drone-001",
|
|
333
|
+
ws_url=_url(gateway.port),
|
|
334
|
+
)
|
|
335
|
+
# Default concurrency is "accept".
|
|
336
|
+
t.register_command("init", slow)
|
|
337
|
+
t.start()
|
|
338
|
+
try:
|
|
339
|
+
assert t.wait_authenticated(timeout=3)
|
|
340
|
+
gateway.send_command_sync("cmd-1", "init", {})
|
|
341
|
+
gateway.send_command_sync("cmd-2", "init", {})
|
|
342
|
+
# Both handlers run at the same time (neither has returned yet).
|
|
343
|
+
assert _wait_until(lambda: len(active) == 2)
|
|
344
|
+
finally:
|
|
345
|
+
release.set()
|
|
346
|
+
t.stop()
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def test_reject_releases_lock_if_thread_start_fails(monkeypatch):
|
|
350
|
+
import plexus.ws as wsmod
|
|
351
|
+
|
|
352
|
+
t = WebSocketTransport(
|
|
353
|
+
api_key="plx_test_abc",
|
|
354
|
+
source_id="drone-001",
|
|
355
|
+
ws_url="ws://127.0.0.1:1",
|
|
356
|
+
)
|
|
357
|
+
t.register_command("init", lambda n, p: None, concurrency="reject")
|
|
358
|
+
|
|
359
|
+
class _BoomThread:
|
|
360
|
+
def __init__(self, *a, **k):
|
|
361
|
+
pass
|
|
362
|
+
|
|
363
|
+
def start(self):
|
|
364
|
+
raise RuntimeError("cannot start thread")
|
|
365
|
+
|
|
366
|
+
monkeypatch.setattr(wsmod.threading, "Thread", _BoomThread)
|
|
367
|
+
|
|
368
|
+
# Dispatch directly (no socket needed; _send_frame no-ops while _ws is None).
|
|
369
|
+
# The failed thread start must not leave the concurrency lock held.
|
|
370
|
+
t._handle_command({"id": "cmd-1", "command": "init", "params": {}})
|
|
371
|
+
|
|
372
|
+
reg = t._commands["init"]
|
|
373
|
+
assert reg._lock.acquire(blocking=False), "lock leaked after thread start failure"
|
|
374
|
+
reg._lock.release()
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def test_register_command_rejects_bad_concurrency(gateway):
|
|
378
|
+
t = WebSocketTransport(
|
|
379
|
+
api_key="plx_test_abc",
|
|
380
|
+
source_id="drone-001",
|
|
381
|
+
ws_url=_url(gateway.port),
|
|
382
|
+
)
|
|
383
|
+
with pytest.raises(ValueError, match="concurrency"):
|
|
384
|
+
t.register_command("x", lambda n, p: None, concurrency="bogus")
|
|
385
|
+
|
|
386
|
+
|
|
258
387
|
def test_ensure_device_path():
|
|
259
388
|
from plexus.ws import _ensure_device_path
|
|
260
389
|
assert _ensure_device_path("wss://foo") == "wss://foo/ws/device"
|