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 +4 -4
- plexus/_log.py +1 -3
- plexus/buffer.py +12 -0
- plexus/cameras/thermal.py +27 -48
- plexus/client.py +31 -40
- {plexus_python-0.6.2.dist-info → plexus_python-0.7.0.dist-info}/METADATA +1 -1
- plexus_python-0.7.0.dist-info/RECORD +14 -0
- plexus_python-0.6.2.dist-info/RECORD +0 -14
- {plexus_python-0.6.2.dist-info → plexus_python-0.7.0.dist-info}/WHEEL +0 -0
- {plexus_python-0.6.2.dist-info → plexus_python-0.7.0.dist-info}/entry_points.txt +0 -0
- {plexus_python-0.6.2.dist-info → plexus_python-0.7.0.dist-info}/licenses/LICENSE +0 -0
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.
|
|
10
|
+
from plexus.client import Plexus, PlexusError, AuthenticationError, read_mjpeg_frames
|
|
11
|
+
from plexus.config import RetryConfig
|
|
12
12
|
|
|
13
|
-
__version__ = "0.
|
|
14
|
-
__all__ = ["Plexus", "
|
|
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
|
|
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
|
|
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()
|
|
332
|
-
ThermalSource.open("
|
|
333
|
-
ThermalSource.open("
|
|
334
|
-
ThermalSource.open(
|
|
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
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
723
|
-
|
|
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
|
-
|
|
733
|
-
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
|
@@ -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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|