plexus-python 0.4.6__py3-none-any.whl → 0.4.8__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 +3 -3
- plexus/client.py +262 -1
- plexus/ws.py +0 -1
- {plexus_python-0.4.6.dist-info → plexus_python-0.4.8.dist-info}/METADATA +3 -1
- plexus_python-0.4.8.dist-info/RECORD +11 -0
- plexus_python-0.4.6.dist-info/RECORD +0 -11
- {plexus_python-0.4.6.dist-info → plexus_python-0.4.8.dist-info}/WHEEL +0 -0
- {plexus_python-0.4.6.dist-info → plexus_python-0.4.8.dist-info}/entry_points.txt +0 -0
- {plexus_python-0.4.6.dist-info → plexus_python-0.4.8.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
|
|
10
|
+
from plexus.client import Plexus, read_mjpeg_frames
|
|
11
11
|
from plexus.ws import WebSocketTransport
|
|
12
12
|
|
|
13
|
-
__version__ = "0.4.
|
|
14
|
-
__all__ = ["Plexus", "WebSocketTransport"]
|
|
13
|
+
__version__ = "0.4.8"
|
|
14
|
+
__all__ = ["Plexus", "WebSocketTransport", "read_mjpeg_frames"]
|
plexus/client.py
CHANGED
|
@@ -33,14 +33,18 @@ Usage:
|
|
|
33
33
|
Note: Requires authentication. Run 'plexus start' or set PLEXUS_API_KEY.
|
|
34
34
|
"""
|
|
35
35
|
|
|
36
|
+
import base64
|
|
36
37
|
import gzip
|
|
37
38
|
import json
|
|
38
39
|
import logging
|
|
39
40
|
import os
|
|
41
|
+
import shutil
|
|
42
|
+
import subprocess
|
|
40
43
|
import sys
|
|
44
|
+
import threading
|
|
41
45
|
import time
|
|
42
46
|
from contextlib import contextmanager
|
|
43
|
-
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
47
|
+
from typing import Any, Dict, Generator, List, Optional, Tuple, Union
|
|
44
48
|
|
|
45
49
|
import requests
|
|
46
50
|
|
|
@@ -74,6 +78,36 @@ def _say(line: str) -> None:
|
|
|
74
78
|
# Flexible value type - supports any JSON-serializable value
|
|
75
79
|
FlexValue = Union[int, float, str, bool, Dict[str, Any], List[Any]]
|
|
76
80
|
|
|
81
|
+
_JPEG_SOI = b"\xff\xd8"
|
|
82
|
+
_JPEG_EOI = b"\xff\xd9"
|
|
83
|
+
_FRAME_JPEG_MAX = 750_000 # gateway is 1MB; base64 × 1.33 + envelope ≈ 998KB at this size
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def read_mjpeg_frames(pipe, chunk: int = 65536) -> Generator[bytes, None, None]:
|
|
87
|
+
"""Read a raw MJPEG byte stream (e.g. FFmpeg stdout) and yield complete JPEG frames.
|
|
88
|
+
|
|
89
|
+
Scans for SOI (\xff\xd8) / EOI (\xff\xd9) markers to delimit frames.
|
|
90
|
+
Useful when building custom FFmpeg pipelines and handing off bytes to
|
|
91
|
+
send_video_frame().
|
|
92
|
+
"""
|
|
93
|
+
buf = b""
|
|
94
|
+
while True:
|
|
95
|
+
data = pipe.read(chunk)
|
|
96
|
+
if not data:
|
|
97
|
+
break
|
|
98
|
+
buf += data
|
|
99
|
+
while True:
|
|
100
|
+
start = buf.find(_JPEG_SOI)
|
|
101
|
+
if start == -1:
|
|
102
|
+
buf = b""
|
|
103
|
+
break
|
|
104
|
+
end = buf.find(_JPEG_EOI, start + 2)
|
|
105
|
+
if end == -1:
|
|
106
|
+
buf = buf[start:] # keep partial frame
|
|
107
|
+
break
|
|
108
|
+
yield buf[start:end + 2]
|
|
109
|
+
buf = buf[end + 2:]
|
|
110
|
+
|
|
77
111
|
|
|
78
112
|
class PlexusError(Exception):
|
|
79
113
|
"""Base exception for Plexus errors."""
|
|
@@ -134,6 +168,9 @@ class Plexus:
|
|
|
134
168
|
self._run_id: Optional[str] = None
|
|
135
169
|
self._session: Optional[requests.Session] = None
|
|
136
170
|
self._store_frames: bool = False
|
|
171
|
+
self._cv2 = None
|
|
172
|
+
self._pil_image = None # lazy PIL.Image import
|
|
173
|
+
self._fit_warned: bool = False
|
|
137
174
|
|
|
138
175
|
if transport not in ("ws", "http"):
|
|
139
176
|
raise ValueError(f"transport must be 'ws' or 'http', got {transport!r}")
|
|
@@ -342,6 +379,230 @@ class Plexus:
|
|
|
342
379
|
except Exception as e: # pragma: no cover - persistence failure is non-fatal
|
|
343
380
|
logger.debug("failed to persist assigned source_id: %s", e)
|
|
344
381
|
|
|
382
|
+
def _encode_frame(self, frame, quality: int) -> Tuple[bytes, int, int]:
|
|
383
|
+
"""Normalize any supported frame type to (jpeg_bytes, width, height).
|
|
384
|
+
|
|
385
|
+
Accepted inputs:
|
|
386
|
+
- bytes/bytearray: raw JPEG passthrough (magic \\xff\\xd8), or any
|
|
387
|
+
Pillow-readable format (PNG, BMP, WebP) which is decoded and re-encoded
|
|
388
|
+
- numpy ndarray: encoded via OpenCV (cv2 must be installed)
|
|
389
|
+
"""
|
|
390
|
+
import io
|
|
391
|
+
|
|
392
|
+
# --- bytes input ---
|
|
393
|
+
if isinstance(frame, (bytes, bytearray)):
|
|
394
|
+
if frame[:2] == b"\xff\xd8":
|
|
395
|
+
# Already JPEG — passthrough, extract dimensions via Pillow if available
|
|
396
|
+
try:
|
|
397
|
+
pil = self._get_pil()
|
|
398
|
+
img = pil.open(io.BytesIO(frame))
|
|
399
|
+
return bytes(frame), img.width, img.height
|
|
400
|
+
except Exception:
|
|
401
|
+
# Pillow unavailable or unreadable — send as-is, dimensions unknown
|
|
402
|
+
return bytes(frame), 0, 0
|
|
403
|
+
# Non-JPEG bytes (PNG, BMP, WebP, …) — Pillow decode then re-encode as JPEG
|
|
404
|
+
pil = self._get_pil(required=True)
|
|
405
|
+
img = pil.open(io.BytesIO(frame))
|
|
406
|
+
return self._pil_to_jpeg(img, quality)
|
|
407
|
+
|
|
408
|
+
# --- numpy array (OpenCV path) ---
|
|
409
|
+
if hasattr(frame, "shape"):
|
|
410
|
+
cv2 = self._get_cv2(required=True)
|
|
411
|
+
height, width = frame.shape[:2]
|
|
412
|
+
ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, quality])
|
|
413
|
+
if not ok:
|
|
414
|
+
raise PlexusError("cv2.imencode failed to encode frame")
|
|
415
|
+
return buf.tobytes(), width, height
|
|
416
|
+
|
|
417
|
+
raise ValueError(
|
|
418
|
+
f"Unsupported frame type: {type(frame).__name__}. "
|
|
419
|
+
"Expected bytes/bytearray (JPEG or Pillow-readable) or numpy ndarray."
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
def _get_cv2(self, required: bool = False):
|
|
423
|
+
if self._cv2 is None:
|
|
424
|
+
try:
|
|
425
|
+
import cv2 as _cv2
|
|
426
|
+
self._cv2 = _cv2
|
|
427
|
+
except ImportError as e:
|
|
428
|
+
if required:
|
|
429
|
+
raise ImportError(
|
|
430
|
+
"This frame type requires opencv-python. "
|
|
431
|
+
"Install with: pip install opencv-python"
|
|
432
|
+
) from e
|
|
433
|
+
return self._cv2
|
|
434
|
+
|
|
435
|
+
def _get_pil(self, required: bool = False):
|
|
436
|
+
if self._pil_image is None:
|
|
437
|
+
try:
|
|
438
|
+
import PIL.Image as _PILImage
|
|
439
|
+
self._pil_image = _PILImage
|
|
440
|
+
except ImportError as e:
|
|
441
|
+
if required:
|
|
442
|
+
raise ImportError(
|
|
443
|
+
"This frame type requires Pillow. "
|
|
444
|
+
"Install with: pip install plexus-python[video]"
|
|
445
|
+
) from e
|
|
446
|
+
return self._pil_image
|
|
447
|
+
|
|
448
|
+
def _pil_to_jpeg(self, img, quality: int) -> Tuple[bytes, int, int]:
|
|
449
|
+
import io
|
|
450
|
+
if img.mode not in ("RGB", "L"):
|
|
451
|
+
img = img.convert("RGB")
|
|
452
|
+
buf = io.BytesIO()
|
|
453
|
+
img.save(buf, format="JPEG", quality=quality)
|
|
454
|
+
return buf.getvalue(), img.width, img.height
|
|
455
|
+
|
|
456
|
+
def _fit_to_wire(self, jpeg_bytes: bytes, requested_quality: int) -> bytes:
|
|
457
|
+
"""Re-encode JPEG at lower quality if it would exceed the gateway 1MB limit.
|
|
458
|
+
|
|
459
|
+
Warns once per Plexus instance so the user sees the issue at startup
|
|
460
|
+
without being flooded during a live stream.
|
|
461
|
+
"""
|
|
462
|
+
import io
|
|
463
|
+
if len(jpeg_bytes) <= _FRAME_JPEG_MAX:
|
|
464
|
+
return jpeg_bytes
|
|
465
|
+
target_quality = max(10, int(requested_quality * _FRAME_JPEG_MAX / len(jpeg_bytes)))
|
|
466
|
+
pil = self._get_pil()
|
|
467
|
+
if pil is None:
|
|
468
|
+
if not self._fit_warned:
|
|
469
|
+
self._fit_warned = True
|
|
470
|
+
wire_kb = len(jpeg_bytes) * 4 // 3 // 1024
|
|
471
|
+
_say(
|
|
472
|
+
f"frame too large (~{wire_kb}KB on wire) and Pillow is not installed — "
|
|
473
|
+
"install plexus-python[video] to enable automatic downsampling"
|
|
474
|
+
)
|
|
475
|
+
return jpeg_bytes
|
|
476
|
+
try:
|
|
477
|
+
img = pil.open(io.BytesIO(jpeg_bytes))
|
|
478
|
+
buf = io.BytesIO()
|
|
479
|
+
if img.mode not in ("RGB", "L"):
|
|
480
|
+
img = img.convert("RGB")
|
|
481
|
+
img.save(buf, format="JPEG", quality=target_quality)
|
|
482
|
+
result = buf.getvalue()
|
|
483
|
+
if not self._fit_warned:
|
|
484
|
+
self._fit_warned = True
|
|
485
|
+
wire_kb = len(jpeg_bytes) * 4 // 3 // 1024
|
|
486
|
+
_say(
|
|
487
|
+
f"frame too large (quality={requested_quality}, ~{wire_kb}KB on wire), "
|
|
488
|
+
f"re-encoded at quality={target_quality} — lower quality or resolution to silence"
|
|
489
|
+
)
|
|
490
|
+
return result
|
|
491
|
+
except Exception as e:
|
|
492
|
+
logger.debug("_fit_to_wire re-encode failed: %s", e)
|
|
493
|
+
return jpeg_bytes
|
|
494
|
+
|
|
495
|
+
def send_video_frame(
|
|
496
|
+
self,
|
|
497
|
+
frame,
|
|
498
|
+
camera_id: str = "camera:0",
|
|
499
|
+
quality: int = 85,
|
|
500
|
+
timestamp: Optional[float] = None,
|
|
501
|
+
) -> bool:
|
|
502
|
+
"""Send a single video frame to Plexus (WebSocket transport only).
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
frame: One of:
|
|
506
|
+
- numpy ndarray (H, W, C) — from cv2.VideoCapture or picamera2
|
|
507
|
+
- bytes/bytearray — raw JPEG passthrough (zero re-encode), or any
|
|
508
|
+
Pillow-readable format (PNG, BMP, WebP) which is decoded and re-encoded
|
|
509
|
+
camera_id: Logical camera identifier (e.g. "picam:0", "usb:1")
|
|
510
|
+
quality: JPEG compression quality, 1-100. Default 85. Also used as the
|
|
511
|
+
baseline when adaptive downsampling kicks in for oversized frames.
|
|
512
|
+
timestamp: Unix timestamp in seconds. If not provided, uses current time.
|
|
513
|
+
|
|
514
|
+
Returns:
|
|
515
|
+
True if the frame was sent successfully.
|
|
516
|
+
|
|
517
|
+
Raises:
|
|
518
|
+
PlexusError: If transport is not 'ws'.
|
|
519
|
+
ValueError: If frame type is not supported.
|
|
520
|
+
ImportError: If a required optional dependency is missing.
|
|
521
|
+
"""
|
|
522
|
+
if self.transport != "ws":
|
|
523
|
+
raise PlexusError("send_video_frame requires transport='ws'")
|
|
524
|
+
|
|
525
|
+
jpeg_bytes, width, height = self._encode_frame(frame, quality)
|
|
526
|
+
jpeg_bytes = self._fit_to_wire(jpeg_bytes, quality)
|
|
527
|
+
b64 = base64.b64encode(jpeg_bytes).decode()
|
|
528
|
+
|
|
529
|
+
ws = self._ensure_ws()
|
|
530
|
+
if not ws.is_authenticated:
|
|
531
|
+
ws.wait_authenticated(timeout=min(self.timeout, 5.0))
|
|
532
|
+
|
|
533
|
+
return ws._send_frame({
|
|
534
|
+
"type": "video_frame",
|
|
535
|
+
"source_id": self.source_id,
|
|
536
|
+
"camera_id": camera_id,
|
|
537
|
+
"frame": b64,
|
|
538
|
+
"width": width,
|
|
539
|
+
"height": height,
|
|
540
|
+
"timestamp": self._normalize_ts_ms(timestamp),
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
def stream_camera(
|
|
544
|
+
self,
|
|
545
|
+
url: str,
|
|
546
|
+
camera_id: str = "camera:0",
|
|
547
|
+
fps: int = 15,
|
|
548
|
+
quality: int = 85,
|
|
549
|
+
) -> "threading.Event":
|
|
550
|
+
"""Stream video from an RTSP URL or file path via FFmpeg (WebSocket only).
|
|
551
|
+
|
|
552
|
+
Requires FFmpeg to be installed and available on $PATH.
|
|
553
|
+
|
|
554
|
+
Args:
|
|
555
|
+
url: RTSP stream URL (rtsp://...), video file path, or any FFmpeg-supported source.
|
|
556
|
+
camera_id: Logical camera identifier forwarded in each frame.
|
|
557
|
+
fps: Maximum frames per second to send. Default 15.
|
|
558
|
+
quality: JPEG quality for re-encoded frames, 1-100. Default 85.
|
|
559
|
+
|
|
560
|
+
Returns:
|
|
561
|
+
A threading.Event. Call .set() on it to stop streaming.
|
|
562
|
+
|
|
563
|
+
Raises:
|
|
564
|
+
PlexusError: If transport is not 'ws' or FFmpeg is not found.
|
|
565
|
+
|
|
566
|
+
Example:
|
|
567
|
+
stop = px.stream_camera("rtsp://192.168.1.100/stream", camera_id="front:0")
|
|
568
|
+
time.sleep(60)
|
|
569
|
+
stop.set()
|
|
570
|
+
"""
|
|
571
|
+
if self.transport != "ws":
|
|
572
|
+
raise PlexusError("stream_camera requires transport='ws'")
|
|
573
|
+
if shutil.which("ffmpeg") is None:
|
|
574
|
+
raise PlexusError(
|
|
575
|
+
"FFmpeg not found. Install it: https://ffmpeg.org/download.html"
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
stop_event = threading.Event()
|
|
579
|
+
|
|
580
|
+
def _run():
|
|
581
|
+
cmd = [
|
|
582
|
+
"ffmpeg", "-loglevel", "error",
|
|
583
|
+
"-i", url,
|
|
584
|
+
"-vf", f"fps={fps}",
|
|
585
|
+
"-f", "image2pipe",
|
|
586
|
+
"-vcodec", "mjpeg",
|
|
587
|
+
"pipe:1",
|
|
588
|
+
]
|
|
589
|
+
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
590
|
+
try:
|
|
591
|
+
for jpeg in read_mjpeg_frames(proc.stdout):
|
|
592
|
+
if stop_event.is_set():
|
|
593
|
+
break
|
|
594
|
+
try:
|
|
595
|
+
self.send_video_frame(jpeg, camera_id=camera_id, quality=quality)
|
|
596
|
+
except Exception as e:
|
|
597
|
+
logger.debug("stream_camera send error: %s", e)
|
|
598
|
+
finally:
|
|
599
|
+
proc.terminate()
|
|
600
|
+
proc.wait()
|
|
601
|
+
|
|
602
|
+
t = threading.Thread(target=_run, daemon=True)
|
|
603
|
+
t.start()
|
|
604
|
+
return stop_event
|
|
605
|
+
|
|
345
606
|
def on_command(
|
|
346
607
|
self,
|
|
347
608
|
name: str,
|
plexus/ws.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plexus-python
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.8
|
|
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
|
|
@@ -30,6 +30,8 @@ Requires-Dist: pytest; extra == 'dev'
|
|
|
30
30
|
Requires-Dist: pytest-cov; extra == 'dev'
|
|
31
31
|
Requires-Dist: ruff; extra == 'dev'
|
|
32
32
|
Requires-Dist: websockets>=12; extra == 'dev'
|
|
33
|
+
Provides-Extra: video
|
|
34
|
+
Requires-Dist: pillow>=9.0; extra == 'video'
|
|
33
35
|
Description-Content-Type: text/markdown
|
|
34
36
|
|
|
35
37
|
# plexus-python
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
plexus/__init__.py,sha256=TSQdqBAXxoyHxbeAr-UNcqZG411PCmxiM8kpI-hcc2s,385
|
|
2
|
+
plexus/buffer.py,sha256=3ykybqLs7yMXxQWFajAT8nGe3cs_lW8_6Xvn0vQ69dE,9262
|
|
3
|
+
plexus/cli.py,sha256=-2wvHXQzobx3_tDGTXpaE2PlHv884y93Mu29kZE8qZE,14214
|
|
4
|
+
plexus/client.py,sha256=qWooAr2-Y8FUr_wpwnjXwdygGuHhPZLCYuVVU2cBziY,33047
|
|
5
|
+
plexus/config.py,sha256=wsG6lhNLmKe3JRlVycyRUKQeywnPUPPfrWkXFxYwELE,6179
|
|
6
|
+
plexus/ws.py,sha256=4nmNganwS_1BujdFkH3u3lLBB7rEkPYd_oYrbdYkdY4,14818
|
|
7
|
+
plexus_python-0.4.8.dist-info/METADATA,sha256=ssT_r6xkkeT5E6SBx2RWCP008vYhlMqtFMyi1yBRhd4,8188
|
|
8
|
+
plexus_python-0.4.8.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
plexus_python-0.4.8.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
|
|
10
|
+
plexus_python-0.4.8.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
|
|
11
|
+
plexus_python-0.4.8.dist-info/RECORD,,
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
plexus/__init__.py,sha256=upqHP1A19Sp_CnCeCBHHJ1GUKKzeCJiJw5w3wD2K4cE,345
|
|
2
|
-
plexus/buffer.py,sha256=3ykybqLs7yMXxQWFajAT8nGe3cs_lW8_6Xvn0vQ69dE,9262
|
|
3
|
-
plexus/cli.py,sha256=-2wvHXQzobx3_tDGTXpaE2PlHv884y93Mu29kZE8qZE,14214
|
|
4
|
-
plexus/client.py,sha256=JmEKKRu4ik548uBPMEOCcB_r_asRmuNORZF4snjFosQ,22956
|
|
5
|
-
plexus/config.py,sha256=wsG6lhNLmKe3JRlVycyRUKQeywnPUPPfrWkXFxYwELE,6179
|
|
6
|
-
plexus/ws.py,sha256=aynJTH8LHRA4_U1OfIu-2aaOg1tLniUcWbzB2yuiQvU,14865
|
|
7
|
-
plexus_python-0.4.6.dist-info/METADATA,sha256=d6Vp27f4EsfSHeS0NT6z9oWF85UIsLskMzzborehyQQ,8121
|
|
8
|
-
plexus_python-0.4.6.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
-
plexus_python-0.4.6.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
|
|
10
|
-
plexus_python-0.4.6.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
|
|
11
|
-
plexus_python-0.4.6.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|