plexus-python 0.4.3__tar.gz → 0.4.6__tar.gz
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_python-0.4.3 → plexus_python-0.4.6}/API.md +41 -5
- {plexus_python-0.4.3 → plexus_python-0.4.6}/CHANGELOG.md +30 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/PKG-INFO +19 -1
- {plexus_python-0.4.3 → plexus_python-0.4.6}/README.md +18 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/plexus/__init__.py +1 -1
- {plexus_python-0.4.3 → plexus_python-0.4.6}/plexus/client.py +94 -10
- {plexus_python-0.4.3 → plexus_python-0.4.6}/plexus/ws.py +56 -1
- {plexus_python-0.4.3 → plexus_python-0.4.6}/pyproject.toml +1 -1
- {plexus_python-0.4.3 → plexus_python-0.4.6}/tests/test_basic.py +18 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/tests/test_ws.py +46 -0
- plexus_python-0.4.6/uv.lock +1167 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/.github/workflows/ci.yml +0 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/.github/workflows/publish.yml +0 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/.gitignore +0 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/AGENTS.md +0 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/CODE_OF_CONDUCT.md +0 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/CONTRIBUTING.md +0 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/LICENSE +0 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/SECURITY.md +0 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/examples/README.md +0 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/examples/basic.py +0 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/examples/can.py +0 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/examples/i2c_bme280.py +0 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/examples/mavlink.py +0 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/examples/mqtt.py +0 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/plexus/buffer.py +0 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/plexus/cli.py +0 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/plexus/config.py +0 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/scripts/plexus.service +0 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/scripts/scan_buses.py +0 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/scripts/setup.sh +0 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/tests/test_buffer.py +0 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/tests/test_config.py +0 -0
- {plexus_python-0.4.3 → plexus_python-0.4.6}/tests/test_retry.py +0 -0
|
@@ -98,7 +98,7 @@ x-api-key: plx_xxxxx
|
|
|
98
98
|
| ------------ | ------ | -------- | ---------------------------------------------- |
|
|
99
99
|
| `metric` | string | Yes | Metric name (e.g., `temperature`, `motor.rpm`) |
|
|
100
100
|
| `value` | any | Yes | See supported value types below |
|
|
101
|
-
| `timestamp` | float | No | Unix timestamp (
|
|
101
|
+
| `timestamp` | float | No | Unix timestamp in seconds (or ms if ≥ 1e12). Omit to use device time. Over WebSocket, the Python SDK applies a server-synced clock correction when omitted — see [Clock correction](#clock-correction). |
|
|
102
102
|
| `source_id` | string | Yes | Your source identifier |
|
|
103
103
|
| `tags` | object | No | Key-value labels |
|
|
104
104
|
| `session_id` | string | No | Group data into sessions |
|
|
@@ -168,18 +168,22 @@ Devices authenticate using an API key. The `source_id` in the request is the dev
|
|
|
168
168
|
// Server → Device
|
|
169
169
|
{
|
|
170
170
|
"type": "authenticated",
|
|
171
|
-
"source_id": "drone-01"
|
|
171
|
+
"source_id": "drone-01",
|
|
172
|
+
"server_time_ms": 1746100800000
|
|
172
173
|
}
|
|
173
174
|
|
|
174
175
|
// Server → Device (collision case)
|
|
175
176
|
{
|
|
176
177
|
"type": "authenticated",
|
|
177
|
-
"source_id": "drone-01_2"
|
|
178
|
+
"source_id": "drone-01_2",
|
|
179
|
+
"server_time_ms": 1746100800000
|
|
178
180
|
}
|
|
179
181
|
```
|
|
180
182
|
|
|
181
183
|
The SDK **adopts** whatever `source_id` the server returns and uses it for all subsequent frames, heartbeats, and reconnects. It also persists the assigned name locally so reconnects go straight to the claimed slot.
|
|
182
184
|
|
|
185
|
+
`server_time_ms` is the gateway's current Unix time in milliseconds. The Python SDK uses it to compute a clock offset (`server_time - device_time`) that is applied to every SDK-generated timestamp for the lifetime of the connection. This corrects for devices that boot without NTP or have an unreliable RTC — a common condition on embedded Linux. See [Clock correction](#clock-correction) for details and limitations.
|
|
186
|
+
|
|
183
187
|
`install_id` is a stable per-installation UUID, generated on the device's first run and saved to `~/.plexus/config.json`. It lets the server distinguish a rebooting device from a new device trying to claim an existing name. Legacy SDKs that omit `install_id` continue to work as before (the server passes the declared `source_id` through unchanged).
|
|
184
188
|
|
|
185
189
|
### Message Types (Dashboard → Device)
|
|
@@ -354,6 +358,9 @@ func main() {
|
|
|
354
358
|
#include <WiFi.h>
|
|
355
359
|
#include <HTTPClient.h>
|
|
356
360
|
|
|
361
|
+
// Call configTime(0, 0, "pool.ntp.org") in setup() before sending.
|
|
362
|
+
// time(nullptr) returns 0 until NTP sync completes — omit the timestamp
|
|
363
|
+
// field entirely if you cannot guarantee NTP sync at send time.
|
|
357
364
|
void sendToPlexus(const char* metric, float value) {
|
|
358
365
|
HTTPClient http;
|
|
359
366
|
http.begin("https://plexus-gateway.fly.dev/ingest");
|
|
@@ -363,7 +370,7 @@ void sendToPlexus(const char* metric, float value) {
|
|
|
363
370
|
String payload = "{\"points\":[{";
|
|
364
371
|
payload += "\"metric\":\"" + String(metric) + "\",";
|
|
365
372
|
payload += "\"value\":" + String(value) + ",";
|
|
366
|
-
payload += "\"timestamp\":" + String(
|
|
373
|
+
payload += "\"timestamp\":" + String(time(nullptr)) + ",";
|
|
367
374
|
payload += "\"source_id\":\"esp32-001\"";
|
|
368
375
|
payload += "}]}";
|
|
369
376
|
|
|
@@ -469,10 +476,39 @@ class MySensor(BaseSensor):
|
|
|
469
476
|
| 404 | Resource not found |
|
|
470
477
|
| 410 | Resource expired |
|
|
471
478
|
|
|
479
|
+
## Clock correction
|
|
480
|
+
|
|
481
|
+
Embedded devices commonly boot with a wrong system clock — no hardware RTC, NTP unreachable on first boot, or a fresh OS image whose filesystem timestamp is months in the past. Without correction, all telemetry lands at the wrong place on the timeline.
|
|
482
|
+
|
|
483
|
+
The Python SDK corrects for this automatically over WebSocket. On every connection the gateway returns `server_time_ms` in the `authenticated` frame. The SDK computes `offset = server_time - device_time` and adds it to every timestamp it generates. Data lands at the right time on the dashboard regardless of what the device clock says.
|
|
484
|
+
|
|
485
|
+
**When the correction applies:**
|
|
486
|
+
|
|
487
|
+
The offset is applied when `timestamp` is omitted (the SDK generates the time). If you pass an explicit `timestamp`, it is used as-is — the SDK cannot tell whether your value is a wall-clock time or a hardware-relative counter, so it leaves it alone.
|
|
488
|
+
|
|
489
|
+
```python
|
|
490
|
+
px.send("temperature", 72.5) # SDK picks time → correction applied
|
|
491
|
+
px.send("temperature", 72.5, timestamp=t) # your timestamp → used as-is, no correction
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
**When to pass an explicit timestamp:**
|
|
495
|
+
- You have a reliable wall-clock source (GPS, trusted hardware RTC, host NTP)
|
|
496
|
+
- You are replaying or backfilling historical data
|
|
497
|
+
- Your sensor provides its own wall-clock timestamp
|
|
498
|
+
|
|
499
|
+
**When to omit timestamp:**
|
|
500
|
+
- The device may have booted without NTP (Raspberry Pi, Jetson, field robots without network on first boot)
|
|
501
|
+
- You have no reliable external time source
|
|
502
|
+
|
|
503
|
+
**Known limitations:**
|
|
504
|
+
- The clock offset refreshes only on WebSocket reconnect. A device with a drifting RTC that stays connected for many days will accumulate uncorrected drift between reconnects proportional to the drift rate.
|
|
505
|
+
- HTTP transport (`transport="http"`) does not receive clock sync — timestamps default to the device clock uncorrected.
|
|
506
|
+
- `send_batch()` takes one shared `timestamp` for the whole batch, not per-point. For per-point timestamps, call `send()` in a loop.
|
|
507
|
+
|
|
472
508
|
## Best Practices
|
|
473
509
|
|
|
474
510
|
- **Batch points** - Send up to 100 points per request for HTTP
|
|
475
|
-
- **
|
|
511
|
+
- **Omit timestamp when unsure** - The Python SDK applies server-synced clock correction when `timestamp` is omitted over WebSocket; only pass an explicit timestamp when you have a reliable wall-clock source
|
|
476
512
|
- **Consistent source_id** - Use the same ID for each physical device/source
|
|
477
513
|
- **Use tags** - Label data for filtering (e.g., `{"location": "lab"}`)
|
|
478
514
|
- **Use sessions** - Group related data for easier analysis
|
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.5] - 2026-04-27 - Stderr status output (re-release of 0.4.4)
|
|
4
|
+
|
|
5
|
+
Same code as 0.4.4 — the 0.4.4 publish workflow failed lint on a stray
|
|
6
|
+
`f`-prefix in `plexus/client.py:488`. PyPI doesn't allow re-uploading a
|
|
7
|
+
version, so 0.4.5 is the corrected re-release.
|
|
8
|
+
|
|
9
|
+
## [0.4.4] - 2026-04-27 - Stderr status output
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `[plexus] …` status lines on stderr at every meaningful state change so
|
|
14
|
+
scripts that don't configure the `logging` module still tell the user
|
|
15
|
+
what's going on. Set `PLEXUS_QUIET=1` to suppress.
|
|
16
|
+
- `✓ Connected to gateway as <source_id>` on first WS auth
|
|
17
|
+
- `✓ Reconnected as <source_id>` after a drop
|
|
18
|
+
- `✓ First N points landed (via ws|http)` on first successful send
|
|
19
|
+
- `⚠ WebSocket unavailable, falling back to POST /ingest` on WS failure
|
|
20
|
+
- `✗ Auth rejected by gateway: …` / `✗ Gateway rejected the API key (401)`
|
|
21
|
+
on auth failures, with a `plexus whoami` hint
|
|
22
|
+
- `⏸ Send failed, buffering points locally (N queued)` when offline
|
|
23
|
+
- `✓ Sending again (drained the local buffer)` on recovery
|
|
24
|
+
|
|
25
|
+
### Why
|
|
26
|
+
|
|
27
|
+
Users running `python my_script.py` saw nothing — by default Python's
|
|
28
|
+
`logging` module emits at WARNING and above only on the console, so a
|
|
29
|
+
silent SDK was indistinguishable from "everything's working" until they
|
|
30
|
+
checked the dashboard. This makes the trip from `python my_script.py` to
|
|
31
|
+
"first row visible in the UI" auditable in one terminal.
|
|
32
|
+
|
|
3
33
|
## [0.4.3] - 2026-04-27 - Re-release of 0.4.2 with correct __version__
|
|
4
34
|
|
|
5
35
|
The 0.4.2 wheel shipped with `plexus.__version__ == "0.4.1"` because the
|
|
@@ -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:
|
|
@@ -102,6 +102,24 @@ px.buffer_size()
|
|
|
102
102
|
px.flush_buffer()
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
+
## Timestamps and clock correction
|
|
106
|
+
|
|
107
|
+
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).
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
px.send("temperature", 72.5) # SDK picks time; gateway-synced over WS
|
|
111
|
+
px.send("temperature", 72.5, timestamp=t) # your timestamp, used as-is, no correction
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**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.
|
|
115
|
+
|
|
116
|
+
**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.
|
|
117
|
+
|
|
118
|
+
**Known limits:**
|
|
119
|
+
- Clock sync refreshes on WebSocket (re)connect. A device with a drifting RTC that stays connected for many days accumulates uncorrected drift between reconnects.
|
|
120
|
+
- HTTP-only transport (`transport="http"`) does not receive clock sync — timestamps default to the uncorrected device clock.
|
|
121
|
+
- `send_batch()` shares one timestamp across the whole batch. For per-point timestamps, call `send()` in a loop.
|
|
122
|
+
|
|
105
123
|
## Transport
|
|
106
124
|
|
|
107
125
|
By default the SDK connects over a **WebSocket** to `/ws/device` on the gateway — same wire protocol as the C SDK. This gives you:
|
|
@@ -36,6 +36,8 @@ Note: Requires authentication. Run 'plexus start' or set PLEXUS_API_KEY.
|
|
|
36
36
|
import gzip
|
|
37
37
|
import json
|
|
38
38
|
import logging
|
|
39
|
+
import os
|
|
40
|
+
import sys
|
|
39
41
|
import time
|
|
40
42
|
from contextlib import contextmanager
|
|
41
43
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
@@ -55,6 +57,20 @@ from plexus.config import (
|
|
|
55
57
|
)
|
|
56
58
|
logger = logging.getLogger(__name__)
|
|
57
59
|
|
|
60
|
+
# Status messages to stderr so users running `python my_script.py` see what's
|
|
61
|
+
# happening without having to configure logging. Set PLEXUS_QUIET=1 to disable.
|
|
62
|
+
_QUIET = os.environ.get("PLEXUS_QUIET", "").lower() in ("1", "true", "yes")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _say(line: str) -> None:
|
|
66
|
+
if _QUIET:
|
|
67
|
+
return
|
|
68
|
+
try:
|
|
69
|
+
sys.stderr.write(f"[plexus] {line}\n")
|
|
70
|
+
sys.stderr.flush()
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
|
|
58
74
|
# Flexible value type - supports any JSON-serializable value
|
|
59
75
|
FlexValue = Union[int, float, str, bool, Dict[str, Any], List[Any]]
|
|
60
76
|
|
|
@@ -124,6 +140,7 @@ class Plexus:
|
|
|
124
140
|
self.transport = transport
|
|
125
141
|
self._ws_url = (ws_url or get_gateway_ws_url())
|
|
126
142
|
self._ws = None # lazily constructed in _ensure_ws()
|
|
143
|
+
self._clock_offset_ms: int = 0
|
|
127
144
|
|
|
128
145
|
# Pluggable buffer backend for failed sends
|
|
129
146
|
if persistent_buffer:
|
|
@@ -133,6 +150,12 @@ class Plexus:
|
|
|
133
150
|
else:
|
|
134
151
|
self._buffer: BufferBackend = MemoryBuffer(max_size=max_buffer_size)
|
|
135
152
|
|
|
153
|
+
# State that drives the [plexus] stderr status line.
|
|
154
|
+
self._announced_first_send = False
|
|
155
|
+
self._announced_http_fallback = False
|
|
156
|
+
self._announced_buffering = False
|
|
157
|
+
self._send_count = 0
|
|
158
|
+
|
|
136
159
|
@property
|
|
137
160
|
def max_buffer_size(self):
|
|
138
161
|
return self._max_buffer_size
|
|
@@ -153,17 +176,16 @@ class Plexus:
|
|
|
153
176
|
self._session.headers["User-Agent"] = f"plexus-python/{__version__}"
|
|
154
177
|
return self._session
|
|
155
178
|
|
|
156
|
-
|
|
157
|
-
def _normalize_ts_ms(timestamp: Optional[float] = None) -> int:
|
|
179
|
+
def _normalize_ts_ms(self, timestamp: Optional[float] = None) -> int:
|
|
158
180
|
"""Normalize a timestamp to milliseconds.
|
|
159
181
|
|
|
160
182
|
Accepts:
|
|
161
|
-
- None: returns current time in ms
|
|
162
|
-
- float seconds (e.g. time.time()): converts to ms
|
|
163
|
-
- 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)
|
|
164
186
|
"""
|
|
165
187
|
if timestamp is None:
|
|
166
|
-
return int(time.time() * 1000)
|
|
188
|
+
return int(time.time() * 1000) + self._clock_offset_ms
|
|
167
189
|
# Heuristic: values < 1e12 are seconds
|
|
168
190
|
if timestamp > 0 and timestamp < 1e12:
|
|
169
191
|
return int(timestamp * 1000)
|
|
@@ -236,6 +258,30 @@ class Plexus:
|
|
|
236
258
|
point = self._make_point(metric, value, timestamp, tags, data_class)
|
|
237
259
|
return self._send_points([point])
|
|
238
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
|
+
|
|
239
285
|
def send_batch(
|
|
240
286
|
self,
|
|
241
287
|
points: List[Tuple[str, FlexValue]],
|
|
@@ -261,8 +307,8 @@ class Plexus:
|
|
|
261
307
|
("position", {"x": 1.0, "y": 2.0}),
|
|
262
308
|
])
|
|
263
309
|
"""
|
|
264
|
-
|
|
265
|
-
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]
|
|
266
312
|
return self._send_points(data_points)
|
|
267
313
|
|
|
268
314
|
def _ensure_ws(self):
|
|
@@ -278,10 +324,14 @@ class Plexus:
|
|
|
278
324
|
install_id=get_install_id(),
|
|
279
325
|
agent_version=__version__,
|
|
280
326
|
on_source_id_assigned=self._on_source_id_assigned,
|
|
327
|
+
on_clock_synced=self._on_clock_synced,
|
|
281
328
|
)
|
|
282
329
|
self._ws.start()
|
|
283
330
|
return self._ws
|
|
284
331
|
|
|
332
|
+
def _on_clock_synced(self, offset_ms: int) -> None:
|
|
333
|
+
self._clock_offset_ms = offset_ms
|
|
334
|
+
|
|
285
335
|
def _on_source_id_assigned(self, assigned: str) -> None:
|
|
286
336
|
"""Callback from WebSocketTransport when the gateway returns an
|
|
287
337
|
auto-suffixed source_id. Persists it so subsequent runs (and the HTTP
|
|
@@ -344,8 +394,14 @@ class Plexus:
|
|
|
344
394
|
ws.wait_authenticated(timeout=min(self.timeout, 5.0))
|
|
345
395
|
if ws.send_points(all_points):
|
|
346
396
|
self._clear_buffer()
|
|
397
|
+
self._note_send(len(all_points), via="ws")
|
|
347
398
|
return True
|
|
348
399
|
# Socket unavailable → fall through to HTTP.
|
|
400
|
+
if not self._announced_http_fallback:
|
|
401
|
+
_say(
|
|
402
|
+
f"⚠ WebSocket unavailable, falling back to POST {self.gateway_url}/ingest"
|
|
403
|
+
)
|
|
404
|
+
self._announced_http_fallback = True
|
|
349
405
|
|
|
350
406
|
url = f"{self.gateway_url}/ingest"
|
|
351
407
|
last_error: Optional[Exception] = None
|
|
@@ -372,8 +428,11 @@ class Plexus:
|
|
|
372
428
|
|
|
373
429
|
# Auth errors - don't retry, raise immediately
|
|
374
430
|
if response.status_code == 401:
|
|
431
|
+
_say("✗ Gateway rejected the API key (401).")
|
|
432
|
+
_say(" Run `plexus whoami` to confirm what's on disk.")
|
|
375
433
|
raise AuthenticationError("Invalid API key")
|
|
376
434
|
elif response.status_code == 403:
|
|
435
|
+
_say("✗ API key lacks write scope (403).")
|
|
377
436
|
raise AuthenticationError("API key doesn't have write permissions")
|
|
378
437
|
|
|
379
438
|
# Bad request errors - don't retry (client error)
|
|
@@ -403,6 +462,7 @@ class Plexus:
|
|
|
403
462
|
# Success - clear the buffer and return
|
|
404
463
|
elif response.status_code < 400:
|
|
405
464
|
self._clear_buffer()
|
|
465
|
+
self._note_send(len(all_points), via="http")
|
|
406
466
|
return True
|
|
407
467
|
|
|
408
468
|
# Other 4xx errors - don't retry
|
|
@@ -427,11 +487,35 @@ class Plexus:
|
|
|
427
487
|
|
|
428
488
|
# All retries failed - buffer the points for later
|
|
429
489
|
self._add_to_buffer(points)
|
|
490
|
+
if not self._announced_buffering:
|
|
491
|
+
_say(
|
|
492
|
+
f"⏸ Send failed, buffering points locally ({self.buffer_size()} queued). "
|
|
493
|
+
f"Will retry on next call."
|
|
494
|
+
)
|
|
495
|
+
self._announced_buffering = True
|
|
430
496
|
|
|
431
497
|
if last_error:
|
|
432
498
|
raise last_error
|
|
433
499
|
raise PlexusError("Send failed after all retries")
|
|
434
500
|
|
|
501
|
+
def _note_send(self, count: int, via: str) -> None:
|
|
502
|
+
"""Bookkeeping so the user sees the moment data starts flowing.
|
|
503
|
+
|
|
504
|
+
First successful send → "✓ First N points landed (via WS/HTTP)".
|
|
505
|
+
Recovery from a buffering state → "✓ Sending again (was buffered)".
|
|
506
|
+
Otherwise silent — every-send chatter would be unbearable at 100 Hz.
|
|
507
|
+
"""
|
|
508
|
+
self._send_count += count
|
|
509
|
+
if not self._announced_first_send:
|
|
510
|
+
_say(
|
|
511
|
+
f"✓ First {count} point{'s' if count != 1 else ''} landed "
|
|
512
|
+
f"(via {via}). source_id={self.source_id!r}"
|
|
513
|
+
)
|
|
514
|
+
self._announced_first_send = True
|
|
515
|
+
elif self._announced_buffering:
|
|
516
|
+
_say("✓ Sending again (drained the local buffer).")
|
|
517
|
+
self._announced_buffering = False
|
|
518
|
+
|
|
435
519
|
def _add_to_buffer(self, points: List[Dict[str, Any]]) -> None:
|
|
436
520
|
"""Add points to the local buffer for later retry."""
|
|
437
521
|
self._buffer.add(points)
|
|
@@ -499,7 +583,7 @@ class Plexus:
|
|
|
499
583
|
"source_id": self.source_id,
|
|
500
584
|
"status": "started",
|
|
501
585
|
"tags": tags,
|
|
502
|
-
"timestamp": time.time(),
|
|
586
|
+
"timestamp": (int(time.time() * 1000) + self._clock_offset_ms) / 1000,
|
|
503
587
|
},
|
|
504
588
|
timeout=self.timeout,
|
|
505
589
|
)
|
|
@@ -517,7 +601,7 @@ class Plexus:
|
|
|
517
601
|
"run_id": run_id,
|
|
518
602
|
"source_id": self.source_id,
|
|
519
603
|
"status": "ended",
|
|
520
|
-
"timestamp": time.time(),
|
|
604
|
+
"timestamp": (int(time.time() * 1000) + self._clock_offset_ms) / 1000,
|
|
521
605
|
},
|
|
522
606
|
timeout=self.timeout,
|
|
523
607
|
)
|
|
@@ -25,9 +25,12 @@ 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
|
|
31
|
+
import os
|
|
30
32
|
import random
|
|
33
|
+
import sys
|
|
31
34
|
import threading
|
|
32
35
|
import time
|
|
33
36
|
from dataclasses import dataclass, field
|
|
@@ -43,6 +46,23 @@ except ImportError as e: # pragma: no cover - import-time failure is obvious
|
|
|
43
46
|
|
|
44
47
|
logger = logging.getLogger(__name__)
|
|
45
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")
|
|
53
|
+
|
|
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
|
|
65
|
+
|
|
46
66
|
AUTH_TIMEOUT_S = 10.0
|
|
47
67
|
HEARTBEAT_INTERVAL_S = 30.0
|
|
48
68
|
BACKOFF_BASE_S = 1.0
|
|
@@ -89,6 +109,7 @@ class WebSocketTransport:
|
|
|
89
109
|
platform: str = "python-sdk",
|
|
90
110
|
auto_reconnect: bool = True,
|
|
91
111
|
on_source_id_assigned: Optional[Callable[[str], None]] = None,
|
|
112
|
+
on_clock_synced: Optional[Callable[[int], None]] = None,
|
|
92
113
|
):
|
|
93
114
|
if not api_key:
|
|
94
115
|
raise ValueError("api_key required")
|
|
@@ -103,6 +124,7 @@ class WebSocketTransport:
|
|
|
103
124
|
self.platform = platform
|
|
104
125
|
self.auto_reconnect = auto_reconnect
|
|
105
126
|
self._on_source_id_assigned = on_source_id_assigned
|
|
127
|
+
self._on_clock_synced = on_clock_synced
|
|
106
128
|
|
|
107
129
|
self._commands: Dict[str, _RegisteredCommand] = {}
|
|
108
130
|
self._ws: Optional[websocket.WebSocket] = None
|
|
@@ -111,6 +133,7 @@ class WebSocketTransport:
|
|
|
111
133
|
self._stop = threading.Event()
|
|
112
134
|
self._thread: Optional[threading.Thread] = None
|
|
113
135
|
self._backoff_attempt = 0
|
|
136
|
+
self._clock_offset_ms: int = 0
|
|
114
137
|
|
|
115
138
|
# ------------------------------------------------------------------ public
|
|
116
139
|
|
|
@@ -136,6 +159,7 @@ class WebSocketTransport:
|
|
|
136
159
|
target=self._run, name="plexus-ws", daemon=True
|
|
137
160
|
)
|
|
138
161
|
self._thread.start()
|
|
162
|
+
atexit.register(self.stop)
|
|
139
163
|
|
|
140
164
|
def stop(self, timeout: float = 2.0) -> None:
|
|
141
165
|
self._stop.set()
|
|
@@ -156,6 +180,10 @@ class WebSocketTransport:
|
|
|
156
180
|
def is_authenticated(self) -> bool:
|
|
157
181
|
return self._authenticated.is_set()
|
|
158
182
|
|
|
183
|
+
@property
|
|
184
|
+
def clock_offset_ms(self) -> int:
|
|
185
|
+
return self._clock_offset_ms
|
|
186
|
+
|
|
159
187
|
def send_points(self, points: List[Dict[str, Any]]) -> bool:
|
|
160
188
|
"""Send a telemetry frame. Returns False if the socket is not
|
|
161
189
|
authenticated — caller is expected to fall back to HTTP."""
|
|
@@ -169,11 +197,22 @@ class WebSocketTransport:
|
|
|
169
197
|
# ------------------------------------------------------------------ thread
|
|
170
198
|
|
|
171
199
|
def _run(self) -> None:
|
|
200
|
+
first_attempt = True
|
|
172
201
|
while not self._stop.is_set():
|
|
173
202
|
try:
|
|
174
203
|
self._connect_and_serve()
|
|
175
204
|
except Exception as e:
|
|
176
|
-
|
|
205
|
+
msg = str(e)
|
|
206
|
+
logger.warning("plexus ws loop error: %s", msg)
|
|
207
|
+
# Loud the first time so users running a script see it,
|
|
208
|
+
# quieter on subsequent retries to avoid log spam.
|
|
209
|
+
if first_attempt:
|
|
210
|
+
if "auth failed" in msg.lower() or "invalid api key" in msg.lower():
|
|
211
|
+
_say(f"✗ Auth rejected by gateway: {msg}")
|
|
212
|
+
_say(" Check your key — `plexus whoami` shows what's on disk.")
|
|
213
|
+
else:
|
|
214
|
+
_say(f"✗ Connection failed: {msg}")
|
|
215
|
+
_say(" SDK will keep retrying with backoff.")
|
|
177
216
|
finally:
|
|
178
217
|
self._authenticated.clear()
|
|
179
218
|
with self._ws_lock:
|
|
@@ -185,6 +224,7 @@ class WebSocketTransport:
|
|
|
185
224
|
delay = _backoff_delay(self._backoff_attempt)
|
|
186
225
|
self._backoff_attempt = min(self._backoff_attempt + 1, 10)
|
|
187
226
|
logger.info("plexus ws reconnect in %.1fs", delay)
|
|
227
|
+
first_attempt = False
|
|
188
228
|
if self._stop.wait(timeout=delay):
|
|
189
229
|
break
|
|
190
230
|
|
|
@@ -219,6 +259,15 @@ class WebSocketTransport:
|
|
|
219
259
|
if msg.get("type") != "authenticated":
|
|
220
260
|
raise RuntimeError(f"auth failed: {msg}")
|
|
221
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
|
+
|
|
222
271
|
# The gateway may return a different source_id if the desired name
|
|
223
272
|
# was already claimed by another install — adopt the assigned value
|
|
224
273
|
# so all subsequent frames (heartbeats, future reconnects) use it.
|
|
@@ -235,9 +284,15 @@ class WebSocketTransport:
|
|
|
235
284
|
except Exception as e: # pragma: no cover - callback errors must not break auth
|
|
236
285
|
logger.debug("on_source_id_assigned callback raised: %s", e)
|
|
237
286
|
|
|
287
|
+
was_reconnect = self._backoff_attempt > 0
|
|
238
288
|
self._authenticated.set()
|
|
239
289
|
self._backoff_attempt = 0
|
|
240
290
|
logger.info("plexus ws authenticated as %s", self.source_id)
|
|
291
|
+
if was_reconnect:
|
|
292
|
+
_say(f"✓ Reconnected as {self.source_id}")
|
|
293
|
+
else:
|
|
294
|
+
_say(f"✓ Connected to gateway as {self.source_id}")
|
|
295
|
+
_say(f" endpoint: {self.ws_url}")
|
|
241
296
|
|
|
242
297
|
# 3. Read loop with heartbeat pump
|
|
243
298
|
ws.settimeout(1.0)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Basic tests for plexus-python."""
|
|
2
2
|
|
|
3
|
+
import time
|
|
4
|
+
|
|
3
5
|
from plexus import __version__
|
|
4
6
|
from plexus.client import Plexus
|
|
5
7
|
from plexus.config import DEFAULT_CONFIG
|
|
@@ -41,3 +43,19 @@ def test_make_point_with_tags():
|
|
|
41
43
|
point = px._make_point("temperature", 72.5, tags={"sensor": "A1"})
|
|
42
44
|
|
|
43
45
|
assert point["tags"] == {"sensor": "A1"}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_normalize_ts_ms_applies_clock_offset():
|
|
49
|
+
px = Plexus(api_key="test", endpoint="http://localhost")
|
|
50
|
+
px._clock_offset_ms = 5000
|
|
51
|
+
before = int(time.time() * 1000)
|
|
52
|
+
ts = px._normalize_ts_ms(None)
|
|
53
|
+
after = int(time.time() * 1000)
|
|
54
|
+
assert before + 5000 <= ts <= after + 5000
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_normalize_ts_ms_ignores_offset_for_supplied_timestamp():
|
|
58
|
+
px = Plexus(api_key="test", endpoint="http://localhost")
|
|
59
|
+
px._clock_offset_ms = 5000
|
|
60
|
+
ts = px._normalize_ts_ms(1_700_000_000.0)
|
|
61
|
+
assert ts == 1_700_000_000_000
|
|
@@ -61,6 +61,7 @@ class _StubGateway:
|
|
|
61
61
|
await ws.send(json.dumps({
|
|
62
62
|
"type": "authenticated",
|
|
63
63
|
"source_id": returned_source_id,
|
|
64
|
+
"server_time_ms": int(time.time() * 1000),
|
|
64
65
|
}))
|
|
65
66
|
try:
|
|
66
67
|
async for raw in ws:
|
|
@@ -332,3 +333,48 @@ def test_ensure_device_path():
|
|
|
332
333
|
assert _ensure_device_path("wss://foo") == "wss://foo/ws/device"
|
|
333
334
|
assert _ensure_device_path("wss://foo/") == "wss://foo/ws/device"
|
|
334
335
|
assert _ensure_device_path("wss://foo/ws/device") == "wss://foo/ws/device"
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def test_clock_offset_computed_from_authenticated_frame():
|
|
339
|
+
# Stub sends a server_time_ms that is 30 seconds ahead of real time.
|
|
340
|
+
# The transport's clock_offset_ms should be close to +30_000.
|
|
341
|
+
fake_offset_ms = 30_000
|
|
342
|
+
|
|
343
|
+
class _OffsetStubGateway(_StubGateway):
|
|
344
|
+
async def _handler(self, ws, path="/ws/device"):
|
|
345
|
+
self._ws = ws
|
|
346
|
+
raw = await ws.recv()
|
|
347
|
+
msg = json.loads(raw)
|
|
348
|
+
self.auth_frame = msg
|
|
349
|
+
returned_source_id = self.assigned_source_id or msg.get("source_id")
|
|
350
|
+
await ws.send(json.dumps({
|
|
351
|
+
"type": "authenticated",
|
|
352
|
+
"source_id": returned_source_id,
|
|
353
|
+
"server_time_ms": int(time.time() * 1000) + fake_offset_ms,
|
|
354
|
+
}))
|
|
355
|
+
try:
|
|
356
|
+
async for raw in ws:
|
|
357
|
+
self.received.append(json.loads(raw))
|
|
358
|
+
except websockets.ConnectionClosed:
|
|
359
|
+
return
|
|
360
|
+
|
|
361
|
+
g = _OffsetStubGateway()
|
|
362
|
+
g.start()
|
|
363
|
+
try:
|
|
364
|
+
seen: List[int] = []
|
|
365
|
+
t = WebSocketTransport(
|
|
366
|
+
api_key="plx_test_abc",
|
|
367
|
+
source_id="drone-001",
|
|
368
|
+
ws_url=_url(g.port),
|
|
369
|
+
on_clock_synced=lambda offset: seen.append(offset),
|
|
370
|
+
)
|
|
371
|
+
t.start()
|
|
372
|
+
try:
|
|
373
|
+
assert t.wait_authenticated(timeout=3)
|
|
374
|
+
assert abs(t.clock_offset_ms - fake_offset_ms) < 500
|
|
375
|
+
assert len(seen) == 1
|
|
376
|
+
assert abs(seen[0] - fake_offset_ms) < 500
|
|
377
|
+
finally:
|
|
378
|
+
t.stop()
|
|
379
|
+
finally:
|
|
380
|
+
g.stop()
|