plexus-python 0.6.1__tar.gz → 0.6.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. {plexus_python-0.6.1 → plexus_python-0.6.3}/CHANGELOG.md +47 -0
  2. {plexus_python-0.6.1 → plexus_python-0.6.3}/PKG-INFO +4 -5
  3. plexus_python-0.6.3/TODO.md +1 -0
  4. plexus_python-0.6.3/plexus/__init__.py +14 -0
  5. {plexus_python-0.6.1 → plexus_python-0.6.3}/plexus/_log.py +1 -3
  6. {plexus_python-0.6.1 → plexus_python-0.6.3}/plexus/buffer.py +12 -0
  7. {plexus_python-0.6.1 → plexus_python-0.6.3}/plexus/cameras/thermal.py +27 -48
  8. {plexus_python-0.6.1 → plexus_python-0.6.3}/plexus/client.py +36 -59
  9. {plexus_python-0.6.1 → plexus_python-0.6.3}/plexus/config.py +0 -48
  10. {plexus_python-0.6.1 → plexus_python-0.6.3}/plexus/ws.py +1 -24
  11. {plexus_python-0.6.1 → plexus_python-0.6.3}/pyproject.toml +8 -9
  12. {plexus_python-0.6.1 → plexus_python-0.6.3}/tests/test_ws.py +1 -74
  13. {plexus_python-0.6.1 → plexus_python-0.6.3}/uv.lock +17 -19
  14. plexus_python-0.6.1/plexus/__init__.py +0 -14
  15. {plexus_python-0.6.1 → plexus_python-0.6.3}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  16. {plexus_python-0.6.1 → plexus_python-0.6.3}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  17. {plexus_python-0.6.1 → plexus_python-0.6.3}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  18. {plexus_python-0.6.1 → plexus_python-0.6.3}/.github/workflows/ci.yml +0 -0
  19. {plexus_python-0.6.1 → plexus_python-0.6.3}/.github/workflows/publish.yml +0 -0
  20. {plexus_python-0.6.1 → plexus_python-0.6.3}/.gitignore +0 -0
  21. {plexus_python-0.6.1 → plexus_python-0.6.3}/AGENTS.md +0 -0
  22. {plexus_python-0.6.1 → plexus_python-0.6.3}/API.md +0 -0
  23. {plexus_python-0.6.1 → plexus_python-0.6.3}/CODE_OF_CONDUCT.md +0 -0
  24. {plexus_python-0.6.1 → plexus_python-0.6.3}/CONTRIBUTING.md +0 -0
  25. {plexus_python-0.6.1 → plexus_python-0.6.3}/LICENSE +0 -0
  26. {plexus_python-0.6.1 → plexus_python-0.6.3}/README.md +0 -0
  27. {plexus_python-0.6.1 → plexus_python-0.6.3}/SECURITY.md +0 -0
  28. {plexus_python-0.6.1 → plexus_python-0.6.3}/examples/.python-version +0 -0
  29. {plexus_python-0.6.1 → plexus_python-0.6.3}/examples/README.md +0 -0
  30. {plexus_python-0.6.1 → plexus_python-0.6.3}/examples/basic.py +0 -0
  31. {plexus_python-0.6.1 → plexus_python-0.6.3}/examples/can.py +0 -0
  32. {plexus_python-0.6.1 → plexus_python-0.6.3}/examples/i2c_bme280.py +0 -0
  33. {plexus_python-0.6.1 → plexus_python-0.6.3}/examples/mac_metrics.py +0 -0
  34. {plexus_python-0.6.1 → plexus_python-0.6.3}/examples/mavlink.py +0 -0
  35. {plexus_python-0.6.1 → plexus_python-0.6.3}/examples/mqtt.py +0 -0
  36. {plexus_python-0.6.1 → plexus_python-0.6.3}/examples/pyproject.toml +0 -0
  37. {plexus_python-0.6.1 → plexus_python-0.6.3}/examples/thermal_camera.py +0 -0
  38. {plexus_python-0.6.1 → plexus_python-0.6.3}/examples/uv.lock +0 -0
  39. {plexus_python-0.6.1 → plexus_python-0.6.3}/plexus/cameras/__init__.py +0 -0
  40. {plexus_python-0.6.1 → plexus_python-0.6.3}/plexus/cli.py +0 -0
  41. {plexus_python-0.6.1 → plexus_python-0.6.3}/scripts/plexus.service +0 -0
  42. {plexus_python-0.6.1 → plexus_python-0.6.3}/scripts/release.sh +0 -0
  43. {plexus_python-0.6.1 → plexus_python-0.6.3}/scripts/scan_buses.py +0 -0
  44. {plexus_python-0.6.1 → plexus_python-0.6.3}/scripts/setup.sh +0 -0
  45. {plexus_python-0.6.1 → plexus_python-0.6.3}/skills/plexus/SKILL.md +0 -0
  46. {plexus_python-0.6.1 → plexus_python-0.6.3}/skills/plexus/references/api.md +0 -0
  47. {plexus_python-0.6.1 → plexus_python-0.6.3}/skills/plexus/references/sdk.md +0 -0
  48. {plexus_python-0.6.1 → plexus_python-0.6.3}/tests/test_basic.py +0 -0
  49. {plexus_python-0.6.1 → plexus_python-0.6.3}/tests/test_buffer.py +0 -0
  50. {plexus_python-0.6.1 → plexus_python-0.6.3}/tests/test_config.py +0 -0
  51. {plexus_python-0.6.1 → plexus_python-0.6.3}/tests/test_retry.py +0 -0
  52. {plexus_python-0.6.1 → plexus_python-0.6.3}/tests/test_thermal.py +0 -0
  53. {plexus_python-0.6.1 → plexus_python-0.6.3}/tests/test_video.py +0 -0
@@ -1,5 +1,52 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.3] - 2026-06-02 - Remove install_id / source_id auto-suffix
4
+
5
+ ### Changed
6
+
7
+ - Removed `install_id` from the device auth frame and `WebSocketTransport`.
8
+ - Removed server-side source_id auto-suffix handling (`on_source_id_assigned` callback, `set_source_id` persistence).
9
+ - Removed `get_install_id` and `set_source_id` from `plexus.config`.
10
+
11
+ ## [0.7.0] - 2026-05-29 - SDK hardening
12
+
13
+ ### Fixed
14
+
15
+ - Buffer overflow now surfaces on stderr (`⚠ buffer full, dropped N oldest points`) instead
16
+ of going silently to `logging.warning`. Both `MemoryBuffer` and `SqliteBuffer` are covered.
17
+ - `on_command()` late-registration warning now goes through `logging.warning()` so it is not
18
+ suppressed by `PLEXUS_QUIET=1`.
19
+ - `PLEXUS_QUIET` env var is now read at call time rather than import time — setting it after
20
+ module load now takes effect.
21
+ - `max_buffer_size` setter no longer directly mutates `_buffer._max_size`; routes through a
22
+ new `resize()` method on `BufferBackend`.
23
+
24
+ ### Changed
25
+
26
+ - `PlexusError`, `AuthenticationError`, and `RetryConfig` are now exported from the top-level
27
+ `plexus` package. `from plexus import PlexusError` now works as expected.
28
+ - `WebSocketTransport` and `read_mjpeg_frames` removed from `__all__` — both remain importable
29
+ via their source modules but are no longer part of the top-level public API.
30
+
31
+ ### Breaking
32
+
33
+ - `Plexus.__init__` no longer accepts a `transport` parameter. WebSocket with automatic HTTP
34
+ fallback is the only send path.
35
+ - `ThermalSource.open()` now requires an explicit hint (`"sim"`, `"mlx90640"`, `"mlx90641"`,
36
+ `"usb"`, or a device index). Auto-detection has been removed — it could not reliably
37
+ distinguish USB thermal cameras from regular webcams.
38
+
39
+ ## [0.6.2] - 2026-05-28 - Dependency fix
40
+
41
+ ### Fixed
42
+
43
+ - Replaced `opencv-python` with `opencv-python-headless` in the `[video]` extra —
44
+ headless is the correct choice for embedded/server use and avoids pulling in Qt/GUI
45
+ dependencies that don't belong on Raspberry Pi or headless Linux.
46
+ - Merged the `[thermal]` extra into `[video]`. OpenCV is needed for all video frame
47
+ encoding, not just thermal cameras. Install with `pip install plexus-python[video]`.
48
+ - Synced `plexus.__version__` with `pyproject.toml` (both now `0.6.2`).
49
+
3
50
  ## [0.6.1] - 2026-05-28 - Thermal camera streaming
4
51
 
5
52
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plexus-python
3
- Version: 0.6.1
3
+ Version: 0.6.3
4
4
  Summary: Thin Python SDK for Plexus — send telemetry in one line
5
5
  Project-URL: Homepage, https://plexus.dev
6
6
  Project-URL: Documentation, https://docs.plexus.dev
@@ -24,15 +24,14 @@ Requires-Python: >=3.10
24
24
  Requires-Dist: websocket-client>=1.7
25
25
  Provides-Extra: dev
26
26
  Requires-Dist: numpy>=1.24; extra == 'dev'
27
- Requires-Dist: opencv-python>=4.8; extra == 'dev'
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
30
  Requires-Dist: ruff; extra == 'dev'
31
31
  Requires-Dist: websockets>=12; extra == 'dev'
32
- Provides-Extra: thermal
33
- Requires-Dist: numpy>=1.24; extra == 'thermal'
34
- Requires-Dist: opencv-python>=4.8; extra == 'thermal'
35
32
  Provides-Extra: video
33
+ Requires-Dist: numpy>=1.24; extra == 'video'
34
+ Requires-Dist: opencv-python-headless>=4.8; extra == 'video'
36
35
  Requires-Dist: pillow>=12.2.0; extra == 'video'
37
36
  Description-Content-Type: text/markdown
38
37
 
@@ -0,0 +1 @@
1
+ # TODO
@@ -0,0 +1,14 @@
1
+ """
2
+ Plexus — thin Python SDK for sending telemetry to the Plexus gateway.
3
+
4
+ from plexus import Plexus
5
+
6
+ px = Plexus(api_key="plx_xxx", source_id="device-001")
7
+ px.send("temperature", 72.5)
8
+ """
9
+
10
+ from plexus.client import Plexus, PlexusError, AuthenticationError, read_mjpeg_frames
11
+ from plexus.config import RetryConfig
12
+
13
+ __version__ = "0.6.3"
14
+ __all__ = ["Plexus", "PlexusError", "AuthenticationError", "RetryConfig", "read_mjpeg_frames"]
@@ -1,12 +1,10 @@
1
1
  import os
2
2
  import sys
3
3
 
4
- _QUIET = os.environ.get("PLEXUS_QUIET", "").lower() in ("1", "true", "yes")
5
-
6
4
 
7
5
  def _say(line: str) -> None:
8
6
  """Single-line status message to stderr. Skipped if PLEXUS_QUIET=1."""
9
- if _QUIET:
7
+ if os.environ.get("PLEXUS_QUIET", "").lower() in ("1", "true", "yes"):
10
8
  return
11
9
  try:
12
10
  sys.stderr.write(f"[plexus] {line}\n")
@@ -41,6 +41,10 @@ class BufferBackend(ABC):
41
41
  def size(self) -> int:
42
42
  """Return current number of buffered points."""
43
43
 
44
+ @abstractmethod
45
+ def resize(self, max_size: int) -> None:
46
+ """Update the maximum buffer capacity."""
47
+
44
48
  def drain(self, batch_size: int = 5000) -> Tuple[List[Dict[str, Any]], int]:
45
49
  """Remove and return the oldest batch_size points atomically.
46
50
 
@@ -97,6 +101,10 @@ class MemoryBuffer(BufferBackend):
97
101
  with self._lock:
98
102
  return len(self._buffer)
99
103
 
104
+ def resize(self, max_size: int) -> None:
105
+ with self._lock:
106
+ self._max_size = max_size
107
+
100
108
  def drain(self, batch_size: int = 5000) -> Tuple[List[Dict[str, Any]], int]:
101
109
  with self._lock:
102
110
  batch = self._buffer[:batch_size]
@@ -185,6 +193,10 @@ class SqliteBuffer(BufferBackend):
185
193
  cursor = self._conn.execute("SELECT COUNT(*) FROM points")
186
194
  return cursor.fetchone()[0]
187
195
 
196
+ def resize(self, max_size: int) -> None:
197
+ with self._lock:
198
+ self._max_size = max_size
199
+
188
200
  def drain(self, batch_size: int = 5000) -> Tuple[List[Dict[str, Any]], int]:
189
201
  """Remove and return the oldest batch_size points atomically.
190
202
 
@@ -8,8 +8,9 @@ ThermalFrame containing a colorized JPEG image plus temperature metadata.
8
8
  Usage:
9
9
  from plexus.cameras.thermal import ThermalSource
10
10
 
11
- cam = ThermalSource.open() # auto-detect
12
11
  cam = ThermalSource.open("sim") # simulated, no hardware
12
+ cam = ThermalSource.open("mlx90640") # I2C MLX90640
13
+ cam = ThermalSource.open("usb") # USB thermal at index 0
13
14
 
14
15
  while True:
15
16
  px.send_thermal_frame(cam.read_frame(), camera_id="thermal")
@@ -325,19 +326,33 @@ class MLX90641Camera(ThermalCamera):
325
326
 
326
327
 
327
328
  class ThermalSource:
328
- """Factory that opens the first available thermal camera.
329
+ """Factory for opening a thermal camera by type.
330
+
331
+ A camera type must be specified explicitly — auto-detection is not supported
332
+ because USB thermal cameras and regular webcams are indistinguishable at the
333
+ OS level without knowing what you're looking for.
334
+
335
+ Args:
336
+ hint: Which camera to open. One of:
337
+ "sim" — simulated 32×24 camera, no hardware required
338
+ "mlx90640" — MLX90640 32×24 I2C sensor (Raspberry Pi, 3.3V)
339
+ "mlx90641" — MLX90641 16×12 I2C sensor (Raspberry Pi, 3.3V)
340
+ "usb" — USB thermal camera at device index 0
341
+ <int> — USB thermal camera at a specific device index
342
+
343
+ Raises:
344
+ ValueError: If hint is None or unrecognised.
345
+ NoCameraFound: If the specified device cannot be opened.
329
346
 
330
347
  Examples:
331
- ThermalSource.open() # auto-detect
332
- ThermalSource.open("sim") # simulated
333
- ThermalSource.open("mlx90640") # force I2C MLX90640
334
- ThermalSource.open("mlx90641") # force I2C MLX90641
335
- ThermalSource.open("usb") # USB thermal at index 0
336
- ThermalSource.open(2) # USB thermal at index 2
348
+ cam = ThermalSource.open("sim") # no hardware
349
+ cam = ThermalSource.open("mlx90640") # I2C MLX90640
350
+ cam = ThermalSource.open("usb") # USB at index 0
351
+ cam = ThermalSource.open(2) # USB at index 2
337
352
  """
338
353
 
339
354
  @staticmethod
340
- def open(hint: str | int | None = None) -> ThermalCamera:
355
+ def open(hint: str | int) -> ThermalCamera:
341
356
  if hint == "sim":
342
357
  return SimulatedThermalCamera()
343
358
  if hint == "mlx90640":
@@ -346,43 +361,7 @@ class ThermalSource:
346
361
  return MLX90641Camera()
347
362
  if hint == "usb" or isinstance(hint, int):
348
363
  return USBThermalCamera(0 if hint == "usb" else hint)
349
- return ThermalSource._autodetect()
350
-
351
- @staticmethod
352
- def _autodetect() -> ThermalCamera:
353
- cam = ThermalSource._try_i2c()
354
- if cam:
355
- return cam
356
- cam = ThermalSource._try_usb()
357
- if cam:
358
- return cam
359
- raise NoCameraFound(
360
- "No thermal camera detected. Use ThermalSource.open('sim') to run without hardware."
364
+ raise ValueError(
365
+ f"Unknown camera hint {hint!r}. "
366
+ "Valid options: 'sim', 'mlx90640', 'mlx90641', 'usb', or a device index (int)."
361
367
  )
362
-
363
- @staticmethod
364
- def _try_i2c() -> ThermalCamera | None:
365
- try:
366
- import board
367
- import busio
368
-
369
- i2c = busio.I2C(board.SCL, board.SDA)
370
- i2c.try_lock()
371
- addrs = i2c.scan()
372
- i2c.unlock()
373
- if 0x33 in addrs:
374
- return MLX90640Camera()
375
- if 0x60 in addrs:
376
- return MLX90641Camera()
377
- except Exception:
378
- pass
379
- return None
380
-
381
- @staticmethod
382
- def _try_usb() -> ThermalCamera | None:
383
- for idx in range(10):
384
- try:
385
- return USBThermalCamera(idx)
386
- except NoCameraFound:
387
- continue
388
- return None
@@ -55,9 +55,7 @@ from plexus.config import (
55
55
  get_endpoint,
56
56
  get_gateway_url,
57
57
  get_gateway_ws_url,
58
- get_install_id,
59
58
  get_source_id,
60
- set_source_id,
61
59
  )
62
60
 
63
61
  logger = logging.getLogger(__name__)
@@ -187,7 +185,6 @@ class Plexus:
187
185
  max_buffer_size: int = 10000,
188
186
  persistent_buffer: bool = True,
189
187
  buffer_path: Optional[str] = None,
190
- transport: str = "ws",
191
188
  ws_url: Optional[str] = None,
192
189
  ):
193
190
  self.api_key = api_key or get_api_key()
@@ -212,9 +209,6 @@ class Plexus:
212
209
  self._pil_image = None # lazy PIL.Image import
213
210
  self._fit_warned: bool = False
214
211
 
215
- if transport not in ("ws", "http"):
216
- raise ValueError(f"transport must be 'ws' or 'http', got {transport!r}")
217
- self.transport = transport
218
212
  self._ws_url = (ws_url or get_gateway_ws_url())
219
213
  self._ws = None # lazily constructed in _ensure_ws()
220
214
  self._clock_offset_ms: int = 0
@@ -222,10 +216,14 @@ class Plexus:
222
216
  # Pluggable buffer backend for failed sends
223
217
  if persistent_buffer:
224
218
  self._buffer: BufferBackend = SqliteBuffer(
225
- path=buffer_path, max_size=max_buffer_size
219
+ path=buffer_path, max_size=max_buffer_size,
220
+ on_overflow=self._on_buffer_overflow,
226
221
  )
227
222
  else:
228
- self._buffer: BufferBackend = MemoryBuffer(max_size=max_buffer_size)
223
+ self._buffer: BufferBackend = MemoryBuffer(
224
+ max_size=max_buffer_size,
225
+ on_overflow=self._on_buffer_overflow,
226
+ )
229
227
 
230
228
  # State that drives the [plexus] stderr status line.
231
229
  self._announced_first_send = False
@@ -240,7 +238,7 @@ class Plexus:
240
238
  @max_buffer_size.setter
241
239
  def max_buffer_size(self, value):
242
240
  self._max_buffer_size = value
243
- self._buffer._max_size = value
241
+ self._buffer.resize(value)
244
242
 
245
243
  def _get_session(self) -> _Session:
246
244
  if self._session is None:
@@ -414,27 +412,18 @@ class Plexus:
414
412
  api_key=self.api_key,
415
413
  source_id=self.source_id,
416
414
  ws_url=self._ws_url,
417
- install_id=get_install_id(),
418
415
  agent_version=__version__,
419
- on_source_id_assigned=self._on_source_id_assigned,
420
416
  on_clock_synced=self._on_clock_synced,
421
417
  )
422
418
  self._ws.start()
423
419
  return self._ws
424
420
 
421
+ def _on_buffer_overflow(self, dropped: int) -> None:
422
+ _say(f"⚠ buffer full, dropped {dropped} oldest points (gateway unreachable?)")
423
+
425
424
  def _on_clock_synced(self, offset_ms: int) -> None:
426
425
  self._clock_offset_ms = offset_ms
427
426
 
428
- def _on_source_id_assigned(self, assigned: str) -> None:
429
- """Callback from WebSocketTransport when the gateway returns an
430
- auto-suffixed source_id. Persists it so subsequent runs (and the HTTP
431
- fallback path in this process) use the assigned name directly."""
432
- self.source_id = assigned
433
- try:
434
- set_source_id(assigned)
435
- except Exception as e: # pragma: no cover - persistence failure is non-fatal
436
- logger.debug("failed to persist assigned source_id: %s", e)
437
-
438
427
  def _encode_frame(self, frame, quality: int) -> Tuple[bytes, int, int]:
439
428
  """Normalize any supported frame type to (jpeg_bytes, width, height).
440
429
 
@@ -483,8 +472,8 @@ class Plexus:
483
472
  except ImportError as e:
484
473
  if required:
485
474
  raise ImportError(
486
- "This frame type requires opencv-python. "
487
- "Install with: pip install opencv-python"
475
+ "This frame type requires opencv-python-headless. "
476
+ "Install with: pip install plexus-python[video]"
488
477
  ) from e
489
478
  return self._cv2
490
479
 
@@ -575,9 +564,6 @@ class Plexus:
575
564
  ValueError: If frame type is not supported.
576
565
  ImportError: If a required optional dependency is missing.
577
566
  """
578
- if self.transport != "ws":
579
- raise PlexusError("send_video_frame requires transport='ws'")
580
-
581
567
  jpeg_bytes, width, height = self._encode_frame(frame, quality)
582
568
  jpeg_bytes = self._fit_to_wire(jpeg_bytes, quality)
583
569
 
@@ -611,17 +597,14 @@ class Plexus:
611
597
 
612
598
  Raises:
613
599
  PlexusError: If transport is not 'ws'.
614
- ImportError: If opencv-python is not installed.
600
+ ImportError: If opencv-python-headless is not installed.
615
601
  """
616
- if self.transport != "ws":
617
- raise PlexusError("send_thermal_frame requires transport='ws'")
618
-
619
602
  try:
620
603
  from plexus.cameras.thermal import build_thermal_frame
621
604
  except ImportError as e:
622
605
  raise ImportError(
623
- "send_thermal_frame requires opencv-python. "
624
- "Install with: pip install opencv-python"
606
+ "send_thermal_frame requires opencv-python-headless. "
607
+ "Install with: pip install plexus-python[video]"
625
608
  ) from e
626
609
 
627
610
  frame = build_thermal_frame(temps, timestamp_ms=self._normalize_ts_ms(timestamp))
@@ -663,8 +646,6 @@ class Plexus:
663
646
  time.sleep(60)
664
647
  stop.set()
665
648
  """
666
- if self.transport != "ws":
667
- raise PlexusError("stream_camera requires transport='ws'")
668
649
  if shutil.which("ffmpeg") is None:
669
650
  raise PlexusError(
670
651
  "FFmpeg not found. Install it: https://ffmpeg.org/download.html"
@@ -715,24 +696,21 @@ class Plexus:
715
696
  Must be called before the first send() so the command is advertised
716
697
  in the auth frame.
717
698
  """
718
- if self.transport != "ws":
719
- raise PlexusError("on_command requires transport='ws'")
720
699
  ws = self._ensure_ws()
721
700
  if ws.is_authenticated:
722
- _say(
723
- f"on_command('{name}') called after connection is already authenticated — "
701
+ logger.warning(
702
+ "on_command('%s') called after connection is already authenticated — "
724
703
  "command will not be advertised to the dashboard until next reconnect. "
725
- "Call on_command() before the first send()."
704
+ "Call on_command() before the first send().",
705
+ name,
726
706
  )
727
707
  ws.register_command(name, handler, description=description, params=params)
728
708
 
729
709
  def _send_points(self, points: List[Dict[str, Any]]) -> bool:
730
710
  """Send data points to the gateway with retry and buffering.
731
711
 
732
- Path:
733
- - transport='ws': try the WebSocket first; if not yet authenticated or
734
- the socket fails, fall through to the HTTP path so points still land.
735
- - transport='http': always POST /ingest with retries.
712
+ Tries WebSocket first; if not yet authenticated or the socket fails,
713
+ falls through to HTTP POST so points still land.
736
714
 
737
715
  Retry behavior (HTTP path):
738
716
  - Retries on: Timeout, ConnectionError, HTTP 429, HTTP 5xx
@@ -748,22 +726,21 @@ class Plexus:
748
726
  all_points = self._get_buffered_points() + points
749
727
 
750
728
  # Preferred path: WebSocket.
751
- if self.transport == "ws":
752
- ws = self._ensure_ws()
753
- # Brief wait on first call so startup races don't dump every point
754
- # into the HTTP fallback path.
755
- if not ws.is_authenticated:
756
- ws.wait_authenticated(timeout=min(self.timeout, 5.0))
757
- if ws.send_points(all_points):
758
- self._clear_buffer()
759
- self._note_send(len(all_points), via="ws")
760
- return True
761
- # Socket unavailable → fall through to HTTP.
762
- if not self._announced_http_fallback:
763
- _say(
764
- f"⚠ WebSocket unavailable, falling back to POST {self.gateway_url}/ingest"
765
- )
766
- self._announced_http_fallback = True
729
+ 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:
733
+ 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
738
+ # Socket unavailable → fall through to HTTP.
739
+ if not self._announced_http_fallback:
740
+ _say(
741
+ f"⚠ WebSocket unavailable, falling back to POST {self.gateway_url}/ingest"
742
+ )
743
+ self._announced_http_fallback = True
767
744
 
768
745
  url = f"{self.gateway_url}/ingest"
769
746
  last_error: Optional[Exception] = None
@@ -145,54 +145,6 @@ def get_source_id() -> Optional[str]:
145
145
  return source_id
146
146
 
147
147
 
148
- def get_install_id() -> str:
149
- """Get the device install ID, generating one if not set.
150
-
151
- The install_id is a stable per-installation UUID. It is generated lazily
152
- on first run (NOT at image-build time) so that cloned SD-card images
153
- naturally get distinct install_ids on their first boot. The gateway uses
154
- it to tell "same device reconnecting" from "different device claiming the
155
- same name" when resolving source_id collisions.
156
-
157
- Resolution order:
158
- 1. ``PLEXUS_INSTALL_ID`` env var — lets ephemeral containers (Fly
159
- machines, CI runners, Kubernetes pods) pin a stable identity
160
- across restarts when the config filesystem is ephemeral. Without
161
- this, every redeploy generates a new install_id and the gateway
162
- auto-suffixes the source_id to avoid a collision with the prior
163
- install ("gw-001" → "gw-001_2" → "gw-001_3"…).
164
- 2. ``install_id`` in the on-disk config.
165
- 3. Newly-generated UUID, persisted to config.
166
- """
167
- env_id = os.environ.get("PLEXUS_INSTALL_ID", "").strip()
168
- if env_id:
169
- return env_id
170
-
171
- config = load_config()
172
- install_id = config.get("install_id")
173
-
174
- if not install_id:
175
- import uuid
176
- install_id = uuid.uuid4().hex
177
- config["install_id"] = install_id
178
- save_config(config)
179
-
180
- return install_id
181
-
182
-
183
- def set_source_id(source_id: str) -> None:
184
- """Persist an updated source_id to the config file.
185
-
186
- Called by the SDK when the gateway returns an auto-suffixed name so the
187
- assigned name is stable across reconnects.
188
- """
189
- config = load_config()
190
- if config.get("source_id") == source_id:
191
- return
192
- config["source_id"] = source_id
193
- save_config(config)
194
-
195
-
196
148
  def get_persistent_buffer() -> bool:
197
149
  """Get persistent buffer setting. Default True (store-and-forward enabled)."""
198
150
  config = load_config()
@@ -89,11 +89,9 @@ class WebSocketTransport:
89
89
  source_id: str,
90
90
  ws_url: str,
91
91
  *,
92
- install_id: str = "",
93
92
  agent_version: str = "0.0.0",
94
93
  platform: str = "python-sdk",
95
94
  auto_reconnect: bool = True,
96
- on_source_id_assigned: Optional[Callable[[str], None]] = None,
97
95
  on_clock_synced: Optional[Callable[[int], None]] = None,
98
96
  ):
99
97
  if not api_key:
@@ -103,12 +101,10 @@ class WebSocketTransport:
103
101
 
104
102
  self.api_key = api_key
105
103
  self.source_id = source_id
106
- self.install_id = install_id
107
104
  self.ws_url = _ensure_device_path(ws_url)
108
105
  self.agent_version = agent_version
109
106
  self.platform = platform
110
107
  self.auto_reconnect = auto_reconnect
111
- self._on_source_id_assigned = on_source_id_assigned
112
108
  self._on_clock_synced = on_clock_synced
113
109
 
114
110
  self._commands: Dict[str, _RegisteredCommand] = {}
@@ -278,16 +274,13 @@ class WebSocketTransport:
278
274
  self._ws = ws
279
275
 
280
276
  # 1. Send device_auth
281
- desired_source_id = self.source_id
282
277
  auth = {
283
278
  "type": "device_auth",
284
279
  "api_key": self.api_key,
285
- "source_id": desired_source_id,
280
+ "source_id": self.source_id,
286
281
  "platform": self.platform,
287
282
  "agent_version": self.agent_version,
288
283
  }
289
- if self.install_id:
290
- auth["install_id"] = self.install_id
291
284
  if self._commands:
292
285
  auth["commands"] = [c.to_manifest() for c in self._commands.values()]
293
286
  ws.send(json.dumps(auth))
@@ -312,22 +305,6 @@ class WebSocketTransport:
312
305
  except Exception as e:
313
306
  logger.debug("on_clock_synced callback raised: %s", e)
314
307
 
315
- # The gateway may return a different source_id if the desired name
316
- # was already claimed by another install — adopt the assigned value
317
- # so all subsequent frames (heartbeats, future reconnects) use it.
318
- assigned = msg.get("source_id")
319
- if isinstance(assigned, str) and assigned and assigned != self.source_id:
320
- logger.info(
321
- "plexus ws source_id auto-suffixed: requested=%s assigned=%s",
322
- desired_source_id, assigned,
323
- )
324
- self.source_id = assigned
325
- if self._on_source_id_assigned is not None:
326
- try:
327
- self._on_source_id_assigned(assigned)
328
- except Exception as e: # pragma: no cover - callback errors must not break auth
329
- logger.debug("on_source_id_assigned callback raised: %s", e)
330
-
331
308
  was_reconnect = self._backoff_attempt > 0
332
309
  self._authenticated.set()
333
310
  self._backoff_attempt = 0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "plexus-python"
7
- version = "0.6.1"
7
+ version = "0.6.3"
8
8
  description = "Thin Python SDK for Plexus — send telemetry in one line"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -30,13 +30,12 @@ dependencies = [
30
30
  ]
31
31
 
32
32
  [project.optional-dependencies]
33
- video = ["Pillow>=12.2.0"]
34
- # Thermal cameras: colorization + Y16/USB capture. I2C sensors additionally
35
- # need a board-specific driver, installed separately on the device, e.g.
36
- # adafruit-circuitpython-mlx90640 (these require Raspberry Pi hardware libs
37
- # and don't install on other platforms, so they're not listed here).
38
- thermal = ["opencv-python>=4.8", "numpy>=1.24"]
39
- dev = ["pytest>=9.0.3", "pytest-cov", "ruff", "websockets>=12", "opencv-python>=4.8", "numpy>=1.24"]
33
+ # Video/camera support: frame encoding, thermal cameras, etc.
34
+ # I2C thermal sensors additionally need a board-specific driver installed
35
+ # separately on the device (e.g. adafruit-circuitpython-mlx90640 — requires
36
+ # Raspberry Pi hardware libs and doesn't install on other platforms).
37
+ video = ["Pillow>=12.2.0", "opencv-python-headless>=4.8", "numpy>=1.24"]
38
+ dev = ["pytest>=9.0.3", "pytest-cov", "ruff", "websockets>=12", "opencv-python-headless>=4.8", "numpy>=1.24"]
40
39
 
41
40
  [project.scripts]
42
41
  plexus = "plexus.cli:main"
@@ -52,4 +51,4 @@ packages = ["plexus"]
52
51
 
53
52
  [tool.ruff]
54
53
  line-length = 100
55
- target-version = "py38"
54
+ target-version = "py310"
@@ -126,7 +126,6 @@ def test_auth_handshake_and_telemetry(gateway):
126
126
  api_key="plx_test_abc",
127
127
  source_id="drone-001",
128
128
  ws_url=_url(gateway.port),
129
- install_id="install-A",
130
129
  agent_version="9.9.9",
131
130
  )
132
131
  t.start()
@@ -137,7 +136,7 @@ def test_auth_handshake_and_telemetry(gateway):
137
136
  assert gateway.auth_frame["type"] == "device_auth"
138
137
  assert gateway.auth_frame["api_key"] == "plx_test_abc"
139
138
  assert gateway.auth_frame["source_id"] == "drone-001"
140
- assert gateway.auth_frame["install_id"] == "install-A"
139
+ assert "install_id" not in gateway.auth_frame
141
140
  assert gateway.auth_frame["platform"] == "python-sdk"
142
141
  assert gateway.auth_frame["agent_version"] == "9.9.9"
143
142
  # commands is omitted when none registered
@@ -256,78 +255,6 @@ def test_handler_exception_returns_error(gateway):
256
255
  t.stop()
257
256
 
258
257
 
259
- def test_install_id_omitted_when_empty():
260
- # Default install_id="" should not leak an empty install_id field into
261
- # the auth frame — that keeps the wire shape identical for legacy SDK
262
- # builds that don't set one.
263
- g = _StubGateway()
264
- g.start()
265
- try:
266
- t = WebSocketTransport(
267
- api_key="plx_test_abc",
268
- source_id="drone-001",
269
- ws_url=_url(g.port),
270
- )
271
- t.start()
272
- try:
273
- assert t.wait_authenticated(timeout=3)
274
- assert "install_id" not in g.auth_frame
275
- finally:
276
- t.stop()
277
- finally:
278
- g.stop()
279
-
280
-
281
- def test_server_assigned_source_id_is_adopted():
282
- # Simulate the auto-suffix path: SDK asks for "drone-01", the gateway
283
- # returns "drone-01_2" in the authenticated frame. The transport must
284
- # adopt the assigned name and fire the on_source_id_assigned callback.
285
- g = _StubGateway(assigned_source_id="drone-01_2")
286
- g.start()
287
- try:
288
- seen: List[str] = []
289
- t = WebSocketTransport(
290
- api_key="plx_test_abc",
291
- source_id="drone-01",
292
- ws_url=_url(g.port),
293
- install_id="install-B",
294
- on_source_id_assigned=lambda s: seen.append(s),
295
- )
296
- t.start()
297
- try:
298
- assert t.wait_authenticated(timeout=3)
299
- assert t.source_id == "drone-01_2"
300
- assert seen == ["drone-01_2"]
301
- finally:
302
- t.stop()
303
- finally:
304
- g.stop()
305
-
306
-
307
- def test_same_assigned_source_id_does_not_fire_callback():
308
- # Happy path — gateway returns the same name. No callback, source_id unchanged.
309
- g = _StubGateway() # echoes whatever was sent
310
- g.start()
311
- try:
312
- seen: List[str] = []
313
- t = WebSocketTransport(
314
- api_key="plx_test_abc",
315
- source_id="drone-01",
316
- ws_url=_url(g.port),
317
- install_id="install-A",
318
- on_source_id_assigned=lambda s: seen.append(s),
319
- )
320
- t.start()
321
- try:
322
- assert t.wait_authenticated(timeout=3)
323
- assert t.source_id == "drone-01"
324
- assert seen == []
325
- finally:
326
- t.stop()
327
- finally:
328
- g.stop()
329
-
330
-
331
258
  def test_ensure_device_path():
332
259
  from plexus.ws import _ensure_device_path
333
260
  assert _ensure_device_path("wss://foo") == "wss://foo/ws/device"
@@ -302,7 +302,7 @@ wheels = [
302
302
  ]
303
303
 
304
304
  [[package]]
305
- name = "opencv-python"
305
+ name = "opencv-python-headless"
306
306
  version = "4.13.0.92"
307
307
  source = { registry = "https://pypi.org/simple" }
308
308
  dependencies = [
@@ -310,14 +310,14 @@ dependencies = [
310
310
  { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
311
311
  ]
312
312
  wheels = [
313
- { url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" },
314
- { url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" },
315
- { url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" },
316
- { url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" },
317
- { url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" },
318
- { url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" },
319
- { url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" },
320
- { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" },
313
+ { url = "https://files.pythonhosted.org/packages/79/42/2310883be3b8826ac58c3f2787b9358a2d46923d61f88fedf930bc59c60c/opencv_python_headless-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:1a7d040ac656c11b8c38677cc8cccdc149f98535089dbe5b081e80a4e5903209", size = 46247192, upload-time = "2026-02-05T07:01:35.187Z" },
314
+ { url = "https://files.pythonhosted.org/packages/2d/1e/6f9e38005a6f7f22af785df42a43139d0e20f169eb5787ce8be37ee7fcc9/opencv_python_headless-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:3e0a6f0a37994ec6ce5f59e936be21d5d6384a4556f2d2da9c2f9c5dc948394c", size = 32568914, upload-time = "2026-02-05T07:01:51.989Z" },
315
+ { url = "https://files.pythonhosted.org/packages/21/76/9417a6aef9def70e467a5bf560579f816148a4c658b7d525581b356eda9e/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c8cfc8e87ed452b5cecb9419473ee5560a989859fe1d10d1ce11ae87b09a2cb", size = 33703709, upload-time = "2026-02-05T10:24:46.469Z" },
316
+ { url = "https://files.pythonhosted.org/packages/92/ce/bd17ff5772938267fd49716e94ca24f616ff4cb1ff4c6be13085108037be/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0525a3d2c0b46c611e2130b5fdebc94cf404845d8fa64d2f3a3b679572a5bd22", size = 56016764, upload-time = "2026-02-05T10:26:48.904Z" },
317
+ { url = "https://files.pythonhosted.org/packages/8f/b4/b7bcbf7c874665825a8c8e1097e93ea25d1f1d210a3e20d4451d01da30aa/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb60e36b237b1ebd40a912da5384b348df8ed534f6f644d8e0b4f103e272ba7d", size = 35010236, upload-time = "2026-02-05T10:28:11.031Z" },
318
+ { url = "https://files.pythonhosted.org/packages/4b/33/b5db29a6c00eb8f50708110d8d453747ca125c8b805bc437b289dbdcc057/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0bd48544f77c68b2941392fcdf9bcd2b9cdf00e98cb8c29b2455d194763cf99e", size = 60391106, upload-time = "2026-02-05T10:30:14.236Z" },
319
+ { url = "https://files.pythonhosted.org/packages/fb/c3/52cfea47cd33e53e8c0fbd6e7c800b457245c1fda7d61660b4ffe9596a7f/opencv_python_headless-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:a7cf08e5b191f4ebb530791acc0825a7986e0d0dee2a3c491184bd8599848a4b", size = 30812232, upload-time = "2026-02-05T07:02:29.594Z" },
320
+ { url = "https://files.pythonhosted.org/packages/4a/90/b338326131ccb2aaa3c2c85d00f41822c0050139a4bfe723cfd95455bd2d/opencv_python_headless-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:77a82fe35ddcec0f62c15f2ba8a12ecc2ed4207c17b0902c7a3151ae29f37fb6", size = 40070414, upload-time = "2026-02-05T07:02:26.448Z" },
321
321
  ]
322
322
 
323
323
  [[package]]
@@ -429,7 +429,7 @@ wheels = [
429
429
 
430
430
  [[package]]
431
431
  name = "plexus-python"
432
- version = "0.5.2"
432
+ version = "0.6.3"
433
433
  source = { editable = "." }
434
434
  dependencies = [
435
435
  { name = "websocket-client" },
@@ -439,27 +439,25 @@ dependencies = [
439
439
  dev = [
440
440
  { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
441
441
  { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
442
- { name = "opencv-python" },
442
+ { name = "opencv-python-headless" },
443
443
  { name = "pytest" },
444
444
  { name = "pytest-cov" },
445
445
  { name = "ruff" },
446
446
  { name = "websockets" },
447
447
  ]
448
- thermal = [
448
+ video = [
449
449
  { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
450
450
  { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
451
- { name = "opencv-python" },
452
- ]
453
- video = [
451
+ { name = "opencv-python-headless" },
454
452
  { name = "pillow" },
455
453
  ]
456
454
 
457
455
  [package.metadata]
458
456
  requires-dist = [
459
457
  { name = "numpy", marker = "extra == 'dev'", specifier = ">=1.24" },
460
- { name = "numpy", marker = "extra == 'thermal'", specifier = ">=1.24" },
461
- { name = "opencv-python", marker = "extra == 'dev'", specifier = ">=4.8" },
462
- { name = "opencv-python", marker = "extra == 'thermal'", specifier = ">=4.8" },
458
+ { name = "numpy", marker = "extra == 'video'", specifier = ">=1.24" },
459
+ { name = "opencv-python-headless", marker = "extra == 'dev'", specifier = ">=4.8" },
460
+ { name = "opencv-python-headless", marker = "extra == 'video'", specifier = ">=4.8" },
463
461
  { name = "pillow", marker = "extra == 'video'", specifier = ">=12.2.0" },
464
462
  { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" },
465
463
  { name = "pytest-cov", marker = "extra == 'dev'" },
@@ -467,7 +465,7 @@ requires-dist = [
467
465
  { name = "websocket-client", specifier = ">=1.7" },
468
466
  { name = "websockets", marker = "extra == 'dev'", specifier = ">=12" },
469
467
  ]
470
- provides-extras = ["video", "thermal", "dev"]
468
+ provides-extras = ["video", "dev"]
471
469
 
472
470
  [[package]]
473
471
  name = "pluggy"
@@ -1,14 +0,0 @@
1
- """
2
- Plexus — thin Python SDK for sending telemetry to the Plexus gateway.
3
-
4
- from plexus import Plexus
5
-
6
- px = Plexus(api_key="plx_xxx", source_id="device-001")
7
- px.send("temperature", 72.5)
8
- """
9
-
10
- from plexus.client import Plexus, read_mjpeg_frames
11
- from plexus.ws import WebSocketTransport
12
-
13
- __version__ = "0.5.2"
14
- __all__ = ["Plexus", "WebSocketTransport", "read_mjpeg_frames"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes