plexus-python 0.4.8__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 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.4.8"
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[requests.Session] = None
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) -> requests.Session:
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 = requests.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._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
- })
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 requests.exceptions.Timeout:
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 requests.exceptions.ConnectionError as e:
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.4.8
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,23 +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.8
19
- Classifier: Programming Language :: Python :: 3.9
20
18
  Classifier: Programming Language :: Python :: 3.10
21
19
  Classifier: Programming Language :: Python :: 3.11
22
20
  Classifier: Programming Language :: Python :: 3.12
23
21
  Classifier: Topic :: Scientific/Engineering
24
22
  Classifier: Topic :: System :: Hardware
25
- Requires-Python: >=3.8
26
- Requires-Dist: requests>=2.28.0
23
+ Requires-Python: >=3.10
27
24
  Requires-Dist: websocket-client>=1.7
28
25
  Provides-Extra: dev
29
- Requires-Dist: pytest; extra == 'dev'
30
26
  Requires-Dist: pytest-cov; extra == 'dev'
27
+ Requires-Dist: pytest>=9.0.3; extra == 'dev'
31
28
  Requires-Dist: ruff; extra == 'dev'
32
29
  Requires-Dist: websockets>=12; extra == 'dev'
33
30
  Provides-Extra: video
34
- Requires-Dist: pillow>=9.0; extra == 'video'
31
+ Requires-Dist: pillow>=12.2.0; extra == 'video'
35
32
  Description-Content-Type: text/markdown
36
33
 
37
34
  # plexus-python
@@ -71,34 +68,108 @@ The name must match `^[a-z0-9][a-z0-9_-]{1,62}$`. `setup.sh` refuses to run with
71
68
 
72
69
  In normal code, you usually just pass `source_id=...` explicitly to `Plexus(...)` and never have to think about it.
73
70
 
74
- ## Usage
71
+ ## Core methods
75
72
 
76
- ```python
77
- from plexus import Plexus
73
+ ### `send(metric, value)` — stream a reading
74
+
75
+ The main method. Call it every time you have a new sensor reading.
78
76
 
77
+ ```python
79
78
  px = Plexus(source_id="rig-01") # reads PLEXUS_API_KEY from env
80
79
 
81
- # Numbers
82
80
  px.send("engine.rpm", 3450)
83
- px.send("coolant.temperature", 82.3, tags={"unit": "C"})
81
+ px.send("coolant.temp", 82.3)
82
+ ```
84
83
 
85
- # Strings, bools, objects, arrays all JSON-serializable
86
- px.send("vehicle.state", "RUNNING")
87
- px.send("motor.enabled", True)
88
- 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:
89
85
 
90
- # Batch
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
91
103
  px.send_batch([
92
- ("temperature", 72.5),
93
- ("pressure", 1013.25),
104
+ ("temperature", 22.4),
105
+ ("humidity", 58.1),
106
+ ("pressure", 1013.2),
94
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
95
125
 
96
- # Named run for grouping related data
126
+ ```python
97
127
  with px.run("thermal-cycle-001"):
98
128
  while running:
99
129
  px.send("temperature", read_temp())
100
130
  ```
101
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
+
102
173
  ## Bring Your Own Protocol
103
174
 
104
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=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,,