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 CHANGED
@@ -10,5 +10,5 @@ Plexus — thin Python SDK for sending telemetry to the Plexus gateway.
10
10
  from plexus.client import Plexus
11
11
  from plexus.ws import WebSocketTransport
12
12
 
13
- __version__ = "0.4.5"
13
+ __version__ = "0.4.7"
14
14
  __all__ = ["Plexus", "WebSocketTransport"]
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
- @staticmethod
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
- ts = timestamp if timestamp is not None else time.time()
287
- data_points = [self._make_point(m, v, ts, tags) for m, v in points]
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.5
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,,