plexus-python 0.7.1__py3-none-any.whl → 0.9.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
@@ -7,8 +7,8 @@ Plexus — thin Python SDK for sending telemetry to the Plexus gateway.
7
7
  px.send("temperature", 72.5)
8
8
  """
9
9
 
10
- from plexus.client import Plexus, PlexusError, AuthenticationError, read_mjpeg_frames
10
+ from plexus.client import AuthenticationError, Plexus, PlexusError, read_mjpeg_frames
11
11
  from plexus.config import RetryConfig
12
12
 
13
- __version__ = "0.7.1"
14
- __all__ = ["Plexus", "PlexusError", "AuthenticationError", "RetryConfig", "read_mjpeg_frames"]
13
+ __version__ = "0.8.0"
14
+ __all__ = ["AuthenticationError", "Plexus", "PlexusError", "RetryConfig", "read_mjpeg_frames"]
plexus/buffer.py CHANGED
@@ -17,7 +17,8 @@ import os
17
17
  import sqlite3
18
18
  import threading
19
19
  from abc import ABC, abstractmethod
20
- from typing import Any, Callable, Dict, List, Optional, Tuple
20
+ from collections.abc import Callable
21
+ from typing import Any
21
22
 
22
23
  logger = logging.getLogger(__name__)
23
24
 
@@ -26,11 +27,11 @@ class BufferBackend(ABC):
26
27
  """Abstract buffer backend for storing telemetry points locally."""
27
28
 
28
29
  @abstractmethod
29
- def add(self, points: List[Dict[str, Any]]) -> None:
30
+ def add(self, points: list[dict[str, Any]]) -> None:
30
31
  """Add points to the buffer, evicting oldest if over capacity."""
31
32
 
32
33
  @abstractmethod
33
- def get_all(self) -> List[Dict[str, Any]]:
34
+ def get_all(self) -> list[dict[str, Any]]:
34
35
  """Return a copy of all buffered points without clearing."""
35
36
 
36
37
  @abstractmethod
@@ -45,7 +46,7 @@ class BufferBackend(ABC):
45
46
  def resize(self, max_size: int) -> None:
46
47
  """Update the maximum buffer capacity."""
47
48
 
48
- def drain(self, batch_size: int = 5000) -> Tuple[List[Dict[str, Any]], int]:
49
+ def drain(self, batch_size: int = 5000) -> tuple[list[dict[str, Any]], int]:
49
50
  """Remove and return the oldest batch_size points atomically.
50
51
 
51
52
  Returns (points, remaining_count). Points are deleted from the buffer
@@ -73,13 +74,13 @@ class MemoryBuffer(BufferBackend):
73
74
  This extracts the original behavior from Plexus client._failed_buffer.
74
75
  """
75
76
 
76
- def __init__(self, max_size: int = 10_000, on_overflow: Optional[Callable[[int], None]] = None):
77
+ def __init__(self, max_size: int = 10_000, on_overflow: Callable[[int], None] | None = None):
77
78
  self._max_size = max_size
78
79
  self._on_overflow = on_overflow
79
- self._buffer: List[Dict[str, Any]] = []
80
+ self._buffer: list[dict[str, Any]] = []
80
81
  self._lock = threading.Lock()
81
82
 
82
- def add(self, points: List[Dict[str, Any]]) -> None:
83
+ def add(self, points: list[dict[str, Any]]) -> None:
83
84
  with self._lock:
84
85
  self._buffer.extend(points)
85
86
  if len(self._buffer) > self._max_size:
@@ -89,7 +90,7 @@ class MemoryBuffer(BufferBackend):
89
90
  if self._on_overflow:
90
91
  self._on_overflow(overflow)
91
92
 
92
- def get_all(self) -> List[Dict[str, Any]]:
93
+ def get_all(self) -> list[dict[str, Any]]:
93
94
  with self._lock:
94
95
  return list(self._buffer)
95
96
 
@@ -105,7 +106,7 @@ class MemoryBuffer(BufferBackend):
105
106
  with self._lock:
106
107
  self._max_size = max_size
107
108
 
108
- def drain(self, batch_size: int = 5000) -> Tuple[List[Dict[str, Any]], int]:
109
+ def drain(self, batch_size: int = 5000) -> tuple[list[dict[str, Any]], int]:
109
110
  with self._lock:
110
111
  batch = self._buffer[:batch_size]
111
112
  self._buffer = self._buffer[batch_size:]
@@ -129,10 +130,10 @@ class SqliteBuffer(BufferBackend):
129
130
 
130
131
  def __init__(
131
132
  self,
132
- path: Optional[str] = None,
133
- max_size: Optional[int] = 100_000,
134
- max_bytes: Optional[int] = None,
135
- on_overflow: Optional[Callable[[int], None]] = None,
133
+ path: str | None = None,
134
+ max_size: int | None = 100_000,
135
+ max_bytes: int | None = None,
136
+ on_overflow: Callable[[int], None] | None = None,
136
137
  ):
137
138
  self._max_size = max_size
138
139
  self._max_bytes = max_bytes
@@ -159,7 +160,7 @@ class SqliteBuffer(BufferBackend):
159
160
  )
160
161
  self._conn.commit()
161
162
 
162
- def add(self, points: List[Dict[str, Any]]) -> None:
163
+ def add(self, points: list[dict[str, Any]]) -> None:
163
164
  if not points:
164
165
  return
165
166
  with self._lock:
@@ -178,7 +179,7 @@ class SqliteBuffer(BufferBackend):
178
179
  self._conn.commit()
179
180
  self._evict()
180
181
 
181
- def get_all(self) -> List[Dict[str, Any]]:
182
+ def get_all(self) -> list[dict[str, Any]]:
182
183
  with self._lock:
183
184
  cursor = self._conn.execute("SELECT data FROM points ORDER BY id")
184
185
  return [json.loads(row[0]) for row in cursor.fetchall()]
@@ -197,7 +198,7 @@ class SqliteBuffer(BufferBackend):
197
198
  with self._lock:
198
199
  self._max_size = max_size
199
200
 
200
- def drain(self, batch_size: int = 5000) -> Tuple[List[Dict[str, Any]], int]:
201
+ def drain(self, batch_size: int = 5000) -> tuple[list[dict[str, Any]], int]:
201
202
  """Remove and return the oldest batch_size points atomically.
202
203
 
203
204
  Uses a single transaction: SELECT then DELETE by rowid. If the process
@@ -1,7 +1,7 @@
1
1
  from plexus.cameras.thermal import (
2
- NoCameraFound,
3
2
  MLX90640Camera,
4
3
  MLX90641Camera,
4
+ NoCameraFound,
5
5
  SimulatedThermalCamera,
6
6
  ThermalCamera,
7
7
  ThermalFrame,
@@ -12,9 +12,9 @@ from plexus.cameras.thermal import (
12
12
  )
13
13
 
14
14
  __all__ = [
15
- "NoCameraFound",
16
15
  "MLX90640Camera",
17
16
  "MLX90641Camera",
17
+ "NoCameraFound",
18
18
  "SimulatedThermalCamera",
19
19
  "ThermalCamera",
20
20
  "ThermalFrame",
plexus/cameras/thermal.py CHANGED
@@ -23,7 +23,7 @@ import base64
23
23
  import time
24
24
  from abc import ABC, abstractmethod
25
25
  from dataclasses import dataclass
26
- from typing import Any, Dict, Optional
26
+ from typing import Any
27
27
 
28
28
  import cv2
29
29
  import numpy as np
@@ -89,17 +89,17 @@ class ThermalFrame:
89
89
  sensor_height: int # native sensor height
90
90
  temp_min: float
91
91
  temp_max: float
92
- temps: Optional[np.ndarray] # native res; None when sensor > threshold
92
+ temps: np.ndarray | None # native res; None when sensor > threshold
93
93
  timestamp_ms: int
94
94
 
95
95
  def to_message(
96
- self, camera_id: str, source_id: Optional[str] = None, quality: int = 85
97
- ) -> Dict[str, Any]:
96
+ self, camera_id: str, source_id: str | None = None, quality: int = 85
97
+ ) -> dict[str, Any]:
98
98
  """Build the gateway `video_frame` wire message for this frame."""
99
99
  _, buf = cv2.imencode(".jpg", self.image, [cv2.IMWRITE_JPEG_QUALITY, quality])
100
100
  b64 = base64.b64encode(buf.tobytes()).decode("ascii")
101
101
 
102
- msg: Dict[str, Any] = {
102
+ msg: dict[str, Any] = {
103
103
  "type": "video_frame",
104
104
  "camera_id": camera_id,
105
105
  "frame": b64,
@@ -133,7 +133,7 @@ def _upscale_size(sw: int, sh: int) -> tuple[int, int]:
133
133
 
134
134
 
135
135
  def build_thermal_frame(
136
- temps: np.ndarray, timestamp_ms: Optional[int] = None
136
+ temps: np.ndarray, timestamp_ms: int | None = None
137
137
  ) -> ThermalFrame:
138
138
  """Colorize a temperature array into a ThermalFrame.
139
139
 
plexus/cli.py CHANGED
@@ -2,7 +2,7 @@
2
2
  Plexus CLI — `plexus init` style auth, plus a few sibling commands.
3
3
 
4
4
  Designed to feel like fly.io / vercel CLIs:
5
- $ pip install plexus
5
+ $ pip install plexus-python
6
6
  $ plexus init
7
7
  Opening browser to https://app.plexus.company/auth/cli...
8
8
  ✓ Saved API key as cli-<host>. You're set up.
@@ -29,11 +29,9 @@ import sys
29
29
  import threading
30
30
  import urllib.parse
31
31
  import webbrowser
32
- from typing import Optional
33
32
 
34
33
  from . import config
35
34
 
36
-
37
35
  DEFAULT_TIMEOUT_SECONDS = 300
38
36
  SUCCESS_REDIRECT_SECONDS = 10
39
37
  SUCCESS_HTML_TEMPLATE = """<!doctype html>
@@ -187,7 +185,7 @@ def _success_html(target: str) -> bytes:
187
185
  target_js=repr(target),
188
186
  ).encode("utf-8")
189
187
 
190
- ERROR_HTML = """<!doctype html>
188
+ ERROR_HTML = b"""<!doctype html>
191
189
  <html lang="en">
192
190
  <head>
193
191
  <meta charset="utf-8" />
@@ -272,13 +270,13 @@ ERROR_HTML = """<!doctype html>
272
270
  </div>
273
271
  </div>
274
272
  </body>
275
- </html>""".encode("utf-8")
273
+ </html>"""
276
274
 
277
275
 
278
276
  class _CallbackResult:
279
- key: Optional[str] = None
280
- state: Optional[str] = None
281
- error: Optional[str] = None
277
+ key: str | None = None
278
+ state: str | None = None
279
+ error: str | None = None
282
280
 
283
281
 
284
282
  def _pick_free_port() -> int:
@@ -476,7 +474,7 @@ def build_parser() -> argparse.ArgumentParser:
476
474
  return parser
477
475
 
478
476
 
479
- def main(argv: Optional[list] = None) -> int:
477
+ def main(argv: list | None = None) -> int:
480
478
  parser = build_parser()
481
479
  args = parser.parse_args(argv)
482
480
  return args.func(args)
plexus/client.py CHANGED
@@ -24,12 +24,6 @@ Usage:
24
24
  ("pressure", 1013.25),
25
25
  ])
26
26
 
27
- # Run recording
28
- with px.run("motor-test-001"):
29
- while True:
30
- px.send("temperature", read_temp())
31
- time.sleep(0.01)
32
-
33
27
  Note: Requires authentication. Run 'plexus init' or set PLEXUS_API_KEY.
34
28
  """
35
29
 
@@ -44,8 +38,8 @@ import threading
44
38
  import time
45
39
  import urllib.error
46
40
  import urllib.request
47
- from contextlib import contextmanager
48
- from typing import Any, Dict, Generator, List, Optional, Tuple, Union
41
+ from collections.abc import Generator
42
+ from typing import Any, Union
49
43
 
50
44
  from plexus._log import _say
51
45
  from plexus.buffer import BufferBackend, MemoryBuffer, SqliteBuffer
@@ -71,9 +65,9 @@ class _Response:
71
65
 
72
66
  class _Session:
73
67
  def __init__(self):
74
- self.headers: Dict[str, str] = {}
68
+ self.headers: dict[str, str] = {}
75
69
 
76
- def post(self, url: str, data: bytes = b"", headers: Optional[Dict[str, str]] = None, timeout: float = 10.0) -> "_Response":
70
+ def post(self, url: str, data: bytes = b"", headers: dict[str, str] | None = None, timeout: float = 10.0) -> "_Response":
77
71
  req_headers = {**self.headers, **(headers or {})}
78
72
  req = urllib.request.Request(url, data=data, headers=req_headers, method="POST")
79
73
  try:
@@ -85,7 +79,7 @@ class _Session:
85
79
  if isinstance(e.reason, socket.timeout):
86
80
  raise _Timeout(str(e.reason))
87
81
  raise _ConnError(str(e.reason))
88
- except (TimeoutError, socket.timeout) as e:
82
+ except TimeoutError as e:
89
83
  raise _Timeout(str(e))
90
84
 
91
85
  def close(self) -> None:
@@ -101,7 +95,7 @@ class _ConnError(OSError):
101
95
 
102
96
 
103
97
  # Flexible value type - supports any JSON-serializable value
104
- FlexValue = Union[int, float, str, bool, Dict[str, Any], List[Any]]
98
+ FlexValue = Union[int, float, str, bool, dict[str, Any], list[Any]]
105
99
 
106
100
  _JPEG_SOI = b"\xff\xd8"
107
101
  _JPEG_EOI = b"\xff\xd9"
@@ -137,27 +131,72 @@ def read_mjpeg_frames(pipe, chunk: int = 65536) -> Generator[bytes, None, None]:
137
131
  class PlexusError(Exception):
138
132
  """Base exception for Plexus errors."""
139
133
 
140
- pass
141
134
 
142
135
 
143
136
  class AuthenticationError(PlexusError):
144
137
  """Raised when API key is missing or invalid."""
145
138
 
146
- pass
147
139
 
148
140
 
149
- _SOURCE_ID_RE = re.compile(r'^[a-z0-9][a-z0-9_-]{1,62}$')
141
+ # The wire slug rule (gateway validate.go sourceIDPattern, max length =
142
+ # MaxStringLen 256) — the old stricter local regex rejected dots, 1-char and
143
+ # >63-char slugs that the gateway accepts. Uuid-shaped slugs are additionally
144
+ # rejected (TypeScript SDK parity): the Plexus app resolves uuid-shaped refs
145
+ # as internal ids, which would make such a source unreachable.
146
+ _SOURCE_ID_RE = re.compile(r'^[a-z0-9][a-z0-9._-]*$')
147
+ _SOURCE_ID_UUID_RE = re.compile(
148
+ r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
149
+ )
150
+ _SOURCE_ID_MAX_LEN = 256
150
151
 
151
152
 
152
153
  def _validate_source_id(source_id: str) -> None:
153
- if not _SOURCE_ID_RE.match(source_id):
154
+ if (
155
+ not source_id
156
+ or len(source_id) > _SOURCE_ID_MAX_LEN
157
+ or not _SOURCE_ID_RE.match(source_id)
158
+ or _SOURCE_ID_UUID_RE.match(source_id)
159
+ ):
154
160
  raise ValueError(
155
161
  f"Invalid source_id {source_id!r}. "
156
- "Must match ^[a-z0-9][a-z0-9_-]{1,62}$ "
157
- "(lowercase letters, digits, hyphens, underscores; start with letter or digit)."
162
+ "Must match ^[a-z0-9][a-z0-9._-]*$ (max 256 chars; lowercase "
163
+ "letters, digits, dots, hyphens, underscores; start with a letter "
164
+ "or digit) and must not look like a UUID."
158
165
  )
159
166
 
160
167
 
168
+ # Allowance for the {"type": "telemetry", "points": [...]} envelope when
169
+ # estimating a WS frame's serialized size from the sum of its points.
170
+ _WS_ENVELOPE_BYTES = 64
171
+
172
+
173
+ def _split_ws_frames(
174
+ points: list[dict[str, Any]], byte_budget: int
175
+ ) -> list[list[dict[str, Any]]]:
176
+ """Split points into telemetry-frame chunks under byte_budget serialized.
177
+
178
+ The gateway enforces a 1MB read limit per WebSocket message and rejects
179
+ oversized frames server-side — after the local socket write has already
180
+ "succeeded". Frame size is estimated as the sum of each point's JSON
181
+ serialization plus a small envelope allowance. A single point larger than
182
+ the budget still goes out alone (nothing more can be done client-side).
183
+ """
184
+ chunks: list[list[dict[str, Any]]] = []
185
+ cur: list[dict[str, Any]] = []
186
+ cur_bytes = _WS_ENVELOPE_BYTES
187
+ for p in points:
188
+ p_bytes = len(json.dumps(p).encode("utf-8")) + 1 # +1 for the comma
189
+ if cur and cur_bytes + p_bytes > byte_budget:
190
+ chunks.append(cur)
191
+ cur = []
192
+ cur_bytes = _WS_ENVELOPE_BYTES
193
+ cur.append(p)
194
+ cur_bytes += p_bytes
195
+ if cur:
196
+ chunks.append(cur)
197
+ return chunks
198
+
199
+
161
200
  class Plexus:
162
201
  """
163
202
  Client for sending sensor data to Plexus.
@@ -177,15 +216,15 @@ class Plexus:
177
216
 
178
217
  def __init__(
179
218
  self,
180
- api_key: Optional[str] = None,
181
- endpoint: Optional[str] = None,
182
- source_id: Optional[str] = None,
219
+ api_key: str | None = None,
220
+ endpoint: str | None = None,
221
+ source_id: str | None = None,
183
222
  timeout: float = 10.0,
184
- retry_config: Optional[RetryConfig] = None,
223
+ retry_config: RetryConfig | None = None,
185
224
  max_buffer_size: int = 10000,
186
225
  persistent_buffer: bool = True,
187
- buffer_path: Optional[str] = None,
188
- ws_url: Optional[str] = None,
226
+ buffer_path: str | None = None,
227
+ ws_url: str | None = None,
189
228
  ):
190
229
  self.api_key = api_key or get_api_key()
191
230
  if not self.api_key:
@@ -202,15 +241,14 @@ class Plexus:
202
241
  self.retry_config = retry_config or RetryConfig()
203
242
  self._max_buffer_size = max_buffer_size
204
243
 
205
- self._run_id: Optional[str] = None
206
- self._session: Optional[_Session] = None
207
- self._store_frames: bool = False
244
+ self._session: _Session | None = None
208
245
  self._cv2 = None
209
246
  self._pil_image = None # lazy PIL.Image import
210
247
  self._fit_warned: bool = False
211
248
 
212
249
  self._ws_url = (ws_url or get_gateway_ws_url())
213
250
  self._ws = None # lazily constructed in _ensure_ws()
251
+ self._ws_auth_waited = False # first-send auth wait paid at most once
214
252
  self._clock_offset_ms: int = 0
215
253
 
216
254
  # Pluggable buffer backend for failed sends
@@ -250,7 +288,7 @@ class Plexus:
250
288
  self._session.headers["User-Agent"] = f"plexus-python/{__version__}"
251
289
  return self._session
252
290
 
253
- def _normalize_ts_ms(self, timestamp: Optional[float] = None) -> int:
291
+ def _normalize_ts_ms(self, timestamp: float | None = None) -> int:
254
292
  """Normalize a timestamp to milliseconds.
255
293
 
256
294
  Accepts:
@@ -265,14 +303,24 @@ class Plexus:
265
303
  return int(timestamp * 1000)
266
304
  return int(timestamp)
267
305
 
306
+ @staticmethod
307
+ def _infer_class(value: FlexValue) -> str:
308
+ """Numbers are metrics; everything else (str/bool/dict/list) is an event.
309
+
310
+ Mirrors the gateway (ingest.go inferClass) and the TypeScript SDK
311
+ (wire.ts inferClass). bool is a subclass of int in Python, so it must
312
+ be excluded explicitly or True/False would wrongly become metrics.
313
+ """
314
+ return "metric" if isinstance(value, (int, float)) and not isinstance(value, bool) else "event"
315
+
268
316
  def _make_point(
269
317
  self,
270
318
  metric: str,
271
319
  value: FlexValue,
272
- timestamp: Optional[float] = None,
273
- tags: Optional[Dict[str, str]] = None,
274
- data_class: str = "metric",
275
- ) -> Dict[str, Any]:
320
+ timestamp: float | None = None,
321
+ tags: dict[str, str] | None = None,
322
+ data_class: str | None = None,
323
+ ) -> dict[str, Any]:
276
324
  """Create a data point dictionary.
277
325
 
278
326
  Value can be:
@@ -281,26 +329,42 @@ class Plexus:
281
329
  - bool: Binary flags, enabled/disabled states
282
330
  - dict: Complex objects, vectors, nested data
283
331
  - list: Arrays, coordinates, multi-value readings
332
+
333
+ When `data_class` is not given it is inferred from the value type.
334
+ The gateway rejects a non-numeric value on class="metric" and drops
335
+ the whole frame (taking buffered points with it), so inferring here —
336
+ and raising early on an explicit metric with a bad value — is what
337
+ makes the advertised flexible values actually work.
284
338
  """
339
+ cls = data_class if data_class is not None else self._infer_class(value)
340
+ if cls == "metric" and (
341
+ not isinstance(value, (int, float))
342
+ or isinstance(value, bool)
343
+ or value != value # NaN
344
+ or value in (float("inf"), float("-inf"))
345
+ ):
346
+ raise PlexusError(
347
+ f'Metric "{metric}" requires a finite number value, got '
348
+ f'{type(value).__name__}. Pass data_class="event" for '
349
+ f"non-numeric values, or use px.event()."
350
+ )
285
351
  point = {
286
- "class": data_class,
352
+ "class": cls,
287
353
  "metric": metric,
288
354
  "value": value,
289
355
  "timestamp": self._normalize_ts_ms(timestamp),
290
356
  }
291
357
  if tags:
292
358
  point["tags"] = tags
293
- if self._run_id:
294
- point["run_id"] = self._run_id
295
359
  return point
296
360
 
297
361
  def send(
298
362
  self,
299
363
  metric: str,
300
364
  value: FlexValue,
301
- timestamp: Optional[float] = None,
302
- tags: Optional[Dict[str, str]] = None,
303
- data_class: str = "metric",
365
+ timestamp: float | None = None,
366
+ tags: dict[str, str] | None = None,
367
+ data_class: str | None = None,
304
368
  ) -> bool:
305
369
  """
306
370
  Send a single metric value to Plexus.
@@ -315,7 +379,9 @@ class Plexus:
315
379
  - list: px.send("angles", [0.5, 1.2, -0.3])
316
380
  timestamp: Unix timestamp. If not provided, uses current time.
317
381
  tags: Optional key-value tags for the metric
318
- data_class: Pipeline data class - "metric" (default) or "event"
382
+ data_class: Pipeline data class - "metric" or "event". If omitted,
383
+ inferred from the value type (numbers → metric, everything
384
+ else → event).
319
385
 
320
386
  Returns:
321
387
  True if successful
@@ -336,8 +402,8 @@ class Plexus:
336
402
  self,
337
403
  name: str,
338
404
  data: FlexValue,
339
- timestamp: Optional[float] = None,
340
- tags: Optional[Dict[str, str]] = None,
405
+ timestamp: float | None = None,
406
+ tags: dict[str, str] | None = None,
341
407
  ) -> bool:
342
408
  """
343
409
  Send a named event with text or structured data.
@@ -358,9 +424,9 @@ class Plexus:
358
424
 
359
425
  def send_batch(
360
426
  self,
361
- points: List[Union[Tuple[str, FlexValue], Tuple[str, FlexValue, float]]],
362
- timestamp: Optional[float] = None,
363
- tags: Optional[Dict[str, str]] = None,
427
+ points: list[tuple[str, FlexValue] | tuple[str, FlexValue, float]],
428
+ timestamp: float | None = None,
429
+ tags: dict[str, str] | None = None,
364
430
  ) -> bool:
365
431
  """
366
432
  Send multiple metrics at once.
@@ -406,8 +472,8 @@ class Plexus:
406
472
  """Lazily construct and start the WebSocket transport."""
407
473
  if self._ws is not None:
408
474
  return self._ws
409
- from plexus.ws import WebSocketTransport
410
475
  from plexus import __version__
476
+ from plexus.ws import WebSocketTransport
411
477
  self._ws = WebSocketTransport(
412
478
  api_key=self.api_key,
413
479
  source_id=self.source_id,
@@ -424,7 +490,7 @@ class Plexus:
424
490
  def _on_clock_synced(self, offset_ms: int) -> None:
425
491
  self._clock_offset_ms = offset_ms
426
492
 
427
- def _encode_frame(self, frame, quality: int) -> Tuple[bytes, int, int]:
493
+ def _encode_frame(self, frame, quality: int) -> tuple[bytes, int, int]:
428
494
  """Normalize any supported frame type to (jpeg_bytes, width, height).
429
495
 
430
496
  Accepted inputs:
@@ -490,7 +556,7 @@ class Plexus:
490
556
  ) from e
491
557
  return self._pil_image
492
558
 
493
- def _pil_to_jpeg(self, img, quality: int) -> Tuple[bytes, int, int]:
559
+ def _pil_to_jpeg(self, img, quality: int) -> tuple[bytes, int, int]:
494
560
  import io
495
561
  if img.mode not in ("RGB", "L"):
496
562
  img = img.convert("RGB")
@@ -542,7 +608,7 @@ class Plexus:
542
608
  frame,
543
609
  camera_id: str = "camera:0",
544
610
  quality: int = 85,
545
- timestamp: Optional[float] = None,
611
+ timestamp: float | None = None,
546
612
  ) -> bool:
547
613
  """Send a single video frame to Plexus (WebSocket transport only).
548
614
 
@@ -581,7 +647,7 @@ class Plexus:
581
647
  temps,
582
648
  camera_id: str = "thermal:0",
583
649
  quality: int = 85,
584
- timestamp: Optional[float] = None,
650
+ timestamp: float | None = None,
585
651
  ) -> bool:
586
652
  """Send a thermal camera frame to Plexus (WebSocket transport only).
587
653
 
@@ -684,8 +750,9 @@ class Plexus:
684
750
  name: str,
685
751
  handler,
686
752
  *,
687
- description: Optional[str] = None,
688
- params: Optional[List[Dict[str, Any]]] = None,
753
+ description: str | None = None,
754
+ params: list[dict[str, Any]] | None = None,
755
+ concurrency: str = "accept",
689
756
  ) -> None:
690
757
  """Register a command handler (WebSocket transport only).
691
758
 
@@ -695,6 +762,12 @@ class Plexus:
695
762
 
696
763
  Must be called before the first send() so the command is advertised
697
764
  in the auth frame.
765
+
766
+ concurrency: "accept" (default) runs overlapping invocations of the
767
+ same command concurrently; "reject" refuses a new invocation with
768
+ an error result while a previous one is still running. Use
769
+ "reject" for handlers that drive exclusive hardware (e.g. a pump
770
+ init) so a retry or double-click can't start two at once.
698
771
  """
699
772
  ws = self._ensure_ws()
700
773
  if ws.is_authenticated:
@@ -704,50 +777,114 @@ class Plexus:
704
777
  "Call on_command() before the first send().",
705
778
  name,
706
779
  )
707
- ws.register_command(name, handler, description=description, params=params)
780
+ ws.register_command(
781
+ name, handler, description=description, params=params,
782
+ concurrency=concurrency,
783
+ )
784
+
785
+ # Gateway hard limits (gateway gateway_config.go): 10k points per
786
+ # batch/frame and 1MB per WebSocket message. Sending in chunks well under
787
+ # both means a large local backlog can always drain, instead of one giant
788
+ # merged request drawing a non-retryable 400 that would wedge the client
789
+ # at exactly the buffer cap.
790
+ _SEND_CHUNK_POINTS = 5000
791
+ _WS_FRAME_BYTE_BUDGET = 900_000
708
792
 
709
- def _send_points(self, points: List[Dict[str, Any]]) -> bool:
793
+ def _send_points(self, points: list[dict[str, Any]]) -> bool:
710
794
  """Send data points to the gateway with retry and buffering.
711
795
 
712
- Tries WebSocket first; if not yet authenticated or the socket fails,
713
- falls through to HTTP POST so points still land.
796
+ Any locally buffered backlog is drained together with the new points
797
+ in chunks of at most _SEND_CHUNK_POINTS, so a request never exceeds
798
+ the gateway's 10k-points-per-batch limit no matter how large the
799
+ backlog has grown. Each chunk tries WebSocket first and falls through
800
+ to HTTP POST so points still land.
714
801
 
715
802
  Retry behavior (HTTP path):
716
803
  - Retries on: Timeout, ConnectionError, HTTP 429, HTTP 5xx
717
804
  - No retry on: HTTP 401/403 (auth), HTTP 400/422 (bad request)
718
- - After max retries: buffers points locally for next send attempt
805
+ - On failure: the unsent chunk and all not-yet-sent points are
806
+ buffered locally for the next send attempt, then the error is
807
+ raised. Nothing is dropped on the floor.
719
808
  """
720
809
  if not self.api_key:
721
810
  raise AuthenticationError(
722
811
  "No API key configured. Run 'plexus init' or set PLEXUS_API_KEY"
723
812
  )
724
813
 
725
- # Include any previously buffered points
726
- all_points = self._get_buffered_points() + points
727
-
728
- # Preferred path: WebSocket.
729
814
  ws = self._ensure_ws()
730
- # Brief wait on first call so startup races don't dump every point
731
- # into the HTTP fallback path.
732
- if not ws.is_authenticated:
815
+ # Brief wait on the FIRST send only, so startup races don't dump the
816
+ # first points into the HTTP fallback path. Waiting on every send
817
+ # would stall an unauthenticated client ~5s per call; once the wait
818
+ # has been paid — or a reconnect backoff is already pending — sends
819
+ # proceed immediately and use HTTP until the socket authenticates.
820
+ if (
821
+ not ws.is_authenticated
822
+ and not self._ws_auth_waited
823
+ and not ws.reconnect_pending
824
+ ):
825
+ self._ws_auth_waited = True
733
826
  ws.wait_authenticated(timeout=min(self.timeout, 5.0))
734
- if ws.send_points(all_points):
735
- self._clear_buffer()
736
- self._note_send(len(all_points), via="ws")
737
- return True
827
+
828
+ pending_new = list(points)
829
+ while True:
830
+ # Oldest buffered points first, topped up with new points — the
831
+ # common no-backlog case still goes out as a single request.
832
+ batch, _remaining = self._buffer.drain(self._SEND_CHUNK_POINTS)
833
+ if len(batch) < self._SEND_CHUNK_POINTS and pending_new:
834
+ take = self._SEND_CHUNK_POINTS - len(batch)
835
+ batch.extend(pending_new[:take])
836
+ pending_new = pending_new[take:]
837
+ if not batch:
838
+ return True
839
+ try:
840
+ self._send_chunk(ws, batch)
841
+ except Exception:
842
+ # The chunk did not land (drain() already removed it from the
843
+ # buffer) — put it back along with every not-yet-sent point so
844
+ # nothing is lost, then surface the error.
845
+ self._add_to_buffer(batch)
846
+ if pending_new:
847
+ self._add_to_buffer(pending_new)
848
+ if not self._announced_buffering:
849
+ _say(
850
+ f"⏸ Send failed, buffering points locally "
851
+ f"({self.buffer_size()} queued). Will retry on next call."
852
+ )
853
+ self._announced_buffering = True
854
+ raise
855
+
856
+ def _send_chunk(self, ws, points: list[dict[str, Any]]) -> None:
857
+ """Send one bounded chunk (≤ _SEND_CHUNK_POINTS). Raises on failure.
858
+
859
+ WebSocket preferred. A ws.send_points() True only confirms the frame
860
+ reached the local socket — the gateway silently drops frames over its
861
+ 1MB read limit server-side — so chunks are sub-split to stay under
862
+ _WS_FRAME_BYTE_BUDGET before being treated as delivered. If the
863
+ socket fails partway through, the whole chunk falls back to HTTP:
864
+ at-least-once delivery, duplicates preferred over loss.
865
+ """
866
+ if ws is not None and ws.is_authenticated:
867
+ subframes = _split_ws_frames(points, self._WS_FRAME_BYTE_BUDGET)
868
+ if all(ws.send_points(sub) for sub in subframes):
869
+ self._note_send(len(points), via="ws")
870
+ return
738
871
  # Socket unavailable → fall through to HTTP.
739
872
  if not self._announced_http_fallback:
740
873
  _say(
741
874
  f"⚠ WebSocket unavailable, falling back to POST {self.gateway_url}/ingest"
742
875
  )
743
876
  self._announced_http_fallback = True
877
+ self._send_http(points)
878
+ self._note_send(len(points), via="http")
744
879
 
880
+ def _send_http(self, points: list[dict[str, Any]]) -> None:
881
+ """POST one chunk of points to /ingest with retries. Raises on failure."""
745
882
  url = f"{self.gateway_url}/ingest"
746
- last_error: Optional[Exception] = None
883
+ last_error: Exception | None = None
747
884
 
748
885
  for attempt in range(self.retry_config.max_retries + 1):
749
886
  try:
750
- payload = json.dumps({"source_id": self.source_id, "points": all_points})
887
+ payload = json.dumps({"source_id": self.source_id, "points": points})
751
888
  payload_bytes = payload.encode("utf-8")
752
889
 
753
890
  # Gzip compress payloads > 1KB for bandwidth efficiency
@@ -798,11 +935,9 @@ class Plexus:
798
935
  continue
799
936
  break
800
937
 
801
- # Success - clear the buffer and return
938
+ # Success
802
939
  elif response.status_code < 400:
803
- self._clear_buffer()
804
- self._note_send(len(all_points), via="http")
805
- return True
940
+ return
806
941
 
807
942
  # Other 4xx errors - don't retry
808
943
  else:
@@ -824,15 +959,6 @@ class Plexus:
824
959
  continue
825
960
  break
826
961
 
827
- # All retries failed - buffer the points for later
828
- self._add_to_buffer(points)
829
- if not self._announced_buffering:
830
- _say(
831
- f"⏸ Send failed, buffering points locally ({self.buffer_size()} queued). "
832
- f"Will retry on next call."
833
- )
834
- self._announced_buffering = True
835
-
836
962
  if last_error:
837
963
  raise last_error
838
964
  raise PlexusError("Send failed after all retries")
@@ -855,11 +981,11 @@ class Plexus:
855
981
  _say("✓ Sending again (drained the local buffer).")
856
982
  self._announced_buffering = False
857
983
 
858
- def _add_to_buffer(self, points: List[Dict[str, Any]]) -> None:
984
+ def _add_to_buffer(self, points: list[dict[str, Any]]) -> None:
859
985
  """Add points to the local buffer for later retry."""
860
986
  self._buffer.add(points)
861
987
 
862
- def _get_buffered_points(self) -> List[Dict[str, Any]]:
988
+ def _get_buffered_points(self) -> list[dict[str, Any]]:
863
989
  """Get a copy of buffered points without clearing."""
864
990
  return self._buffer.get_all()
865
991
 
@@ -890,65 +1016,6 @@ class Plexus:
890
1016
  # Send with empty new points list - will include buffered points
891
1017
  return self._send_points([])
892
1018
 
893
- @contextmanager
894
- def run(self, run_id: str, tags: Optional[Dict[str, str]] = None, store_frames: bool = False):
895
- """
896
- Context manager for recording a run.
897
-
898
- All sends within this context will be tagged with the run ID,
899
- making it easy to replay and analyze later.
900
-
901
- Args:
902
- run_id: Unique identifier for this run (e.g., "motor-test-001")
903
- tags: Optional tags to apply to all points in this run
904
- store_frames: If True, camera frames are uploaded to the Plexus API
905
- for persistent storage alongside the live WebSocket stream.
906
-
907
- Example:
908
- with px.run("motor-test-001", store_frames=True):
909
- while True:
910
- px.send("temperature", read_temp())
911
- time.sleep(0.01)
912
- """
913
- self._run_id = run_id
914
- self._store_frames = store_frames
915
-
916
- # Notify API that run started
917
- try:
918
- self._get_session().post(
919
- f"{self.endpoint}/api/runs",
920
- data=json.dumps({
921
- "run_id": run_id,
922
- "source_id": self.source_id,
923
- "status": "started",
924
- "tags": tags,
925
- "timestamp": (int(time.time() * 1000) + self._clock_offset_ms) / 1000,
926
- }).encode("utf-8"),
927
- timeout=self.timeout,
928
- )
929
- except Exception as e:
930
- logger.debug(f"Run start notification failed: {e}")
931
-
932
- try:
933
- yield
934
- finally:
935
- # Notify API that run ended
936
- try:
937
- self._get_session().post(
938
- f"{self.endpoint}/api/runs",
939
- data=json.dumps({
940
- "run_id": run_id,
941
- "source_id": self.source_id,
942
- "status": "ended",
943
- "timestamp": (int(time.time() * 1000) + self._clock_offset_ms) / 1000,
944
- }).encode("utf-8"),
945
- timeout=self.timeout,
946
- )
947
- except Exception as e:
948
- logger.debug(f"Run end notification failed: {e}")
949
- self._run_id = None
950
- self._store_frames = False
951
-
952
1019
  def close(self):
953
1020
  """Close the client, flush any buffered points, and release resources."""
954
1021
  if self.buffer_size() > 0:
plexus/config.py CHANGED
@@ -9,7 +9,6 @@ import os
9
9
  import random
10
10
  from dataclasses import dataclass
11
11
  from pathlib import Path
12
- from typing import Optional
13
12
 
14
13
 
15
14
  @dataclass
@@ -49,8 +48,8 @@ CONFIG_DIR = Path.home() / ".plexus"
49
48
  CONFIG_FILE = CONFIG_DIR / "config.json"
50
49
 
51
50
  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"
51
+ PLEXUS_GATEWAY_URL = "https://gateway.plexus.company"
52
+ PLEXUS_GATEWAY_WS_URL = "wss://gateway.plexus.company"
54
53
 
55
54
  DEFAULT_CONFIG = {
56
55
  "api_key": None,
@@ -73,7 +72,7 @@ def load_config() -> dict:
73
72
  config = json.load(f)
74
73
  # Merge with defaults to handle missing keys
75
74
  return {**DEFAULT_CONFIG, **config}
76
- except (json.JSONDecodeError, IOError):
75
+ except (OSError, json.JSONDecodeError):
77
76
  return DEFAULT_CONFIG.copy()
78
77
 
79
78
 
@@ -90,7 +89,7 @@ def save_config(config: dict) -> None:
90
89
  os.chmod(CONFIG_FILE, 0o600)
91
90
 
92
91
 
93
- def get_api_key() -> Optional[str]:
92
+ def get_api_key() -> str | None:
94
93
  """Get API key from config or environment variable."""
95
94
  # Environment variable takes precedence
96
95
  env_key = os.environ.get("PLEXUS_API_KEY")
@@ -131,7 +130,7 @@ def get_gateway_ws_url() -> str:
131
130
  return (config.get("gateway_ws_url") or PLEXUS_GATEWAY_WS_URL).rstrip("/")
132
131
 
133
132
 
134
- def get_source_id() -> Optional[str]:
133
+ def get_source_id() -> str | None:
135
134
  """Get the source ID, generating one if not set."""
136
135
  config = load_config()
137
136
  source_id = config.get("source_id")
plexus/ws.py CHANGED
@@ -5,14 +5,13 @@ Wire-compatible with the C SDK (`plexus_ws.c`). Targets the gateway's
5
5
  `/ws/device` endpoint and exchanges the same JSON frames:
6
6
 
7
7
  client → {"type": "device_auth", "api_key": ..., "source_id": ...,
8
- "install_id": ..., "platform": "python-sdk",
9
- "agent_version": ..., "commands": [...]}
10
- server → {"type": "authenticated", "source_id": ...}
11
-
12
- The server-returned `source_id` in the `authenticated` frame is
13
- authoritative: if the gateway auto-suffixed on a collision (e.g. the
14
- desired name was already claimed by a different install_id), the
15
- client's `source_id` is updated in place to match.
8
+ "platform": "python-sdk", "agent_version": ...,
9
+ "commands": [...]}
10
+ server → {"type": "authenticated", "source_id": ..., "server_time_ms": ...}
11
+
12
+ The gateway echoes the declared `source_id` back unchanged; the client
13
+ uses the `authenticated` frame only for the `server_time_ms` clock sync.
14
+ (install_id and server-side source_id auto-suffixing were removed in 0.7.1.)
16
15
  client → {"type": "telemetry", "points": [...]}
17
16
  client → {"type": "heartbeat", "source_id": ..., "agent_version": ...} # every 30s
18
17
  server → {"type": "typed_command", "id": ..., "command": ..., "params": {...}}
@@ -33,8 +32,9 @@ import random
33
32
  import struct
34
33
  import threading
35
34
  import time
35
+ from collections.abc import Callable
36
36
  from dataclasses import dataclass, field
37
- from typing import Any, Callable, Dict, List, Optional
37
+ from typing import Any
38
38
 
39
39
  try:
40
40
  import websocket # websocket-client
@@ -53,18 +53,23 @@ HEARTBEAT_INTERVAL_S = 30.0
53
53
  BACKOFF_BASE_S = 1.0
54
54
  BACKOFF_MAX_S = 60.0
55
55
 
56
- CommandHandler = Callable[[str, Dict[str, Any]], Optional[Dict[str, Any]]]
56
+ CommandHandler = Callable[[str, dict[str, Any]], dict[str, Any] | None]
57
57
 
58
58
 
59
59
  @dataclass
60
60
  class _RegisteredCommand:
61
61
  name: str
62
62
  handler: CommandHandler
63
- description: Optional[str] = None
64
- params: List[Dict[str, Any]] = field(default_factory=list)
65
-
66
- def to_manifest(self) -> Dict[str, Any]:
67
- m: Dict[str, Any] = {"name": self.name}
63
+ description: str | None = None
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)
70
+
71
+ def to_manifest(self) -> dict[str, Any]:
72
+ m: dict[str, Any] = {"name": self.name}
68
73
  if self.description:
69
74
  m["description"] = self.description
70
75
  if self.params:
@@ -92,7 +97,7 @@ class WebSocketTransport:
92
97
  agent_version: str = "0.0.0",
93
98
  platform: str = "python-sdk",
94
99
  auto_reconnect: bool = True,
95
- on_clock_synced: Optional[Callable[[int], None]] = None,
100
+ on_clock_synced: Callable[[int], None] | None = None,
96
101
  ):
97
102
  if not api_key:
98
103
  raise ValueError("api_key required")
@@ -107,16 +112,16 @@ class WebSocketTransport:
107
112
  self.auto_reconnect = auto_reconnect
108
113
  self._on_clock_synced = on_clock_synced
109
114
 
110
- self._commands: Dict[str, _RegisteredCommand] = {}
111
- self._ws: Optional[websocket.WebSocket] = None
115
+ self._commands: dict[str, _RegisteredCommand] = {}
116
+ self._ws: websocket.WebSocket | None = None
112
117
  self._ws_lock = threading.Lock()
113
118
  self._authenticated = threading.Event()
114
119
  self._stop = threading.Event()
115
- self._thread: Optional[threading.Thread] = None
120
+ self._thread: threading.Thread | None = None
116
121
  self._backoff_attempt = 0
117
122
  self._clock_offset_ms: int = 0
118
- self._video_queue: "queue.Queue[bytes]" = queue.Queue(maxsize=2)
119
- self._video_thread: Optional[threading.Thread] = None
123
+ self._video_queue: queue.Queue[bytes] = queue.Queue(maxsize=2)
124
+ self._video_thread: threading.Thread | None = None
120
125
 
121
126
  # ------------------------------------------------------------------ public
122
127
 
@@ -125,13 +130,26 @@ class WebSocketTransport:
125
130
  name: str,
126
131
  handler: CommandHandler,
127
132
  *,
128
- description: Optional[str] = None,
129
- params: Optional[List[Dict[str, Any]]] = None,
133
+ description: str | None = None,
134
+ params: list[dict[str, Any]] | None = 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:
@@ -169,11 +187,18 @@ class WebSocketTransport:
169
187
  def is_authenticated(self) -> bool:
170
188
  return self._authenticated.is_set()
171
189
 
190
+ @property
191
+ def reconnect_pending(self) -> bool:
192
+ """True after a failed connect while the transport is backing off /
193
+ retrying. Callers should not block waiting for auth in this state —
194
+ no connection attempt may even be in flight."""
195
+ return self._backoff_attempt > 0 and not self._authenticated.is_set()
196
+
172
197
  @property
173
198
  def clock_offset_ms(self) -> int:
174
199
  return self._clock_offset_ms
175
200
 
176
- def send_points(self, points: List[Dict[str, Any]]) -> bool:
201
+ def send_points(self, points: list[dict[str, Any]]) -> bool:
177
202
  """Send a telemetry frame. Returns False if the socket is not
178
203
  authenticated — caller is expected to fall back to HTTP."""
179
204
  if not points:
@@ -205,7 +230,7 @@ class WebSocketTransport:
205
230
  except queue.Full:
206
231
  return False
207
232
 
208
- def send_json_video_frame(self, msg: Dict[str, Any]) -> bool:
233
+ def send_json_video_frame(self, msg: dict[str, Any]) -> bool:
209
234
  """Send a JSON video_frame message. Used for frames that carry extra
210
235
  metadata (e.g. thermal cameras) that the binary format cannot express."""
211
236
  if not self._authenticated.is_set():
@@ -339,7 +364,7 @@ class WebSocketTransport:
339
364
  continue
340
365
  self._dispatch(_safe_json(raw))
341
366
 
342
- def _dispatch(self, msg: Dict[str, Any]) -> None:
367
+ def _dispatch(self, msg: dict[str, Any]) -> None:
343
368
  mtype = msg.get("type")
344
369
  if mtype == "typed_command":
345
370
  self._handle_command(msg)
@@ -347,7 +372,7 @@ class WebSocketTransport:
347
372
  logger.warning("plexus ws server error: %s", msg.get("detail") or msg)
348
373
  # ignore unknown types — forward-compat
349
374
 
350
- def _handle_command(self, msg: Dict[str, Any]) -> None:
375
+ def _handle_command(self, msg: dict[str, Any]) -> None:
351
376
  cmd_id = msg.get("id") or ""
352
377
  command = msg.get("command") or ""
353
378
  params = msg.get("params") or {}
@@ -371,20 +396,50 @@ class WebSocketTransport:
371
396
  })
372
397
  return
373
398
 
399
+ # concurrency="reject": if an invocation is already running, refuse
400
+ # this one immediately rather than starting a second in parallel.
401
+ holds_lock = False
402
+ if reg.concurrency == "reject":
403
+ if not reg._lock.acquire(blocking=False):
404
+ self._send_frame({
405
+ "type": "command_result",
406
+ "id": cmd_id,
407
+ "command": command,
408
+ "event": "error",
409
+ "error": f"command already in progress: {command}",
410
+ })
411
+ return
412
+ holds_lock = True
413
+
374
414
  # Run the handler off the read-loop thread so a slow handler doesn't
375
415
  # 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()
416
+ try:
417
+ threading.Thread(
418
+ target=self._run_handler,
419
+ args=(reg, cmd_id, command, params, holds_lock),
420
+ daemon=True,
421
+ ).start()
422
+ except Exception as e:
423
+ # Thread creation failed (e.g. resource exhaustion). Release the
424
+ # concurrency lock so the command isn't wedged as "in progress",
425
+ # and report the failure rather than leaving it silently un-acked.
426
+ if holds_lock:
427
+ reg._lock.release()
428
+ self._send_frame({
429
+ "type": "command_result",
430
+ "id": cmd_id,
431
+ "command": command,
432
+ "event": "error",
433
+ "error": f"failed to start command handler: {e}",
434
+ })
381
435
 
382
436
  def _run_handler(
383
437
  self,
384
438
  reg: _RegisteredCommand,
385
439
  cmd_id: str,
386
440
  command: str,
387
- params: Dict[str, Any],
441
+ params: dict[str, Any],
442
+ holds_lock: bool = False,
388
443
  ) -> None:
389
444
  try:
390
445
  result = reg.handler(command, params)
@@ -397,6 +452,11 @@ class WebSocketTransport:
397
452
  "error": str(e),
398
453
  })
399
454
  return
455
+ finally:
456
+ # Release the concurrency lock (if held) as soon as the handler
457
+ # returns, regardless of success or failure.
458
+ if holds_lock:
459
+ reg._lock.release()
400
460
  self._send_frame({
401
461
  "type": "command_result",
402
462
  "id": cmd_id,
@@ -405,7 +465,7 @@ class WebSocketTransport:
405
465
  "result": result if result is not None else {},
406
466
  })
407
467
 
408
- def _send_frame(self, frame: Dict[str, Any]) -> bool:
468
+ def _send_frame(self, frame: dict[str, Any]) -> bool:
409
469
  with self._ws_lock:
410
470
  ws = self._ws
411
471
  if ws is None:
@@ -459,7 +519,7 @@ def _ensure_device_path(url: str) -> str:
459
519
  return url + "/ws/device"
460
520
 
461
521
 
462
- def _safe_json(raw: Any) -> Dict[str, Any]:
522
+ def _safe_json(raw: Any) -> dict[str, Any]:
463
523
  if isinstance(raw, (bytes, bytearray)):
464
524
  raw = raw.decode("utf-8", errors="replace")
465
525
  if not isinstance(raw, str):
@@ -1,12 +1,12 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: plexus-python
3
- Version: 0.7.1
3
+ Version: 0.9.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
@@ -27,7 +27,7 @@ Requires-Dist: numpy>=1.24; extra == 'dev'
27
27
  Requires-Dist: opencv-python-headless>=4.8; extra == 'dev'
28
28
  Requires-Dist: pytest-cov; extra == 'dev'
29
29
  Requires-Dist: pytest>=9.0.3; extra == 'dev'
30
- Requires-Dist: ruff; extra == 'dev'
30
+ Requires-Dist: ruff==0.15.12; extra == 'dev'
31
31
  Requires-Dist: websockets>=12; extra == 'dev'
32
32
  Provides-Extra: video
33
33
  Requires-Dist: numpy>=1.24; extra == 'video'
@@ -68,7 +68,7 @@ curl -sL https://app.plexus.company/setup | bash -s -- \
68
68
 
69
69
  The name must match `^[a-z0-9][a-z0-9_-]{1,62}$`. `setup.sh` refuses to run without `--name` (or without a TTY to prompt for one) — this is deliberate, because the previous `hostname` fallback silently merged telemetry from cloned SD-card images that all booted as `raspberrypi`.
70
70
 
71
- **If two devices end up requesting the same name**, the gateway auto-suffixes: the first connection gets `drone-01`, the second gets `drone-01_2`, the third `drone-01_3`, and so on. The SDK logs the rename at INFO and persists the assigned name to `~/.plexus/config.json` so the device keeps its identity across reboots. Under the hood, a per-installation UUID (`install_id`, lazily generated on first run) is what lets the gateway tell "same device reconnecting" from "different device claiming the same name."
71
+ **Names are not auto-deduplicated.** The gateway echoes back whatever `source_id` you declare, unchanged pick a unique name per device (that's what `--name` and `source_id=...` are for). Two devices that declare the same name write into the same source.
72
72
 
73
73
  In normal code, you usually just pass `source_id=...` explicitly to `Plexus(...)` and never have to think about it.
74
74
 
@@ -111,7 +111,7 @@ px.send_batch([
111
111
  ])
112
112
  ```
113
113
 
114
- `points` is a list of `(metric, value)` tuples. All points share the same timestamp (now, unless you pass `timestamp=t`). For independent timestamps per point, call `send()` in a loop instead.
114
+ `points` is a list of `(metric, value)` tuples, or `(metric, value, timestamp)` 3-tuples when you need a per-point timestamp. Points without their own timestamp share the batch timestamp (now, unless you pass `timestamp=t`).
115
115
 
116
116
  ### `event(name, data)` — record a discrete occurrence
117
117
 
@@ -125,16 +125,6 @@ px.event("sensor_error", {"sensor": "imu", "code": 42}, tags={"motor": "A"})
125
125
 
126
126
  The platform displays events as markers overlaid on your telemetry charts, not as time-series lines.
127
127
 
128
- ### `run(run_id)` — group data into a named recording
129
-
130
- ```python
131
- with px.run("thermal-cycle-001"):
132
- while running:
133
- px.send("temperature", read_temp())
134
- ```
135
-
136
- All `send()` calls inside the context are tagged with `run_id`, making it easy to isolate and replay that slice of data in the dashboard.
137
-
138
128
  ## Video streaming
139
129
 
140
130
  Two methods depending on whether you control the capture loop or just have a URL.
@@ -228,8 +218,8 @@ px.send("temperature", 72.5, timestamp=t) # your timestamp, used as-is, no cor
228
218
 
229
219
  **Known limits:**
230
220
  - Clock sync refreshes on WebSocket (re)connect. A device with a drifting RTC that stays connected for many days accumulates uncorrected drift between reconnects.
231
- - HTTP-only transport (`transport="http"`) does not receive clock sync — timestamps default to the uncorrected device clock.
232
- - `send_batch()` shares one timestamp across the whole batch. For per-point timestamps, call `send()` in a loop.
221
+ - The HTTP fallback path (used when the WebSocket is unavailable) does not receive clock sync — timestamps default to the uncorrected device clock.
222
+ - `send_batch()` shares one timestamp across the batch by default; pass `(metric, value, timestamp)` 3-tuples for per-point timestamps.
233
223
 
234
224
  ## Transport
235
225
 
@@ -241,13 +231,12 @@ By default the SDK connects over a **WebSocket** to `/ws/device` on the gateway
241
231
  If the socket is unavailable, sends transparently fall back to `POST /ingest` so no data is lost.
242
232
 
243
233
  ```python
244
- # default — ws with http fallback
234
+ # ws with transparent http fallback — this is the only mode
245
235
  px = Plexus()
246
-
247
- # force http (legacy)
248
- px = Plexus(transport="http")
249
236
  ```
250
237
 
238
+ There is no transport selector: the SDK always prefers the WebSocket and falls back to `POST /ingest` on its own when the socket is unavailable.
239
+
251
240
  ### Handling commands
252
241
 
253
242
  Register a handler before the first `send()` so the command is advertised in the auth frame:
@@ -270,8 +259,8 @@ The SDK sends an `ack` frame before invoking the handler, then a `result` frame
270
259
  | Variable | Description | Default |
271
260
  | ----------------------- | ---------------------------- | -------------------------------- |
272
261
  | `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` |
262
+ | `PLEXUS_GATEWAY_URL` | HTTP ingest URL | `https://gateway.plexus.company` |
263
+ | `PLEXUS_GATEWAY_WS_URL` | WebSocket URL | `wss://gateway.plexus.company` |
275
264
 
276
265
  ## Architecture
277
266
 
@@ -0,0 +1,14 @@
1
+ plexus/__init__.py,sha256=bXzZn--_gT7vRM5tvRy36tMkWIPk-9NBHa7iFPArLUw,447
2
+ plexus/_log.py,sha256=3fjXrHFZghQ_17umMcvDUjjTH6aTQB3J4SpVDBiH03w,335
3
+ plexus/buffer.py,sha256=UNv_jEcrDwbkjJ6uhCehb7uBI2EuFEwO40waDpZn_5I,9579
4
+ plexus/cli.py,sha256=yc3j-5kUHhdYJv3b87ch7A41JVu491xb-UYdkRNubhg,14165
5
+ plexus/client.py,sha256=ku8znMnJlO9bMCWYIVwixAx5usVGcUsV0l5tKjyXJkk,39872
6
+ plexus/config.py,sha256=RuDh5UdVGdVQld5kQlXZO6CVXO4tS0HBalyaoAlXNvc,4416
7
+ plexus/ws.py,sha256=YmaxOwtEdYHOsc7E5AjEUlFTJhvn7X1xWqJKQKgHWYo,18942
8
+ plexus/cameras/__init__.py,sha256=AVu1vE1xfYk9lz1cprHlTfi0ieHf84UhQh9Z92E05b8,490
9
+ plexus/cameras/thermal.py,sha256=-klCEJQG5GlLtELaowWOPYomwMsLK8O5pmmX0PqUrTA,12022
10
+ plexus_python-0.9.0.dist-info/METADATA,sha256=WRRIS6gnGMvCuH8PyhxZ1uSNAgzq9zhoGbUnx9li1EQ,11394
11
+ plexus_python-0.9.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
12
+ plexus_python-0.9.0.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
13
+ plexus_python-0.9.0.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
14
+ plexus_python-0.9.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.30.1
2
+ Generator: hatchling 1.32.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,14 +0,0 @@
1
- plexus/__init__.py,sha256=ZzZDGRCztNWGUtN-YF1EwwYzngxsZkU9zb_rW1Tfalg,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=SZ4V9FN-bt3QKqVgp9S5QKdRXw934WXZxKlYUb7bWHw,35810
6
- plexus/config.py,sha256=RNym2Fon6JOCVi1rXPSRWjPFAdT8DSmokY5JPEljQOc,4450
7
- plexus/ws.py,sha256=9DiQchqCQU7O8r8-FuqotJh8vYAQBO7npJn4BFNzLAE,16242
8
- plexus/cameras/__init__.py,sha256=OvnU9KGKxkVtFLlk56H9x-ATa6UvpLI7PANa0HQO2cc,490
9
- plexus/cameras/thermal.py,sha256=7o33QsF1RiZLManTxZ2E36nO8lRAHppCDkS3zXBHCxs,12047
10
- plexus_python-0.7.1.dist-info/METADATA,sha256=OUDqqIIcHUaZms1QCwL7XdaFo6j11LeN0HZ3EipZEIM,11739
11
- plexus_python-0.7.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
12
- plexus_python-0.7.1.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
13
- plexus_python-0.7.1.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
14
- plexus_python-0.7.1.dist-info/RECORD,,