plexus-python 0.8.0__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
13
  __version__ = "0.8.0"
14
- __all__ = ["Plexus", "PlexusError", "AuthenticationError", "RetryConfig", "read_mjpeg_frames"]
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,8 @@ 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,
689
755
  concurrency: str = "accept",
690
756
  ) -> None:
691
757
  """Register a command handler (WebSocket transport only).
@@ -716,48 +782,109 @@ class Plexus:
716
782
  concurrency=concurrency,
717
783
  )
718
784
 
719
- def _send_points(self, points: List[Dict[str, Any]]) -> bool:
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
792
+
793
+ def _send_points(self, points: list[dict[str, Any]]) -> bool:
720
794
  """Send data points to the gateway with retry and buffering.
721
795
 
722
- Tries WebSocket first; if not yet authenticated or the socket fails,
723
- 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.
724
801
 
725
802
  Retry behavior (HTTP path):
726
803
  - Retries on: Timeout, ConnectionError, HTTP 429, HTTP 5xx
727
804
  - No retry on: HTTP 401/403 (auth), HTTP 400/422 (bad request)
728
- - 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.
729
808
  """
730
809
  if not self.api_key:
731
810
  raise AuthenticationError(
732
811
  "No API key configured. Run 'plexus init' or set PLEXUS_API_KEY"
733
812
  )
734
813
 
735
- # Include any previously buffered points
736
- all_points = self._get_buffered_points() + points
737
-
738
- # Preferred path: WebSocket.
739
814
  ws = self._ensure_ws()
740
- # Brief wait on first call so startup races don't dump every point
741
- # into the HTTP fallback path.
742
- 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
743
826
  ws.wait_authenticated(timeout=min(self.timeout, 5.0))
744
- if ws.send_points(all_points):
745
- self._clear_buffer()
746
- self._note_send(len(all_points), via="ws")
747
- 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
748
871
  # Socket unavailable → fall through to HTTP.
749
872
  if not self._announced_http_fallback:
750
873
  _say(
751
874
  f"⚠ WebSocket unavailable, falling back to POST {self.gateway_url}/ingest"
752
875
  )
753
876
  self._announced_http_fallback = True
877
+ self._send_http(points)
878
+ self._note_send(len(points), via="http")
754
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."""
755
882
  url = f"{self.gateway_url}/ingest"
756
- last_error: Optional[Exception] = None
883
+ last_error: Exception | None = None
757
884
 
758
885
  for attempt in range(self.retry_config.max_retries + 1):
759
886
  try:
760
- payload = json.dumps({"source_id": self.source_id, "points": all_points})
887
+ payload = json.dumps({"source_id": self.source_id, "points": points})
761
888
  payload_bytes = payload.encode("utf-8")
762
889
 
763
890
  # Gzip compress payloads > 1KB for bandwidth efficiency
@@ -808,11 +935,9 @@ class Plexus:
808
935
  continue
809
936
  break
810
937
 
811
- # Success - clear the buffer and return
938
+ # Success
812
939
  elif response.status_code < 400:
813
- self._clear_buffer()
814
- self._note_send(len(all_points), via="http")
815
- return True
940
+ return
816
941
 
817
942
  # Other 4xx errors - don't retry
818
943
  else:
@@ -834,15 +959,6 @@ class Plexus:
834
959
  continue
835
960
  break
836
961
 
837
- # All retries failed - buffer the points for later
838
- self._add_to_buffer(points)
839
- if not self._announced_buffering:
840
- _say(
841
- f"⏸ Send failed, buffering points locally ({self.buffer_size()} queued). "
842
- f"Will retry on next call."
843
- )
844
- self._announced_buffering = True
845
-
846
962
  if last_error:
847
963
  raise last_error
848
964
  raise PlexusError("Send failed after all retries")
@@ -865,11 +981,11 @@ class Plexus:
865
981
  _say("✓ Sending again (drained the local buffer).")
866
982
  self._announced_buffering = False
867
983
 
868
- def _add_to_buffer(self, points: List[Dict[str, Any]]) -> None:
984
+ def _add_to_buffer(self, points: list[dict[str, Any]]) -> None:
869
985
  """Add points to the local buffer for later retry."""
870
986
  self._buffer.add(points)
871
987
 
872
- def _get_buffered_points(self) -> List[Dict[str, Any]]:
988
+ def _get_buffered_points(self) -> list[dict[str, Any]]:
873
989
  """Get a copy of buffered points without clearing."""
874
990
  return self._buffer.get_all()
875
991
 
@@ -900,65 +1016,6 @@ class Plexus:
900
1016
  # Send with empty new points list - will include buffered points
901
1017
  return self._send_points([])
902
1018
 
903
- @contextmanager
904
- def run(self, run_id: str, tags: Optional[Dict[str, str]] = None, store_frames: bool = False):
905
- """
906
- Context manager for recording a run.
907
-
908
- All sends within this context will be tagged with the run ID,
909
- making it easy to replay and analyze later.
910
-
911
- Args:
912
- run_id: Unique identifier for this run (e.g., "motor-test-001")
913
- tags: Optional tags to apply to all points in this run
914
- store_frames: If True, camera frames are uploaded to the Plexus API
915
- for persistent storage alongside the live WebSocket stream.
916
-
917
- Example:
918
- with px.run("motor-test-001", store_frames=True):
919
- while True:
920
- px.send("temperature", read_temp())
921
- time.sleep(0.01)
922
- """
923
- self._run_id = run_id
924
- self._store_frames = store_frames
925
-
926
- # Notify API that run started
927
- try:
928
- self._get_session().post(
929
- f"{self.endpoint}/api/runs",
930
- data=json.dumps({
931
- "run_id": run_id,
932
- "source_id": self.source_id,
933
- "status": "started",
934
- "tags": tags,
935
- "timestamp": (int(time.time() * 1000) + self._clock_offset_ms) / 1000,
936
- }).encode("utf-8"),
937
- timeout=self.timeout,
938
- )
939
- except Exception as e:
940
- logger.debug(f"Run start notification failed: {e}")
941
-
942
- try:
943
- yield
944
- finally:
945
- # Notify API that run ended
946
- try:
947
- self._get_session().post(
948
- f"{self.endpoint}/api/runs",
949
- data=json.dumps({
950
- "run_id": run_id,
951
- "source_id": self.source_id,
952
- "status": "ended",
953
- "timestamp": (int(time.time() * 1000) + self._clock_offset_ms) / 1000,
954
- }).encode("utf-8"),
955
- timeout=self.timeout,
956
- )
957
- except Exception as e:
958
- logger.debug(f"Run end notification failed: {e}")
959
- self._run_id = None
960
- self._store_frames = False
961
-
962
1019
  def close(self):
963
1020
  """Close the client, flush any buffered points, and release resources."""
964
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
@@ -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,23 +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)
63
+ description: str | None = None
64
+ params: list[dict[str, Any]] = field(default_factory=list)
65
65
  # "accept" (default): run overlapping invocations concurrently.
66
66
  # "reject": refuse a new invocation with an error result while a
67
67
  # previous one of the same command is still running.
68
68
  concurrency: str = "accept"
69
69
  _lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
70
70
 
71
- def to_manifest(self) -> Dict[str, Any]:
72
- m: Dict[str, Any] = {"name": self.name}
71
+ def to_manifest(self) -> dict[str, Any]:
72
+ m: dict[str, Any] = {"name": self.name}
73
73
  if self.description:
74
74
  m["description"] = self.description
75
75
  if self.params:
@@ -97,7 +97,7 @@ class WebSocketTransport:
97
97
  agent_version: str = "0.0.0",
98
98
  platform: str = "python-sdk",
99
99
  auto_reconnect: bool = True,
100
- on_clock_synced: Optional[Callable[[int], None]] = None,
100
+ on_clock_synced: Callable[[int], None] | None = None,
101
101
  ):
102
102
  if not api_key:
103
103
  raise ValueError("api_key required")
@@ -112,16 +112,16 @@ class WebSocketTransport:
112
112
  self.auto_reconnect = auto_reconnect
113
113
  self._on_clock_synced = on_clock_synced
114
114
 
115
- self._commands: Dict[str, _RegisteredCommand] = {}
116
- self._ws: Optional[websocket.WebSocket] = None
115
+ self._commands: dict[str, _RegisteredCommand] = {}
116
+ self._ws: websocket.WebSocket | None = None
117
117
  self._ws_lock = threading.Lock()
118
118
  self._authenticated = threading.Event()
119
119
  self._stop = threading.Event()
120
- self._thread: Optional[threading.Thread] = None
120
+ self._thread: threading.Thread | None = None
121
121
  self._backoff_attempt = 0
122
122
  self._clock_offset_ms: int = 0
123
- self._video_queue: "queue.Queue[bytes]" = queue.Queue(maxsize=2)
124
- 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
125
125
 
126
126
  # ------------------------------------------------------------------ public
127
127
 
@@ -130,8 +130,8 @@ class WebSocketTransport:
130
130
  name: str,
131
131
  handler: CommandHandler,
132
132
  *,
133
- description: Optional[str] = None,
134
- params: Optional[List[Dict[str, Any]]] = None,
133
+ description: str | None = None,
134
+ params: list[dict[str, Any]] | None = None,
135
135
  concurrency: str = "accept",
136
136
  ) -> None:
137
137
  """Register a command handler. Must be called before start() to be
@@ -187,11 +187,18 @@ class WebSocketTransport:
187
187
  def is_authenticated(self) -> bool:
188
188
  return self._authenticated.is_set()
189
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
+
190
197
  @property
191
198
  def clock_offset_ms(self) -> int:
192
199
  return self._clock_offset_ms
193
200
 
194
- def send_points(self, points: List[Dict[str, Any]]) -> bool:
201
+ def send_points(self, points: list[dict[str, Any]]) -> bool:
195
202
  """Send a telemetry frame. Returns False if the socket is not
196
203
  authenticated — caller is expected to fall back to HTTP."""
197
204
  if not points:
@@ -223,7 +230,7 @@ class WebSocketTransport:
223
230
  except queue.Full:
224
231
  return False
225
232
 
226
- def send_json_video_frame(self, msg: Dict[str, Any]) -> bool:
233
+ def send_json_video_frame(self, msg: dict[str, Any]) -> bool:
227
234
  """Send a JSON video_frame message. Used for frames that carry extra
228
235
  metadata (e.g. thermal cameras) that the binary format cannot express."""
229
236
  if not self._authenticated.is_set():
@@ -357,7 +364,7 @@ class WebSocketTransport:
357
364
  continue
358
365
  self._dispatch(_safe_json(raw))
359
366
 
360
- def _dispatch(self, msg: Dict[str, Any]) -> None:
367
+ def _dispatch(self, msg: dict[str, Any]) -> None:
361
368
  mtype = msg.get("type")
362
369
  if mtype == "typed_command":
363
370
  self._handle_command(msg)
@@ -365,7 +372,7 @@ class WebSocketTransport:
365
372
  logger.warning("plexus ws server error: %s", msg.get("detail") or msg)
366
373
  # ignore unknown types — forward-compat
367
374
 
368
- def _handle_command(self, msg: Dict[str, Any]) -> None:
375
+ def _handle_command(self, msg: dict[str, Any]) -> None:
369
376
  cmd_id = msg.get("id") or ""
370
377
  command = msg.get("command") or ""
371
378
  params = msg.get("params") or {}
@@ -431,7 +438,7 @@ class WebSocketTransport:
431
438
  reg: _RegisteredCommand,
432
439
  cmd_id: str,
433
440
  command: str,
434
- params: Dict[str, Any],
441
+ params: dict[str, Any],
435
442
  holds_lock: bool = False,
436
443
  ) -> None:
437
444
  try:
@@ -458,7 +465,7 @@ class WebSocketTransport:
458
465
  "result": result if result is not None else {},
459
466
  })
460
467
 
461
- def _send_frame(self, frame: Dict[str, Any]) -> bool:
468
+ def _send_frame(self, frame: dict[str, Any]) -> bool:
462
469
  with self._ws_lock:
463
470
  ws = self._ws
464
471
  if ws is None:
@@ -512,7 +519,7 @@ def _ensure_device_path(url: str) -> str:
512
519
  return url + "/ws/device"
513
520
 
514
521
 
515
- def _safe_json(raw: Any) -> Dict[str, Any]:
522
+ def _safe_json(raw: Any) -> dict[str, Any]:
516
523
  if isinstance(raw, (bytes, bytearray)):
517
524
  raw = raw.decode("utf-8", errors="replace")
518
525
  if not isinstance(raw, str):
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: plexus-python
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: Thin Python SDK for Plexus — send telemetry in one line
5
5
  Project-URL: Homepage, https://plexus.company
6
6
  Project-URL: Documentation, https://docs.plexus.company
@@ -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:
@@ -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=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,,