plexus-python 0.7.0__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.0 → plexus_python-0.8.0}/AGENTS.md +2 -2
  2. {plexus_python-0.7.0 → plexus_python-0.8.0}/API.md +17 -16
  3. {plexus_python-0.7.0 → plexus_python-0.8.0}/CHANGELOG.md +21 -2
  4. {plexus_python-0.7.0 → plexus_python-0.8.0}/PKG-INFO +6 -6
  5. {plexus_python-0.7.0 → plexus_python-0.8.0}/README.md +2 -2
  6. {plexus_python-0.7.0 → plexus_python-0.8.0}/plexus/__init__.py +1 -1
  7. {plexus_python-0.7.0 → plexus_python-0.8.0}/plexus/client.py +11 -15
  8. {plexus_python-0.7.0 → plexus_python-0.8.0}/plexus/config.py +2 -50
  9. {plexus_python-0.7.0 → plexus_python-0.8.0}/plexus/ws.py +61 -31
  10. {plexus_python-0.7.0 → plexus_python-0.8.0}/pyproject.toml +4 -4
  11. {plexus_python-0.7.0 → plexus_python-0.8.0}/tests/test_ws.py +119 -63
  12. {plexus_python-0.7.0 → plexus_python-0.8.0}/uv.lock +1 -1
  13. plexus_python-0.7.0/skills/plexus/SKILL.md +0 -189
  14. plexus_python-0.7.0/skills/plexus/references/api.md +0 -331
  15. plexus_python-0.7.0/skills/plexus/references/sdk.md +0 -227
  16. {plexus_python-0.7.0 → plexus_python-0.8.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  17. {plexus_python-0.7.0 → plexus_python-0.8.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  18. {plexus_python-0.7.0 → plexus_python-0.8.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  19. {plexus_python-0.7.0 → plexus_python-0.8.0}/.github/workflows/ci.yml +0 -0
  20. {plexus_python-0.7.0 → plexus_python-0.8.0}/.github/workflows/publish.yml +0 -0
  21. {plexus_python-0.7.0 → plexus_python-0.8.0}/.gitignore +0 -0
  22. {plexus_python-0.7.0 → plexus_python-0.8.0}/CODE_OF_CONDUCT.md +0 -0
  23. {plexus_python-0.7.0 → plexus_python-0.8.0}/CONTRIBUTING.md +0 -0
  24. {plexus_python-0.7.0 → plexus_python-0.8.0}/LICENSE +0 -0
  25. {plexus_python-0.7.0 → plexus_python-0.8.0}/SECURITY.md +0 -0
  26. {plexus_python-0.7.0 → plexus_python-0.8.0}/TODO.md +0 -0
  27. {plexus_python-0.7.0 → plexus_python-0.8.0}/examples/.python-version +0 -0
  28. {plexus_python-0.7.0 → plexus_python-0.8.0}/examples/README.md +0 -0
  29. {plexus_python-0.7.0 → plexus_python-0.8.0}/examples/basic.py +0 -0
  30. {plexus_python-0.7.0 → plexus_python-0.8.0}/examples/can.py +0 -0
  31. {plexus_python-0.7.0 → plexus_python-0.8.0}/examples/i2c_bme280.py +0 -0
  32. {plexus_python-0.7.0 → plexus_python-0.8.0}/examples/mac_metrics.py +0 -0
  33. {plexus_python-0.7.0 → plexus_python-0.8.0}/examples/mavlink.py +0 -0
  34. {plexus_python-0.7.0 → plexus_python-0.8.0}/examples/mqtt.py +0 -0
  35. {plexus_python-0.7.0 → plexus_python-0.8.0}/examples/pyproject.toml +0 -0
  36. {plexus_python-0.7.0 → plexus_python-0.8.0}/examples/thermal_camera.py +0 -0
  37. {plexus_python-0.7.0 → plexus_python-0.8.0}/examples/uv.lock +0 -0
  38. {plexus_python-0.7.0 → plexus_python-0.8.0}/plexus/_log.py +0 -0
  39. {plexus_python-0.7.0 → plexus_python-0.8.0}/plexus/buffer.py +0 -0
  40. {plexus_python-0.7.0 → plexus_python-0.8.0}/plexus/cameras/__init__.py +0 -0
  41. {plexus_python-0.7.0 → plexus_python-0.8.0}/plexus/cameras/thermal.py +0 -0
  42. {plexus_python-0.7.0 → plexus_python-0.8.0}/plexus/cli.py +0 -0
  43. {plexus_python-0.7.0 → plexus_python-0.8.0}/scripts/plexus.service +0 -0
  44. {plexus_python-0.7.0 → plexus_python-0.8.0}/scripts/release.sh +0 -0
  45. {plexus_python-0.7.0 → plexus_python-0.8.0}/scripts/scan_buses.py +0 -0
  46. {plexus_python-0.7.0 → plexus_python-0.8.0}/scripts/setup.sh +0 -0
  47. {plexus_python-0.7.0 → plexus_python-0.8.0}/tests/test_basic.py +0 -0
  48. {plexus_python-0.7.0 → plexus_python-0.8.0}/tests/test_buffer.py +0 -0
  49. {plexus_python-0.7.0 → plexus_python-0.8.0}/tests/test_config.py +0 -0
  50. {plexus_python-0.7.0 → plexus_python-0.8.0}/tests/test_retry.py +0 -0
  51. {plexus_python-0.7.0 → plexus_python-0.8.0}/tests/test_thermal.py +0 -0
  52. {plexus_python-0.7.0 → plexus_python-0.8.0}/tests/test_video.py +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,24 @@
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
+
14
+ ## [0.7.1] - 2026-06-02 - Remove install_id / source_id auto-suffix
15
+
16
+ ### Changed
17
+
18
+ - Removed `install_id` from the device auth frame and `WebSocketTransport`.
19
+ - Removed server-side source_id auto-suffix handling (`on_source_id_assigned` callback, `set_source_id` persistence).
20
+ - Removed `get_install_id` and `set_source_id` from `plexus.config`.
21
+
3
22
  ## [0.7.0] - 2026-05-29 - SDK hardening
4
23
 
5
24
  ### Fixed
@@ -176,7 +195,7 @@
176
195
 
177
196
  ### Changed
178
197
 
179
- - Gateway WebSocket URL (`wss://plexus-gateway.fly.dev`) is now the SDK
198
+ - Gateway WebSocket URL (`wss://gateway.plexus.company`) is now the SDK
180
199
  default — no need to pass `ws_url` explicitly.
181
200
  - Removed the `[plexus] endpoint: …` line from the connection printout.
182
201
 
@@ -334,7 +353,7 @@ Breaking. `plexus-python` is now just the thin client — no agent, adapters, se
334
353
 
335
354
  ### Changed
336
355
 
337
- - 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
338
357
  - Client raises `ValueError` clearly when no API key is available, instead of invoking a login flow
339
358
 
340
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.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.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.0"
13
+ __version__ = "0.8.0"
14
14
  __all__ = ["Plexus", "PlexusError", "AuthenticationError", "RetryConfig", "read_mjpeg_frames"]
@@ -55,9 +55,7 @@ from plexus.config import (
55
55
  get_endpoint,
56
56
  get_gateway_url,
57
57
  get_gateway_ws_url,
58
- get_install_id,
59
58
  get_source_id,
60
- set_source_id,
61
59
  )
62
60
 
63
61
  logger = logging.getLogger(__name__)
@@ -414,9 +412,7 @@ class Plexus:
414
412
  api_key=self.api_key,
415
413
  source_id=self.source_id,
416
414
  ws_url=self._ws_url,
417
- install_id=get_install_id(),
418
415
  agent_version=__version__,
419
- on_source_id_assigned=self._on_source_id_assigned,
420
416
  on_clock_synced=self._on_clock_synced,
421
417
  )
422
418
  self._ws.start()
@@ -428,16 +424,6 @@ class Plexus:
428
424
  def _on_clock_synced(self, offset_ms: int) -> None:
429
425
  self._clock_offset_ms = offset_ms
430
426
 
431
- def _on_source_id_assigned(self, assigned: str) -> None:
432
- """Callback from WebSocketTransport when the gateway returns an
433
- auto-suffixed source_id. Persists it so subsequent runs (and the HTTP
434
- fallback path in this process) use the assigned name directly."""
435
- self.source_id = assigned
436
- try:
437
- set_source_id(assigned)
438
- except Exception as e: # pragma: no cover - persistence failure is non-fatal
439
- logger.debug("failed to persist assigned source_id: %s", e)
440
-
441
427
  def _encode_frame(self, frame, quality: int) -> Tuple[bytes, int, int]:
442
428
  """Normalize any supported frame type to (jpeg_bytes, width, height).
443
429
 
@@ -700,6 +686,7 @@ class Plexus:
700
686
  *,
701
687
  description: Optional[str] = None,
702
688
  params: Optional[List[Dict[str, Any]]] = None,
689
+ concurrency: str = "accept",
703
690
  ) -> None:
704
691
  """Register a command handler (WebSocket transport only).
705
692
 
@@ -709,6 +696,12 @@ class Plexus:
709
696
 
710
697
  Must be called before the first send() so the command is advertised
711
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.
712
705
  """
713
706
  ws = self._ensure_ws()
714
707
  if ws.is_authenticated:
@@ -718,7 +711,10 @@ class Plexus:
718
711
  "Call on_command() before the first send().",
719
712
  name,
720
713
  )
721
- ws.register_command(name, handler, description=description, params=params)
714
+ ws.register_command(
715
+ name, handler, description=description, params=params,
716
+ concurrency=concurrency,
717
+ )
722
718
 
723
719
  def _send_points(self, points: List[Dict[str, Any]]) -> bool:
724
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,
@@ -145,54 +145,6 @@ def get_source_id() -> Optional[str]:
145
145
  return source_id
146
146
 
147
147
 
148
- def get_install_id() -> str:
149
- """Get the device install ID, generating one if not set.
150
-
151
- The install_id is a stable per-installation UUID. It is generated lazily
152
- on first run (NOT at image-build time) so that cloned SD-card images
153
- naturally get distinct install_ids on their first boot. The gateway uses
154
- it to tell "same device reconnecting" from "different device claiming the
155
- same name" when resolving source_id collisions.
156
-
157
- Resolution order:
158
- 1. ``PLEXUS_INSTALL_ID`` env var — lets ephemeral containers (Fly
159
- machines, CI runners, Kubernetes pods) pin a stable identity
160
- across restarts when the config filesystem is ephemeral. Without
161
- this, every redeploy generates a new install_id and the gateway
162
- auto-suffixes the source_id to avoid a collision with the prior
163
- install ("gw-001" → "gw-001_2" → "gw-001_3"…).
164
- 2. ``install_id`` in the on-disk config.
165
- 3. Newly-generated UUID, persisted to config.
166
- """
167
- env_id = os.environ.get("PLEXUS_INSTALL_ID", "").strip()
168
- if env_id:
169
- return env_id
170
-
171
- config = load_config()
172
- install_id = config.get("install_id")
173
-
174
- if not install_id:
175
- import uuid
176
- install_id = uuid.uuid4().hex
177
- config["install_id"] = install_id
178
- save_config(config)
179
-
180
- return install_id
181
-
182
-
183
- def set_source_id(source_id: str) -> None:
184
- """Persist an updated source_id to the config file.
185
-
186
- Called by the SDK when the gateway returns an auto-suffixed name so the
187
- assigned name is stable across reconnects.
188
- """
189
- config = load_config()
190
- if config.get("source_id") == source_id:
191
- return
192
- config["source_id"] = source_id
193
- save_config(config)
194
-
195
-
196
148
  def get_persistent_buffer() -> bool:
197
149
  """Get persistent buffer setting. Default True (store-and-forward enabled)."""
198
150
  config = load_config()
@@ -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}
@@ -89,11 +94,9 @@ class WebSocketTransport:
89
94
  source_id: str,
90
95
  ws_url: str,
91
96
  *,
92
- install_id: str = "",
93
97
  agent_version: str = "0.0.0",
94
98
  platform: str = "python-sdk",
95
99
  auto_reconnect: bool = True,
96
- on_source_id_assigned: Optional[Callable[[str], None]] = None,
97
100
  on_clock_synced: Optional[Callable[[int], None]] = None,
98
101
  ):
99
102
  if not api_key:
@@ -103,12 +106,10 @@ class WebSocketTransport:
103
106
 
104
107
  self.api_key = api_key
105
108
  self.source_id = source_id
106
- self.install_id = install_id
107
109
  self.ws_url = _ensure_device_path(ws_url)
108
110
  self.agent_version = agent_version
109
111
  self.platform = platform
110
112
  self.auto_reconnect = auto_reconnect
111
- self._on_source_id_assigned = on_source_id_assigned
112
113
  self._on_clock_synced = on_clock_synced
113
114
 
114
115
  self._commands: Dict[str, _RegisteredCommand] = {}
@@ -131,11 +132,24 @@ class WebSocketTransport:
131
132
  *,
132
133
  description: Optional[str] = None,
133
134
  params: Optional[List[Dict[str, Any]]] = None,
135
+ concurrency: str = "accept",
134
136
  ) -> None:
135
137
  """Register a command handler. Must be called before start() to be
136
- 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
+ )
137
150
  self._commands[name] = _RegisteredCommand(
138
- name=name, handler=handler, description=description, params=params or []
151
+ name=name, handler=handler, description=description,
152
+ params=params or [], concurrency=concurrency,
139
153
  )
140
154
 
141
155
  def start(self) -> None:
@@ -278,16 +292,13 @@ class WebSocketTransport:
278
292
  self._ws = ws
279
293
 
280
294
  # 1. Send device_auth
281
- desired_source_id = self.source_id
282
295
  auth = {
283
296
  "type": "device_auth",
284
297
  "api_key": self.api_key,
285
- "source_id": desired_source_id,
298
+ "source_id": self.source_id,
286
299
  "platform": self.platform,
287
300
  "agent_version": self.agent_version,
288
301
  }
289
- if self.install_id:
290
- auth["install_id"] = self.install_id
291
302
  if self._commands:
292
303
  auth["commands"] = [c.to_manifest() for c in self._commands.values()]
293
304
  ws.send(json.dumps(auth))
@@ -312,22 +323,6 @@ class WebSocketTransport:
312
323
  except Exception as e:
313
324
  logger.debug("on_clock_synced callback raised: %s", e)
314
325
 
315
- # The gateway may return a different source_id if the desired name
316
- # was already claimed by another install — adopt the assigned value
317
- # so all subsequent frames (heartbeats, future reconnects) use it.
318
- assigned = msg.get("source_id")
319
- if isinstance(assigned, str) and assigned and assigned != self.source_id:
320
- logger.info(
321
- "plexus ws source_id auto-suffixed: requested=%s assigned=%s",
322
- desired_source_id, assigned,
323
- )
324
- self.source_id = assigned
325
- if self._on_source_id_assigned is not None:
326
- try:
327
- self._on_source_id_assigned(assigned)
328
- except Exception as e: # pragma: no cover - callback errors must not break auth
329
- logger.debug("on_source_id_assigned callback raised: %s", e)
330
-
331
326
  was_reconnect = self._backoff_attempt > 0
332
327
  self._authenticated.set()
333
328
  self._backoff_attempt = 0
@@ -394,13 +389,42 @@ class WebSocketTransport:
394
389
  })
395
390
  return
396
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
+
397
407
  # Run the handler off the read-loop thread so a slow handler doesn't
398
408
  # block heartbeats or other inbound frames.
399
- threading.Thread(
400
- target=self._run_handler,
401
- args=(reg, cmd_id, command, params),
402
- daemon=True,
403
- ).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
+ })
404
428
 
405
429
  def _run_handler(
406
430
  self,
@@ -408,6 +432,7 @@ class WebSocketTransport:
408
432
  cmd_id: str,
409
433
  command: str,
410
434
  params: Dict[str, Any],
435
+ holds_lock: bool = False,
411
436
  ) -> None:
412
437
  try:
413
438
  result = reg.handler(command, params)
@@ -420,6 +445,11 @@ class WebSocketTransport:
420
445
  "error": str(e),
421
446
  })
422
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()
423
453
  self._send_frame({
424
454
  "type": "command_result",
425
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.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 = "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