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 +1 -1
- plexus/client.py +38 -10
- plexus/ws.py +18 -0
- {plexus_python-0.4.5.dist-info → plexus_python-0.4.6.dist-info}/METADATA +19 -1
- plexus_python-0.4.6.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.6.dist-info}/WHEEL +0 -0
- {plexus_python-0.4.5.dist-info → plexus_python-0.4.6.dist-info}/entry_points.txt +0 -0
- {plexus_python-0.4.5.dist-info → plexus_python-0.4.6.dist-info}/licenses/LICENSE +0 -0
plexus/__init__.py
CHANGED
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
|
-
|
|
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
|
-
|
|
287
|
-
data_points = [self._make_point(m, v,
|
|
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.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|