plexus-python 0.7.0__py3-none-any.whl → 0.8.0__py3-none-any.whl

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/__init__.py CHANGED
@@ -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"]
plexus/client.py CHANGED
@@ -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.
plexus/config.py CHANGED
@@ -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()
plexus/ws.py CHANGED
@@ -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,
@@ -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
 
@@ -0,0 +1,14 @@
1
+ plexus/__init__.py,sha256=O9GpL-CqlDKRoEfQq5ojYNsIMa7CtXuYvdw3zCrC8pg,447
2
+ plexus/_log.py,sha256=3fjXrHFZghQ_17umMcvDUjjTH6aTQB3J4SpVDBiH03w,335
3
+ plexus/buffer.py,sha256=0i6PLgoj904jFNv9RCrlskvPQsPSu_KNdYWMasFOvsg,9596
4
+ plexus/cli.py,sha256=-2wvHXQzobx3_tDGTXpaE2PlHv884y93Mu29kZE8qZE,14214
5
+ plexus/client.py,sha256=12qnUgmZbTEXuSFtMCAXqosh-V84odSiqagbhtfjAc8,36280
6
+ plexus/config.py,sha256=Y4rPo1zeqAOnz-pQWrDBAXZ1gjfr0UD5Q6HHTMmbKew,4450
7
+ plexus/ws.py,sha256=PSVVrS1vpGi20qGdRfTi4LWCXzt3Dx3hR09FuoT-QIs,18643
8
+ plexus/cameras/__init__.py,sha256=OvnU9KGKxkVtFLlk56H9x-ATa6UvpLI7PANa0HQO2cc,490
9
+ plexus/cameras/thermal.py,sha256=7o33QsF1RiZLManTxZ2E36nO8lRAHppCDkS3zXBHCxs,12047
10
+ plexus_python-0.8.0.dist-info/METADATA,sha256=MfH0vB7R8uBYeJhTTY8Xbd_Qi3qCYZ3jzSIS8cPFNmg,11750
11
+ plexus_python-0.8.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
12
+ plexus_python-0.8.0.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
13
+ plexus_python-0.8.0.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
14
+ plexus_python-0.8.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.29.0
2
+ Generator: hatchling 1.30.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,14 +0,0 @@
1
- plexus/__init__.py,sha256=HNRVhpDrkyyT-SQqfFzNf3E_uwbwQA-XcDKPoBytdEo,447
2
- plexus/_log.py,sha256=3fjXrHFZghQ_17umMcvDUjjTH6aTQB3J4SpVDBiH03w,335
3
- plexus/buffer.py,sha256=0i6PLgoj904jFNv9RCrlskvPQsPSu_KNdYWMasFOvsg,9596
4
- plexus/cli.py,sha256=-2wvHXQzobx3_tDGTXpaE2PlHv884y93Mu29kZE8qZE,14214
5
- plexus/client.py,sha256=DaCPURIhVswwjf4oGrp381Atiy02gk3viLy6NioBAtQ,36480
6
- plexus/config.py,sha256=wsG6lhNLmKe3JRlVycyRUKQeywnPUPPfrWkXFxYwELE,6179
7
- plexus/ws.py,sha256=_c--U-yySZbMXZsaj0fUaYKF_WesolHsrMDx4oWYfFQ,17428
8
- plexus/cameras/__init__.py,sha256=OvnU9KGKxkVtFLlk56H9x-ATa6UvpLI7PANa0HQO2cc,490
9
- plexus/cameras/thermal.py,sha256=7o33QsF1RiZLManTxZ2E36nO8lRAHppCDkS3zXBHCxs,12047
10
- plexus_python-0.7.0.dist-info/METADATA,sha256=tlsktssdsOI7dyA7O2I6azPay2Xj998Z4zNxbFf4H6s,11739
11
- plexus_python-0.7.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
12
- plexus_python-0.7.0.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
13
- plexus_python-0.7.0.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
14
- plexus_python-0.7.0.dist-info/RECORD,,