plexus-python 0.4.5__py3-none-any.whl → 0.4.6__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.6"
14
14
  __all__ = ["Plexus", "WebSocketTransport"]
plexus/client.py CHANGED
@@ -140,6 +140,7 @@ class Plexus:
140
140
  self.transport = transport
141
141
  self._ws_url = (ws_url or get_gateway_ws_url())
142
142
  self._ws = None # lazily constructed in _ensure_ws()
143
+ self._clock_offset_ms: int = 0
143
144
 
144
145
  # Pluggable buffer backend for failed sends
145
146
  if persistent_buffer:
@@ -175,17 +176,16 @@ class Plexus:
175
176
  self._session.headers["User-Agent"] = f"plexus-python/{__version__}"
176
177
  return self._session
177
178
 
178
- @staticmethod
179
- def _normalize_ts_ms(timestamp: Optional[float] = None) -> int:
179
+ def _normalize_ts_ms(self, timestamp: Optional[float] = None) -> int:
180
180
  """Normalize a timestamp to milliseconds.
181
181
 
182
182
  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
183
+ - None: returns current time in ms, corrected by server clock offset
184
+ - float seconds (e.g. time.time()): converts to ms (no offset applied)
185
+ - int/float ms: returned as-is (no offset applied)
186
186
  """
187
187
  if timestamp is None:
188
- return int(time.time() * 1000)
188
+ return int(time.time() * 1000) + self._clock_offset_ms
189
189
  # Heuristic: values < 1e12 are seconds
190
190
  if timestamp > 0 and timestamp < 1e12:
191
191
  return int(timestamp * 1000)
@@ -258,6 +258,30 @@ class Plexus:
258
258
  point = self._make_point(metric, value, timestamp, tags, data_class)
259
259
  return self._send_points([point])
260
260
 
261
+ def event(
262
+ self,
263
+ name: str,
264
+ data: FlexValue,
265
+ timestamp: Optional[float] = None,
266
+ tags: Optional[Dict[str, str]] = None,
267
+ ) -> bool:
268
+ """
269
+ Send a named event with text or structured data.
270
+
271
+ Args:
272
+ name: Event type (e.g., "fault", "state_change", "log")
273
+ data: Text or JSON-serializable value (string, dict, list, bool, number)
274
+ timestamp: Unix timestamp. If not provided, uses current time.
275
+ tags: Optional key-value tags
276
+
277
+ Example:
278
+ px.event("fault", "E-stop triggered")
279
+ px.event("state_change", {"from": "IDLE", "to": "RUNNING"})
280
+ px.event("sensor_error", {"sensor": "imu", "code": 42}, tags={"motor": "A"})
281
+ """
282
+ point = self._make_point(name, data, timestamp, tags, data_class="event")
283
+ return self._send_points([point])
284
+
261
285
  def send_batch(
262
286
  self,
263
287
  points: List[Tuple[str, FlexValue]],
@@ -283,8 +307,8 @@ class Plexus:
283
307
  ("position", {"x": 1.0, "y": 2.0}),
284
308
  ])
285
309
  """
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]
310
+ ts_ms = self._normalize_ts_ms(timestamp)
311
+ data_points = [self._make_point(m, v, ts_ms, tags) for m, v in points]
288
312
  return self._send_points(data_points)
289
313
 
290
314
  def _ensure_ws(self):
@@ -300,10 +324,14 @@ class Plexus:
300
324
  install_id=get_install_id(),
301
325
  agent_version=__version__,
302
326
  on_source_id_assigned=self._on_source_id_assigned,
327
+ on_clock_synced=self._on_clock_synced,
303
328
  )
304
329
  self._ws.start()
305
330
  return self._ws
306
331
 
332
+ def _on_clock_synced(self, offset_ms: int) -> None:
333
+ self._clock_offset_ms = offset_ms
334
+
307
335
  def _on_source_id_assigned(self, assigned: str) -> None:
308
336
  """Callback from WebSocketTransport when the gateway returns an
309
337
  auto-suffixed source_id. Persists it so subsequent runs (and the HTTP
@@ -555,7 +583,7 @@ class Plexus:
555
583
  "source_id": self.source_id,
556
584
  "status": "started",
557
585
  "tags": tags,
558
- "timestamp": time.time(),
586
+ "timestamp": (int(time.time() * 1000) + self._clock_offset_ms) / 1000,
559
587
  },
560
588
  timeout=self.timeout,
561
589
  )
@@ -573,7 +601,7 @@ class Plexus:
573
601
  "run_id": run_id,
574
602
  "source_id": self.source_id,
575
603
  "status": "ended",
576
- "timestamp": time.time(),
604
+ "timestamp": (int(time.time() * 1000) + self._clock_offset_ms) / 1000,
577
605
  },
578
606
  timeout=self.timeout,
579
607
  )
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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plexus-python
3
- Version: 0.4.5
3
+ Version: 0.4.6
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=upqHP1A19Sp_CnCeCBHHJ1GUKKzeCJiJw5w3wD2K4cE,345
2
+ plexus/buffer.py,sha256=3ykybqLs7yMXxQWFajAT8nGe3cs_lW8_6Xvn0vQ69dE,9262
3
+ plexus/cli.py,sha256=-2wvHXQzobx3_tDGTXpaE2PlHv884y93Mu29kZE8qZE,14214
4
+ plexus/client.py,sha256=JmEKKRu4ik548uBPMEOCcB_r_asRmuNORZF4snjFosQ,22956
5
+ plexus/config.py,sha256=wsG6lhNLmKe3JRlVycyRUKQeywnPUPPfrWkXFxYwELE,6179
6
+ plexus/ws.py,sha256=aynJTH8LHRA4_U1OfIu-2aaOg1tLniUcWbzB2yuiQvU,14865
7
+ plexus_python-0.4.6.dist-info/METADATA,sha256=d6Vp27f4EsfSHeS0NT6z9oWF85UIsLskMzzborehyQQ,8121
8
+ plexus_python-0.4.6.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ plexus_python-0.4.6.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
10
+ plexus_python-0.4.6.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
11
+ plexus_python-0.4.6.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,,