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.
Files changed (52) hide show
  1. {plexus_python-0.7.1 → plexus_python-0.8.0}/AGENTS.md +2 -2
  2. {plexus_python-0.7.1 → plexus_python-0.8.0}/API.md +17 -16
  3. {plexus_python-0.7.1 → plexus_python-0.8.0}/CHANGELOG.md +13 -2
  4. {plexus_python-0.7.1 → plexus_python-0.8.0}/PKG-INFO +6 -6
  5. {plexus_python-0.7.1 → plexus_python-0.8.0}/README.md +2 -2
  6. {plexus_python-0.7.1 → plexus_python-0.8.0}/plexus/__init__.py +1 -1
  7. {plexus_python-0.7.1 → plexus_python-0.8.0}/plexus/client.py +11 -1
  8. {plexus_python-0.7.1 → plexus_python-0.8.0}/plexus/config.py +2 -2
  9. {plexus_python-0.7.1 → plexus_python-0.8.0}/plexus/ws.py +60 -7
  10. {plexus_python-0.7.1 → plexus_python-0.8.0}/pyproject.toml +4 -4
  11. {plexus_python-0.7.1 → plexus_python-0.8.0}/tests/test_ws.py +129 -0
  12. plexus_python-0.7.1/skills/plexus/SKILL.md +0 -189
  13. plexus_python-0.7.1/skills/plexus/references/api.md +0 -331
  14. plexus_python-0.7.1/skills/plexus/references/sdk.md +0 -227
  15. {plexus_python-0.7.1 → plexus_python-0.8.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  16. {plexus_python-0.7.1 → plexus_python-0.8.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  17. {plexus_python-0.7.1 → plexus_python-0.8.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  18. {plexus_python-0.7.1 → plexus_python-0.8.0}/.github/workflows/ci.yml +0 -0
  19. {plexus_python-0.7.1 → plexus_python-0.8.0}/.github/workflows/publish.yml +0 -0
  20. {plexus_python-0.7.1 → plexus_python-0.8.0}/.gitignore +0 -0
  21. {plexus_python-0.7.1 → plexus_python-0.8.0}/CODE_OF_CONDUCT.md +0 -0
  22. {plexus_python-0.7.1 → plexus_python-0.8.0}/CONTRIBUTING.md +0 -0
  23. {plexus_python-0.7.1 → plexus_python-0.8.0}/LICENSE +0 -0
  24. {plexus_python-0.7.1 → plexus_python-0.8.0}/SECURITY.md +0 -0
  25. {plexus_python-0.7.1 → plexus_python-0.8.0}/TODO.md +0 -0
  26. {plexus_python-0.7.1 → plexus_python-0.8.0}/examples/.python-version +0 -0
  27. {plexus_python-0.7.1 → plexus_python-0.8.0}/examples/README.md +0 -0
  28. {plexus_python-0.7.1 → plexus_python-0.8.0}/examples/basic.py +0 -0
  29. {plexus_python-0.7.1 → plexus_python-0.8.0}/examples/can.py +0 -0
  30. {plexus_python-0.7.1 → plexus_python-0.8.0}/examples/i2c_bme280.py +0 -0
  31. {plexus_python-0.7.1 → plexus_python-0.8.0}/examples/mac_metrics.py +0 -0
  32. {plexus_python-0.7.1 → plexus_python-0.8.0}/examples/mavlink.py +0 -0
  33. {plexus_python-0.7.1 → plexus_python-0.8.0}/examples/mqtt.py +0 -0
  34. {plexus_python-0.7.1 → plexus_python-0.8.0}/examples/pyproject.toml +0 -0
  35. {plexus_python-0.7.1 → plexus_python-0.8.0}/examples/thermal_camera.py +0 -0
  36. {plexus_python-0.7.1 → plexus_python-0.8.0}/examples/uv.lock +0 -0
  37. {plexus_python-0.7.1 → plexus_python-0.8.0}/plexus/_log.py +0 -0
  38. {plexus_python-0.7.1 → plexus_python-0.8.0}/plexus/buffer.py +0 -0
  39. {plexus_python-0.7.1 → plexus_python-0.8.0}/plexus/cameras/__init__.py +0 -0
  40. {plexus_python-0.7.1 → plexus_python-0.8.0}/plexus/cameras/thermal.py +0 -0
  41. {plexus_python-0.7.1 → plexus_python-0.8.0}/plexus/cli.py +0 -0
  42. {plexus_python-0.7.1 → plexus_python-0.8.0}/scripts/plexus.service +0 -0
  43. {plexus_python-0.7.1 → plexus_python-0.8.0}/scripts/release.sh +0 -0
  44. {plexus_python-0.7.1 → plexus_python-0.8.0}/scripts/scan_buses.py +0 -0
  45. {plexus_python-0.7.1 → plexus_python-0.8.0}/scripts/setup.sh +0 -0
  46. {plexus_python-0.7.1 → plexus_python-0.8.0}/tests/test_basic.py +0 -0
  47. {plexus_python-0.7.1 → plexus_python-0.8.0}/tests/test_buffer.py +0 -0
  48. {plexus_python-0.7.1 → plexus_python-0.8.0}/tests/test_config.py +0 -0
  49. {plexus_python-0.7.1 → plexus_python-0.8.0}/tests/test_retry.py +0 -0
  50. {plexus_python-0.7.1 → plexus_python-0.8.0}/tests/test_thermal.py +0 -0
  51. {plexus_python-0.7.1 → plexus_python-0.8.0}/tests/test_video.py +0 -0
  52. {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://plexus-gateway.fly.dev` |
11
- | `PLEXUS_GATEWAY_WS_URL` | Gateway WebSocket URL | `wss://plexus-gateway.fly.dev` |
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://plexus-gateway.fly.dev/ingest \
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 *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).
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://plexus-gateway.fly.dev/ingest",
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://plexus-gateway.fly.dev/ingest", {
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://plexus-gateway.fly.dev/ingest", bytes.NewBuffer(body))
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://plexus-gateway.fly.dev/ingest");
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://plexus-gateway.fly.dev/ingest \
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://plexus-gateway.fly.dev`) is now the SDK
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://plexus-gateway.fly.dev/ingest`), not the Next.js app proxy
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.7.1
3
+ Version: 0.8.0
4
4
  Summary: Thin Python SDK for Plexus — send telemetry in one line
5
- Project-URL: Homepage, https://plexus.dev
6
- Project-URL: Documentation, https://docs.plexus.dev
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 <hello@plexus.dev>
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://plexus-gateway.fly.dev` |
274
- | `PLEXUS_GATEWAY_WS_URL` | WebSocket URL | `wss://plexus-gateway.fly.dev` |
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://plexus-gateway.fly.dev` |
237
- | `PLEXUS_GATEWAY_WS_URL` | WebSocket URL | `wss://plexus-gateway.fly.dev` |
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.7.1"
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(name, handler, description=description, params=params)
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://plexus-gateway.fly.dev"
53
- PLEXUS_GATEWAY_WS_URL = "wss://plexus-gateway.fly.dev"
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, params=params or []
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
- threading.Thread(
377
- target=self._run_handler,
378
- args=(reg, cmd_id, command, params),
379
- daemon=True,
380
- ).start()
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.1"
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 = "hello@plexus.dev" }
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.dev"
45
- Documentation = "https://docs.plexus.dev"
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"