plexus-python 0.4.9__py3-none-any.whl → 0.5.1__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 +1 -1
- plexus/client.py +56 -23
- plexus/ws.py +85 -0
- {plexus_python-0.4.9.dist-info → plexus_python-0.5.1.dist-info}/METADATA +91 -19
- plexus_python-0.5.1.dist-info/RECORD +11 -0
- plexus_python-0.4.9.dist-info/RECORD +0 -11
- {plexus_python-0.4.9.dist-info → plexus_python-0.5.1.dist-info}/WHEEL +0 -0
- {plexus_python-0.4.9.dist-info → plexus_python-0.5.1.dist-info}/entry_points.txt +0 -0
- {plexus_python-0.4.9.dist-info → plexus_python-0.5.1.dist-info}/licenses/LICENSE +0 -0
plexus/__init__.py
CHANGED
|
@@ -10,5 +10,5 @@ Plexus — thin Python SDK for sending telemetry to the Plexus gateway.
|
|
|
10
10
|
from plexus.client import Plexus, read_mjpeg_frames
|
|
11
11
|
from plexus.ws import WebSocketTransport
|
|
12
12
|
|
|
13
|
-
__version__ = "0.
|
|
13
|
+
__version__ = "0.5.1"
|
|
14
14
|
__all__ = ["Plexus", "WebSocketTransport", "read_mjpeg_frames"]
|
plexus/client.py
CHANGED
|
@@ -33,21 +33,21 @@ Usage:
|
|
|
33
33
|
Note: Requires authentication. Run 'plexus start' or set PLEXUS_API_KEY.
|
|
34
34
|
"""
|
|
35
35
|
|
|
36
|
-
import base64
|
|
37
36
|
import gzip
|
|
38
37
|
import json
|
|
39
38
|
import logging
|
|
40
39
|
import os
|
|
41
40
|
import shutil
|
|
41
|
+
import socket
|
|
42
42
|
import subprocess
|
|
43
43
|
import sys
|
|
44
44
|
import threading
|
|
45
45
|
import time
|
|
46
|
+
import urllib.error
|
|
47
|
+
import urllib.request
|
|
46
48
|
from contextlib import contextmanager
|
|
47
49
|
from typing import Any, Dict, Generator, List, Optional, Tuple, Union
|
|
48
50
|
|
|
49
|
-
import requests
|
|
50
|
-
|
|
51
51
|
from plexus.buffer import BufferBackend, MemoryBuffer, SqliteBuffer
|
|
52
52
|
from plexus.config import (
|
|
53
53
|
RetryConfig,
|
|
@@ -61,6 +61,46 @@ from plexus.config import (
|
|
|
61
61
|
)
|
|
62
62
|
logger = logging.getLogger(__name__)
|
|
63
63
|
|
|
64
|
+
|
|
65
|
+
class _Response:
|
|
66
|
+
__slots__ = ("status_code", "text")
|
|
67
|
+
|
|
68
|
+
def __init__(self, status_code: int, text: str):
|
|
69
|
+
self.status_code = status_code
|
|
70
|
+
self.text = text
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _Session:
|
|
74
|
+
def __init__(self):
|
|
75
|
+
self.headers: Dict[str, str] = {}
|
|
76
|
+
|
|
77
|
+
def post(self, url: str, data: bytes = b"", headers: Optional[Dict[str, str]] = None, timeout: float = 10.0) -> "_Response":
|
|
78
|
+
req_headers = {**self.headers, **(headers or {})}
|
|
79
|
+
req = urllib.request.Request(url, data=data, headers=req_headers, method="POST")
|
|
80
|
+
try:
|
|
81
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
82
|
+
return _Response(resp.status, resp.read().decode("utf-8", errors="replace"))
|
|
83
|
+
except urllib.error.HTTPError as e:
|
|
84
|
+
return _Response(e.code, e.read().decode("utf-8", errors="replace"))
|
|
85
|
+
except urllib.error.URLError as e:
|
|
86
|
+
if isinstance(e.reason, socket.timeout):
|
|
87
|
+
raise _Timeout(str(e.reason))
|
|
88
|
+
raise _ConnError(str(e.reason))
|
|
89
|
+
except (TimeoutError, socket.timeout) as e:
|
|
90
|
+
raise _Timeout(str(e))
|
|
91
|
+
|
|
92
|
+
def close(self) -> None:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class _Timeout(OSError):
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class _ConnError(OSError):
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
|
|
64
104
|
# Status messages to stderr so users running `python my_script.py` see what's
|
|
65
105
|
# happening without having to configure logging. Set PLEXUS_QUIET=1 to disable.
|
|
66
106
|
_QUIET = os.environ.get("PLEXUS_QUIET", "").lower() in ("1", "true", "yes")
|
|
@@ -166,7 +206,7 @@ class Plexus:
|
|
|
166
206
|
self._max_buffer_size = max_buffer_size
|
|
167
207
|
|
|
168
208
|
self._run_id: Optional[str] = None
|
|
169
|
-
self._session: Optional[
|
|
209
|
+
self._session: Optional[_Session] = None
|
|
170
210
|
self._store_frames: bool = False
|
|
171
211
|
self._cv2 = None
|
|
172
212
|
self._pil_image = None # lazy PIL.Image import
|
|
@@ -202,10 +242,9 @@ class Plexus:
|
|
|
202
242
|
self._max_buffer_size = value
|
|
203
243
|
self._buffer._max_size = value
|
|
204
244
|
|
|
205
|
-
def _get_session(self) ->
|
|
206
|
-
"""Get or create a requests session for connection pooling."""
|
|
245
|
+
def _get_session(self) -> _Session:
|
|
207
246
|
if self._session is None:
|
|
208
|
-
self._session =
|
|
247
|
+
self._session = _Session()
|
|
209
248
|
if self.api_key:
|
|
210
249
|
self._session.headers["x-api-key"] = self.api_key
|
|
211
250
|
self._session.headers["Content-Type"] = "application/json"
|
|
@@ -524,21 +563,15 @@ class Plexus:
|
|
|
524
563
|
|
|
525
564
|
jpeg_bytes, width, height = self._encode_frame(frame, quality)
|
|
526
565
|
jpeg_bytes = self._fit_to_wire(jpeg_bytes, quality)
|
|
527
|
-
b64 = base64.b64encode(jpeg_bytes).decode()
|
|
528
566
|
|
|
529
567
|
ws = self._ensure_ws()
|
|
530
568
|
if not ws.is_authenticated:
|
|
531
569
|
ws.wait_authenticated(timeout=min(self.timeout, 5.0))
|
|
532
570
|
|
|
533
|
-
return ws.
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
"frame": b64,
|
|
538
|
-
"width": width,
|
|
539
|
-
"height": height,
|
|
540
|
-
"timestamp": self._normalize_ts_ms(timestamp),
|
|
541
|
-
})
|
|
571
|
+
return ws.send_video_frame_async(
|
|
572
|
+
self.source_id, camera_id, jpeg_bytes, width, height,
|
|
573
|
+
self._normalize_ts_ms(timestamp),
|
|
574
|
+
)
|
|
542
575
|
|
|
543
576
|
def stream_camera(
|
|
544
577
|
self,
|
|
@@ -732,14 +765,14 @@ class Plexus:
|
|
|
732
765
|
f"API error: {response.status_code} - {response.text}"
|
|
733
766
|
)
|
|
734
767
|
|
|
735
|
-
except
|
|
768
|
+
except _Timeout:
|
|
736
769
|
last_error = PlexusError(f"Request timed out after {self.timeout}s")
|
|
737
770
|
if attempt < self.retry_config.max_retries:
|
|
738
771
|
time.sleep(self.retry_config.get_delay(attempt))
|
|
739
772
|
continue
|
|
740
773
|
break
|
|
741
774
|
|
|
742
|
-
except
|
|
775
|
+
except _ConnError as e:
|
|
743
776
|
last_error = PlexusError(f"Connection failed: {e}")
|
|
744
777
|
if attempt < self.retry_config.max_retries:
|
|
745
778
|
time.sleep(self.retry_config.get_delay(attempt))
|
|
@@ -839,13 +872,13 @@ class Plexus:
|
|
|
839
872
|
try:
|
|
840
873
|
self._get_session().post(
|
|
841
874
|
f"{self.endpoint}/api/runs",
|
|
842
|
-
json
|
|
875
|
+
data=json.dumps({
|
|
843
876
|
"run_id": run_id,
|
|
844
877
|
"source_id": self.source_id,
|
|
845
878
|
"status": "started",
|
|
846
879
|
"tags": tags,
|
|
847
880
|
"timestamp": (int(time.time() * 1000) + self._clock_offset_ms) / 1000,
|
|
848
|
-
},
|
|
881
|
+
}).encode("utf-8"),
|
|
849
882
|
timeout=self.timeout,
|
|
850
883
|
)
|
|
851
884
|
except Exception as e:
|
|
@@ -858,12 +891,12 @@ class Plexus:
|
|
|
858
891
|
try:
|
|
859
892
|
self._get_session().post(
|
|
860
893
|
f"{self.endpoint}/api/runs",
|
|
861
|
-
json
|
|
894
|
+
data=json.dumps({
|
|
862
895
|
"run_id": run_id,
|
|
863
896
|
"source_id": self.source_id,
|
|
864
897
|
"status": "ended",
|
|
865
898
|
"timestamp": (int(time.time() * 1000) + self._clock_offset_ms) / 1000,
|
|
866
|
-
},
|
|
899
|
+
}).encode("utf-8"),
|
|
867
900
|
timeout=self.timeout,
|
|
868
901
|
)
|
|
869
902
|
except Exception as e:
|
plexus/ws.py
CHANGED
|
@@ -29,7 +29,9 @@ import atexit
|
|
|
29
29
|
import json
|
|
30
30
|
import logging
|
|
31
31
|
import os
|
|
32
|
+
import queue
|
|
32
33
|
import random
|
|
34
|
+
import struct
|
|
33
35
|
import sys
|
|
34
36
|
import threading
|
|
35
37
|
import time
|
|
@@ -134,6 +136,8 @@ class WebSocketTransport:
|
|
|
134
136
|
self._thread: Optional[threading.Thread] = None
|
|
135
137
|
self._backoff_attempt = 0
|
|
136
138
|
self._clock_offset_ms: int = 0
|
|
139
|
+
self._video_queue: "queue.Queue[bytes]" = queue.Queue(maxsize=2)
|
|
140
|
+
self._video_thread: Optional[threading.Thread] = None
|
|
137
141
|
|
|
138
142
|
# ------------------------------------------------------------------ public
|
|
139
143
|
|
|
@@ -159,6 +163,10 @@ class WebSocketTransport:
|
|
|
159
163
|
target=self._run, name="plexus-ws", daemon=True
|
|
160
164
|
)
|
|
161
165
|
self._thread.start()
|
|
166
|
+
self._video_thread = threading.Thread(
|
|
167
|
+
target=self._video_sender_loop, name="plexus-video", daemon=True
|
|
168
|
+
)
|
|
169
|
+
self._video_thread.start()
|
|
162
170
|
atexit.register(self.stop)
|
|
163
171
|
|
|
164
172
|
def stop(self, timeout: float = 2.0) -> None:
|
|
@@ -172,6 +180,8 @@ class WebSocketTransport:
|
|
|
172
180
|
pass
|
|
173
181
|
if self._thread:
|
|
174
182
|
self._thread.join(timeout=timeout)
|
|
183
|
+
if self._video_thread:
|
|
184
|
+
self._video_thread.join(timeout=timeout)
|
|
175
185
|
|
|
176
186
|
def wait_authenticated(self, timeout: float = AUTH_TIMEOUT_S) -> bool:
|
|
177
187
|
return self._authenticated.wait(timeout=timeout)
|
|
@@ -194,6 +204,28 @@ class WebSocketTransport:
|
|
|
194
204
|
frame = {"type": "telemetry", "points": points}
|
|
195
205
|
return self._send_frame(frame)
|
|
196
206
|
|
|
207
|
+
def send_video_frame_async(
|
|
208
|
+
self,
|
|
209
|
+
source_id: str,
|
|
210
|
+
camera_id: str,
|
|
211
|
+
jpeg_bytes: bytes,
|
|
212
|
+
width: int,
|
|
213
|
+
height: int,
|
|
214
|
+
timestamp_ms: int,
|
|
215
|
+
) -> bool:
|
|
216
|
+
"""Encode and enqueue a binary video frame. Non-blocking — drops the
|
|
217
|
+
frame if the queue is full rather than blocking the caller."""
|
|
218
|
+
if not self._authenticated.is_set():
|
|
219
|
+
return False
|
|
220
|
+
payload = _encode_binary_video_frame(
|
|
221
|
+
source_id, camera_id, jpeg_bytes, width, height, timestamp_ms
|
|
222
|
+
)
|
|
223
|
+
try:
|
|
224
|
+
self._video_queue.put_nowait(payload)
|
|
225
|
+
return True
|
|
226
|
+
except queue.Full:
|
|
227
|
+
return False
|
|
228
|
+
|
|
197
229
|
# ------------------------------------------------------------------ thread
|
|
198
230
|
|
|
199
231
|
def _run(self) -> None:
|
|
@@ -228,6 +260,28 @@ class WebSocketTransport:
|
|
|
228
260
|
if self._stop.wait(timeout=delay):
|
|
229
261
|
break
|
|
230
262
|
|
|
263
|
+
def _video_sender_loop(self) -> None:
|
|
264
|
+
"""Drain _video_queue and send binary WebSocket frames.
|
|
265
|
+
|
|
266
|
+
Runs on a dedicated thread so slow sends never block the caller.
|
|
267
|
+
Drops frames during reconnect rather than queuing stale video.
|
|
268
|
+
"""
|
|
269
|
+
while not self._stop.is_set():
|
|
270
|
+
try:
|
|
271
|
+
payload = self._video_queue.get(timeout=0.5)
|
|
272
|
+
except queue.Empty:
|
|
273
|
+
continue
|
|
274
|
+
if not self._authenticated.is_set():
|
|
275
|
+
continue # drop during auth / reconnect
|
|
276
|
+
with self._ws_lock:
|
|
277
|
+
ws = self._ws
|
|
278
|
+
if ws is None:
|
|
279
|
+
continue
|
|
280
|
+
try:
|
|
281
|
+
ws.send_binary(payload)
|
|
282
|
+
except Exception as e:
|
|
283
|
+
logger.debug("plexus video send failed: %s", e)
|
|
284
|
+
|
|
231
285
|
def _connect_and_serve(self) -> None:
|
|
232
286
|
ws = websocket.create_connection(self.ws_url, timeout=AUTH_TIMEOUT_S)
|
|
233
287
|
with self._ws_lock:
|
|
@@ -400,6 +454,37 @@ class WebSocketTransport:
|
|
|
400
454
|
# --------------------------------------------------------------------- helpers
|
|
401
455
|
|
|
402
456
|
|
|
457
|
+
def _encode_binary_video_frame(
|
|
458
|
+
source_id: str,
|
|
459
|
+
camera_id: str,
|
|
460
|
+
jpeg_bytes: bytes,
|
|
461
|
+
width: int,
|
|
462
|
+
height: int,
|
|
463
|
+
timestamp_ms: int,
|
|
464
|
+
) -> bytes:
|
|
465
|
+
"""Pack a video frame into the binary wire format.
|
|
466
|
+
|
|
467
|
+
Wire layout:
|
|
468
|
+
[0x01] 1 byte version
|
|
469
|
+
[src_len] 1 byte source_id byte length (capped at 255)
|
|
470
|
+
[source_id] N bytes
|
|
471
|
+
[cam_len] 1 byte camera_id byte length (capped at 255)
|
|
472
|
+
[camera_id] M bytes
|
|
473
|
+
[width] 4 bytes uint32 big-endian
|
|
474
|
+
[height] 4 bytes uint32 big-endian
|
|
475
|
+
[timestamp_ms] 8 bytes int64 big-endian
|
|
476
|
+
[jpeg_bytes] rest
|
|
477
|
+
"""
|
|
478
|
+
src = source_id.encode("utf-8")[:255]
|
|
479
|
+
cam = camera_id.encode("utf-8")[:255]
|
|
480
|
+
header = (
|
|
481
|
+
bytes([0x01, len(src)]) + src
|
|
482
|
+
+ bytes([len(cam)]) + cam
|
|
483
|
+
+ struct.pack(">IIq", width, height, timestamp_ms)
|
|
484
|
+
)
|
|
485
|
+
return header + jpeg_bytes
|
|
486
|
+
|
|
487
|
+
|
|
403
488
|
def _ensure_device_path(url: str) -> str:
|
|
404
489
|
url = url.rstrip("/")
|
|
405
490
|
if url.endswith("/ws/device"):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plexus-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.1
|
|
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
|
|
@@ -15,22 +15,20 @@ Classifier: Intended Audience :: Developers
|
|
|
15
15
|
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
16
|
Classifier: Operating System :: OS Independent
|
|
17
17
|
Classifier: Programming Language :: Python :: 3
|
|
18
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
19
18
|
Classifier: Programming Language :: Python :: 3.10
|
|
20
19
|
Classifier: Programming Language :: Python :: 3.11
|
|
21
20
|
Classifier: Programming Language :: Python :: 3.12
|
|
22
21
|
Classifier: Topic :: Scientific/Engineering
|
|
23
22
|
Classifier: Topic :: System :: Hardware
|
|
24
|
-
Requires-Python: >=3.
|
|
25
|
-
Requires-Dist: requests>=2.32.4
|
|
23
|
+
Requires-Python: >=3.10
|
|
26
24
|
Requires-Dist: websocket-client>=1.7
|
|
27
25
|
Provides-Extra: dev
|
|
28
26
|
Requires-Dist: pytest-cov; extra == 'dev'
|
|
29
|
-
Requires-Dist: pytest>=
|
|
27
|
+
Requires-Dist: pytest>=9.0.3; extra == 'dev'
|
|
30
28
|
Requires-Dist: ruff; extra == 'dev'
|
|
31
29
|
Requires-Dist: websockets>=12; extra == 'dev'
|
|
32
30
|
Provides-Extra: video
|
|
33
|
-
Requires-Dist: pillow>=
|
|
31
|
+
Requires-Dist: pillow>=12.2.0; extra == 'video'
|
|
34
32
|
Description-Content-Type: text/markdown
|
|
35
33
|
|
|
36
34
|
# plexus-python
|
|
@@ -70,34 +68,108 @@ The name must match `^[a-z0-9][a-z0-9_-]{1,62}$`. `setup.sh` refuses to run with
|
|
|
70
68
|
|
|
71
69
|
In normal code, you usually just pass `source_id=...` explicitly to `Plexus(...)` and never have to think about it.
|
|
72
70
|
|
|
73
|
-
##
|
|
71
|
+
## Core methods
|
|
74
72
|
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
### `send(metric, value)` — stream a reading
|
|
74
|
+
|
|
75
|
+
The main method. Call it every time you have a new sensor reading.
|
|
77
76
|
|
|
77
|
+
```python
|
|
78
78
|
px = Plexus(source_id="rig-01") # reads PLEXUS_API_KEY from env
|
|
79
79
|
|
|
80
|
-
# Numbers
|
|
81
80
|
px.send("engine.rpm", 3450)
|
|
82
|
-
px.send("coolant.
|
|
81
|
+
px.send("coolant.temp", 82.3)
|
|
82
|
+
```
|
|
83
83
|
|
|
84
|
-
|
|
85
|
-
px.send("vehicle.state", "RUNNING")
|
|
86
|
-
px.send("motor.enabled", True)
|
|
87
|
-
px.send("position", {"x": 1.5, "y": 2.3, "z": 0.8})
|
|
84
|
+
`metric` is a dot-namespaced string (`"motor.rpm"`, `"gps.fix_quality"`). `value` accepts any JSON-serializable type:
|
|
88
85
|
|
|
89
|
-
|
|
86
|
+
| Type | Example | When to use |
|
|
87
|
+
|------|---------|-------------|
|
|
88
|
+
| `float` / `int` | `72.5`, `3450` | Sensor readings, counters |
|
|
89
|
+
| `str` | `"RUNNING"`, `"E_STALL"` | State machines, error codes |
|
|
90
|
+
| `bool` | `True` | Binary flags |
|
|
91
|
+
| `dict` | `{"x": 1.5, "y": 2.3}` | Vectors, structured readings |
|
|
92
|
+
| `list` | `[0.5, 1.2, -0.3]` | Waveforms, joint angles |
|
|
93
|
+
|
|
94
|
+
Optional arguments:
|
|
95
|
+
- `tags={"motor_id": "A1"}` — key-value labels for filtering in the dashboard
|
|
96
|
+
- `timestamp=t` — explicit Unix timestamp in seconds; omit to let the SDK pick (see [Timestamps](#timestamps-and-clock-correction))
|
|
97
|
+
|
|
98
|
+
### `send_batch(points)` — send multiple readings at once
|
|
99
|
+
|
|
100
|
+
Use this when you sample several sensors together and want them to share a timestamp and land in one network call.
|
|
101
|
+
|
|
102
|
+
```python
|
|
90
103
|
px.send_batch([
|
|
91
|
-
("temperature",
|
|
92
|
-
("
|
|
104
|
+
("temperature", 22.4),
|
|
105
|
+
("humidity", 58.1),
|
|
106
|
+
("pressure", 1013.2),
|
|
93
107
|
])
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`points` is a list of `(metric, value)` tuples. All points share the same timestamp (now, unless you pass `timestamp=t`). For independent timestamps per point, call `send()` in a loop instead.
|
|
111
|
+
|
|
112
|
+
### `event(name, data)` — record a discrete occurrence
|
|
113
|
+
|
|
114
|
+
Use `event()` for things that *happen* rather than things you *measure continuously*. Faults, state transitions, operator actions, log entries — anything you'd put on a timeline as a marker rather than plot as a graph.
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
px.event("fault", "E-stop triggered")
|
|
118
|
+
px.event("state_change", {"from": "IDLE", "to": "RUNNING"})
|
|
119
|
+
px.event("sensor_error", {"sensor": "imu", "code": 42}, tags={"motor": "A"})
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The platform displays events as markers overlaid on your telemetry charts, not as time-series lines.
|
|
123
|
+
|
|
124
|
+
### `run(run_id)` — group data into a named recording
|
|
94
125
|
|
|
95
|
-
|
|
126
|
+
```python
|
|
96
127
|
with px.run("thermal-cycle-001"):
|
|
97
128
|
while running:
|
|
98
129
|
px.send("temperature", read_temp())
|
|
99
130
|
```
|
|
100
131
|
|
|
132
|
+
All `send()` calls inside the context are tagged with `run_id`, making it easy to isolate and replay that slice of data in the dashboard.
|
|
133
|
+
|
|
134
|
+
## Video streaming
|
|
135
|
+
|
|
136
|
+
Two methods depending on whether you control the capture loop or just have a URL.
|
|
137
|
+
|
|
138
|
+
### `send_video_frame(frame, camera_id)` — send frames you capture yourself
|
|
139
|
+
|
|
140
|
+
Use this when your code owns the capture loop — a `picamera2` callback, an OpenCV `VideoCapture` loop, or an FFmpeg pipe you manage. Pass each frame and the SDK ships it to Plexus over WebSocket.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
import cv2
|
|
144
|
+
|
|
145
|
+
cap = cv2.VideoCapture(0)
|
|
146
|
+
while True:
|
|
147
|
+
ok, frame = cap.read()
|
|
148
|
+
if ok:
|
|
149
|
+
px.send_video_frame(frame, camera_id="front")
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Accepted frame types:
|
|
153
|
+
- **numpy ndarray** (H × W × C) — from OpenCV or picamera2; requires `opencv-python`
|
|
154
|
+
- **JPEG bytes** — passed through as-is, zero re-encode overhead
|
|
155
|
+
- **Other image bytes** (PNG, BMP, WebP) — decoded and re-encoded as JPEG via Pillow; requires `pip install plexus-python[video]`
|
|
156
|
+
|
|
157
|
+
`camera_id` identifies which camera the frame came from. Use distinct IDs when streaming from multiple cameras simultaneously (`"front"`, `"rear"`, `"cam:0"`).
|
|
158
|
+
|
|
159
|
+
### `stream_camera(url, camera_id)` — stream from an RTSP URL or file
|
|
160
|
+
|
|
161
|
+
Use this when you have an RTSP stream or video file and don't want to manage the capture loop yourself. The SDK runs FFmpeg internally and handles the rest. Requires FFmpeg on `$PATH`.
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
stop = px.stream_camera("rtsp://192.168.1.100/stream", camera_id="front")
|
|
165
|
+
# ... do other work ...
|
|
166
|
+
stop.set() # stop streaming
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Returns a `threading.Event` — call `.set()` to stop. Runs in a background thread so it doesn't block your main loop.
|
|
170
|
+
|
|
171
|
+
**Which to use:** if you're piping from `rpicam-vid`, `picamera2`, or your own capture process, use `send_video_frame()`. If you have an RTSP URL or file path, use `stream_camera()`.
|
|
172
|
+
|
|
101
173
|
## Bring Your Own Protocol
|
|
102
174
|
|
|
103
175
|
This package ships no adapters, auto-detection, or daemons — just the client. Use whatever library you'd use anyway and pipe values into `px.send()`.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
plexus/__init__.py,sha256=mNRP4PCXuMoMvaKh05M-1u3GrJL4TAhqA8nEY1xdiSY,385
|
|
2
|
+
plexus/buffer.py,sha256=3ykybqLs7yMXxQWFajAT8nGe3cs_lW8_6Xvn0vQ69dE,9262
|
|
3
|
+
plexus/cli.py,sha256=-2wvHXQzobx3_tDGTXpaE2PlHv884y93Mu29kZE8qZE,14214
|
|
4
|
+
plexus/client.py,sha256=z8CNy0EFA0Ol3If1lIKgyndYYaqD67lvB0iuGV62Nsc,34034
|
|
5
|
+
plexus/config.py,sha256=wsG6lhNLmKe3JRlVycyRUKQeywnPUPPfrWkXFxYwELE,6179
|
|
6
|
+
plexus/ws.py,sha256=lSVv-Yf4ODZ0TaziKEF9pEmgVOLAJHMlluULqVydePs,17683
|
|
7
|
+
plexus_python-0.5.1.dist-info/METADATA,sha256=HHSsxh19qh99fkDFErlFzdCqxtT3zj0q4VCj8jre72Y,11531
|
|
8
|
+
plexus_python-0.5.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
plexus_python-0.5.1.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
|
|
10
|
+
plexus_python-0.5.1.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
|
|
11
|
+
plexus_python-0.5.1.dist-info/RECORD,,
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
plexus/__init__.py,sha256=7LgRvPPwXqrArG6-2PM6ActdnpZVl18a4aEk37wy71s,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.9.dist-info/METADATA,sha256=TW8FtYMzeaFvRGS43MCr1sEzOgbJTEhITow7B0mMbzA,8148
|
|
8
|
-
plexus_python-0.4.9.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
-
plexus_python-0.4.9.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
|
|
10
|
-
plexus_python-0.4.9.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
|
|
11
|
-
plexus_python-0.4.9.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|