plexus-python 0.4.8__tar.gz → 0.5.1__tar.gz

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.
Files changed (44) hide show
  1. {plexus_python-0.4.8 → plexus_python-0.5.1}/.github/workflows/ci.yml +1 -1
  2. {plexus_python-0.4.8 → plexus_python-0.5.1}/CHANGELOG.md +59 -1
  3. {plexus_python-0.4.8 → plexus_python-0.5.1}/PKG-INFO +91 -20
  4. {plexus_python-0.4.8 → plexus_python-0.5.1}/README.md +87 -13
  5. {plexus_python-0.4.8 → plexus_python-0.5.1}/plexus/__init__.py +1 -1
  6. {plexus_python-0.4.8 → plexus_python-0.5.1}/plexus/client.py +56 -23
  7. {plexus_python-0.4.8 → plexus_python-0.5.1}/plexus/ws.py +85 -0
  8. {plexus_python-0.4.8 → plexus_python-0.5.1}/pyproject.toml +4 -7
  9. plexus_python-0.5.1/scripts/release.sh +61 -0
  10. {plexus_python-0.4.8 → plexus_python-0.5.1}/tests/test_retry.py +10 -11
  11. plexus_python-0.5.1/uv.lock +503 -0
  12. plexus_python-0.4.8/uv.lock +0 -1480
  13. {plexus_python-0.4.8 → plexus_python-0.5.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  14. {plexus_python-0.4.8 → plexus_python-0.5.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  15. {plexus_python-0.4.8 → plexus_python-0.5.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  16. {plexus_python-0.4.8 → plexus_python-0.5.1}/.github/workflows/publish.yml +0 -0
  17. {plexus_python-0.4.8 → plexus_python-0.5.1}/.gitignore +0 -0
  18. {plexus_python-0.4.8 → plexus_python-0.5.1}/AGENTS.md +0 -0
  19. {plexus_python-0.4.8 → plexus_python-0.5.1}/API.md +0 -0
  20. {plexus_python-0.4.8 → plexus_python-0.5.1}/CODE_OF_CONDUCT.md +0 -0
  21. {plexus_python-0.4.8 → plexus_python-0.5.1}/CONTRIBUTING.md +0 -0
  22. {plexus_python-0.4.8 → plexus_python-0.5.1}/LICENSE +0 -0
  23. {plexus_python-0.4.8 → plexus_python-0.5.1}/SECURITY.md +0 -0
  24. {plexus_python-0.4.8 → plexus_python-0.5.1}/examples/.python-version +0 -0
  25. {plexus_python-0.4.8 → plexus_python-0.5.1}/examples/README.md +0 -0
  26. {plexus_python-0.4.8 → plexus_python-0.5.1}/examples/basic.py +0 -0
  27. {plexus_python-0.4.8 → plexus_python-0.5.1}/examples/can.py +0 -0
  28. {plexus_python-0.4.8 → plexus_python-0.5.1}/examples/i2c_bme280.py +0 -0
  29. {plexus_python-0.4.8 → plexus_python-0.5.1}/examples/mac_metrics.py +0 -0
  30. {plexus_python-0.4.8 → plexus_python-0.5.1}/examples/mavlink.py +0 -0
  31. {plexus_python-0.4.8 → plexus_python-0.5.1}/examples/mqtt.py +0 -0
  32. {plexus_python-0.4.8 → plexus_python-0.5.1}/examples/pyproject.toml +0 -0
  33. {plexus_python-0.4.8 → plexus_python-0.5.1}/examples/uv.lock +0 -0
  34. {plexus_python-0.4.8 → plexus_python-0.5.1}/plexus/buffer.py +0 -0
  35. {plexus_python-0.4.8 → plexus_python-0.5.1}/plexus/cli.py +0 -0
  36. {plexus_python-0.4.8 → plexus_python-0.5.1}/plexus/config.py +0 -0
  37. {plexus_python-0.4.8 → plexus_python-0.5.1}/scripts/plexus.service +0 -0
  38. {plexus_python-0.4.8 → plexus_python-0.5.1}/scripts/scan_buses.py +0 -0
  39. {plexus_python-0.4.8 → plexus_python-0.5.1}/scripts/setup.sh +0 -0
  40. {plexus_python-0.4.8 → plexus_python-0.5.1}/tests/test_basic.py +0 -0
  41. {plexus_python-0.4.8 → plexus_python-0.5.1}/tests/test_buffer.py +0 -0
  42. {plexus_python-0.4.8 → plexus_python-0.5.1}/tests/test_config.py +0 -0
  43. {plexus_python-0.4.8 → plexus_python-0.5.1}/tests/test_video.py +0 -0
  44. {plexus_python-0.4.8 → plexus_python-0.5.1}/tests/test_ws.py +0 -0
@@ -31,7 +31,7 @@ jobs:
31
31
  runs-on: ubuntu-latest
32
32
  strategy:
33
33
  matrix:
34
- python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
34
+ python-version: ['3.10', '3.11', '3.12', '3.13']
35
35
 
36
36
  steps:
37
37
  - uses: actions/checkout@v4
@@ -1,6 +1,60 @@
1
1
  # Changelog
2
2
 
3
- ## [0.4.8] - 2026-05-19 - Video input broadening and wire safety
3
+ ## [0.5.1] - 2026-05-19 - Binary video frames + non-blocking send
4
+
5
+ ### Performance
6
+
7
+ - `send_video_frame()` now sends a compact binary WebSocket frame instead of
8
+ JSON+base64. The binary header encodes source_id, camera_id, width, height,
9
+ and timestamp_ms; the JPEG payload follows raw. Eliminates the 33% base64
10
+ wire overhead, reducing per-frame bandwidth by ~25% and raising the
11
+ sustainable FPS ceiling from ~15–20 fps to ~20–25 fps at 1280×720 quality 85.
12
+ - Gateway decodes the binary header and re-encodes as JSON+base64 before
13
+ relaying to browsers — no changes required in the frontend, data_api, or any
14
+ other consumer.
15
+
16
+ ### Reliability
17
+
18
+ - `send_video_frame()` is now non-blocking. Frames are placed into a
19
+ `queue.Queue(maxsize=2)` drained by a dedicated `plexus-video` daemon thread.
20
+ When the queue is full (sender backlogged) frames are dropped rather than
21
+ blocking the capture pipeline, preventing deadlocks at any FPS.
22
+ - `stop()` / `close()` now exits cleanly within 0.5 s regardless of in-flight
23
+ sends. Previously a slow or hung WebSocket write could stall shutdown
24
+ indefinitely.
25
+
26
+ ### Changed
27
+
28
+ - Removed `import base64` from `client.py` (no longer needed on the send path).
29
+ - `send_video_frame()` calls `ws.send_video_frame_async()` instead of the
30
+ internal `ws._send_frame()`.
31
+
32
+ ### Wire protocol
33
+
34
+ - Gateway handles both binary frames (SDK ≥ 0.5.1) and legacy JSON text frames
35
+ transparently — older SDKs continue to work unchanged.
36
+
37
+ ## [0.5.0] - 2026-05-19 - Security hardening, dep cleanup, Python 3.10+ only
38
+
39
+ ### Security
40
+
41
+ - Removed `requests` (and its transitive deps `urllib3`, `idna`) entirely —
42
+ replaced with stdlib `urllib.request`. Closes 6 Dependabot alerts (#6, #9,
43
+ #10, #11, #12, #13, #19) by eliminating the vulnerability surface rather than
44
+ patching it.
45
+ - Bumped `Pillow>=12.2.0` (fixes #14, #15, #16, #17, #18, #20 — OOB write,
46
+ FITS decompression bomb, font integer overflow, PDF parsing DoS, and related
47
+ CVEs).
48
+ - Bumped `pytest>=9.0.3` in dev deps (fixes #7).
49
+
50
+ ### Changed
51
+
52
+ - Dropped Python 3.8 and 3.9 support — both are past EOL and the patched
53
+ versions of Pillow and pytest all require `>=3.10`. `requires-python` is now
54
+ `>=3.10`.
55
+ - CI matrix: removed 3.8/3.9 runners, added 3.13.
56
+
57
+ ## [0.4.9] - 2026-05-19 - Video input broadening and wire safety
4
58
 
5
59
  ### Added
6
60
 
@@ -24,6 +78,10 @@
24
78
  to stderr; subsequent frames are silently clamped.
25
79
  - `stream_camera` raises `PlexusError` synchronously (before spawning a thread)
26
80
  when FFmpeg is not found, rather than silently dying in the background.
81
+ - Minimum `requests` bumped to `>=2.32.4` (fixes CVE in `extract_zipped_paths`).
82
+ - Minimum `Pillow` bumped to `>=11.2.1` (fixes OOB write, FITS decompression bomb,
83
+ font integer overflow, PDF parsing DoS).
84
+ - Dropped Python 3.8 support (EOL October 2024); minimum is now Python 3.9.
27
85
 
28
86
  ## [0.4.7] - 2026-05-14 - Video streaming API
29
87
 
@@ -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()`.
@@ -35,34 +35,108 @@ The name must match `^[a-z0-9][a-z0-9_-]{1,62}$`. `setup.sh` refuses to run with
35
35
 
36
36
  In normal code, you usually just pass `source_id=...` explicitly to `Plexus(...)` and never have to think about it.
37
37
 
38
- ## Usage
38
+ ## Core methods
39
39
 
40
- ```python
41
- from plexus import Plexus
40
+ ### `send(metric, value)` — stream a reading
41
+
42
+ The main method. Call it every time you have a new sensor reading.
42
43
 
44
+ ```python
43
45
  px = Plexus(source_id="rig-01") # reads PLEXUS_API_KEY from env
44
46
 
45
- # Numbers
46
47
  px.send("engine.rpm", 3450)
47
- px.send("coolant.temperature", 82.3, tags={"unit": "C"})
48
+ px.send("coolant.temp", 82.3)
49
+ ```
48
50
 
49
- # Strings, bools, objects, arrays all JSON-serializable
50
- px.send("vehicle.state", "RUNNING")
51
- px.send("motor.enabled", True)
52
- px.send("position", {"x": 1.5, "y": 2.3, "z": 0.8})
51
+ `metric` is a dot-namespaced string (`"motor.rpm"`, `"gps.fix_quality"`). `value` accepts any JSON-serializable type:
53
52
 
54
- # Batch
53
+ | Type | Example | When to use |
54
+ |------|---------|-------------|
55
+ | `float` / `int` | `72.5`, `3450` | Sensor readings, counters |
56
+ | `str` | `"RUNNING"`, `"E_STALL"` | State machines, error codes |
57
+ | `bool` | `True` | Binary flags |
58
+ | `dict` | `{"x": 1.5, "y": 2.3}` | Vectors, structured readings |
59
+ | `list` | `[0.5, 1.2, -0.3]` | Waveforms, joint angles |
60
+
61
+ Optional arguments:
62
+ - `tags={"motor_id": "A1"}` — key-value labels for filtering in the dashboard
63
+ - `timestamp=t` — explicit Unix timestamp in seconds; omit to let the SDK pick (see [Timestamps](#timestamps-and-clock-correction))
64
+
65
+ ### `send_batch(points)` — send multiple readings at once
66
+
67
+ Use this when you sample several sensors together and want them to share a timestamp and land in one network call.
68
+
69
+ ```python
55
70
  px.send_batch([
56
- ("temperature", 72.5),
57
- ("pressure", 1013.25),
71
+ ("temperature", 22.4),
72
+ ("humidity", 58.1),
73
+ ("pressure", 1013.2),
58
74
  ])
75
+ ```
76
+
77
+ `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.
78
+
79
+ ### `event(name, data)` — record a discrete occurrence
80
+
81
+ 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.
82
+
83
+ ```python
84
+ px.event("fault", "E-stop triggered")
85
+ px.event("state_change", {"from": "IDLE", "to": "RUNNING"})
86
+ px.event("sensor_error", {"sensor": "imu", "code": 42}, tags={"motor": "A"})
87
+ ```
88
+
89
+ The platform displays events as markers overlaid on your telemetry charts, not as time-series lines.
90
+
91
+ ### `run(run_id)` — group data into a named recording
59
92
 
60
- # Named run for grouping related data
93
+ ```python
61
94
  with px.run("thermal-cycle-001"):
62
95
  while running:
63
96
  px.send("temperature", read_temp())
64
97
  ```
65
98
 
99
+ 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.
100
+
101
+ ## Video streaming
102
+
103
+ Two methods depending on whether you control the capture loop or just have a URL.
104
+
105
+ ### `send_video_frame(frame, camera_id)` — send frames you capture yourself
106
+
107
+ 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.
108
+
109
+ ```python
110
+ import cv2
111
+
112
+ cap = cv2.VideoCapture(0)
113
+ while True:
114
+ ok, frame = cap.read()
115
+ if ok:
116
+ px.send_video_frame(frame, camera_id="front")
117
+ ```
118
+
119
+ Accepted frame types:
120
+ - **numpy ndarray** (H × W × C) — from OpenCV or picamera2; requires `opencv-python`
121
+ - **JPEG bytes** — passed through as-is, zero re-encode overhead
122
+ - **Other image bytes** (PNG, BMP, WebP) — decoded and re-encoded as JPEG via Pillow; requires `pip install plexus-python[video]`
123
+
124
+ `camera_id` identifies which camera the frame came from. Use distinct IDs when streaming from multiple cameras simultaneously (`"front"`, `"rear"`, `"cam:0"`).
125
+
126
+ ### `stream_camera(url, camera_id)` — stream from an RTSP URL or file
127
+
128
+ 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`.
129
+
130
+ ```python
131
+ stop = px.stream_camera("rtsp://192.168.1.100/stream", camera_id="front")
132
+ # ... do other work ...
133
+ stop.set() # stop streaming
134
+ ```
135
+
136
+ Returns a `threading.Event` — call `.set()` to stop. Runs in a background thread so it doesn't block your main loop.
137
+
138
+ **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()`.
139
+
66
140
  ## Bring Your Own Protocol
67
141
 
68
142
  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()`.
@@ -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"]
@@ -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:
@@ -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"):