plexus-python 0.6.2__py3-none-any.whl → 0.7.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, read_mjpeg_frames
11
- from plexus.ws import WebSocketTransport
10
+ from plexus.client import Plexus, PlexusError, AuthenticationError, read_mjpeg_frames
11
+ from plexus.config import RetryConfig
12
12
 
13
- __version__ = "0.6.2"
14
- __all__ = ["Plexus", "WebSocketTransport", "read_mjpeg_frames"]
13
+ __version__ = "0.7.0"
14
+ __all__ = ["Plexus", "PlexusError", "AuthenticationError", "RetryConfig", "read_mjpeg_frames"]
plexus/_log.py CHANGED
@@ -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")
plexus/buffer.py CHANGED
@@ -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
 
plexus/cameras/thermal.py CHANGED
@@ -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
plexus/client.py CHANGED
@@ -187,7 +187,6 @@ class Plexus:
187
187
  max_buffer_size: int = 10000,
188
188
  persistent_buffer: bool = True,
189
189
  buffer_path: Optional[str] = None,
190
- transport: str = "ws",
191
190
  ws_url: Optional[str] = None,
192
191
  ):
193
192
  self.api_key = api_key or get_api_key()
@@ -212,9 +211,6 @@ class Plexus:
212
211
  self._pil_image = None # lazy PIL.Image import
213
212
  self._fit_warned: bool = False
214
213
 
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
214
  self._ws_url = (ws_url or get_gateway_ws_url())
219
215
  self._ws = None # lazily constructed in _ensure_ws()
220
216
  self._clock_offset_ms: int = 0
@@ -222,10 +218,14 @@ class Plexus:
222
218
  # Pluggable buffer backend for failed sends
223
219
  if persistent_buffer:
224
220
  self._buffer: BufferBackend = SqliteBuffer(
225
- path=buffer_path, max_size=max_buffer_size
221
+ path=buffer_path, max_size=max_buffer_size,
222
+ on_overflow=self._on_buffer_overflow,
226
223
  )
227
224
  else:
228
- self._buffer: BufferBackend = MemoryBuffer(max_size=max_buffer_size)
225
+ self._buffer: BufferBackend = MemoryBuffer(
226
+ max_size=max_buffer_size,
227
+ on_overflow=self._on_buffer_overflow,
228
+ )
229
229
 
230
230
  # State that drives the [plexus] stderr status line.
231
231
  self._announced_first_send = False
@@ -240,7 +240,7 @@ class Plexus:
240
240
  @max_buffer_size.setter
241
241
  def max_buffer_size(self, value):
242
242
  self._max_buffer_size = value
243
- self._buffer._max_size = value
243
+ self._buffer.resize(value)
244
244
 
245
245
  def _get_session(self) -> _Session:
246
246
  if self._session is None:
@@ -422,6 +422,9 @@ class Plexus:
422
422
  self._ws.start()
423
423
  return self._ws
424
424
 
425
+ def _on_buffer_overflow(self, dropped: int) -> None:
426
+ _say(f"⚠ buffer full, dropped {dropped} oldest points (gateway unreachable?)")
427
+
425
428
  def _on_clock_synced(self, offset_ms: int) -> None:
426
429
  self._clock_offset_ms = offset_ms
427
430
 
@@ -575,9 +578,6 @@ class Plexus:
575
578
  ValueError: If frame type is not supported.
576
579
  ImportError: If a required optional dependency is missing.
577
580
  """
578
- if self.transport != "ws":
579
- raise PlexusError("send_video_frame requires transport='ws'")
580
-
581
581
  jpeg_bytes, width, height = self._encode_frame(frame, quality)
582
582
  jpeg_bytes = self._fit_to_wire(jpeg_bytes, quality)
583
583
 
@@ -613,9 +613,6 @@ class Plexus:
613
613
  PlexusError: If transport is not 'ws'.
614
614
  ImportError: If opencv-python-headless is not installed.
615
615
  """
616
- if self.transport != "ws":
617
- raise PlexusError("send_thermal_frame requires transport='ws'")
618
-
619
616
  try:
620
617
  from plexus.cameras.thermal import build_thermal_frame
621
618
  except ImportError as e:
@@ -663,8 +660,6 @@ class Plexus:
663
660
  time.sleep(60)
664
661
  stop.set()
665
662
  """
666
- if self.transport != "ws":
667
- raise PlexusError("stream_camera requires transport='ws'")
668
663
  if shutil.which("ffmpeg") is None:
669
664
  raise PlexusError(
670
665
  "FFmpeg not found. Install it: https://ffmpeg.org/download.html"
@@ -715,24 +710,21 @@ class Plexus:
715
710
  Must be called before the first send() so the command is advertised
716
711
  in the auth frame.
717
712
  """
718
- if self.transport != "ws":
719
- raise PlexusError("on_command requires transport='ws'")
720
713
  ws = self._ensure_ws()
721
714
  if ws.is_authenticated:
722
- _say(
723
- f"on_command('{name}') called after connection is already authenticated — "
715
+ logger.warning(
716
+ "on_command('%s') called after connection is already authenticated — "
724
717
  "command will not be advertised to the dashboard until next reconnect. "
725
- "Call on_command() before the first send()."
718
+ "Call on_command() before the first send().",
719
+ name,
726
720
  )
727
721
  ws.register_command(name, handler, description=description, params=params)
728
722
 
729
723
  def _send_points(self, points: List[Dict[str, Any]]) -> bool:
730
724
  """Send data points to the gateway with retry and buffering.
731
725
 
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.
726
+ Tries WebSocket first; if not yet authenticated or the socket fails,
727
+ falls through to HTTP POST so points still land.
736
728
 
737
729
  Retry behavior (HTTP path):
738
730
  - Retries on: Timeout, ConnectionError, HTTP 429, HTTP 5xx
@@ -748,22 +740,21 @@ class Plexus:
748
740
  all_points = self._get_buffered_points() + points
749
741
 
750
742
  # 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
743
+ ws = self._ensure_ws()
744
+ # Brief wait on first call so startup races don't dump every point
745
+ # into the HTTP fallback path.
746
+ if not ws.is_authenticated:
747
+ ws.wait_authenticated(timeout=min(self.timeout, 5.0))
748
+ if ws.send_points(all_points):
749
+ self._clear_buffer()
750
+ self._note_send(len(all_points), via="ws")
751
+ return True
752
+ # Socket unavailable → fall through to HTTP.
753
+ if not self._announced_http_fallback:
754
+ _say(
755
+ f"⚠ WebSocket unavailable, falling back to POST {self.gateway_url}/ingest"
756
+ )
757
+ self._announced_http_fallback = True
767
758
 
768
759
  url = f"{self.gateway_url}/ingest"
769
760
  last_error: Optional[Exception] = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plexus-python
3
- Version: 0.6.2
3
+ Version: 0.7.0
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
@@ -0,0 +1,14 @@
1
+ plexus/__init__.py,sha256=HNRVhpDrkyyT-SQqfFzNf3E_uwbwQA-XcDKPoBytdEo,447
2
+ plexus/_log.py,sha256=3fjXrHFZghQ_17umMcvDUjjTH6aTQB3J4SpVDBiH03w,335
3
+ plexus/buffer.py,sha256=0i6PLgoj904jFNv9RCrlskvPQsPSu_KNdYWMasFOvsg,9596
4
+ plexus/cli.py,sha256=-2wvHXQzobx3_tDGTXpaE2PlHv884y93Mu29kZE8qZE,14214
5
+ plexus/client.py,sha256=DaCPURIhVswwjf4oGrp381Atiy02gk3viLy6NioBAtQ,36480
6
+ plexus/config.py,sha256=wsG6lhNLmKe3JRlVycyRUKQeywnPUPPfrWkXFxYwELE,6179
7
+ plexus/ws.py,sha256=_c--U-yySZbMXZsaj0fUaYKF_WesolHsrMDx4oWYfFQ,17428
8
+ plexus/cameras/__init__.py,sha256=OvnU9KGKxkVtFLlk56H9x-ATa6UvpLI7PANa0HQO2cc,490
9
+ plexus/cameras/thermal.py,sha256=7o33QsF1RiZLManTxZ2E36nO8lRAHppCDkS3zXBHCxs,12047
10
+ plexus_python-0.7.0.dist-info/METADATA,sha256=tlsktssdsOI7dyA7O2I6azPay2Xj998Z4zNxbFf4H6s,11739
11
+ plexus_python-0.7.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
12
+ plexus_python-0.7.0.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
13
+ plexus_python-0.7.0.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
14
+ plexus_python-0.7.0.dist-info/RECORD,,
@@ -1,14 +0,0 @@
1
- plexus/__init__.py,sha256=XFUsFY9sq7SzSuCP2D6NVAf8RxP0vsXROezHxPMY0ek,385
2
- plexus/_log.py,sha256=y1A9ahcPuCefTQ8xsZtOJTb6iT0pP9LAlkeP7U-yvGE,352
3
- plexus/buffer.py,sha256=3ykybqLs7yMXxQWFajAT8nGe3cs_lW8_6Xvn0vQ69dE,9262
4
- plexus/cli.py,sha256=-2wvHXQzobx3_tDGTXpaE2PlHv884y93Mu29kZE8qZE,14214
5
- plexus/client.py,sha256=1SzzqQDIx0B2zLDezO5cYpmwK8XeUrMd5jMdU7YB90M,36995
6
- plexus/config.py,sha256=wsG6lhNLmKe3JRlVycyRUKQeywnPUPPfrWkXFxYwELE,6179
7
- plexus/ws.py,sha256=_c--U-yySZbMXZsaj0fUaYKF_WesolHsrMDx4oWYfFQ,17428
8
- plexus/cameras/__init__.py,sha256=OvnU9KGKxkVtFLlk56H9x-ATa6UvpLI7PANa0HQO2cc,490
9
- plexus/cameras/thermal.py,sha256=-Ov8pgHGKtu5W-zyHRffVACDbsb50lpTmTGEbs_j4lg,12267
10
- plexus_python-0.6.2.dist-info/METADATA,sha256=kxGSad9N5G842Pjh_G4RvhJ6hdXrr9xEIjJHRhWXAr8,11739
11
- plexus_python-0.6.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
12
- plexus_python-0.6.2.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
13
- plexus_python-0.6.2.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
14
- plexus_python-0.6.2.dist-info/RECORD,,