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 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.9"
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 start' or set PLEXUS_API_KEY.
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 os
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 requests
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
- # Status messages to stderr so users running `python my_script.py` see what's
65
- # happening without having to configure logging. Set PLEXUS_QUIET=1 to disable.
66
- _QUIET = os.environ.get("PLEXUS_QUIET", "").lower() in ("1", "true", "yes")
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
- def _say(line: str) -> None:
70
- if _QUIET:
71
- return
72
- try:
73
- sys.stderr.write(f"[plexus] {line}\n")
74
- sys.stderr.flush()
75
- except Exception:
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 = False,
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[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"
@@ -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) tuples. Values can be any FlexValue type.
333
- timestamp: Shared timestamp for all points. If not provided, uses current time.
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
- ts_ms = self._normalize_ts_ms(timestamp)
348
- data_points = [self._make_point(m, v, ts_ms, tags) for m, v in 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._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
- })
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 start' or set PLEXUS_API_KEY"
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 requests.exceptions.Timeout:
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 requests.exceptions.ConnectionError as e:
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 os
31
+ import queue
32
32
  import random
33
- import sys
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
- logger = logging.getLogger(__name__)
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.4.9
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.9
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>=8.3.5; extra == 'dev'
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>=11.2.1; extra == 'video'
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
- ## Usage
71
+ ## Core methods
74
72
 
75
- ```python
76
- 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.
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.temperature", 82.3, tags={"unit": "C"})
81
+ px.send("coolant.temp", 82.3)
82
+ ```
83
83
 
84
- # Strings, bools, objects, arrays all JSON-serializable
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
- # 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
90
103
  px.send_batch([
91
- ("temperature", 72.5),
92
- ("pressure", 1013.25),
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
- # Named run for grouping related data
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,,