plexus-python 0.4.9__py3-none-any.whl → 0.5.2__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/_log.py +15 -0
- plexus/client.py +105 -44
- plexus/ws.py +87 -19
- {plexus_python-0.4.9.dist-info → plexus_python-0.5.2.dist-info}/METADATA +91 -19
- plexus_python-0.5.2.dist-info/RECORD +12 -0
- plexus_python-0.4.9.dist-info/RECORD +0 -11
- {plexus_python-0.4.9.dist-info → plexus_python-0.5.2.dist-info}/WHEEL +0 -0
- {plexus_python-0.4.9.dist-info → plexus_python-0.5.2.dist-info}/entry_points.txt +0 -0
- {plexus_python-0.4.9.dist-info → plexus_python-0.5.2.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.2"
|
|
14
14
|
__all__ = ["Plexus", "WebSocketTransport", "read_mjpeg_frames"]
|
plexus/_log.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
_QUIET = os.environ.get("PLEXUS_QUIET", "").lower() in ("1", "true", "yes")
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _say(line: str) -> None:
|
|
8
|
+
"""Single-line status message to stderr. Skipped if PLEXUS_QUIET=1."""
|
|
9
|
+
if _QUIET:
|
|
10
|
+
return
|
|
11
|
+
try:
|
|
12
|
+
sys.stderr.write(f"[plexus] {line}\n")
|
|
13
|
+
sys.stderr.flush()
|
|
14
|
+
except Exception:
|
|
15
|
+
pass
|
plexus/client.py
CHANGED
|
@@ -30,24 +30,24 @@ Usage:
|
|
|
30
30
|
px.send("temperature", read_temp())
|
|
31
31
|
time.sleep(0.01)
|
|
32
32
|
|
|
33
|
-
Note: Requires authentication. Run 'plexus
|
|
33
|
+
Note: Requires authentication. Run 'plexus init' 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
|
-
import
|
|
39
|
+
import re
|
|
41
40
|
import shutil
|
|
41
|
+
import socket
|
|
42
42
|
import subprocess
|
|
43
|
-
import sys
|
|
44
43
|
import threading
|
|
45
44
|
import time
|
|
45
|
+
import urllib.error
|
|
46
|
+
import urllib.request
|
|
46
47
|
from contextlib import contextmanager
|
|
47
48
|
from typing import Any, Dict, Generator, List, Optional, Tuple, Union
|
|
48
49
|
|
|
49
|
-
import
|
|
50
|
-
|
|
50
|
+
from plexus._log import _say
|
|
51
51
|
from plexus.buffer import BufferBackend, MemoryBuffer, SqliteBuffer
|
|
52
52
|
from plexus.config import (
|
|
53
53
|
RetryConfig,
|
|
@@ -59,22 +59,49 @@ from plexus.config import (
|
|
|
59
59
|
get_source_id,
|
|
60
60
|
set_source_id,
|
|
61
61
|
)
|
|
62
|
+
|
|
62
63
|
logger = logging.getLogger(__name__)
|
|
63
64
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
|
|
66
|
+
class _Response:
|
|
67
|
+
__slots__ = ("status_code", "text")
|
|
68
|
+
|
|
69
|
+
def __init__(self, status_code: int, text: str):
|
|
70
|
+
self.status_code = status_code
|
|
71
|
+
self.text = text
|
|
67
72
|
|
|
68
73
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
class _Session:
|
|
75
|
+
def __init__(self):
|
|
76
|
+
self.headers: Dict[str, str] = {}
|
|
77
|
+
|
|
78
|
+
def post(self, url: str, data: bytes = b"", headers: Optional[Dict[str, str]] = None, timeout: float = 10.0) -> "_Response":
|
|
79
|
+
req_headers = {**self.headers, **(headers or {})}
|
|
80
|
+
req = urllib.request.Request(url, data=data, headers=req_headers, method="POST")
|
|
81
|
+
try:
|
|
82
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
83
|
+
return _Response(resp.status, resp.read().decode("utf-8", errors="replace"))
|
|
84
|
+
except urllib.error.HTTPError as e:
|
|
85
|
+
return _Response(e.code, e.read().decode("utf-8", errors="replace"))
|
|
86
|
+
except urllib.error.URLError as e:
|
|
87
|
+
if isinstance(e.reason, socket.timeout):
|
|
88
|
+
raise _Timeout(str(e.reason))
|
|
89
|
+
raise _ConnError(str(e.reason))
|
|
90
|
+
except (TimeoutError, socket.timeout) as e:
|
|
91
|
+
raise _Timeout(str(e))
|
|
92
|
+
|
|
93
|
+
def close(self) -> None:
|
|
76
94
|
pass
|
|
77
95
|
|
|
96
|
+
|
|
97
|
+
class _Timeout(OSError):
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class _ConnError(OSError):
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
|
|
78
105
|
# Flexible value type - supports any JSON-serializable value
|
|
79
106
|
FlexValue = Union[int, float, str, bool, Dict[str, Any], List[Any]]
|
|
80
107
|
|
|
@@ -121,6 +148,18 @@ class AuthenticationError(PlexusError):
|
|
|
121
148
|
pass
|
|
122
149
|
|
|
123
150
|
|
|
151
|
+
_SOURCE_ID_RE = re.compile(r'^[a-z0-9][a-z0-9_-]{1,62}$')
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _validate_source_id(source_id: str) -> None:
|
|
155
|
+
if not _SOURCE_ID_RE.match(source_id):
|
|
156
|
+
raise ValueError(
|
|
157
|
+
f"Invalid source_id {source_id!r}. "
|
|
158
|
+
"Must match ^[a-z0-9][a-z0-9_-]{1,62}$ "
|
|
159
|
+
"(lowercase letters, digits, hyphens, underscores; start with letter or digit)."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
124
163
|
class Plexus:
|
|
125
164
|
"""
|
|
126
165
|
Client for sending sensor data to Plexus.
|
|
@@ -146,7 +185,7 @@ class Plexus:
|
|
|
146
185
|
timeout: float = 10.0,
|
|
147
186
|
retry_config: Optional[RetryConfig] = None,
|
|
148
187
|
max_buffer_size: int = 10000,
|
|
149
|
-
persistent_buffer: bool =
|
|
188
|
+
persistent_buffer: bool = True,
|
|
150
189
|
buffer_path: Optional[str] = None,
|
|
151
190
|
transport: str = "ws",
|
|
152
191
|
ws_url: Optional[str] = None,
|
|
@@ -161,12 +200,13 @@ class Plexus:
|
|
|
161
200
|
self.endpoint = (endpoint or get_endpoint()).rstrip("/")
|
|
162
201
|
self.gateway_url = get_gateway_url()
|
|
163
202
|
self.source_id = source_id or get_source_id()
|
|
203
|
+
_validate_source_id(self.source_id)
|
|
164
204
|
self.timeout = timeout
|
|
165
205
|
self.retry_config = retry_config or RetryConfig()
|
|
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"
|
|
@@ -321,7 +360,7 @@ class Plexus:
|
|
|
321
360
|
|
|
322
361
|
def send_batch(
|
|
323
362
|
self,
|
|
324
|
-
points: List[Tuple[str, FlexValue]],
|
|
363
|
+
points: List[Union[Tuple[str, FlexValue], Tuple[str, FlexValue, float]]],
|
|
325
364
|
timestamp: Optional[float] = None,
|
|
326
365
|
tags: Optional[Dict[str, str]] = None,
|
|
327
366
|
) -> bool:
|
|
@@ -329,8 +368,11 @@ class Plexus:
|
|
|
329
368
|
Send multiple metrics at once.
|
|
330
369
|
|
|
331
370
|
Args:
|
|
332
|
-
points: List of (metric, value)
|
|
333
|
-
|
|
371
|
+
points: List of (metric, value) or (metric, value, timestamp) tuples.
|
|
372
|
+
Values can be any FlexValue type. Per-point timestamps override
|
|
373
|
+
the shared timestamp argument.
|
|
374
|
+
timestamp: Shared timestamp for points that don't supply their own.
|
|
375
|
+
If not provided, uses current time.
|
|
334
376
|
tags: Shared tags for all points
|
|
335
377
|
|
|
336
378
|
Returns:
|
|
@@ -343,9 +385,23 @@ class Plexus:
|
|
|
343
385
|
("robot.state", "RUNNING"),
|
|
344
386
|
("position", {"x": 1.0, "y": 2.0}),
|
|
345
387
|
])
|
|
388
|
+
|
|
389
|
+
# Per-point timestamps (e.g. sensors on different interrupt timers):
|
|
390
|
+
px.send_batch([
|
|
391
|
+
("imu.accel_x", 0.12, t_imu),
|
|
392
|
+
("pressure", 1013.2, t_baro),
|
|
393
|
+
("temperature", 22.4), # uses shared timestamp
|
|
394
|
+
])
|
|
346
395
|
"""
|
|
347
|
-
|
|
348
|
-
data_points = [
|
|
396
|
+
default_ts_ms = self._normalize_ts_ms(timestamp)
|
|
397
|
+
data_points = []
|
|
398
|
+
for p in points:
|
|
399
|
+
if len(p) == 3:
|
|
400
|
+
m, v, t = p
|
|
401
|
+
data_points.append(self._make_point(m, v, self._normalize_ts_ms(t), tags))
|
|
402
|
+
else:
|
|
403
|
+
m, v = p
|
|
404
|
+
data_points.append(self._make_point(m, v, default_ts_ms, tags))
|
|
349
405
|
return self._send_points(data_points)
|
|
350
406
|
|
|
351
407
|
def _ensure_ws(self):
|
|
@@ -524,21 +580,15 @@ class Plexus:
|
|
|
524
580
|
|
|
525
581
|
jpeg_bytes, width, height = self._encode_frame(frame, quality)
|
|
526
582
|
jpeg_bytes = self._fit_to_wire(jpeg_bytes, quality)
|
|
527
|
-
b64 = base64.b64encode(jpeg_bytes).decode()
|
|
528
583
|
|
|
529
584
|
ws = self._ensure_ws()
|
|
530
585
|
if not ws.is_authenticated:
|
|
531
586
|
ws.wait_authenticated(timeout=min(self.timeout, 5.0))
|
|
532
587
|
|
|
533
|
-
return ws.
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
"frame": b64,
|
|
538
|
-
"width": width,
|
|
539
|
-
"height": height,
|
|
540
|
-
"timestamp": self._normalize_ts_ms(timestamp),
|
|
541
|
-
})
|
|
588
|
+
return ws.send_video_frame_async(
|
|
589
|
+
self.source_id, camera_id, jpeg_bytes, width, height,
|
|
590
|
+
self._normalize_ts_ms(timestamp),
|
|
591
|
+
)
|
|
542
592
|
|
|
543
593
|
def stream_camera(
|
|
544
594
|
self,
|
|
@@ -623,6 +673,12 @@ class Plexus:
|
|
|
623
673
|
if self.transport != "ws":
|
|
624
674
|
raise PlexusError("on_command requires transport='ws'")
|
|
625
675
|
ws = self._ensure_ws()
|
|
676
|
+
if ws.is_authenticated:
|
|
677
|
+
_say(
|
|
678
|
+
f"⚠ on_command('{name}') called after connection is already authenticated — "
|
|
679
|
+
"command will not be advertised to the dashboard until next reconnect. "
|
|
680
|
+
"Call on_command() before the first send()."
|
|
681
|
+
)
|
|
626
682
|
ws.register_command(name, handler, description=description, params=params)
|
|
627
683
|
|
|
628
684
|
def _send_points(self, points: List[Dict[str, Any]]) -> bool:
|
|
@@ -640,7 +696,7 @@ class Plexus:
|
|
|
640
696
|
"""
|
|
641
697
|
if not self.api_key:
|
|
642
698
|
raise AuthenticationError(
|
|
643
|
-
"No API key configured. Run 'plexus
|
|
699
|
+
"No API key configured. Run 'plexus init' or set PLEXUS_API_KEY"
|
|
644
700
|
)
|
|
645
701
|
|
|
646
702
|
# Include any previously buffered points
|
|
@@ -732,14 +788,14 @@ class Plexus:
|
|
|
732
788
|
f"API error: {response.status_code} - {response.text}"
|
|
733
789
|
)
|
|
734
790
|
|
|
735
|
-
except
|
|
791
|
+
except _Timeout:
|
|
736
792
|
last_error = PlexusError(f"Request timed out after {self.timeout}s")
|
|
737
793
|
if attempt < self.retry_config.max_retries:
|
|
738
794
|
time.sleep(self.retry_config.get_delay(attempt))
|
|
739
795
|
continue
|
|
740
796
|
break
|
|
741
797
|
|
|
742
|
-
except
|
|
798
|
+
except _ConnError as e:
|
|
743
799
|
last_error = PlexusError(f"Connection failed: {e}")
|
|
744
800
|
if attempt < self.retry_config.max_retries:
|
|
745
801
|
time.sleep(self.retry_config.get_delay(attempt))
|
|
@@ -839,13 +895,13 @@ class Plexus:
|
|
|
839
895
|
try:
|
|
840
896
|
self._get_session().post(
|
|
841
897
|
f"{self.endpoint}/api/runs",
|
|
842
|
-
json
|
|
898
|
+
data=json.dumps({
|
|
843
899
|
"run_id": run_id,
|
|
844
900
|
"source_id": self.source_id,
|
|
845
901
|
"status": "started",
|
|
846
902
|
"tags": tags,
|
|
847
903
|
"timestamp": (int(time.time() * 1000) + self._clock_offset_ms) / 1000,
|
|
848
|
-
},
|
|
904
|
+
}).encode("utf-8"),
|
|
849
905
|
timeout=self.timeout,
|
|
850
906
|
)
|
|
851
907
|
except Exception as e:
|
|
@@ -858,12 +914,12 @@ class Plexus:
|
|
|
858
914
|
try:
|
|
859
915
|
self._get_session().post(
|
|
860
916
|
f"{self.endpoint}/api/runs",
|
|
861
|
-
json
|
|
917
|
+
data=json.dumps({
|
|
862
918
|
"run_id": run_id,
|
|
863
919
|
"source_id": self.source_id,
|
|
864
920
|
"status": "ended",
|
|
865
921
|
"timestamp": (int(time.time() * 1000) + self._clock_offset_ms) / 1000,
|
|
866
|
-
},
|
|
922
|
+
}).encode("utf-8"),
|
|
867
923
|
timeout=self.timeout,
|
|
868
924
|
)
|
|
869
925
|
except Exception as e:
|
|
@@ -872,7 +928,12 @@ class Plexus:
|
|
|
872
928
|
self._store_frames = False
|
|
873
929
|
|
|
874
930
|
def close(self):
|
|
875
|
-
"""Close the client and release resources."""
|
|
931
|
+
"""Close the client, flush any buffered points, and release resources."""
|
|
932
|
+
if self.buffer_size() > 0:
|
|
933
|
+
try:
|
|
934
|
+
self.flush_buffer()
|
|
935
|
+
except Exception as e:
|
|
936
|
+
logger.debug("flush on close failed: %s", e)
|
|
876
937
|
if self._ws is not None:
|
|
877
938
|
self._ws.stop()
|
|
878
939
|
self._ws = None
|
plexus/ws.py
CHANGED
|
@@ -28,9 +28,9 @@ from __future__ import annotations
|
|
|
28
28
|
import atexit
|
|
29
29
|
import json
|
|
30
30
|
import logging
|
|
31
|
-
import
|
|
31
|
+
import queue
|
|
32
32
|
import random
|
|
33
|
-
import
|
|
33
|
+
import struct
|
|
34
34
|
import threading
|
|
35
35
|
import time
|
|
36
36
|
from dataclasses import dataclass, field
|
|
@@ -44,24 +44,9 @@ except ImportError as e: # pragma: no cover - import-time failure is obvious
|
|
|
44
44
|
"Install with: pip install websocket-client"
|
|
45
45
|
) from e
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
# By default, print connection status to stderr so users running
|
|
50
|
-
# `python my_script.py` can see what's happening without having to
|
|
51
|
-
# configure the logging module. Set PLEXUS_QUIET=1 to disable.
|
|
52
|
-
_QUIET = os.environ.get("PLEXUS_QUIET", "").lower() in ("1", "true", "yes")
|
|
47
|
+
from plexus._log import _say
|
|
53
48
|
|
|
54
|
-
|
|
55
|
-
def _say(line: str) -> None:
|
|
56
|
-
"""Single-line status message to stderr. Skipped if PLEXUS_QUIET=1."""
|
|
57
|
-
if _QUIET:
|
|
58
|
-
return
|
|
59
|
-
try:
|
|
60
|
-
sys.stderr.write(f"[plexus] {line}\n")
|
|
61
|
-
sys.stderr.flush()
|
|
62
|
-
except Exception:
|
|
63
|
-
# Stderr blew up — don't take the whole client down with it.
|
|
64
|
-
pass
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
65
50
|
|
|
66
51
|
AUTH_TIMEOUT_S = 10.0
|
|
67
52
|
HEARTBEAT_INTERVAL_S = 30.0
|
|
@@ -134,6 +119,8 @@ class WebSocketTransport:
|
|
|
134
119
|
self._thread: Optional[threading.Thread] = None
|
|
135
120
|
self._backoff_attempt = 0
|
|
136
121
|
self._clock_offset_ms: int = 0
|
|
122
|
+
self._video_queue: "queue.Queue[bytes]" = queue.Queue(maxsize=2)
|
|
123
|
+
self._video_thread: Optional[threading.Thread] = None
|
|
137
124
|
|
|
138
125
|
# ------------------------------------------------------------------ public
|
|
139
126
|
|
|
@@ -159,6 +146,10 @@ class WebSocketTransport:
|
|
|
159
146
|
target=self._run, name="plexus-ws", daemon=True
|
|
160
147
|
)
|
|
161
148
|
self._thread.start()
|
|
149
|
+
self._video_thread = threading.Thread(
|
|
150
|
+
target=self._video_sender_loop, name="plexus-video", daemon=True
|
|
151
|
+
)
|
|
152
|
+
self._video_thread.start()
|
|
162
153
|
atexit.register(self.stop)
|
|
163
154
|
|
|
164
155
|
def stop(self, timeout: float = 2.0) -> None:
|
|
@@ -172,6 +163,8 @@ class WebSocketTransport:
|
|
|
172
163
|
pass
|
|
173
164
|
if self._thread:
|
|
174
165
|
self._thread.join(timeout=timeout)
|
|
166
|
+
if self._video_thread:
|
|
167
|
+
self._video_thread.join(timeout=timeout)
|
|
175
168
|
|
|
176
169
|
def wait_authenticated(self, timeout: float = AUTH_TIMEOUT_S) -> bool:
|
|
177
170
|
return self._authenticated.wait(timeout=timeout)
|
|
@@ -194,6 +187,28 @@ class WebSocketTransport:
|
|
|
194
187
|
frame = {"type": "telemetry", "points": points}
|
|
195
188
|
return self._send_frame(frame)
|
|
196
189
|
|
|
190
|
+
def send_video_frame_async(
|
|
191
|
+
self,
|
|
192
|
+
source_id: str,
|
|
193
|
+
camera_id: str,
|
|
194
|
+
jpeg_bytes: bytes,
|
|
195
|
+
width: int,
|
|
196
|
+
height: int,
|
|
197
|
+
timestamp_ms: int,
|
|
198
|
+
) -> bool:
|
|
199
|
+
"""Encode and enqueue a binary video frame. Non-blocking — drops the
|
|
200
|
+
frame if the queue is full rather than blocking the caller."""
|
|
201
|
+
if not self._authenticated.is_set():
|
|
202
|
+
return False
|
|
203
|
+
payload = _encode_binary_video_frame(
|
|
204
|
+
source_id, camera_id, jpeg_bytes, width, height, timestamp_ms
|
|
205
|
+
)
|
|
206
|
+
try:
|
|
207
|
+
self._video_queue.put_nowait(payload)
|
|
208
|
+
return True
|
|
209
|
+
except queue.Full:
|
|
210
|
+
return False
|
|
211
|
+
|
|
197
212
|
# ------------------------------------------------------------------ thread
|
|
198
213
|
|
|
199
214
|
def _run(self) -> None:
|
|
@@ -228,6 +243,28 @@ class WebSocketTransport:
|
|
|
228
243
|
if self._stop.wait(timeout=delay):
|
|
229
244
|
break
|
|
230
245
|
|
|
246
|
+
def _video_sender_loop(self) -> None:
|
|
247
|
+
"""Drain _video_queue and send binary WebSocket frames.
|
|
248
|
+
|
|
249
|
+
Runs on a dedicated thread so slow sends never block the caller.
|
|
250
|
+
Drops frames during reconnect rather than queuing stale video.
|
|
251
|
+
"""
|
|
252
|
+
while not self._stop.is_set():
|
|
253
|
+
try:
|
|
254
|
+
payload = self._video_queue.get(timeout=0.5)
|
|
255
|
+
except queue.Empty:
|
|
256
|
+
continue
|
|
257
|
+
if not self._authenticated.is_set():
|
|
258
|
+
continue # drop during auth / reconnect
|
|
259
|
+
with self._ws_lock:
|
|
260
|
+
ws = self._ws
|
|
261
|
+
if ws is None:
|
|
262
|
+
continue
|
|
263
|
+
try:
|
|
264
|
+
ws.send_binary(payload)
|
|
265
|
+
except Exception as e:
|
|
266
|
+
logger.debug("plexus video send failed: %s", e)
|
|
267
|
+
|
|
231
268
|
def _connect_and_serve(self) -> None:
|
|
232
269
|
ws = websocket.create_connection(self.ws_url, timeout=AUTH_TIMEOUT_S)
|
|
233
270
|
with self._ws_lock:
|
|
@@ -400,6 +437,37 @@ class WebSocketTransport:
|
|
|
400
437
|
# --------------------------------------------------------------------- helpers
|
|
401
438
|
|
|
402
439
|
|
|
440
|
+
def _encode_binary_video_frame(
|
|
441
|
+
source_id: str,
|
|
442
|
+
camera_id: str,
|
|
443
|
+
jpeg_bytes: bytes,
|
|
444
|
+
width: int,
|
|
445
|
+
height: int,
|
|
446
|
+
timestamp_ms: int,
|
|
447
|
+
) -> bytes:
|
|
448
|
+
"""Pack a video frame into the binary wire format.
|
|
449
|
+
|
|
450
|
+
Wire layout:
|
|
451
|
+
[0x01] 1 byte version
|
|
452
|
+
[src_len] 1 byte source_id byte length (capped at 255)
|
|
453
|
+
[source_id] N bytes
|
|
454
|
+
[cam_len] 1 byte camera_id byte length (capped at 255)
|
|
455
|
+
[camera_id] M bytes
|
|
456
|
+
[width] 4 bytes uint32 big-endian
|
|
457
|
+
[height] 4 bytes uint32 big-endian
|
|
458
|
+
[timestamp_ms] 8 bytes int64 big-endian
|
|
459
|
+
[jpeg_bytes] rest
|
|
460
|
+
"""
|
|
461
|
+
src = source_id.encode("utf-8")[:255]
|
|
462
|
+
cam = camera_id.encode("utf-8")[:255]
|
|
463
|
+
header = (
|
|
464
|
+
bytes([0x01, len(src)]) + src
|
|
465
|
+
+ bytes([len(cam)]) + cam
|
|
466
|
+
+ struct.pack(">IIq", width, height, timestamp_ms)
|
|
467
|
+
)
|
|
468
|
+
return header + jpeg_bytes
|
|
469
|
+
|
|
470
|
+
|
|
403
471
|
def _ensure_device_path(url: str) -> str:
|
|
404
472
|
url = url.rstrip("/")
|
|
405
473
|
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.2
|
|
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,12 @@
|
|
|
1
|
+
plexus/__init__.py,sha256=2Q3D7gMSFmMpFzcc_ywnCMH_1zgysy-PH6jW21bONgc,385
|
|
2
|
+
plexus/_log.py,sha256=y1A9ahcPuCefTQ8xsZtOJTb6iT0pP9LAlkeP7U-yvGE,352
|
|
3
|
+
plexus/buffer.py,sha256=3ykybqLs7yMXxQWFajAT8nGe3cs_lW8_6Xvn0vQ69dE,9262
|
|
4
|
+
plexus/cli.py,sha256=-2wvHXQzobx3_tDGTXpaE2PlHv884y93Mu29kZE8qZE,14214
|
|
5
|
+
plexus/client.py,sha256=S0AVpT73sKjMvDENZsWovk47ZuxZjT_vXQCAdNUVfG0,35338
|
|
6
|
+
plexus/config.py,sha256=wsG6lhNLmKe3JRlVycyRUKQeywnPUPPfrWkXFxYwELE,6179
|
|
7
|
+
plexus/ws.py,sha256=yTvxcloHOqiXkLtPQ4Vquv8KSrGoluOEkYIDp7MFImo,17095
|
|
8
|
+
plexus_python-0.5.2.dist-info/METADATA,sha256=L87GlCwfwMIjXmetRak1E2vjkmdXyYj5v3nlzsFHtSU,11531
|
|
9
|
+
plexus_python-0.5.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
10
|
+
plexus_python-0.5.2.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
|
|
11
|
+
plexus_python-0.5.2.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
|
|
12
|
+
plexus_python-0.5.2.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
|