plexus-python 0.4.5__py3-none-any.whl → 0.4.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plexus/__init__.py +1 -1
- plexus/client.py +99 -10
- plexus/ws.py +18 -1
- {plexus_python-0.4.5.dist-info → plexus_python-0.4.7.dist-info}/METADATA +19 -1
- plexus_python-0.4.7.dist-info/RECORD +11 -0
- plexus_python-0.4.5.dist-info/RECORD +0 -11
- {plexus_python-0.4.5.dist-info → plexus_python-0.4.7.dist-info}/WHEEL +0 -0
- {plexus_python-0.4.5.dist-info → plexus_python-0.4.7.dist-info}/entry_points.txt +0 -0
- {plexus_python-0.4.5.dist-info → plexus_python-0.4.7.dist-info}/licenses/LICENSE +0 -0
plexus/__init__.py
CHANGED
plexus/client.py
CHANGED
|
@@ -33,6 +33,7 @@ Usage:
|
|
|
33
33
|
Note: Requires authentication. Run 'plexus start' or set PLEXUS_API_KEY.
|
|
34
34
|
"""
|
|
35
35
|
|
|
36
|
+
import base64
|
|
36
37
|
import gzip
|
|
37
38
|
import json
|
|
38
39
|
import logging
|
|
@@ -134,12 +135,14 @@ class Plexus:
|
|
|
134
135
|
self._run_id: Optional[str] = None
|
|
135
136
|
self._session: Optional[requests.Session] = None
|
|
136
137
|
self._store_frames: bool = False
|
|
138
|
+
self._cv2 = None
|
|
137
139
|
|
|
138
140
|
if transport not in ("ws", "http"):
|
|
139
141
|
raise ValueError(f"transport must be 'ws' or 'http', got {transport!r}")
|
|
140
142
|
self.transport = transport
|
|
141
143
|
self._ws_url = (ws_url or get_gateway_ws_url())
|
|
142
144
|
self._ws = None # lazily constructed in _ensure_ws()
|
|
145
|
+
self._clock_offset_ms: int = 0
|
|
143
146
|
|
|
144
147
|
# Pluggable buffer backend for failed sends
|
|
145
148
|
if persistent_buffer:
|
|
@@ -175,17 +178,16 @@ class Plexus:
|
|
|
175
178
|
self._session.headers["User-Agent"] = f"plexus-python/{__version__}"
|
|
176
179
|
return self._session
|
|
177
180
|
|
|
178
|
-
|
|
179
|
-
def _normalize_ts_ms(timestamp: Optional[float] = None) -> int:
|
|
181
|
+
def _normalize_ts_ms(self, timestamp: Optional[float] = None) -> int:
|
|
180
182
|
"""Normalize a timestamp to milliseconds.
|
|
181
183
|
|
|
182
184
|
Accepts:
|
|
183
|
-
- None: returns current time in ms
|
|
184
|
-
- float seconds (e.g. time.time()): converts to ms
|
|
185
|
-
- int/float ms: returned as-is
|
|
185
|
+
- None: returns current time in ms, corrected by server clock offset
|
|
186
|
+
- float seconds (e.g. time.time()): converts to ms (no offset applied)
|
|
187
|
+
- int/float ms: returned as-is (no offset applied)
|
|
186
188
|
"""
|
|
187
189
|
if timestamp is None:
|
|
188
|
-
return int(time.time() * 1000)
|
|
190
|
+
return int(time.time() * 1000) + self._clock_offset_ms
|
|
189
191
|
# Heuristic: values < 1e12 are seconds
|
|
190
192
|
if timestamp > 0 and timestamp < 1e12:
|
|
191
193
|
return int(timestamp * 1000)
|
|
@@ -258,6 +260,30 @@ class Plexus:
|
|
|
258
260
|
point = self._make_point(metric, value, timestamp, tags, data_class)
|
|
259
261
|
return self._send_points([point])
|
|
260
262
|
|
|
263
|
+
def event(
|
|
264
|
+
self,
|
|
265
|
+
name: str,
|
|
266
|
+
data: FlexValue,
|
|
267
|
+
timestamp: Optional[float] = None,
|
|
268
|
+
tags: Optional[Dict[str, str]] = None,
|
|
269
|
+
) -> bool:
|
|
270
|
+
"""
|
|
271
|
+
Send a named event with text or structured data.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
name: Event type (e.g., "fault", "state_change", "log")
|
|
275
|
+
data: Text or JSON-serializable value (string, dict, list, bool, number)
|
|
276
|
+
timestamp: Unix timestamp. If not provided, uses current time.
|
|
277
|
+
tags: Optional key-value tags
|
|
278
|
+
|
|
279
|
+
Example:
|
|
280
|
+
px.event("fault", "E-stop triggered")
|
|
281
|
+
px.event("state_change", {"from": "IDLE", "to": "RUNNING"})
|
|
282
|
+
px.event("sensor_error", {"sensor": "imu", "code": 42}, tags={"motor": "A"})
|
|
283
|
+
"""
|
|
284
|
+
point = self._make_point(name, data, timestamp, tags, data_class="event")
|
|
285
|
+
return self._send_points([point])
|
|
286
|
+
|
|
261
287
|
def send_batch(
|
|
262
288
|
self,
|
|
263
289
|
points: List[Tuple[str, FlexValue]],
|
|
@@ -283,8 +309,8 @@ class Plexus:
|
|
|
283
309
|
("position", {"x": 1.0, "y": 2.0}),
|
|
284
310
|
])
|
|
285
311
|
"""
|
|
286
|
-
|
|
287
|
-
data_points = [self._make_point(m, v,
|
|
312
|
+
ts_ms = self._normalize_ts_ms(timestamp)
|
|
313
|
+
data_points = [self._make_point(m, v, ts_ms, tags) for m, v in points]
|
|
288
314
|
return self._send_points(data_points)
|
|
289
315
|
|
|
290
316
|
def _ensure_ws(self):
|
|
@@ -300,10 +326,14 @@ class Plexus:
|
|
|
300
326
|
install_id=get_install_id(),
|
|
301
327
|
agent_version=__version__,
|
|
302
328
|
on_source_id_assigned=self._on_source_id_assigned,
|
|
329
|
+
on_clock_synced=self._on_clock_synced,
|
|
303
330
|
)
|
|
304
331
|
self._ws.start()
|
|
305
332
|
return self._ws
|
|
306
333
|
|
|
334
|
+
def _on_clock_synced(self, offset_ms: int) -> None:
|
|
335
|
+
self._clock_offset_ms = offset_ms
|
|
336
|
+
|
|
307
337
|
def _on_source_id_assigned(self, assigned: str) -> None:
|
|
308
338
|
"""Callback from WebSocketTransport when the gateway returns an
|
|
309
339
|
auto-suffixed source_id. Persists it so subsequent runs (and the HTTP
|
|
@@ -314,6 +344,65 @@ class Plexus:
|
|
|
314
344
|
except Exception as e: # pragma: no cover - persistence failure is non-fatal
|
|
315
345
|
logger.debug("failed to persist assigned source_id: %s", e)
|
|
316
346
|
|
|
347
|
+
def send_video_frame(
|
|
348
|
+
self,
|
|
349
|
+
frame,
|
|
350
|
+
camera_id: str = "camera:0",
|
|
351
|
+
quality: int = 85,
|
|
352
|
+
timestamp: Optional[float] = None,
|
|
353
|
+
) -> bool:
|
|
354
|
+
"""
|
|
355
|
+
Send a single video frame to Plexus (WebSocket transport only).
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
frame: A numpy array (H, W, C) as returned by cv2.VideoCapture.read()
|
|
359
|
+
camera_id: Logical camera identifier (e.g. "picam:0", "usb:1")
|
|
360
|
+
quality: JPEG compression quality, 1-100. Default 85.
|
|
361
|
+
timestamp: Unix timestamp. If not provided, uses current time.
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
True if the frame was sent successfully.
|
|
365
|
+
|
|
366
|
+
Raises:
|
|
367
|
+
PlexusError: If transport is not 'ws', or if the send fails.
|
|
368
|
+
ImportError: If opencv-python is not installed.
|
|
369
|
+
|
|
370
|
+
Example:
|
|
371
|
+
cap = cv2.VideoCapture(0)
|
|
372
|
+
ret, frame = cap.read()
|
|
373
|
+
px.send_video_frame(frame, camera_id="picam:0")
|
|
374
|
+
"""
|
|
375
|
+
if self.transport != "ws":
|
|
376
|
+
raise PlexusError("send_video_frame requires transport='ws'")
|
|
377
|
+
|
|
378
|
+
if self._cv2 is None:
|
|
379
|
+
try:
|
|
380
|
+
import cv2 as _cv2
|
|
381
|
+
self._cv2 = _cv2
|
|
382
|
+
except ImportError as e:
|
|
383
|
+
raise ImportError(
|
|
384
|
+
"send_video_frame requires opencv-python. "
|
|
385
|
+
"Install with: pip install opencv-python"
|
|
386
|
+
) from e
|
|
387
|
+
|
|
388
|
+
height, width = frame.shape[:2]
|
|
389
|
+
_, buf = self._cv2.imencode(".jpg", frame, [self._cv2.IMWRITE_JPEG_QUALITY, quality])
|
|
390
|
+
b64 = base64.b64encode(buf).decode()
|
|
391
|
+
|
|
392
|
+
ws = self._ensure_ws()
|
|
393
|
+
if not ws.is_authenticated:
|
|
394
|
+
ws.wait_authenticated(timeout=min(self.timeout, 5.0))
|
|
395
|
+
|
|
396
|
+
return ws._send_frame({
|
|
397
|
+
"type": "video_frame",
|
|
398
|
+
"source_id": self.source_id,
|
|
399
|
+
"camera_id": camera_id,
|
|
400
|
+
"frame": b64,
|
|
401
|
+
"width": width,
|
|
402
|
+
"height": height,
|
|
403
|
+
"timestamp": self._normalize_ts_ms(timestamp),
|
|
404
|
+
})
|
|
405
|
+
|
|
317
406
|
def on_command(
|
|
318
407
|
self,
|
|
319
408
|
name: str,
|
|
@@ -555,7 +644,7 @@ class Plexus:
|
|
|
555
644
|
"source_id": self.source_id,
|
|
556
645
|
"status": "started",
|
|
557
646
|
"tags": tags,
|
|
558
|
-
"timestamp": time.time(),
|
|
647
|
+
"timestamp": (int(time.time() * 1000) + self._clock_offset_ms) / 1000,
|
|
559
648
|
},
|
|
560
649
|
timeout=self.timeout,
|
|
561
650
|
)
|
|
@@ -573,7 +662,7 @@ class Plexus:
|
|
|
573
662
|
"run_id": run_id,
|
|
574
663
|
"source_id": self.source_id,
|
|
575
664
|
"status": "ended",
|
|
576
|
-
"timestamp": time.time(),
|
|
665
|
+
"timestamp": (int(time.time() * 1000) + self._clock_offset_ms) / 1000,
|
|
577
666
|
},
|
|
578
667
|
timeout=self.timeout,
|
|
579
668
|
)
|
plexus/ws.py
CHANGED
|
@@ -25,6 +25,7 @@ Runs the read loop on a background daemon thread so callers can stay sync.
|
|
|
25
25
|
|
|
26
26
|
from __future__ import annotations
|
|
27
27
|
|
|
28
|
+
import atexit
|
|
28
29
|
import json
|
|
29
30
|
import logging
|
|
30
31
|
import os
|
|
@@ -108,6 +109,7 @@ class WebSocketTransport:
|
|
|
108
109
|
platform: str = "python-sdk",
|
|
109
110
|
auto_reconnect: bool = True,
|
|
110
111
|
on_source_id_assigned: Optional[Callable[[str], None]] = None,
|
|
112
|
+
on_clock_synced: Optional[Callable[[int], None]] = None,
|
|
111
113
|
):
|
|
112
114
|
if not api_key:
|
|
113
115
|
raise ValueError("api_key required")
|
|
@@ -122,6 +124,7 @@ class WebSocketTransport:
|
|
|
122
124
|
self.platform = platform
|
|
123
125
|
self.auto_reconnect = auto_reconnect
|
|
124
126
|
self._on_source_id_assigned = on_source_id_assigned
|
|
127
|
+
self._on_clock_synced = on_clock_synced
|
|
125
128
|
|
|
126
129
|
self._commands: Dict[str, _RegisteredCommand] = {}
|
|
127
130
|
self._ws: Optional[websocket.WebSocket] = None
|
|
@@ -130,6 +133,7 @@ class WebSocketTransport:
|
|
|
130
133
|
self._stop = threading.Event()
|
|
131
134
|
self._thread: Optional[threading.Thread] = None
|
|
132
135
|
self._backoff_attempt = 0
|
|
136
|
+
self._clock_offset_ms: int = 0
|
|
133
137
|
|
|
134
138
|
# ------------------------------------------------------------------ public
|
|
135
139
|
|
|
@@ -155,6 +159,7 @@ class WebSocketTransport:
|
|
|
155
159
|
target=self._run, name="plexus-ws", daemon=True
|
|
156
160
|
)
|
|
157
161
|
self._thread.start()
|
|
162
|
+
atexit.register(self.stop)
|
|
158
163
|
|
|
159
164
|
def stop(self, timeout: float = 2.0) -> None:
|
|
160
165
|
self._stop.set()
|
|
@@ -175,6 +180,10 @@ class WebSocketTransport:
|
|
|
175
180
|
def is_authenticated(self) -> bool:
|
|
176
181
|
return self._authenticated.is_set()
|
|
177
182
|
|
|
183
|
+
@property
|
|
184
|
+
def clock_offset_ms(self) -> int:
|
|
185
|
+
return self._clock_offset_ms
|
|
186
|
+
|
|
178
187
|
def send_points(self, points: List[Dict[str, Any]]) -> bool:
|
|
179
188
|
"""Send a telemetry frame. Returns False if the socket is not
|
|
180
189
|
authenticated — caller is expected to fall back to HTTP."""
|
|
@@ -250,6 +259,15 @@ class WebSocketTransport:
|
|
|
250
259
|
if msg.get("type") != "authenticated":
|
|
251
260
|
raise RuntimeError(f"auth failed: {msg}")
|
|
252
261
|
|
|
262
|
+
server_ts = msg.get("server_time_ms")
|
|
263
|
+
if isinstance(server_ts, (int, float)) and server_ts > 0:
|
|
264
|
+
self._clock_offset_ms = int(server_ts) - int(time.time() * 1000)
|
|
265
|
+
if self._on_clock_synced is not None:
|
|
266
|
+
try:
|
|
267
|
+
self._on_clock_synced(self._clock_offset_ms)
|
|
268
|
+
except Exception as e:
|
|
269
|
+
logger.debug("on_clock_synced callback raised: %s", e)
|
|
270
|
+
|
|
253
271
|
# The gateway may return a different source_id if the desired name
|
|
254
272
|
# was already claimed by another install — adopt the assigned value
|
|
255
273
|
# so all subsequent frames (heartbeats, future reconnects) use it.
|
|
@@ -274,7 +292,6 @@ class WebSocketTransport:
|
|
|
274
292
|
_say(f"✓ Reconnected as {self.source_id}")
|
|
275
293
|
else:
|
|
276
294
|
_say(f"✓ Connected to gateway as {self.source_id}")
|
|
277
|
-
_say(f" endpoint: {self.ws_url}")
|
|
278
295
|
|
|
279
296
|
# 3. Read loop with heartbeat pump
|
|
280
297
|
ws.settimeout(1.0)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plexus-python
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.7
|
|
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
|
|
@@ -136,6 +136,24 @@ px.buffer_size()
|
|
|
136
136
|
px.flush_buffer()
|
|
137
137
|
```
|
|
138
138
|
|
|
139
|
+
## Timestamps and clock correction
|
|
140
|
+
|
|
141
|
+
By default — `px.send("temp", 72.5)` with no `timestamp` argument — the SDK picks the time itself. Over WebSocket, it synchronizes with the gateway clock on every connection, so data lands at the right place on the timeline even if the device's system clock is wrong (no NTP on first boot, stale RTC, fresh OS image).
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
px.send("temperature", 72.5) # SDK picks time; gateway-synced over WS
|
|
145
|
+
px.send("temperature", 72.5, timestamp=t) # your timestamp, used as-is, no correction
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
**Pass an explicit timestamp when** you have a reliable external time source (GPS, trusted RTC, host NTP) or are replaying historical data with known timestamps.
|
|
149
|
+
|
|
150
|
+
**Omit timestamp when** the device may have booted without NTP — which is the default on Raspberry Pi, Jetson, and most embedded Linux boards without a network connection at first boot.
|
|
151
|
+
|
|
152
|
+
**Known limits:**
|
|
153
|
+
- Clock sync refreshes on WebSocket (re)connect. A device with a drifting RTC that stays connected for many days accumulates uncorrected drift between reconnects.
|
|
154
|
+
- HTTP-only transport (`transport="http"`) does not receive clock sync — timestamps default to the uncorrected device clock.
|
|
155
|
+
- `send_batch()` shares one timestamp across the whole batch. For per-point timestamps, call `send()` in a loop.
|
|
156
|
+
|
|
139
157
|
## Transport
|
|
140
158
|
|
|
141
159
|
By default the SDK connects over a **WebSocket** to `/ws/device` on the gateway — same wire protocol as the C SDK. This gives you:
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
plexus/__init__.py,sha256=5Ef1BoSacfFvLTXaSV84bJWMSelmuX43AzO7QRphoys,345
|
|
2
|
+
plexus/buffer.py,sha256=3ykybqLs7yMXxQWFajAT8nGe3cs_lW8_6Xvn0vQ69dE,9262
|
|
3
|
+
plexus/cli.py,sha256=-2wvHXQzobx3_tDGTXpaE2PlHv884y93Mu29kZE8qZE,14214
|
|
4
|
+
plexus/client.py,sha256=uSmgxeGs3BOFfhtnTwH2L-uia-zyRuJTtTYEjyCMl0g,25016
|
|
5
|
+
plexus/config.py,sha256=wsG6lhNLmKe3JRlVycyRUKQeywnPUPPfrWkXFxYwELE,6179
|
|
6
|
+
plexus/ws.py,sha256=4nmNganwS_1BujdFkH3u3lLBB7rEkPYd_oYrbdYkdY4,14818
|
|
7
|
+
plexus_python-0.4.7.dist-info/METADATA,sha256=4tdaBPCjJn2jWzk52vQLSg6zGtq2RrvSU5eF6yanqbI,8121
|
|
8
|
+
plexus_python-0.4.7.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
plexus_python-0.4.7.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
|
|
10
|
+
plexus_python-0.4.7.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
|
|
11
|
+
plexus_python-0.4.7.dist-info/RECORD,,
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
plexus/__init__.py,sha256=9USZDYoueDQVWKI7HREgzoDRIaPaxNCYmf8ng-CcDn0,345
|
|
2
|
-
plexus/buffer.py,sha256=3ykybqLs7yMXxQWFajAT8nGe3cs_lW8_6Xvn0vQ69dE,9262
|
|
3
|
-
plexus/cli.py,sha256=-2wvHXQzobx3_tDGTXpaE2PlHv884y93Mu29kZE8qZE,14214
|
|
4
|
-
plexus/client.py,sha256=YtPa7lT5gC0etglIJEs1DInsE3qtPBaMZDZ552DKgS4,21706
|
|
5
|
-
plexus/config.py,sha256=wsG6lhNLmKe3JRlVycyRUKQeywnPUPPfrWkXFxYwELE,6179
|
|
6
|
-
plexus/ws.py,sha256=qGZL9TsYUpzUwEew1tgXIvQdYm1GNxnFu-pLqxfAulA,14134
|
|
7
|
-
plexus_python-0.4.5.dist-info/METADATA,sha256=SLGrAflShkZdfXBbyjZwJIsFUt63tGnd38Hd0ZrwOI0,6800
|
|
8
|
-
plexus_python-0.4.5.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
-
plexus_python-0.4.5.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
|
|
10
|
-
plexus_python-0.4.5.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
|
|
11
|
-
plexus_python-0.4.5.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|