plexus-python 0.8.0__py3-none-any.whl → 0.9.0__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 +2 -2
- plexus/buffer.py +17 -16
- plexus/cameras/__init__.py +2 -2
- plexus/cameras/thermal.py +6 -6
- plexus/cli.py +7 -9
- plexus/client.py +199 -142
- plexus/config.py +3 -4
- plexus/ws.py +36 -29
- {plexus_python-0.8.0.dist-info → plexus_python-0.9.0.dist-info}/METADATA +10 -21
- plexus_python-0.9.0.dist-info/RECORD +14 -0
- {plexus_python-0.8.0.dist-info → plexus_python-0.9.0.dist-info}/WHEEL +1 -1
- plexus_python-0.8.0.dist-info/RECORD +0 -14
- {plexus_python-0.8.0.dist-info → plexus_python-0.9.0.dist-info}/entry_points.txt +0 -0
- {plexus_python-0.8.0.dist-info → plexus_python-0.9.0.dist-info}/licenses/LICENSE +0 -0
plexus/__init__.py
CHANGED
|
@@ -7,8 +7,8 @@ Plexus — thin Python SDK for sending telemetry to the Plexus gateway.
|
|
|
7
7
|
px.send("temperature", 72.5)
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
-
from plexus.client import Plexus, PlexusError,
|
|
10
|
+
from plexus.client import AuthenticationError, Plexus, PlexusError, read_mjpeg_frames
|
|
11
11
|
from plexus.config import RetryConfig
|
|
12
12
|
|
|
13
13
|
__version__ = "0.8.0"
|
|
14
|
-
__all__ = ["
|
|
14
|
+
__all__ = ["AuthenticationError", "Plexus", "PlexusError", "RetryConfig", "read_mjpeg_frames"]
|
plexus/buffer.py
CHANGED
|
@@ -17,7 +17,8 @@ import os
|
|
|
17
17
|
import sqlite3
|
|
18
18
|
import threading
|
|
19
19
|
from abc import ABC, abstractmethod
|
|
20
|
-
from
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from typing import Any
|
|
21
22
|
|
|
22
23
|
logger = logging.getLogger(__name__)
|
|
23
24
|
|
|
@@ -26,11 +27,11 @@ class BufferBackend(ABC):
|
|
|
26
27
|
"""Abstract buffer backend for storing telemetry points locally."""
|
|
27
28
|
|
|
28
29
|
@abstractmethod
|
|
29
|
-
def add(self, points:
|
|
30
|
+
def add(self, points: list[dict[str, Any]]) -> None:
|
|
30
31
|
"""Add points to the buffer, evicting oldest if over capacity."""
|
|
31
32
|
|
|
32
33
|
@abstractmethod
|
|
33
|
-
def get_all(self) ->
|
|
34
|
+
def get_all(self) -> list[dict[str, Any]]:
|
|
34
35
|
"""Return a copy of all buffered points without clearing."""
|
|
35
36
|
|
|
36
37
|
@abstractmethod
|
|
@@ -45,7 +46,7 @@ class BufferBackend(ABC):
|
|
|
45
46
|
def resize(self, max_size: int) -> None:
|
|
46
47
|
"""Update the maximum buffer capacity."""
|
|
47
48
|
|
|
48
|
-
def drain(self, batch_size: int = 5000) ->
|
|
49
|
+
def drain(self, batch_size: int = 5000) -> tuple[list[dict[str, Any]], int]:
|
|
49
50
|
"""Remove and return the oldest batch_size points atomically.
|
|
50
51
|
|
|
51
52
|
Returns (points, remaining_count). Points are deleted from the buffer
|
|
@@ -73,13 +74,13 @@ class MemoryBuffer(BufferBackend):
|
|
|
73
74
|
This extracts the original behavior from Plexus client._failed_buffer.
|
|
74
75
|
"""
|
|
75
76
|
|
|
76
|
-
def __init__(self, max_size: int = 10_000, on_overflow:
|
|
77
|
+
def __init__(self, max_size: int = 10_000, on_overflow: Callable[[int], None] | None = None):
|
|
77
78
|
self._max_size = max_size
|
|
78
79
|
self._on_overflow = on_overflow
|
|
79
|
-
self._buffer:
|
|
80
|
+
self._buffer: list[dict[str, Any]] = []
|
|
80
81
|
self._lock = threading.Lock()
|
|
81
82
|
|
|
82
|
-
def add(self, points:
|
|
83
|
+
def add(self, points: list[dict[str, Any]]) -> None:
|
|
83
84
|
with self._lock:
|
|
84
85
|
self._buffer.extend(points)
|
|
85
86
|
if len(self._buffer) > self._max_size:
|
|
@@ -89,7 +90,7 @@ class MemoryBuffer(BufferBackend):
|
|
|
89
90
|
if self._on_overflow:
|
|
90
91
|
self._on_overflow(overflow)
|
|
91
92
|
|
|
92
|
-
def get_all(self) ->
|
|
93
|
+
def get_all(self) -> list[dict[str, Any]]:
|
|
93
94
|
with self._lock:
|
|
94
95
|
return list(self._buffer)
|
|
95
96
|
|
|
@@ -105,7 +106,7 @@ class MemoryBuffer(BufferBackend):
|
|
|
105
106
|
with self._lock:
|
|
106
107
|
self._max_size = max_size
|
|
107
108
|
|
|
108
|
-
def drain(self, batch_size: int = 5000) ->
|
|
109
|
+
def drain(self, batch_size: int = 5000) -> tuple[list[dict[str, Any]], int]:
|
|
109
110
|
with self._lock:
|
|
110
111
|
batch = self._buffer[:batch_size]
|
|
111
112
|
self._buffer = self._buffer[batch_size:]
|
|
@@ -129,10 +130,10 @@ class SqliteBuffer(BufferBackend):
|
|
|
129
130
|
|
|
130
131
|
def __init__(
|
|
131
132
|
self,
|
|
132
|
-
path:
|
|
133
|
-
max_size:
|
|
134
|
-
max_bytes:
|
|
135
|
-
on_overflow:
|
|
133
|
+
path: str | None = None,
|
|
134
|
+
max_size: int | None = 100_000,
|
|
135
|
+
max_bytes: int | None = None,
|
|
136
|
+
on_overflow: Callable[[int], None] | None = None,
|
|
136
137
|
):
|
|
137
138
|
self._max_size = max_size
|
|
138
139
|
self._max_bytes = max_bytes
|
|
@@ -159,7 +160,7 @@ class SqliteBuffer(BufferBackend):
|
|
|
159
160
|
)
|
|
160
161
|
self._conn.commit()
|
|
161
162
|
|
|
162
|
-
def add(self, points:
|
|
163
|
+
def add(self, points: list[dict[str, Any]]) -> None:
|
|
163
164
|
if not points:
|
|
164
165
|
return
|
|
165
166
|
with self._lock:
|
|
@@ -178,7 +179,7 @@ class SqliteBuffer(BufferBackend):
|
|
|
178
179
|
self._conn.commit()
|
|
179
180
|
self._evict()
|
|
180
181
|
|
|
181
|
-
def get_all(self) ->
|
|
182
|
+
def get_all(self) -> list[dict[str, Any]]:
|
|
182
183
|
with self._lock:
|
|
183
184
|
cursor = self._conn.execute("SELECT data FROM points ORDER BY id")
|
|
184
185
|
return [json.loads(row[0]) for row in cursor.fetchall()]
|
|
@@ -197,7 +198,7 @@ class SqliteBuffer(BufferBackend):
|
|
|
197
198
|
with self._lock:
|
|
198
199
|
self._max_size = max_size
|
|
199
200
|
|
|
200
|
-
def drain(self, batch_size: int = 5000) ->
|
|
201
|
+
def drain(self, batch_size: int = 5000) -> tuple[list[dict[str, Any]], int]:
|
|
201
202
|
"""Remove and return the oldest batch_size points atomically.
|
|
202
203
|
|
|
203
204
|
Uses a single transaction: SELECT then DELETE by rowid. If the process
|
plexus/cameras/__init__.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from plexus.cameras.thermal import (
|
|
2
|
-
NoCameraFound,
|
|
3
2
|
MLX90640Camera,
|
|
4
3
|
MLX90641Camera,
|
|
4
|
+
NoCameraFound,
|
|
5
5
|
SimulatedThermalCamera,
|
|
6
6
|
ThermalCamera,
|
|
7
7
|
ThermalFrame,
|
|
@@ -12,9 +12,9 @@ from plexus.cameras.thermal import (
|
|
|
12
12
|
)
|
|
13
13
|
|
|
14
14
|
__all__ = [
|
|
15
|
-
"NoCameraFound",
|
|
16
15
|
"MLX90640Camera",
|
|
17
16
|
"MLX90641Camera",
|
|
17
|
+
"NoCameraFound",
|
|
18
18
|
"SimulatedThermalCamera",
|
|
19
19
|
"ThermalCamera",
|
|
20
20
|
"ThermalFrame",
|
plexus/cameras/thermal.py
CHANGED
|
@@ -23,7 +23,7 @@ import base64
|
|
|
23
23
|
import time
|
|
24
24
|
from abc import ABC, abstractmethod
|
|
25
25
|
from dataclasses import dataclass
|
|
26
|
-
from typing import Any
|
|
26
|
+
from typing import Any
|
|
27
27
|
|
|
28
28
|
import cv2
|
|
29
29
|
import numpy as np
|
|
@@ -89,17 +89,17 @@ class ThermalFrame:
|
|
|
89
89
|
sensor_height: int # native sensor height
|
|
90
90
|
temp_min: float
|
|
91
91
|
temp_max: float
|
|
92
|
-
temps:
|
|
92
|
+
temps: np.ndarray | None # native res; None when sensor > threshold
|
|
93
93
|
timestamp_ms: int
|
|
94
94
|
|
|
95
95
|
def to_message(
|
|
96
|
-
self, camera_id: str, source_id:
|
|
97
|
-
) ->
|
|
96
|
+
self, camera_id: str, source_id: str | None = None, quality: int = 85
|
|
97
|
+
) -> dict[str, Any]:
|
|
98
98
|
"""Build the gateway `video_frame` wire message for this frame."""
|
|
99
99
|
_, buf = cv2.imencode(".jpg", self.image, [cv2.IMWRITE_JPEG_QUALITY, quality])
|
|
100
100
|
b64 = base64.b64encode(buf.tobytes()).decode("ascii")
|
|
101
101
|
|
|
102
|
-
msg:
|
|
102
|
+
msg: dict[str, Any] = {
|
|
103
103
|
"type": "video_frame",
|
|
104
104
|
"camera_id": camera_id,
|
|
105
105
|
"frame": b64,
|
|
@@ -133,7 +133,7 @@ def _upscale_size(sw: int, sh: int) -> tuple[int, int]:
|
|
|
133
133
|
|
|
134
134
|
|
|
135
135
|
def build_thermal_frame(
|
|
136
|
-
temps: np.ndarray, timestamp_ms:
|
|
136
|
+
temps: np.ndarray, timestamp_ms: int | None = None
|
|
137
137
|
) -> ThermalFrame:
|
|
138
138
|
"""Colorize a temperature array into a ThermalFrame.
|
|
139
139
|
|
plexus/cli.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
Plexus CLI — `plexus init` style auth, plus a few sibling commands.
|
|
3
3
|
|
|
4
4
|
Designed to feel like fly.io / vercel CLIs:
|
|
5
|
-
$ pip install plexus
|
|
5
|
+
$ pip install plexus-python
|
|
6
6
|
$ plexus init
|
|
7
7
|
Opening browser to https://app.plexus.company/auth/cli...
|
|
8
8
|
✓ Saved API key as cli-<host>. You're set up.
|
|
@@ -29,11 +29,9 @@ import sys
|
|
|
29
29
|
import threading
|
|
30
30
|
import urllib.parse
|
|
31
31
|
import webbrowser
|
|
32
|
-
from typing import Optional
|
|
33
32
|
|
|
34
33
|
from . import config
|
|
35
34
|
|
|
36
|
-
|
|
37
35
|
DEFAULT_TIMEOUT_SECONDS = 300
|
|
38
36
|
SUCCESS_REDIRECT_SECONDS = 10
|
|
39
37
|
SUCCESS_HTML_TEMPLATE = """<!doctype html>
|
|
@@ -187,7 +185,7 @@ def _success_html(target: str) -> bytes:
|
|
|
187
185
|
target_js=repr(target),
|
|
188
186
|
).encode("utf-8")
|
|
189
187
|
|
|
190
|
-
ERROR_HTML = """<!doctype html>
|
|
188
|
+
ERROR_HTML = b"""<!doctype html>
|
|
191
189
|
<html lang="en">
|
|
192
190
|
<head>
|
|
193
191
|
<meta charset="utf-8" />
|
|
@@ -272,13 +270,13 @@ ERROR_HTML = """<!doctype html>
|
|
|
272
270
|
</div>
|
|
273
271
|
</div>
|
|
274
272
|
</body>
|
|
275
|
-
</html>"""
|
|
273
|
+
</html>"""
|
|
276
274
|
|
|
277
275
|
|
|
278
276
|
class _CallbackResult:
|
|
279
|
-
key:
|
|
280
|
-
state:
|
|
281
|
-
error:
|
|
277
|
+
key: str | None = None
|
|
278
|
+
state: str | None = None
|
|
279
|
+
error: str | None = None
|
|
282
280
|
|
|
283
281
|
|
|
284
282
|
def _pick_free_port() -> int:
|
|
@@ -476,7 +474,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
476
474
|
return parser
|
|
477
475
|
|
|
478
476
|
|
|
479
|
-
def main(argv:
|
|
477
|
+
def main(argv: list | None = None) -> int:
|
|
480
478
|
parser = build_parser()
|
|
481
479
|
args = parser.parse_args(argv)
|
|
482
480
|
return args.func(args)
|
plexus/client.py
CHANGED
|
@@ -24,12 +24,6 @@ Usage:
|
|
|
24
24
|
("pressure", 1013.25),
|
|
25
25
|
])
|
|
26
26
|
|
|
27
|
-
# Run recording
|
|
28
|
-
with px.run("motor-test-001"):
|
|
29
|
-
while True:
|
|
30
|
-
px.send("temperature", read_temp())
|
|
31
|
-
time.sleep(0.01)
|
|
32
|
-
|
|
33
27
|
Note: Requires authentication. Run 'plexus init' or set PLEXUS_API_KEY.
|
|
34
28
|
"""
|
|
35
29
|
|
|
@@ -44,8 +38,8 @@ import threading
|
|
|
44
38
|
import time
|
|
45
39
|
import urllib.error
|
|
46
40
|
import urllib.request
|
|
47
|
-
from
|
|
48
|
-
from typing import Any,
|
|
41
|
+
from collections.abc import Generator
|
|
42
|
+
from typing import Any, Union
|
|
49
43
|
|
|
50
44
|
from plexus._log import _say
|
|
51
45
|
from plexus.buffer import BufferBackend, MemoryBuffer, SqliteBuffer
|
|
@@ -71,9 +65,9 @@ class _Response:
|
|
|
71
65
|
|
|
72
66
|
class _Session:
|
|
73
67
|
def __init__(self):
|
|
74
|
-
self.headers:
|
|
68
|
+
self.headers: dict[str, str] = {}
|
|
75
69
|
|
|
76
|
-
def post(self, url: str, data: bytes = b"", headers:
|
|
70
|
+
def post(self, url: str, data: bytes = b"", headers: dict[str, str] | None = None, timeout: float = 10.0) -> "_Response":
|
|
77
71
|
req_headers = {**self.headers, **(headers or {})}
|
|
78
72
|
req = urllib.request.Request(url, data=data, headers=req_headers, method="POST")
|
|
79
73
|
try:
|
|
@@ -85,7 +79,7 @@ class _Session:
|
|
|
85
79
|
if isinstance(e.reason, socket.timeout):
|
|
86
80
|
raise _Timeout(str(e.reason))
|
|
87
81
|
raise _ConnError(str(e.reason))
|
|
88
|
-
except
|
|
82
|
+
except TimeoutError as e:
|
|
89
83
|
raise _Timeout(str(e))
|
|
90
84
|
|
|
91
85
|
def close(self) -> None:
|
|
@@ -101,7 +95,7 @@ class _ConnError(OSError):
|
|
|
101
95
|
|
|
102
96
|
|
|
103
97
|
# Flexible value type - supports any JSON-serializable value
|
|
104
|
-
FlexValue = Union[int, float, str, bool,
|
|
98
|
+
FlexValue = Union[int, float, str, bool, dict[str, Any], list[Any]]
|
|
105
99
|
|
|
106
100
|
_JPEG_SOI = b"\xff\xd8"
|
|
107
101
|
_JPEG_EOI = b"\xff\xd9"
|
|
@@ -137,27 +131,72 @@ def read_mjpeg_frames(pipe, chunk: int = 65536) -> Generator[bytes, None, None]:
|
|
|
137
131
|
class PlexusError(Exception):
|
|
138
132
|
"""Base exception for Plexus errors."""
|
|
139
133
|
|
|
140
|
-
pass
|
|
141
134
|
|
|
142
135
|
|
|
143
136
|
class AuthenticationError(PlexusError):
|
|
144
137
|
"""Raised when API key is missing or invalid."""
|
|
145
138
|
|
|
146
|
-
pass
|
|
147
139
|
|
|
148
140
|
|
|
149
|
-
|
|
141
|
+
# The wire slug rule (gateway validate.go sourceIDPattern, max length =
|
|
142
|
+
# MaxStringLen 256) — the old stricter local regex rejected dots, 1-char and
|
|
143
|
+
# >63-char slugs that the gateway accepts. Uuid-shaped slugs are additionally
|
|
144
|
+
# rejected (TypeScript SDK parity): the Plexus app resolves uuid-shaped refs
|
|
145
|
+
# as internal ids, which would make such a source unreachable.
|
|
146
|
+
_SOURCE_ID_RE = re.compile(r'^[a-z0-9][a-z0-9._-]*$')
|
|
147
|
+
_SOURCE_ID_UUID_RE = re.compile(
|
|
148
|
+
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
|
149
|
+
)
|
|
150
|
+
_SOURCE_ID_MAX_LEN = 256
|
|
150
151
|
|
|
151
152
|
|
|
152
153
|
def _validate_source_id(source_id: str) -> None:
|
|
153
|
-
if
|
|
154
|
+
if (
|
|
155
|
+
not source_id
|
|
156
|
+
or len(source_id) > _SOURCE_ID_MAX_LEN
|
|
157
|
+
or not _SOURCE_ID_RE.match(source_id)
|
|
158
|
+
or _SOURCE_ID_UUID_RE.match(source_id)
|
|
159
|
+
):
|
|
154
160
|
raise ValueError(
|
|
155
161
|
f"Invalid source_id {source_id!r}. "
|
|
156
|
-
"Must match ^[a-z0-9][a-z0-
|
|
157
|
-
"
|
|
162
|
+
"Must match ^[a-z0-9][a-z0-9._-]*$ (max 256 chars; lowercase "
|
|
163
|
+
"letters, digits, dots, hyphens, underscores; start with a letter "
|
|
164
|
+
"or digit) and must not look like a UUID."
|
|
158
165
|
)
|
|
159
166
|
|
|
160
167
|
|
|
168
|
+
# Allowance for the {"type": "telemetry", "points": [...]} envelope when
|
|
169
|
+
# estimating a WS frame's serialized size from the sum of its points.
|
|
170
|
+
_WS_ENVELOPE_BYTES = 64
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _split_ws_frames(
|
|
174
|
+
points: list[dict[str, Any]], byte_budget: int
|
|
175
|
+
) -> list[list[dict[str, Any]]]:
|
|
176
|
+
"""Split points into telemetry-frame chunks under byte_budget serialized.
|
|
177
|
+
|
|
178
|
+
The gateway enforces a 1MB read limit per WebSocket message and rejects
|
|
179
|
+
oversized frames server-side — after the local socket write has already
|
|
180
|
+
"succeeded". Frame size is estimated as the sum of each point's JSON
|
|
181
|
+
serialization plus a small envelope allowance. A single point larger than
|
|
182
|
+
the budget still goes out alone (nothing more can be done client-side).
|
|
183
|
+
"""
|
|
184
|
+
chunks: list[list[dict[str, Any]]] = []
|
|
185
|
+
cur: list[dict[str, Any]] = []
|
|
186
|
+
cur_bytes = _WS_ENVELOPE_BYTES
|
|
187
|
+
for p in points:
|
|
188
|
+
p_bytes = len(json.dumps(p).encode("utf-8")) + 1 # +1 for the comma
|
|
189
|
+
if cur and cur_bytes + p_bytes > byte_budget:
|
|
190
|
+
chunks.append(cur)
|
|
191
|
+
cur = []
|
|
192
|
+
cur_bytes = _WS_ENVELOPE_BYTES
|
|
193
|
+
cur.append(p)
|
|
194
|
+
cur_bytes += p_bytes
|
|
195
|
+
if cur:
|
|
196
|
+
chunks.append(cur)
|
|
197
|
+
return chunks
|
|
198
|
+
|
|
199
|
+
|
|
161
200
|
class Plexus:
|
|
162
201
|
"""
|
|
163
202
|
Client for sending sensor data to Plexus.
|
|
@@ -177,15 +216,15 @@ class Plexus:
|
|
|
177
216
|
|
|
178
217
|
def __init__(
|
|
179
218
|
self,
|
|
180
|
-
api_key:
|
|
181
|
-
endpoint:
|
|
182
|
-
source_id:
|
|
219
|
+
api_key: str | None = None,
|
|
220
|
+
endpoint: str | None = None,
|
|
221
|
+
source_id: str | None = None,
|
|
183
222
|
timeout: float = 10.0,
|
|
184
|
-
retry_config:
|
|
223
|
+
retry_config: RetryConfig | None = None,
|
|
185
224
|
max_buffer_size: int = 10000,
|
|
186
225
|
persistent_buffer: bool = True,
|
|
187
|
-
buffer_path:
|
|
188
|
-
ws_url:
|
|
226
|
+
buffer_path: str | None = None,
|
|
227
|
+
ws_url: str | None = None,
|
|
189
228
|
):
|
|
190
229
|
self.api_key = api_key or get_api_key()
|
|
191
230
|
if not self.api_key:
|
|
@@ -202,15 +241,14 @@ class Plexus:
|
|
|
202
241
|
self.retry_config = retry_config or RetryConfig()
|
|
203
242
|
self._max_buffer_size = max_buffer_size
|
|
204
243
|
|
|
205
|
-
self.
|
|
206
|
-
self._session: Optional[_Session] = None
|
|
207
|
-
self._store_frames: bool = False
|
|
244
|
+
self._session: _Session | None = None
|
|
208
245
|
self._cv2 = None
|
|
209
246
|
self._pil_image = None # lazy PIL.Image import
|
|
210
247
|
self._fit_warned: bool = False
|
|
211
248
|
|
|
212
249
|
self._ws_url = (ws_url or get_gateway_ws_url())
|
|
213
250
|
self._ws = None # lazily constructed in _ensure_ws()
|
|
251
|
+
self._ws_auth_waited = False # first-send auth wait paid at most once
|
|
214
252
|
self._clock_offset_ms: int = 0
|
|
215
253
|
|
|
216
254
|
# Pluggable buffer backend for failed sends
|
|
@@ -250,7 +288,7 @@ class Plexus:
|
|
|
250
288
|
self._session.headers["User-Agent"] = f"plexus-python/{__version__}"
|
|
251
289
|
return self._session
|
|
252
290
|
|
|
253
|
-
def _normalize_ts_ms(self, timestamp:
|
|
291
|
+
def _normalize_ts_ms(self, timestamp: float | None = None) -> int:
|
|
254
292
|
"""Normalize a timestamp to milliseconds.
|
|
255
293
|
|
|
256
294
|
Accepts:
|
|
@@ -265,14 +303,24 @@ class Plexus:
|
|
|
265
303
|
return int(timestamp * 1000)
|
|
266
304
|
return int(timestamp)
|
|
267
305
|
|
|
306
|
+
@staticmethod
|
|
307
|
+
def _infer_class(value: FlexValue) -> str:
|
|
308
|
+
"""Numbers are metrics; everything else (str/bool/dict/list) is an event.
|
|
309
|
+
|
|
310
|
+
Mirrors the gateway (ingest.go inferClass) and the TypeScript SDK
|
|
311
|
+
(wire.ts inferClass). bool is a subclass of int in Python, so it must
|
|
312
|
+
be excluded explicitly or True/False would wrongly become metrics.
|
|
313
|
+
"""
|
|
314
|
+
return "metric" if isinstance(value, (int, float)) and not isinstance(value, bool) else "event"
|
|
315
|
+
|
|
268
316
|
def _make_point(
|
|
269
317
|
self,
|
|
270
318
|
metric: str,
|
|
271
319
|
value: FlexValue,
|
|
272
|
-
timestamp:
|
|
273
|
-
tags:
|
|
274
|
-
data_class: str =
|
|
275
|
-
) ->
|
|
320
|
+
timestamp: float | None = None,
|
|
321
|
+
tags: dict[str, str] | None = None,
|
|
322
|
+
data_class: str | None = None,
|
|
323
|
+
) -> dict[str, Any]:
|
|
276
324
|
"""Create a data point dictionary.
|
|
277
325
|
|
|
278
326
|
Value can be:
|
|
@@ -281,26 +329,42 @@ class Plexus:
|
|
|
281
329
|
- bool: Binary flags, enabled/disabled states
|
|
282
330
|
- dict: Complex objects, vectors, nested data
|
|
283
331
|
- list: Arrays, coordinates, multi-value readings
|
|
332
|
+
|
|
333
|
+
When `data_class` is not given it is inferred from the value type.
|
|
334
|
+
The gateway rejects a non-numeric value on class="metric" and drops
|
|
335
|
+
the whole frame (taking buffered points with it), so inferring here —
|
|
336
|
+
and raising early on an explicit metric with a bad value — is what
|
|
337
|
+
makes the advertised flexible values actually work.
|
|
284
338
|
"""
|
|
339
|
+
cls = data_class if data_class is not None else self._infer_class(value)
|
|
340
|
+
if cls == "metric" and (
|
|
341
|
+
not isinstance(value, (int, float))
|
|
342
|
+
or isinstance(value, bool)
|
|
343
|
+
or value != value # NaN
|
|
344
|
+
or value in (float("inf"), float("-inf"))
|
|
345
|
+
):
|
|
346
|
+
raise PlexusError(
|
|
347
|
+
f'Metric "{metric}" requires a finite number value, got '
|
|
348
|
+
f'{type(value).__name__}. Pass data_class="event" for '
|
|
349
|
+
f"non-numeric values, or use px.event()."
|
|
350
|
+
)
|
|
285
351
|
point = {
|
|
286
|
-
"class":
|
|
352
|
+
"class": cls,
|
|
287
353
|
"metric": metric,
|
|
288
354
|
"value": value,
|
|
289
355
|
"timestamp": self._normalize_ts_ms(timestamp),
|
|
290
356
|
}
|
|
291
357
|
if tags:
|
|
292
358
|
point["tags"] = tags
|
|
293
|
-
if self._run_id:
|
|
294
|
-
point["run_id"] = self._run_id
|
|
295
359
|
return point
|
|
296
360
|
|
|
297
361
|
def send(
|
|
298
362
|
self,
|
|
299
363
|
metric: str,
|
|
300
364
|
value: FlexValue,
|
|
301
|
-
timestamp:
|
|
302
|
-
tags:
|
|
303
|
-
data_class: str =
|
|
365
|
+
timestamp: float | None = None,
|
|
366
|
+
tags: dict[str, str] | None = None,
|
|
367
|
+
data_class: str | None = None,
|
|
304
368
|
) -> bool:
|
|
305
369
|
"""
|
|
306
370
|
Send a single metric value to Plexus.
|
|
@@ -315,7 +379,9 @@ class Plexus:
|
|
|
315
379
|
- list: px.send("angles", [0.5, 1.2, -0.3])
|
|
316
380
|
timestamp: Unix timestamp. If not provided, uses current time.
|
|
317
381
|
tags: Optional key-value tags for the metric
|
|
318
|
-
data_class: Pipeline data class - "metric"
|
|
382
|
+
data_class: Pipeline data class - "metric" or "event". If omitted,
|
|
383
|
+
inferred from the value type (numbers → metric, everything
|
|
384
|
+
else → event).
|
|
319
385
|
|
|
320
386
|
Returns:
|
|
321
387
|
True if successful
|
|
@@ -336,8 +402,8 @@ class Plexus:
|
|
|
336
402
|
self,
|
|
337
403
|
name: str,
|
|
338
404
|
data: FlexValue,
|
|
339
|
-
timestamp:
|
|
340
|
-
tags:
|
|
405
|
+
timestamp: float | None = None,
|
|
406
|
+
tags: dict[str, str] | None = None,
|
|
341
407
|
) -> bool:
|
|
342
408
|
"""
|
|
343
409
|
Send a named event with text or structured data.
|
|
@@ -358,9 +424,9 @@ class Plexus:
|
|
|
358
424
|
|
|
359
425
|
def send_batch(
|
|
360
426
|
self,
|
|
361
|
-
points:
|
|
362
|
-
timestamp:
|
|
363
|
-
tags:
|
|
427
|
+
points: list[tuple[str, FlexValue] | tuple[str, FlexValue, float]],
|
|
428
|
+
timestamp: float | None = None,
|
|
429
|
+
tags: dict[str, str] | None = None,
|
|
364
430
|
) -> bool:
|
|
365
431
|
"""
|
|
366
432
|
Send multiple metrics at once.
|
|
@@ -406,8 +472,8 @@ class Plexus:
|
|
|
406
472
|
"""Lazily construct and start the WebSocket transport."""
|
|
407
473
|
if self._ws is not None:
|
|
408
474
|
return self._ws
|
|
409
|
-
from plexus.ws import WebSocketTransport
|
|
410
475
|
from plexus import __version__
|
|
476
|
+
from plexus.ws import WebSocketTransport
|
|
411
477
|
self._ws = WebSocketTransport(
|
|
412
478
|
api_key=self.api_key,
|
|
413
479
|
source_id=self.source_id,
|
|
@@ -424,7 +490,7 @@ class Plexus:
|
|
|
424
490
|
def _on_clock_synced(self, offset_ms: int) -> None:
|
|
425
491
|
self._clock_offset_ms = offset_ms
|
|
426
492
|
|
|
427
|
-
def _encode_frame(self, frame, quality: int) ->
|
|
493
|
+
def _encode_frame(self, frame, quality: int) -> tuple[bytes, int, int]:
|
|
428
494
|
"""Normalize any supported frame type to (jpeg_bytes, width, height).
|
|
429
495
|
|
|
430
496
|
Accepted inputs:
|
|
@@ -490,7 +556,7 @@ class Plexus:
|
|
|
490
556
|
) from e
|
|
491
557
|
return self._pil_image
|
|
492
558
|
|
|
493
|
-
def _pil_to_jpeg(self, img, quality: int) ->
|
|
559
|
+
def _pil_to_jpeg(self, img, quality: int) -> tuple[bytes, int, int]:
|
|
494
560
|
import io
|
|
495
561
|
if img.mode not in ("RGB", "L"):
|
|
496
562
|
img = img.convert("RGB")
|
|
@@ -542,7 +608,7 @@ class Plexus:
|
|
|
542
608
|
frame,
|
|
543
609
|
camera_id: str = "camera:0",
|
|
544
610
|
quality: int = 85,
|
|
545
|
-
timestamp:
|
|
611
|
+
timestamp: float | None = None,
|
|
546
612
|
) -> bool:
|
|
547
613
|
"""Send a single video frame to Plexus (WebSocket transport only).
|
|
548
614
|
|
|
@@ -581,7 +647,7 @@ class Plexus:
|
|
|
581
647
|
temps,
|
|
582
648
|
camera_id: str = "thermal:0",
|
|
583
649
|
quality: int = 85,
|
|
584
|
-
timestamp:
|
|
650
|
+
timestamp: float | None = None,
|
|
585
651
|
) -> bool:
|
|
586
652
|
"""Send a thermal camera frame to Plexus (WebSocket transport only).
|
|
587
653
|
|
|
@@ -684,8 +750,8 @@ class Plexus:
|
|
|
684
750
|
name: str,
|
|
685
751
|
handler,
|
|
686
752
|
*,
|
|
687
|
-
description:
|
|
688
|
-
params:
|
|
753
|
+
description: str | None = None,
|
|
754
|
+
params: list[dict[str, Any]] | None = None,
|
|
689
755
|
concurrency: str = "accept",
|
|
690
756
|
) -> None:
|
|
691
757
|
"""Register a command handler (WebSocket transport only).
|
|
@@ -716,48 +782,109 @@ class Plexus:
|
|
|
716
782
|
concurrency=concurrency,
|
|
717
783
|
)
|
|
718
784
|
|
|
719
|
-
|
|
785
|
+
# Gateway hard limits (gateway gateway_config.go): 10k points per
|
|
786
|
+
# batch/frame and 1MB per WebSocket message. Sending in chunks well under
|
|
787
|
+
# both means a large local backlog can always drain, instead of one giant
|
|
788
|
+
# merged request drawing a non-retryable 400 that would wedge the client
|
|
789
|
+
# at exactly the buffer cap.
|
|
790
|
+
_SEND_CHUNK_POINTS = 5000
|
|
791
|
+
_WS_FRAME_BYTE_BUDGET = 900_000
|
|
792
|
+
|
|
793
|
+
def _send_points(self, points: list[dict[str, Any]]) -> bool:
|
|
720
794
|
"""Send data points to the gateway with retry and buffering.
|
|
721
795
|
|
|
722
|
-
|
|
723
|
-
|
|
796
|
+
Any locally buffered backlog is drained together with the new points
|
|
797
|
+
in chunks of at most _SEND_CHUNK_POINTS, so a request never exceeds
|
|
798
|
+
the gateway's 10k-points-per-batch limit no matter how large the
|
|
799
|
+
backlog has grown. Each chunk tries WebSocket first and falls through
|
|
800
|
+
to HTTP POST so points still land.
|
|
724
801
|
|
|
725
802
|
Retry behavior (HTTP path):
|
|
726
803
|
- Retries on: Timeout, ConnectionError, HTTP 429, HTTP 5xx
|
|
727
804
|
- No retry on: HTTP 401/403 (auth), HTTP 400/422 (bad request)
|
|
728
|
-
-
|
|
805
|
+
- On failure: the unsent chunk and all not-yet-sent points are
|
|
806
|
+
buffered locally for the next send attempt, then the error is
|
|
807
|
+
raised. Nothing is dropped on the floor.
|
|
729
808
|
"""
|
|
730
809
|
if not self.api_key:
|
|
731
810
|
raise AuthenticationError(
|
|
732
811
|
"No API key configured. Run 'plexus init' or set PLEXUS_API_KEY"
|
|
733
812
|
)
|
|
734
813
|
|
|
735
|
-
# Include any previously buffered points
|
|
736
|
-
all_points = self._get_buffered_points() + points
|
|
737
|
-
|
|
738
|
-
# Preferred path: WebSocket.
|
|
739
814
|
ws = self._ensure_ws()
|
|
740
|
-
# Brief wait on
|
|
741
|
-
# into the HTTP fallback path.
|
|
742
|
-
|
|
815
|
+
# Brief wait on the FIRST send only, so startup races don't dump the
|
|
816
|
+
# first points into the HTTP fallback path. Waiting on every send
|
|
817
|
+
# would stall an unauthenticated client ~5s per call; once the wait
|
|
818
|
+
# has been paid — or a reconnect backoff is already pending — sends
|
|
819
|
+
# proceed immediately and use HTTP until the socket authenticates.
|
|
820
|
+
if (
|
|
821
|
+
not ws.is_authenticated
|
|
822
|
+
and not self._ws_auth_waited
|
|
823
|
+
and not ws.reconnect_pending
|
|
824
|
+
):
|
|
825
|
+
self._ws_auth_waited = True
|
|
743
826
|
ws.wait_authenticated(timeout=min(self.timeout, 5.0))
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
827
|
+
|
|
828
|
+
pending_new = list(points)
|
|
829
|
+
while True:
|
|
830
|
+
# Oldest buffered points first, topped up with new points — the
|
|
831
|
+
# common no-backlog case still goes out as a single request.
|
|
832
|
+
batch, _remaining = self._buffer.drain(self._SEND_CHUNK_POINTS)
|
|
833
|
+
if len(batch) < self._SEND_CHUNK_POINTS and pending_new:
|
|
834
|
+
take = self._SEND_CHUNK_POINTS - len(batch)
|
|
835
|
+
batch.extend(pending_new[:take])
|
|
836
|
+
pending_new = pending_new[take:]
|
|
837
|
+
if not batch:
|
|
838
|
+
return True
|
|
839
|
+
try:
|
|
840
|
+
self._send_chunk(ws, batch)
|
|
841
|
+
except Exception:
|
|
842
|
+
# The chunk did not land (drain() already removed it from the
|
|
843
|
+
# buffer) — put it back along with every not-yet-sent point so
|
|
844
|
+
# nothing is lost, then surface the error.
|
|
845
|
+
self._add_to_buffer(batch)
|
|
846
|
+
if pending_new:
|
|
847
|
+
self._add_to_buffer(pending_new)
|
|
848
|
+
if not self._announced_buffering:
|
|
849
|
+
_say(
|
|
850
|
+
f"⏸ Send failed, buffering points locally "
|
|
851
|
+
f"({self.buffer_size()} queued). Will retry on next call."
|
|
852
|
+
)
|
|
853
|
+
self._announced_buffering = True
|
|
854
|
+
raise
|
|
855
|
+
|
|
856
|
+
def _send_chunk(self, ws, points: list[dict[str, Any]]) -> None:
|
|
857
|
+
"""Send one bounded chunk (≤ _SEND_CHUNK_POINTS). Raises on failure.
|
|
858
|
+
|
|
859
|
+
WebSocket preferred. A ws.send_points() True only confirms the frame
|
|
860
|
+
reached the local socket — the gateway silently drops frames over its
|
|
861
|
+
1MB read limit server-side — so chunks are sub-split to stay under
|
|
862
|
+
_WS_FRAME_BYTE_BUDGET before being treated as delivered. If the
|
|
863
|
+
socket fails partway through, the whole chunk falls back to HTTP:
|
|
864
|
+
at-least-once delivery, duplicates preferred over loss.
|
|
865
|
+
"""
|
|
866
|
+
if ws is not None and ws.is_authenticated:
|
|
867
|
+
subframes = _split_ws_frames(points, self._WS_FRAME_BYTE_BUDGET)
|
|
868
|
+
if all(ws.send_points(sub) for sub in subframes):
|
|
869
|
+
self._note_send(len(points), via="ws")
|
|
870
|
+
return
|
|
748
871
|
# Socket unavailable → fall through to HTTP.
|
|
749
872
|
if not self._announced_http_fallback:
|
|
750
873
|
_say(
|
|
751
874
|
f"⚠ WebSocket unavailable, falling back to POST {self.gateway_url}/ingest"
|
|
752
875
|
)
|
|
753
876
|
self._announced_http_fallback = True
|
|
877
|
+
self._send_http(points)
|
|
878
|
+
self._note_send(len(points), via="http")
|
|
754
879
|
|
|
880
|
+
def _send_http(self, points: list[dict[str, Any]]) -> None:
|
|
881
|
+
"""POST one chunk of points to /ingest with retries. Raises on failure."""
|
|
755
882
|
url = f"{self.gateway_url}/ingest"
|
|
756
|
-
last_error:
|
|
883
|
+
last_error: Exception | None = None
|
|
757
884
|
|
|
758
885
|
for attempt in range(self.retry_config.max_retries + 1):
|
|
759
886
|
try:
|
|
760
|
-
payload = json.dumps({"source_id": self.source_id, "points":
|
|
887
|
+
payload = json.dumps({"source_id": self.source_id, "points": points})
|
|
761
888
|
payload_bytes = payload.encode("utf-8")
|
|
762
889
|
|
|
763
890
|
# Gzip compress payloads > 1KB for bandwidth efficiency
|
|
@@ -808,11 +935,9 @@ class Plexus:
|
|
|
808
935
|
continue
|
|
809
936
|
break
|
|
810
937
|
|
|
811
|
-
# Success
|
|
938
|
+
# Success
|
|
812
939
|
elif response.status_code < 400:
|
|
813
|
-
|
|
814
|
-
self._note_send(len(all_points), via="http")
|
|
815
|
-
return True
|
|
940
|
+
return
|
|
816
941
|
|
|
817
942
|
# Other 4xx errors - don't retry
|
|
818
943
|
else:
|
|
@@ -834,15 +959,6 @@ class Plexus:
|
|
|
834
959
|
continue
|
|
835
960
|
break
|
|
836
961
|
|
|
837
|
-
# All retries failed - buffer the points for later
|
|
838
|
-
self._add_to_buffer(points)
|
|
839
|
-
if not self._announced_buffering:
|
|
840
|
-
_say(
|
|
841
|
-
f"⏸ Send failed, buffering points locally ({self.buffer_size()} queued). "
|
|
842
|
-
f"Will retry on next call."
|
|
843
|
-
)
|
|
844
|
-
self._announced_buffering = True
|
|
845
|
-
|
|
846
962
|
if last_error:
|
|
847
963
|
raise last_error
|
|
848
964
|
raise PlexusError("Send failed after all retries")
|
|
@@ -865,11 +981,11 @@ class Plexus:
|
|
|
865
981
|
_say("✓ Sending again (drained the local buffer).")
|
|
866
982
|
self._announced_buffering = False
|
|
867
983
|
|
|
868
|
-
def _add_to_buffer(self, points:
|
|
984
|
+
def _add_to_buffer(self, points: list[dict[str, Any]]) -> None:
|
|
869
985
|
"""Add points to the local buffer for later retry."""
|
|
870
986
|
self._buffer.add(points)
|
|
871
987
|
|
|
872
|
-
def _get_buffered_points(self) ->
|
|
988
|
+
def _get_buffered_points(self) -> list[dict[str, Any]]:
|
|
873
989
|
"""Get a copy of buffered points without clearing."""
|
|
874
990
|
return self._buffer.get_all()
|
|
875
991
|
|
|
@@ -900,65 +1016,6 @@ class Plexus:
|
|
|
900
1016
|
# Send with empty new points list - will include buffered points
|
|
901
1017
|
return self._send_points([])
|
|
902
1018
|
|
|
903
|
-
@contextmanager
|
|
904
|
-
def run(self, run_id: str, tags: Optional[Dict[str, str]] = None, store_frames: bool = False):
|
|
905
|
-
"""
|
|
906
|
-
Context manager for recording a run.
|
|
907
|
-
|
|
908
|
-
All sends within this context will be tagged with the run ID,
|
|
909
|
-
making it easy to replay and analyze later.
|
|
910
|
-
|
|
911
|
-
Args:
|
|
912
|
-
run_id: Unique identifier for this run (e.g., "motor-test-001")
|
|
913
|
-
tags: Optional tags to apply to all points in this run
|
|
914
|
-
store_frames: If True, camera frames are uploaded to the Plexus API
|
|
915
|
-
for persistent storage alongside the live WebSocket stream.
|
|
916
|
-
|
|
917
|
-
Example:
|
|
918
|
-
with px.run("motor-test-001", store_frames=True):
|
|
919
|
-
while True:
|
|
920
|
-
px.send("temperature", read_temp())
|
|
921
|
-
time.sleep(0.01)
|
|
922
|
-
"""
|
|
923
|
-
self._run_id = run_id
|
|
924
|
-
self._store_frames = store_frames
|
|
925
|
-
|
|
926
|
-
# Notify API that run started
|
|
927
|
-
try:
|
|
928
|
-
self._get_session().post(
|
|
929
|
-
f"{self.endpoint}/api/runs",
|
|
930
|
-
data=json.dumps({
|
|
931
|
-
"run_id": run_id,
|
|
932
|
-
"source_id": self.source_id,
|
|
933
|
-
"status": "started",
|
|
934
|
-
"tags": tags,
|
|
935
|
-
"timestamp": (int(time.time() * 1000) + self._clock_offset_ms) / 1000,
|
|
936
|
-
}).encode("utf-8"),
|
|
937
|
-
timeout=self.timeout,
|
|
938
|
-
)
|
|
939
|
-
except Exception as e:
|
|
940
|
-
logger.debug(f"Run start notification failed: {e}")
|
|
941
|
-
|
|
942
|
-
try:
|
|
943
|
-
yield
|
|
944
|
-
finally:
|
|
945
|
-
# Notify API that run ended
|
|
946
|
-
try:
|
|
947
|
-
self._get_session().post(
|
|
948
|
-
f"{self.endpoint}/api/runs",
|
|
949
|
-
data=json.dumps({
|
|
950
|
-
"run_id": run_id,
|
|
951
|
-
"source_id": self.source_id,
|
|
952
|
-
"status": "ended",
|
|
953
|
-
"timestamp": (int(time.time() * 1000) + self._clock_offset_ms) / 1000,
|
|
954
|
-
}).encode("utf-8"),
|
|
955
|
-
timeout=self.timeout,
|
|
956
|
-
)
|
|
957
|
-
except Exception as e:
|
|
958
|
-
logger.debug(f"Run end notification failed: {e}")
|
|
959
|
-
self._run_id = None
|
|
960
|
-
self._store_frames = False
|
|
961
|
-
|
|
962
1019
|
def close(self):
|
|
963
1020
|
"""Close the client, flush any buffered points, and release resources."""
|
|
964
1021
|
if self.buffer_size() > 0:
|
plexus/config.py
CHANGED
|
@@ -9,7 +9,6 @@ import os
|
|
|
9
9
|
import random
|
|
10
10
|
from dataclasses import dataclass
|
|
11
11
|
from pathlib import Path
|
|
12
|
-
from typing import Optional
|
|
13
12
|
|
|
14
13
|
|
|
15
14
|
@dataclass
|
|
@@ -73,7 +72,7 @@ def load_config() -> dict:
|
|
|
73
72
|
config = json.load(f)
|
|
74
73
|
# Merge with defaults to handle missing keys
|
|
75
74
|
return {**DEFAULT_CONFIG, **config}
|
|
76
|
-
except (json.JSONDecodeError
|
|
75
|
+
except (OSError, json.JSONDecodeError):
|
|
77
76
|
return DEFAULT_CONFIG.copy()
|
|
78
77
|
|
|
79
78
|
|
|
@@ -90,7 +89,7 @@ def save_config(config: dict) -> None:
|
|
|
90
89
|
os.chmod(CONFIG_FILE, 0o600)
|
|
91
90
|
|
|
92
91
|
|
|
93
|
-
def get_api_key() ->
|
|
92
|
+
def get_api_key() -> str | None:
|
|
94
93
|
"""Get API key from config or environment variable."""
|
|
95
94
|
# Environment variable takes precedence
|
|
96
95
|
env_key = os.environ.get("PLEXUS_API_KEY")
|
|
@@ -131,7 +130,7 @@ def get_gateway_ws_url() -> str:
|
|
|
131
130
|
return (config.get("gateway_ws_url") or PLEXUS_GATEWAY_WS_URL).rstrip("/")
|
|
132
131
|
|
|
133
132
|
|
|
134
|
-
def get_source_id() ->
|
|
133
|
+
def get_source_id() -> str | None:
|
|
135
134
|
"""Get the source ID, generating one if not set."""
|
|
136
135
|
config = load_config()
|
|
137
136
|
source_id = config.get("source_id")
|
plexus/ws.py
CHANGED
|
@@ -5,14 +5,13 @@ Wire-compatible with the C SDK (`plexus_ws.c`). Targets the gateway's
|
|
|
5
5
|
`/ws/device` endpoint and exchanges the same JSON frames:
|
|
6
6
|
|
|
7
7
|
client → {"type": "device_auth", "api_key": ..., "source_id": ...,
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
server → {"type": "authenticated", "source_id": ...}
|
|
11
|
-
|
|
12
|
-
The
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
client's `source_id` is updated in place to match.
|
|
8
|
+
"platform": "python-sdk", "agent_version": ...,
|
|
9
|
+
"commands": [...]}
|
|
10
|
+
server → {"type": "authenticated", "source_id": ..., "server_time_ms": ...}
|
|
11
|
+
|
|
12
|
+
The gateway echoes the declared `source_id` back unchanged; the client
|
|
13
|
+
uses the `authenticated` frame only for the `server_time_ms` clock sync.
|
|
14
|
+
(install_id and server-side source_id auto-suffixing were removed in 0.7.1.)
|
|
16
15
|
client → {"type": "telemetry", "points": [...]}
|
|
17
16
|
client → {"type": "heartbeat", "source_id": ..., "agent_version": ...} # every 30s
|
|
18
17
|
server → {"type": "typed_command", "id": ..., "command": ..., "params": {...}}
|
|
@@ -33,8 +32,9 @@ import random
|
|
|
33
32
|
import struct
|
|
34
33
|
import threading
|
|
35
34
|
import time
|
|
35
|
+
from collections.abc import Callable
|
|
36
36
|
from dataclasses import dataclass, field
|
|
37
|
-
from typing import Any
|
|
37
|
+
from typing import Any
|
|
38
38
|
|
|
39
39
|
try:
|
|
40
40
|
import websocket # websocket-client
|
|
@@ -53,23 +53,23 @@ HEARTBEAT_INTERVAL_S = 30.0
|
|
|
53
53
|
BACKOFF_BASE_S = 1.0
|
|
54
54
|
BACKOFF_MAX_S = 60.0
|
|
55
55
|
|
|
56
|
-
CommandHandler = Callable[[str,
|
|
56
|
+
CommandHandler = Callable[[str, dict[str, Any]], dict[str, Any] | None]
|
|
57
57
|
|
|
58
58
|
|
|
59
59
|
@dataclass
|
|
60
60
|
class _RegisteredCommand:
|
|
61
61
|
name: str
|
|
62
62
|
handler: CommandHandler
|
|
63
|
-
description:
|
|
64
|
-
params:
|
|
63
|
+
description: str | None = None
|
|
64
|
+
params: list[dict[str, Any]] = field(default_factory=list)
|
|
65
65
|
# "accept" (default): run overlapping invocations concurrently.
|
|
66
66
|
# "reject": refuse a new invocation with an error result while a
|
|
67
67
|
# previous one of the same command is still running.
|
|
68
68
|
concurrency: str = "accept"
|
|
69
69
|
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
|
70
70
|
|
|
71
|
-
def to_manifest(self) ->
|
|
72
|
-
m:
|
|
71
|
+
def to_manifest(self) -> dict[str, Any]:
|
|
72
|
+
m: dict[str, Any] = {"name": self.name}
|
|
73
73
|
if self.description:
|
|
74
74
|
m["description"] = self.description
|
|
75
75
|
if self.params:
|
|
@@ -97,7 +97,7 @@ class WebSocketTransport:
|
|
|
97
97
|
agent_version: str = "0.0.0",
|
|
98
98
|
platform: str = "python-sdk",
|
|
99
99
|
auto_reconnect: bool = True,
|
|
100
|
-
on_clock_synced:
|
|
100
|
+
on_clock_synced: Callable[[int], None] | None = None,
|
|
101
101
|
):
|
|
102
102
|
if not api_key:
|
|
103
103
|
raise ValueError("api_key required")
|
|
@@ -112,16 +112,16 @@ class WebSocketTransport:
|
|
|
112
112
|
self.auto_reconnect = auto_reconnect
|
|
113
113
|
self._on_clock_synced = on_clock_synced
|
|
114
114
|
|
|
115
|
-
self._commands:
|
|
116
|
-
self._ws:
|
|
115
|
+
self._commands: dict[str, _RegisteredCommand] = {}
|
|
116
|
+
self._ws: websocket.WebSocket | None = None
|
|
117
117
|
self._ws_lock = threading.Lock()
|
|
118
118
|
self._authenticated = threading.Event()
|
|
119
119
|
self._stop = threading.Event()
|
|
120
|
-
self._thread:
|
|
120
|
+
self._thread: threading.Thread | None = None
|
|
121
121
|
self._backoff_attempt = 0
|
|
122
122
|
self._clock_offset_ms: int = 0
|
|
123
|
-
self._video_queue:
|
|
124
|
-
self._video_thread:
|
|
123
|
+
self._video_queue: queue.Queue[bytes] = queue.Queue(maxsize=2)
|
|
124
|
+
self._video_thread: threading.Thread | None = None
|
|
125
125
|
|
|
126
126
|
# ------------------------------------------------------------------ public
|
|
127
127
|
|
|
@@ -130,8 +130,8 @@ class WebSocketTransport:
|
|
|
130
130
|
name: str,
|
|
131
131
|
handler: CommandHandler,
|
|
132
132
|
*,
|
|
133
|
-
description:
|
|
134
|
-
params:
|
|
133
|
+
description: str | None = None,
|
|
134
|
+
params: list[dict[str, Any]] | None = None,
|
|
135
135
|
concurrency: str = "accept",
|
|
136
136
|
) -> None:
|
|
137
137
|
"""Register a command handler. Must be called before start() to be
|
|
@@ -187,11 +187,18 @@ class WebSocketTransport:
|
|
|
187
187
|
def is_authenticated(self) -> bool:
|
|
188
188
|
return self._authenticated.is_set()
|
|
189
189
|
|
|
190
|
+
@property
|
|
191
|
+
def reconnect_pending(self) -> bool:
|
|
192
|
+
"""True after a failed connect while the transport is backing off /
|
|
193
|
+
retrying. Callers should not block waiting for auth in this state —
|
|
194
|
+
no connection attempt may even be in flight."""
|
|
195
|
+
return self._backoff_attempt > 0 and not self._authenticated.is_set()
|
|
196
|
+
|
|
190
197
|
@property
|
|
191
198
|
def clock_offset_ms(self) -> int:
|
|
192
199
|
return self._clock_offset_ms
|
|
193
200
|
|
|
194
|
-
def send_points(self, points:
|
|
201
|
+
def send_points(self, points: list[dict[str, Any]]) -> bool:
|
|
195
202
|
"""Send a telemetry frame. Returns False if the socket is not
|
|
196
203
|
authenticated — caller is expected to fall back to HTTP."""
|
|
197
204
|
if not points:
|
|
@@ -223,7 +230,7 @@ class WebSocketTransport:
|
|
|
223
230
|
except queue.Full:
|
|
224
231
|
return False
|
|
225
232
|
|
|
226
|
-
def send_json_video_frame(self, msg:
|
|
233
|
+
def send_json_video_frame(self, msg: dict[str, Any]) -> bool:
|
|
227
234
|
"""Send a JSON video_frame message. Used for frames that carry extra
|
|
228
235
|
metadata (e.g. thermal cameras) that the binary format cannot express."""
|
|
229
236
|
if not self._authenticated.is_set():
|
|
@@ -357,7 +364,7 @@ class WebSocketTransport:
|
|
|
357
364
|
continue
|
|
358
365
|
self._dispatch(_safe_json(raw))
|
|
359
366
|
|
|
360
|
-
def _dispatch(self, msg:
|
|
367
|
+
def _dispatch(self, msg: dict[str, Any]) -> None:
|
|
361
368
|
mtype = msg.get("type")
|
|
362
369
|
if mtype == "typed_command":
|
|
363
370
|
self._handle_command(msg)
|
|
@@ -365,7 +372,7 @@ class WebSocketTransport:
|
|
|
365
372
|
logger.warning("plexus ws server error: %s", msg.get("detail") or msg)
|
|
366
373
|
# ignore unknown types — forward-compat
|
|
367
374
|
|
|
368
|
-
def _handle_command(self, msg:
|
|
375
|
+
def _handle_command(self, msg: dict[str, Any]) -> None:
|
|
369
376
|
cmd_id = msg.get("id") or ""
|
|
370
377
|
command = msg.get("command") or ""
|
|
371
378
|
params = msg.get("params") or {}
|
|
@@ -431,7 +438,7 @@ class WebSocketTransport:
|
|
|
431
438
|
reg: _RegisteredCommand,
|
|
432
439
|
cmd_id: str,
|
|
433
440
|
command: str,
|
|
434
|
-
params:
|
|
441
|
+
params: dict[str, Any],
|
|
435
442
|
holds_lock: bool = False,
|
|
436
443
|
) -> None:
|
|
437
444
|
try:
|
|
@@ -458,7 +465,7 @@ class WebSocketTransport:
|
|
|
458
465
|
"result": result if result is not None else {},
|
|
459
466
|
})
|
|
460
467
|
|
|
461
|
-
def _send_frame(self, frame:
|
|
468
|
+
def _send_frame(self, frame: dict[str, Any]) -> bool:
|
|
462
469
|
with self._ws_lock:
|
|
463
470
|
ws = self._ws
|
|
464
471
|
if ws is None:
|
|
@@ -512,7 +519,7 @@ def _ensure_device_path(url: str) -> str:
|
|
|
512
519
|
return url + "/ws/device"
|
|
513
520
|
|
|
514
521
|
|
|
515
|
-
def _safe_json(raw: Any) ->
|
|
522
|
+
def _safe_json(raw: Any) -> dict[str, Any]:
|
|
516
523
|
if isinstance(raw, (bytes, bytearray)):
|
|
517
524
|
raw = raw.decode("utf-8", errors="replace")
|
|
518
525
|
if not isinstance(raw, str):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: plexus-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Thin Python SDK for Plexus — send telemetry in one line
|
|
5
5
|
Project-URL: Homepage, https://plexus.company
|
|
6
6
|
Project-URL: Documentation, https://docs.plexus.company
|
|
@@ -27,7 +27,7 @@ Requires-Dist: numpy>=1.24; extra == 'dev'
|
|
|
27
27
|
Requires-Dist: opencv-python-headless>=4.8; extra == 'dev'
|
|
28
28
|
Requires-Dist: pytest-cov; extra == 'dev'
|
|
29
29
|
Requires-Dist: pytest>=9.0.3; extra == 'dev'
|
|
30
|
-
Requires-Dist: ruff; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff==0.15.12; extra == 'dev'
|
|
31
31
|
Requires-Dist: websockets>=12; extra == 'dev'
|
|
32
32
|
Provides-Extra: video
|
|
33
33
|
Requires-Dist: numpy>=1.24; extra == 'video'
|
|
@@ -68,7 +68,7 @@ curl -sL https://app.plexus.company/setup | bash -s -- \
|
|
|
68
68
|
|
|
69
69
|
The name must match `^[a-z0-9][a-z0-9_-]{1,62}$`. `setup.sh` refuses to run without `--name` (or without a TTY to prompt for one) — this is deliberate, because the previous `hostname` fallback silently merged telemetry from cloned SD-card images that all booted as `raspberrypi`.
|
|
70
70
|
|
|
71
|
-
**
|
|
71
|
+
**Names are not auto-deduplicated.** The gateway echoes back whatever `source_id` you declare, unchanged — pick a unique name per device (that's what `--name` and `source_id=...` are for). Two devices that declare the same name write into the same source.
|
|
72
72
|
|
|
73
73
|
In normal code, you usually just pass `source_id=...` explicitly to `Plexus(...)` and never have to think about it.
|
|
74
74
|
|
|
@@ -111,7 +111,7 @@ px.send_batch([
|
|
|
111
111
|
])
|
|
112
112
|
```
|
|
113
113
|
|
|
114
|
-
`points` is a list of `(metric, value)` tuples.
|
|
114
|
+
`points` is a list of `(metric, value)` tuples, or `(metric, value, timestamp)` 3-tuples when you need a per-point timestamp. Points without their own timestamp share the batch timestamp (now, unless you pass `timestamp=t`).
|
|
115
115
|
|
|
116
116
|
### `event(name, data)` — record a discrete occurrence
|
|
117
117
|
|
|
@@ -125,16 +125,6 @@ px.event("sensor_error", {"sensor": "imu", "code": 42}, tags={"motor": "A"})
|
|
|
125
125
|
|
|
126
126
|
The platform displays events as markers overlaid on your telemetry charts, not as time-series lines.
|
|
127
127
|
|
|
128
|
-
### `run(run_id)` — group data into a named recording
|
|
129
|
-
|
|
130
|
-
```python
|
|
131
|
-
with px.run("thermal-cycle-001"):
|
|
132
|
-
while running:
|
|
133
|
-
px.send("temperature", read_temp())
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
All `send()` calls inside the context are tagged with `run_id`, making it easy to isolate and replay that slice of data in the dashboard.
|
|
137
|
-
|
|
138
128
|
## Video streaming
|
|
139
129
|
|
|
140
130
|
Two methods depending on whether you control the capture loop or just have a URL.
|
|
@@ -228,8 +218,8 @@ px.send("temperature", 72.5, timestamp=t) # your timestamp, used as-is, no cor
|
|
|
228
218
|
|
|
229
219
|
**Known limits:**
|
|
230
220
|
- Clock sync refreshes on WebSocket (re)connect. A device with a drifting RTC that stays connected for many days accumulates uncorrected drift between reconnects.
|
|
231
|
-
- HTTP
|
|
232
|
-
- `send_batch()` shares one timestamp across the
|
|
221
|
+
- The HTTP fallback path (used when the WebSocket is unavailable) does not receive clock sync — timestamps default to the uncorrected device clock.
|
|
222
|
+
- `send_batch()` shares one timestamp across the batch by default; pass `(metric, value, timestamp)` 3-tuples for per-point timestamps.
|
|
233
223
|
|
|
234
224
|
## Transport
|
|
235
225
|
|
|
@@ -241,13 +231,12 @@ By default the SDK connects over a **WebSocket** to `/ws/device` on the gateway
|
|
|
241
231
|
If the socket is unavailable, sends transparently fall back to `POST /ingest` so no data is lost.
|
|
242
232
|
|
|
243
233
|
```python
|
|
244
|
-
#
|
|
234
|
+
# ws with transparent http fallback — this is the only mode
|
|
245
235
|
px = Plexus()
|
|
246
|
-
|
|
247
|
-
# force http (legacy)
|
|
248
|
-
px = Plexus(transport="http")
|
|
249
236
|
```
|
|
250
237
|
|
|
238
|
+
There is no transport selector: the SDK always prefers the WebSocket and falls back to `POST /ingest` on its own when the socket is unavailable.
|
|
239
|
+
|
|
251
240
|
### Handling commands
|
|
252
241
|
|
|
253
242
|
Register a handler before the first `send()` so the command is advertised in the auth frame:
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
plexus/__init__.py,sha256=bXzZn--_gT7vRM5tvRy36tMkWIPk-9NBHa7iFPArLUw,447
|
|
2
|
+
plexus/_log.py,sha256=3fjXrHFZghQ_17umMcvDUjjTH6aTQB3J4SpVDBiH03w,335
|
|
3
|
+
plexus/buffer.py,sha256=UNv_jEcrDwbkjJ6uhCehb7uBI2EuFEwO40waDpZn_5I,9579
|
|
4
|
+
plexus/cli.py,sha256=yc3j-5kUHhdYJv3b87ch7A41JVu491xb-UYdkRNubhg,14165
|
|
5
|
+
plexus/client.py,sha256=ku8znMnJlO9bMCWYIVwixAx5usVGcUsV0l5tKjyXJkk,39872
|
|
6
|
+
plexus/config.py,sha256=RuDh5UdVGdVQld5kQlXZO6CVXO4tS0HBalyaoAlXNvc,4416
|
|
7
|
+
plexus/ws.py,sha256=YmaxOwtEdYHOsc7E5AjEUlFTJhvn7X1xWqJKQKgHWYo,18942
|
|
8
|
+
plexus/cameras/__init__.py,sha256=AVu1vE1xfYk9lz1cprHlTfi0ieHf84UhQh9Z92E05b8,490
|
|
9
|
+
plexus/cameras/thermal.py,sha256=-klCEJQG5GlLtELaowWOPYomwMsLK8O5pmmX0PqUrTA,12022
|
|
10
|
+
plexus_python-0.9.0.dist-info/METADATA,sha256=WRRIS6gnGMvCuH8PyhxZ1uSNAgzq9zhoGbUnx9li1EQ,11394
|
|
11
|
+
plexus_python-0.9.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
12
|
+
plexus_python-0.9.0.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
|
|
13
|
+
plexus_python-0.9.0.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
|
|
14
|
+
plexus_python-0.9.0.dist-info/RECORD,,
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
plexus/__init__.py,sha256=O9GpL-CqlDKRoEfQq5ojYNsIMa7CtXuYvdw3zCrC8pg,447
|
|
2
|
-
plexus/_log.py,sha256=3fjXrHFZghQ_17umMcvDUjjTH6aTQB3J4SpVDBiH03w,335
|
|
3
|
-
plexus/buffer.py,sha256=0i6PLgoj904jFNv9RCrlskvPQsPSu_KNdYWMasFOvsg,9596
|
|
4
|
-
plexus/cli.py,sha256=-2wvHXQzobx3_tDGTXpaE2PlHv884y93Mu29kZE8qZE,14214
|
|
5
|
-
plexus/client.py,sha256=12qnUgmZbTEXuSFtMCAXqosh-V84odSiqagbhtfjAc8,36280
|
|
6
|
-
plexus/config.py,sha256=Y4rPo1zeqAOnz-pQWrDBAXZ1gjfr0UD5Q6HHTMmbKew,4450
|
|
7
|
-
plexus/ws.py,sha256=PSVVrS1vpGi20qGdRfTi4LWCXzt3Dx3hR09FuoT-QIs,18643
|
|
8
|
-
plexus/cameras/__init__.py,sha256=OvnU9KGKxkVtFLlk56H9x-ATa6UvpLI7PANa0HQO2cc,490
|
|
9
|
-
plexus/cameras/thermal.py,sha256=7o33QsF1RiZLManTxZ2E36nO8lRAHppCDkS3zXBHCxs,12047
|
|
10
|
-
plexus_python-0.8.0.dist-info/METADATA,sha256=MfH0vB7R8uBYeJhTTY8Xbd_Qi3qCYZ3jzSIS8cPFNmg,11750
|
|
11
|
-
plexus_python-0.8.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
12
|
-
plexus_python-0.8.0.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
|
|
13
|
-
plexus_python-0.8.0.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
|
|
14
|
-
plexus_python-0.8.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|