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.
Files changed (37) hide show
  1. {plexus_python-0.4.3 → plexus_python-0.4.6}/API.md +41 -5
  2. {plexus_python-0.4.3 → plexus_python-0.4.6}/CHANGELOG.md +30 -0
  3. {plexus_python-0.4.3 → plexus_python-0.4.6}/PKG-INFO +19 -1
  4. {plexus_python-0.4.3 → plexus_python-0.4.6}/README.md +18 -0
  5. {plexus_python-0.4.3 → plexus_python-0.4.6}/plexus/__init__.py +1 -1
  6. {plexus_python-0.4.3 → plexus_python-0.4.6}/plexus/client.py +94 -10
  7. {plexus_python-0.4.3 → plexus_python-0.4.6}/plexus/ws.py +56 -1
  8. {plexus_python-0.4.3 → plexus_python-0.4.6}/pyproject.toml +1 -1
  9. {plexus_python-0.4.3 → plexus_python-0.4.6}/tests/test_basic.py +18 -0
  10. {plexus_python-0.4.3 → plexus_python-0.4.6}/tests/test_ws.py +46 -0
  11. plexus_python-0.4.6/uv.lock +1167 -0
  12. {plexus_python-0.4.3 → plexus_python-0.4.6}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  13. {plexus_python-0.4.3 → plexus_python-0.4.6}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  14. {plexus_python-0.4.3 → plexus_python-0.4.6}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  15. {plexus_python-0.4.3 → plexus_python-0.4.6}/.github/workflows/ci.yml +0 -0
  16. {plexus_python-0.4.3 → plexus_python-0.4.6}/.github/workflows/publish.yml +0 -0
  17. {plexus_python-0.4.3 → plexus_python-0.4.6}/.gitignore +0 -0
  18. {plexus_python-0.4.3 → plexus_python-0.4.6}/AGENTS.md +0 -0
  19. {plexus_python-0.4.3 → plexus_python-0.4.6}/CODE_OF_CONDUCT.md +0 -0
  20. {plexus_python-0.4.3 → plexus_python-0.4.6}/CONTRIBUTING.md +0 -0
  21. {plexus_python-0.4.3 → plexus_python-0.4.6}/LICENSE +0 -0
  22. {plexus_python-0.4.3 → plexus_python-0.4.6}/SECURITY.md +0 -0
  23. {plexus_python-0.4.3 → plexus_python-0.4.6}/examples/README.md +0 -0
  24. {plexus_python-0.4.3 → plexus_python-0.4.6}/examples/basic.py +0 -0
  25. {plexus_python-0.4.3 → plexus_python-0.4.6}/examples/can.py +0 -0
  26. {plexus_python-0.4.3 → plexus_python-0.4.6}/examples/i2c_bme280.py +0 -0
  27. {plexus_python-0.4.3 → plexus_python-0.4.6}/examples/mavlink.py +0 -0
  28. {plexus_python-0.4.3 → plexus_python-0.4.6}/examples/mqtt.py +0 -0
  29. {plexus_python-0.4.3 → plexus_python-0.4.6}/plexus/buffer.py +0 -0
  30. {plexus_python-0.4.3 → plexus_python-0.4.6}/plexus/cli.py +0 -0
  31. {plexus_python-0.4.3 → plexus_python-0.4.6}/plexus/config.py +0 -0
  32. {plexus_python-0.4.3 → plexus_python-0.4.6}/scripts/plexus.service +0 -0
  33. {plexus_python-0.4.3 → plexus_python-0.4.6}/scripts/scan_buses.py +0 -0
  34. {plexus_python-0.4.3 → plexus_python-0.4.6}/scripts/setup.sh +0 -0
  35. {plexus_python-0.4.3 → plexus_python-0.4.6}/tests/test_buffer.py +0 -0
  36. {plexus_python-0.4.3 → plexus_python-0.4.6}/tests/test_config.py +0 -0
  37. {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 (seconds). Defaults to now |
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(millis() / 1000.0) + ",";
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
- - **Use timestamps** - Always include accurate timestamps
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
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:
@@ -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.3"
13
+ __version__ = "0.4.6"
14
14
  __all__ = ["Plexus", "WebSocketTransport"]
@@ -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
- @staticmethod
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
- ts = timestamp if timestamp is not None else time.time()
265
- 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]
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
- logger.warning("plexus ws loop error: %s", e)
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)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "plexus-python"
7
- version = "0.4.3"
7
+ version = "0.4.6"
8
8
  description = "Thin Python SDK for Plexus — send telemetry in one line"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.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()