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 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.6"
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
@@ -292,7 +292,6 @@ class WebSocketTransport:
292
292
  _say(f"✓ Reconnected as {self.source_id}")
293
293
  else:
294
294
  _say(f"✓ Connected to gateway as {self.source_id}")
295
- _say(f" endpoint: {self.ws_url}")
296
295
 
297
296
  # 3. Read loop with heartbeat pump
298
297
  ws.settimeout(1.0)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plexus-python
3
- Version: 0.4.6
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,,