plexus-python 0.7.1__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 +3 -3
- 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 +210 -143
- plexus/config.py +5 -6
- plexus/ws.py +97 -37
- {plexus_python-0.7.1.dist-info → plexus_python-0.9.0.dist-info}/METADATA +15 -26
- plexus_python-0.9.0.dist-info/RECORD +14 -0
- {plexus_python-0.7.1.dist-info → plexus_python-0.9.0.dist-info}/WHEEL +1 -1
- plexus_python-0.7.1.dist-info/RECORD +0 -14
- {plexus_python-0.7.1.dist-info → plexus_python-0.9.0.dist-info}/entry_points.txt +0 -0
- {plexus_python-0.7.1.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
|
-
__version__ = "0.
|
|
14
|
-
__all__ = ["
|
|
13
|
+
__version__ = "0.8.0"
|
|
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,9 @@ 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,
|
|
755
|
+
concurrency: str = "accept",
|
|
689
756
|
) -> None:
|
|
690
757
|
"""Register a command handler (WebSocket transport only).
|
|
691
758
|
|
|
@@ -695,6 +762,12 @@ class Plexus:
|
|
|
695
762
|
|
|
696
763
|
Must be called before the first send() so the command is advertised
|
|
697
764
|
in the auth frame.
|
|
765
|
+
|
|
766
|
+
concurrency: "accept" (default) runs overlapping invocations of the
|
|
767
|
+
same command concurrently; "reject" refuses a new invocation with
|
|
768
|
+
an error result while a previous one is still running. Use
|
|
769
|
+
"reject" for handlers that drive exclusive hardware (e.g. a pump
|
|
770
|
+
init) so a retry or double-click can't start two at once.
|
|
698
771
|
"""
|
|
699
772
|
ws = self._ensure_ws()
|
|
700
773
|
if ws.is_authenticated:
|
|
@@ -704,50 +777,114 @@ class Plexus:
|
|
|
704
777
|
"Call on_command() before the first send().",
|
|
705
778
|
name,
|
|
706
779
|
)
|
|
707
|
-
ws.register_command(
|
|
780
|
+
ws.register_command(
|
|
781
|
+
name, handler, description=description, params=params,
|
|
782
|
+
concurrency=concurrency,
|
|
783
|
+
)
|
|
784
|
+
|
|
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
|
|
708
792
|
|
|
709
|
-
def _send_points(self, points:
|
|
793
|
+
def _send_points(self, points: list[dict[str, Any]]) -> bool:
|
|
710
794
|
"""Send data points to the gateway with retry and buffering.
|
|
711
795
|
|
|
712
|
-
|
|
713
|
-
|
|
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.
|
|
714
801
|
|
|
715
802
|
Retry behavior (HTTP path):
|
|
716
803
|
- Retries on: Timeout, ConnectionError, HTTP 429, HTTP 5xx
|
|
717
804
|
- No retry on: HTTP 401/403 (auth), HTTP 400/422 (bad request)
|
|
718
|
-
-
|
|
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.
|
|
719
808
|
"""
|
|
720
809
|
if not self.api_key:
|
|
721
810
|
raise AuthenticationError(
|
|
722
811
|
"No API key configured. Run 'plexus init' or set PLEXUS_API_KEY"
|
|
723
812
|
)
|
|
724
813
|
|
|
725
|
-
# Include any previously buffered points
|
|
726
|
-
all_points = self._get_buffered_points() + points
|
|
727
|
-
|
|
728
|
-
# Preferred path: WebSocket.
|
|
729
814
|
ws = self._ensure_ws()
|
|
730
|
-
# Brief wait on
|
|
731
|
-
# into the HTTP fallback path.
|
|
732
|
-
|
|
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
|
|
733
826
|
ws.wait_authenticated(timeout=min(self.timeout, 5.0))
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
|
738
871
|
# Socket unavailable → fall through to HTTP.
|
|
739
872
|
if not self._announced_http_fallback:
|
|
740
873
|
_say(
|
|
741
874
|
f"⚠ WebSocket unavailable, falling back to POST {self.gateway_url}/ingest"
|
|
742
875
|
)
|
|
743
876
|
self._announced_http_fallback = True
|
|
877
|
+
self._send_http(points)
|
|
878
|
+
self._note_send(len(points), via="http")
|
|
744
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."""
|
|
745
882
|
url = f"{self.gateway_url}/ingest"
|
|
746
|
-
last_error:
|
|
883
|
+
last_error: Exception | None = None
|
|
747
884
|
|
|
748
885
|
for attempt in range(self.retry_config.max_retries + 1):
|
|
749
886
|
try:
|
|
750
|
-
payload = json.dumps({"source_id": self.source_id, "points":
|
|
887
|
+
payload = json.dumps({"source_id": self.source_id, "points": points})
|
|
751
888
|
payload_bytes = payload.encode("utf-8")
|
|
752
889
|
|
|
753
890
|
# Gzip compress payloads > 1KB for bandwidth efficiency
|
|
@@ -798,11 +935,9 @@ class Plexus:
|
|
|
798
935
|
continue
|
|
799
936
|
break
|
|
800
937
|
|
|
801
|
-
# Success
|
|
938
|
+
# Success
|
|
802
939
|
elif response.status_code < 400:
|
|
803
|
-
|
|
804
|
-
self._note_send(len(all_points), via="http")
|
|
805
|
-
return True
|
|
940
|
+
return
|
|
806
941
|
|
|
807
942
|
# Other 4xx errors - don't retry
|
|
808
943
|
else:
|
|
@@ -824,15 +959,6 @@ class Plexus:
|
|
|
824
959
|
continue
|
|
825
960
|
break
|
|
826
961
|
|
|
827
|
-
# All retries failed - buffer the points for later
|
|
828
|
-
self._add_to_buffer(points)
|
|
829
|
-
if not self._announced_buffering:
|
|
830
|
-
_say(
|
|
831
|
-
f"⏸ Send failed, buffering points locally ({self.buffer_size()} queued). "
|
|
832
|
-
f"Will retry on next call."
|
|
833
|
-
)
|
|
834
|
-
self._announced_buffering = True
|
|
835
|
-
|
|
836
962
|
if last_error:
|
|
837
963
|
raise last_error
|
|
838
964
|
raise PlexusError("Send failed after all retries")
|
|
@@ -855,11 +981,11 @@ class Plexus:
|
|
|
855
981
|
_say("✓ Sending again (drained the local buffer).")
|
|
856
982
|
self._announced_buffering = False
|
|
857
983
|
|
|
858
|
-
def _add_to_buffer(self, points:
|
|
984
|
+
def _add_to_buffer(self, points: list[dict[str, Any]]) -> None:
|
|
859
985
|
"""Add points to the local buffer for later retry."""
|
|
860
986
|
self._buffer.add(points)
|
|
861
987
|
|
|
862
|
-
def _get_buffered_points(self) ->
|
|
988
|
+
def _get_buffered_points(self) -> list[dict[str, Any]]:
|
|
863
989
|
"""Get a copy of buffered points without clearing."""
|
|
864
990
|
return self._buffer.get_all()
|
|
865
991
|
|
|
@@ -890,65 +1016,6 @@ class Plexus:
|
|
|
890
1016
|
# Send with empty new points list - will include buffered points
|
|
891
1017
|
return self._send_points([])
|
|
892
1018
|
|
|
893
|
-
@contextmanager
|
|
894
|
-
def run(self, run_id: str, tags: Optional[Dict[str, str]] = None, store_frames: bool = False):
|
|
895
|
-
"""
|
|
896
|
-
Context manager for recording a run.
|
|
897
|
-
|
|
898
|
-
All sends within this context will be tagged with the run ID,
|
|
899
|
-
making it easy to replay and analyze later.
|
|
900
|
-
|
|
901
|
-
Args:
|
|
902
|
-
run_id: Unique identifier for this run (e.g., "motor-test-001")
|
|
903
|
-
tags: Optional tags to apply to all points in this run
|
|
904
|
-
store_frames: If True, camera frames are uploaded to the Plexus API
|
|
905
|
-
for persistent storage alongside the live WebSocket stream.
|
|
906
|
-
|
|
907
|
-
Example:
|
|
908
|
-
with px.run("motor-test-001", store_frames=True):
|
|
909
|
-
while True:
|
|
910
|
-
px.send("temperature", read_temp())
|
|
911
|
-
time.sleep(0.01)
|
|
912
|
-
"""
|
|
913
|
-
self._run_id = run_id
|
|
914
|
-
self._store_frames = store_frames
|
|
915
|
-
|
|
916
|
-
# Notify API that run started
|
|
917
|
-
try:
|
|
918
|
-
self._get_session().post(
|
|
919
|
-
f"{self.endpoint}/api/runs",
|
|
920
|
-
data=json.dumps({
|
|
921
|
-
"run_id": run_id,
|
|
922
|
-
"source_id": self.source_id,
|
|
923
|
-
"status": "started",
|
|
924
|
-
"tags": tags,
|
|
925
|
-
"timestamp": (int(time.time() * 1000) + self._clock_offset_ms) / 1000,
|
|
926
|
-
}).encode("utf-8"),
|
|
927
|
-
timeout=self.timeout,
|
|
928
|
-
)
|
|
929
|
-
except Exception as e:
|
|
930
|
-
logger.debug(f"Run start notification failed: {e}")
|
|
931
|
-
|
|
932
|
-
try:
|
|
933
|
-
yield
|
|
934
|
-
finally:
|
|
935
|
-
# Notify API that run ended
|
|
936
|
-
try:
|
|
937
|
-
self._get_session().post(
|
|
938
|
-
f"{self.endpoint}/api/runs",
|
|
939
|
-
data=json.dumps({
|
|
940
|
-
"run_id": run_id,
|
|
941
|
-
"source_id": self.source_id,
|
|
942
|
-
"status": "ended",
|
|
943
|
-
"timestamp": (int(time.time() * 1000) + self._clock_offset_ms) / 1000,
|
|
944
|
-
}).encode("utf-8"),
|
|
945
|
-
timeout=self.timeout,
|
|
946
|
-
)
|
|
947
|
-
except Exception as e:
|
|
948
|
-
logger.debug(f"Run end notification failed: {e}")
|
|
949
|
-
self._run_id = None
|
|
950
|
-
self._store_frames = False
|
|
951
|
-
|
|
952
1019
|
def close(self):
|
|
953
1020
|
"""Close the client, flush any buffered points, and release resources."""
|
|
954
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
|
|
@@ -49,8 +48,8 @@ CONFIG_DIR = Path.home() / ".plexus"
|
|
|
49
48
|
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
50
49
|
|
|
51
50
|
PLEXUS_ENDPOINT = "https://app.plexus.company"
|
|
52
|
-
PLEXUS_GATEWAY_URL = "https://
|
|
53
|
-
PLEXUS_GATEWAY_WS_URL = "wss://
|
|
51
|
+
PLEXUS_GATEWAY_URL = "https://gateway.plexus.company"
|
|
52
|
+
PLEXUS_GATEWAY_WS_URL = "wss://gateway.plexus.company"
|
|
54
53
|
|
|
55
54
|
DEFAULT_CONFIG = {
|
|
56
55
|
"api_key": None,
|
|
@@ -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,18 +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:
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
63
|
+
description: str | None = None
|
|
64
|
+
params: list[dict[str, Any]] = field(default_factory=list)
|
|
65
|
+
# "accept" (default): run overlapping invocations concurrently.
|
|
66
|
+
# "reject": refuse a new invocation with an error result while a
|
|
67
|
+
# previous one of the same command is still running.
|
|
68
|
+
concurrency: str = "accept"
|
|
69
|
+
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
|
70
|
+
|
|
71
|
+
def to_manifest(self) -> dict[str, Any]:
|
|
72
|
+
m: dict[str, Any] = {"name": self.name}
|
|
68
73
|
if self.description:
|
|
69
74
|
m["description"] = self.description
|
|
70
75
|
if self.params:
|
|
@@ -92,7 +97,7 @@ class WebSocketTransport:
|
|
|
92
97
|
agent_version: str = "0.0.0",
|
|
93
98
|
platform: str = "python-sdk",
|
|
94
99
|
auto_reconnect: bool = True,
|
|
95
|
-
on_clock_synced:
|
|
100
|
+
on_clock_synced: Callable[[int], None] | None = None,
|
|
96
101
|
):
|
|
97
102
|
if not api_key:
|
|
98
103
|
raise ValueError("api_key required")
|
|
@@ -107,16 +112,16 @@ class WebSocketTransport:
|
|
|
107
112
|
self.auto_reconnect = auto_reconnect
|
|
108
113
|
self._on_clock_synced = on_clock_synced
|
|
109
114
|
|
|
110
|
-
self._commands:
|
|
111
|
-
self._ws:
|
|
115
|
+
self._commands: dict[str, _RegisteredCommand] = {}
|
|
116
|
+
self._ws: websocket.WebSocket | None = None
|
|
112
117
|
self._ws_lock = threading.Lock()
|
|
113
118
|
self._authenticated = threading.Event()
|
|
114
119
|
self._stop = threading.Event()
|
|
115
|
-
self._thread:
|
|
120
|
+
self._thread: threading.Thread | None = None
|
|
116
121
|
self._backoff_attempt = 0
|
|
117
122
|
self._clock_offset_ms: int = 0
|
|
118
|
-
self._video_queue:
|
|
119
|
-
self._video_thread:
|
|
123
|
+
self._video_queue: queue.Queue[bytes] = queue.Queue(maxsize=2)
|
|
124
|
+
self._video_thread: threading.Thread | None = None
|
|
120
125
|
|
|
121
126
|
# ------------------------------------------------------------------ public
|
|
122
127
|
|
|
@@ -125,13 +130,26 @@ class WebSocketTransport:
|
|
|
125
130
|
name: str,
|
|
126
131
|
handler: CommandHandler,
|
|
127
132
|
*,
|
|
128
|
-
description:
|
|
129
|
-
params:
|
|
133
|
+
description: str | None = None,
|
|
134
|
+
params: list[dict[str, Any]] | None = None,
|
|
135
|
+
concurrency: str = "accept",
|
|
130
136
|
) -> None:
|
|
131
137
|
"""Register a command handler. Must be called before start() to be
|
|
132
|
-
advertised in the auth frame.
|
|
138
|
+
advertised in the auth frame.
|
|
139
|
+
|
|
140
|
+
concurrency controls what happens when a command arrives while a
|
|
141
|
+
previous invocation of the *same* command is still running:
|
|
142
|
+
"accept" (default) — run it concurrently on a new thread.
|
|
143
|
+
"reject" — refuse it with an error result until the
|
|
144
|
+
in-flight invocation finishes.
|
|
145
|
+
"""
|
|
146
|
+
if concurrency not in ("accept", "reject"):
|
|
147
|
+
raise ValueError(
|
|
148
|
+
f"concurrency must be 'accept' or 'reject', got {concurrency!r}"
|
|
149
|
+
)
|
|
133
150
|
self._commands[name] = _RegisteredCommand(
|
|
134
|
-
name=name, handler=handler, description=description,
|
|
151
|
+
name=name, handler=handler, description=description,
|
|
152
|
+
params=params or [], concurrency=concurrency,
|
|
135
153
|
)
|
|
136
154
|
|
|
137
155
|
def start(self) -> None:
|
|
@@ -169,11 +187,18 @@ class WebSocketTransport:
|
|
|
169
187
|
def is_authenticated(self) -> bool:
|
|
170
188
|
return self._authenticated.is_set()
|
|
171
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
|
+
|
|
172
197
|
@property
|
|
173
198
|
def clock_offset_ms(self) -> int:
|
|
174
199
|
return self._clock_offset_ms
|
|
175
200
|
|
|
176
|
-
def send_points(self, points:
|
|
201
|
+
def send_points(self, points: list[dict[str, Any]]) -> bool:
|
|
177
202
|
"""Send a telemetry frame. Returns False if the socket is not
|
|
178
203
|
authenticated — caller is expected to fall back to HTTP."""
|
|
179
204
|
if not points:
|
|
@@ -205,7 +230,7 @@ class WebSocketTransport:
|
|
|
205
230
|
except queue.Full:
|
|
206
231
|
return False
|
|
207
232
|
|
|
208
|
-
def send_json_video_frame(self, msg:
|
|
233
|
+
def send_json_video_frame(self, msg: dict[str, Any]) -> bool:
|
|
209
234
|
"""Send a JSON video_frame message. Used for frames that carry extra
|
|
210
235
|
metadata (e.g. thermal cameras) that the binary format cannot express."""
|
|
211
236
|
if not self._authenticated.is_set():
|
|
@@ -339,7 +364,7 @@ class WebSocketTransport:
|
|
|
339
364
|
continue
|
|
340
365
|
self._dispatch(_safe_json(raw))
|
|
341
366
|
|
|
342
|
-
def _dispatch(self, msg:
|
|
367
|
+
def _dispatch(self, msg: dict[str, Any]) -> None:
|
|
343
368
|
mtype = msg.get("type")
|
|
344
369
|
if mtype == "typed_command":
|
|
345
370
|
self._handle_command(msg)
|
|
@@ -347,7 +372,7 @@ class WebSocketTransport:
|
|
|
347
372
|
logger.warning("plexus ws server error: %s", msg.get("detail") or msg)
|
|
348
373
|
# ignore unknown types — forward-compat
|
|
349
374
|
|
|
350
|
-
def _handle_command(self, msg:
|
|
375
|
+
def _handle_command(self, msg: dict[str, Any]) -> None:
|
|
351
376
|
cmd_id = msg.get("id") or ""
|
|
352
377
|
command = msg.get("command") or ""
|
|
353
378
|
params = msg.get("params") or {}
|
|
@@ -371,20 +396,50 @@ class WebSocketTransport:
|
|
|
371
396
|
})
|
|
372
397
|
return
|
|
373
398
|
|
|
399
|
+
# concurrency="reject": if an invocation is already running, refuse
|
|
400
|
+
# this one immediately rather than starting a second in parallel.
|
|
401
|
+
holds_lock = False
|
|
402
|
+
if reg.concurrency == "reject":
|
|
403
|
+
if not reg._lock.acquire(blocking=False):
|
|
404
|
+
self._send_frame({
|
|
405
|
+
"type": "command_result",
|
|
406
|
+
"id": cmd_id,
|
|
407
|
+
"command": command,
|
|
408
|
+
"event": "error",
|
|
409
|
+
"error": f"command already in progress: {command}",
|
|
410
|
+
})
|
|
411
|
+
return
|
|
412
|
+
holds_lock = True
|
|
413
|
+
|
|
374
414
|
# Run the handler off the read-loop thread so a slow handler doesn't
|
|
375
415
|
# block heartbeats or other inbound frames.
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
416
|
+
try:
|
|
417
|
+
threading.Thread(
|
|
418
|
+
target=self._run_handler,
|
|
419
|
+
args=(reg, cmd_id, command, params, holds_lock),
|
|
420
|
+
daemon=True,
|
|
421
|
+
).start()
|
|
422
|
+
except Exception as e:
|
|
423
|
+
# Thread creation failed (e.g. resource exhaustion). Release the
|
|
424
|
+
# concurrency lock so the command isn't wedged as "in progress",
|
|
425
|
+
# and report the failure rather than leaving it silently un-acked.
|
|
426
|
+
if holds_lock:
|
|
427
|
+
reg._lock.release()
|
|
428
|
+
self._send_frame({
|
|
429
|
+
"type": "command_result",
|
|
430
|
+
"id": cmd_id,
|
|
431
|
+
"command": command,
|
|
432
|
+
"event": "error",
|
|
433
|
+
"error": f"failed to start command handler: {e}",
|
|
434
|
+
})
|
|
381
435
|
|
|
382
436
|
def _run_handler(
|
|
383
437
|
self,
|
|
384
438
|
reg: _RegisteredCommand,
|
|
385
439
|
cmd_id: str,
|
|
386
440
|
command: str,
|
|
387
|
-
params:
|
|
441
|
+
params: dict[str, Any],
|
|
442
|
+
holds_lock: bool = False,
|
|
388
443
|
) -> None:
|
|
389
444
|
try:
|
|
390
445
|
result = reg.handler(command, params)
|
|
@@ -397,6 +452,11 @@ class WebSocketTransport:
|
|
|
397
452
|
"error": str(e),
|
|
398
453
|
})
|
|
399
454
|
return
|
|
455
|
+
finally:
|
|
456
|
+
# Release the concurrency lock (if held) as soon as the handler
|
|
457
|
+
# returns, regardless of success or failure.
|
|
458
|
+
if holds_lock:
|
|
459
|
+
reg._lock.release()
|
|
400
460
|
self._send_frame({
|
|
401
461
|
"type": "command_result",
|
|
402
462
|
"id": cmd_id,
|
|
@@ -405,7 +465,7 @@ class WebSocketTransport:
|
|
|
405
465
|
"result": result if result is not None else {},
|
|
406
466
|
})
|
|
407
467
|
|
|
408
|
-
def _send_frame(self, frame:
|
|
468
|
+
def _send_frame(self, frame: dict[str, Any]) -> bool:
|
|
409
469
|
with self._ws_lock:
|
|
410
470
|
ws = self._ws
|
|
411
471
|
if ws is None:
|
|
@@ -459,7 +519,7 @@ def _ensure_device_path(url: str) -> str:
|
|
|
459
519
|
return url + "/ws/device"
|
|
460
520
|
|
|
461
521
|
|
|
462
|
-
def _safe_json(raw: Any) ->
|
|
522
|
+
def _safe_json(raw: Any) -> dict[str, Any]:
|
|
463
523
|
if isinstance(raw, (bytes, bytearray)):
|
|
464
524
|
raw = raw.decode("utf-8", errors="replace")
|
|
465
525
|
if not isinstance(raw, str):
|
|
@@ -1,12 +1,12 @@
|
|
|
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
|
-
Project-URL: Homepage, https://plexus.
|
|
6
|
-
Project-URL: Documentation, https://docs.plexus.
|
|
5
|
+
Project-URL: Homepage, https://plexus.company
|
|
6
|
+
Project-URL: Documentation, https://docs.plexus.company
|
|
7
7
|
Project-URL: Repository, https://github.com/plexus-oss/plexus-python
|
|
8
8
|
Project-URL: Issues, https://github.com/plexus-oss/plexus-python/issues
|
|
9
|
-
Author-email: Plexus <
|
|
9
|
+
Author-email: Plexus <info@plexus.company>
|
|
10
10
|
License-Expression: Apache-2.0
|
|
11
11
|
License-File: LICENSE
|
|
12
12
|
Keywords: fleet,hardware,iot,monitoring,observability,telemetry
|
|
@@ -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:
|
|
@@ -270,8 +259,8 @@ The SDK sends an `ack` frame before invoking the handler, then a `result` frame
|
|
|
270
259
|
| Variable | Description | Default |
|
|
271
260
|
| ----------------------- | ---------------------------- | -------------------------------- |
|
|
272
261
|
| `PLEXUS_API_KEY` | API key (required) | none |
|
|
273
|
-
| `PLEXUS_GATEWAY_URL` | HTTP ingest URL | `https://
|
|
274
|
-
| `PLEXUS_GATEWAY_WS_URL` | WebSocket URL | `wss://
|
|
262
|
+
| `PLEXUS_GATEWAY_URL` | HTTP ingest URL | `https://gateway.plexus.company` |
|
|
263
|
+
| `PLEXUS_GATEWAY_WS_URL` | WebSocket URL | `wss://gateway.plexus.company` |
|
|
275
264
|
|
|
276
265
|
## Architecture
|
|
277
266
|
|
|
@@ -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=ZzZDGRCztNWGUtN-YF1EwwYzngxsZkU9zb_rW1Tfalg,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=SZ4V9FN-bt3QKqVgp9S5QKdRXw934WXZxKlYUb7bWHw,35810
|
|
6
|
-
plexus/config.py,sha256=RNym2Fon6JOCVi1rXPSRWjPFAdT8DSmokY5JPEljQOc,4450
|
|
7
|
-
plexus/ws.py,sha256=9DiQchqCQU7O8r8-FuqotJh8vYAQBO7npJn4BFNzLAE,16242
|
|
8
|
-
plexus/cameras/__init__.py,sha256=OvnU9KGKxkVtFLlk56H9x-ATa6UvpLI7PANa0HQO2cc,490
|
|
9
|
-
plexus/cameras/thermal.py,sha256=7o33QsF1RiZLManTxZ2E36nO8lRAHppCDkS3zXBHCxs,12047
|
|
10
|
-
plexus_python-0.7.1.dist-info/METADATA,sha256=OUDqqIIcHUaZms1QCwL7XdaFo6j11LeN0HZ3EipZEIM,11739
|
|
11
|
-
plexus_python-0.7.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
12
|
-
plexus_python-0.7.1.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
|
|
13
|
-
plexus_python-0.7.1.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
|
|
14
|
-
plexus_python-0.7.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|