plexus-python 0.5.1__py3-none-any.whl → 0.6.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plexus/__init__.py +1 -1
- plexus/_log.py +15 -0
- plexus/cameras/__init__.py +25 -0
- plexus/cameras/thermal.py +388 -0
- plexus/client.py +98 -25
- plexus/ws.py +9 -19
- {plexus_python-0.5.1.dist-info → plexus_python-0.6.1.dist-info}/METADATA +6 -1
- plexus_python-0.6.1.dist-info/RECORD +14 -0
- plexus_python-0.5.1.dist-info/RECORD +0 -11
- {plexus_python-0.5.1.dist-info → plexus_python-0.6.1.dist-info}/WHEEL +0 -0
- {plexus_python-0.5.1.dist-info → plexus_python-0.6.1.dist-info}/entry_points.txt +0 -0
- {plexus_python-0.5.1.dist-info → plexus_python-0.6.1.dist-info}/licenses/LICENSE +0 -0
plexus/__init__.py
CHANGED
|
@@ -10,5 +10,5 @@ Plexus — thin Python SDK for sending telemetry to the Plexus gateway.
|
|
|
10
10
|
from plexus.client import Plexus, read_mjpeg_frames
|
|
11
11
|
from plexus.ws import WebSocketTransport
|
|
12
12
|
|
|
13
|
-
__version__ = "0.5.
|
|
13
|
+
__version__ = "0.5.2"
|
|
14
14
|
__all__ = ["Plexus", "WebSocketTransport", "read_mjpeg_frames"]
|
plexus/_log.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
_QUIET = os.environ.get("PLEXUS_QUIET", "").lower() in ("1", "true", "yes")
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _say(line: str) -> None:
|
|
8
|
+
"""Single-line status message to stderr. Skipped if PLEXUS_QUIET=1."""
|
|
9
|
+
if _QUIET:
|
|
10
|
+
return
|
|
11
|
+
try:
|
|
12
|
+
sys.stderr.write(f"[plexus] {line}\n")
|
|
13
|
+
sys.stderr.flush()
|
|
14
|
+
except Exception:
|
|
15
|
+
pass
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from plexus.cameras.thermal import (
|
|
2
|
+
NoCameraFound,
|
|
3
|
+
MLX90640Camera,
|
|
4
|
+
MLX90641Camera,
|
|
5
|
+
SimulatedThermalCamera,
|
|
6
|
+
ThermalCamera,
|
|
7
|
+
ThermalFrame,
|
|
8
|
+
ThermalSource,
|
|
9
|
+
USBThermalCamera,
|
|
10
|
+
build_thermal_frame,
|
|
11
|
+
encode_frame,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"NoCameraFound",
|
|
16
|
+
"MLX90640Camera",
|
|
17
|
+
"MLX90641Camera",
|
|
18
|
+
"SimulatedThermalCamera",
|
|
19
|
+
"ThermalCamera",
|
|
20
|
+
"ThermalFrame",
|
|
21
|
+
"ThermalSource",
|
|
22
|
+
"USBThermalCamera",
|
|
23
|
+
"build_thermal_frame",
|
|
24
|
+
"encode_frame",
|
|
25
|
+
]
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Thermal camera drivers for Plexus.
|
|
3
|
+
|
|
4
|
+
Provides a hardware-agnostic interface over I2C sensors (MLX90640, MLX90641)
|
|
5
|
+
and USB thermal cameras (Y16 pixel format). All drivers return a unified
|
|
6
|
+
ThermalFrame containing a colorized JPEG image plus temperature metadata.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from plexus.cameras.thermal import ThermalSource
|
|
10
|
+
|
|
11
|
+
cam = ThermalSource.open() # auto-detect
|
|
12
|
+
cam = ThermalSource.open("sim") # simulated, no hardware
|
|
13
|
+
|
|
14
|
+
while True:
|
|
15
|
+
px.send_thermal_frame(cam.read_frame(), camera_id="thermal")
|
|
16
|
+
time.sleep(1 / 5)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import base64
|
|
22
|
+
import time
|
|
23
|
+
from abc import ABC, abstractmethod
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Any, Dict, Optional
|
|
26
|
+
|
|
27
|
+
import cv2
|
|
28
|
+
import numpy as np
|
|
29
|
+
|
|
30
|
+
# Inferno is perceptually uniform and well-suited for thermal imaging.
|
|
31
|
+
# Swap for cv2.COLORMAP_HOT or cv2.COLORMAP_JET if preferred.
|
|
32
|
+
_COLORMAP = cv2.COLORMAP_INFERNO
|
|
33
|
+
|
|
34
|
+
# Sensors at or below this pixel count include the full temps array in the
|
|
35
|
+
# wire message. I2C sensors (32×24 = 768, 16×12 = 192) always qualify.
|
|
36
|
+
# USB thermal cameras (256×192 = 49 152) do not.
|
|
37
|
+
_TEMPS_INLINE_THRESHOLD = 4096
|
|
38
|
+
|
|
39
|
+
# Upscale sensors whose shortest side is below this threshold.
|
|
40
|
+
_MIN_DISPLAY_SIDE = 160
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class NoCameraFound(RuntimeError):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Abstract interface
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ThermalCamera(ABC):
|
|
53
|
+
"""Hardware-agnostic thermal camera interface.
|
|
54
|
+
|
|
55
|
+
Subclass and implement read_frame() to support any sensor.
|
|
56
|
+
read_frame() must return a 2-D float32 array of temperatures in Celsius,
|
|
57
|
+
shaped (height, width).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def width(self) -> int: ...
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def height(self) -> int: ...
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
def read_frame(self) -> np.ndarray: ...
|
|
70
|
+
|
|
71
|
+
def close(self) -> None:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Unified output
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class ThermalFrame:
|
|
82
|
+
"""Encoded output from a ThermalCamera, ready to send to the gateway."""
|
|
83
|
+
|
|
84
|
+
image: np.ndarray # uint8 BGR colorized image (display size)
|
|
85
|
+
width: int # display width (may differ from sensor if upscaled)
|
|
86
|
+
height: int # display height
|
|
87
|
+
sensor_width: int # native sensor width
|
|
88
|
+
sensor_height: int # native sensor height
|
|
89
|
+
temp_min: float
|
|
90
|
+
temp_max: float
|
|
91
|
+
temps: Optional[np.ndarray] # native res; None when sensor > threshold
|
|
92
|
+
timestamp_ms: int
|
|
93
|
+
|
|
94
|
+
def to_message(
|
|
95
|
+
self, camera_id: str, source_id: Optional[str] = None, quality: int = 85
|
|
96
|
+
) -> Dict[str, Any]:
|
|
97
|
+
"""Build the gateway `video_frame` wire message for this frame."""
|
|
98
|
+
_, buf = cv2.imencode(".jpg", self.image, [cv2.IMWRITE_JPEG_QUALITY, quality])
|
|
99
|
+
b64 = base64.b64encode(buf.tobytes()).decode("ascii")
|
|
100
|
+
|
|
101
|
+
msg: Dict[str, Any] = {
|
|
102
|
+
"type": "video_frame",
|
|
103
|
+
"camera_id": camera_id,
|
|
104
|
+
"frame": b64,
|
|
105
|
+
"width": self.width,
|
|
106
|
+
"height": self.height,
|
|
107
|
+
"timestamp": self.timestamp_ms,
|
|
108
|
+
"video_type": "thermal",
|
|
109
|
+
"sensor_width": self.sensor_width,
|
|
110
|
+
"sensor_height": self.sensor_height,
|
|
111
|
+
"temp_min": self.temp_min,
|
|
112
|
+
"temp_max": self.temp_max,
|
|
113
|
+
}
|
|
114
|
+
if source_id is not None:
|
|
115
|
+
msg["source_id"] = source_id
|
|
116
|
+
if self.temps is not None:
|
|
117
|
+
msg["temps"] = self.temps.flatten().tolist()
|
|
118
|
+
return msg
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# Encoding — single shared path used by both encode_frame() and the SDK's
|
|
123
|
+
# Plexus.send_thermal_frame().
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _upscale_size(sw: int, sh: int) -> tuple[int, int]:
|
|
128
|
+
if sw >= _MIN_DISPLAY_SIDE and sh >= _MIN_DISPLAY_SIDE:
|
|
129
|
+
return sw, sh
|
|
130
|
+
scale = max(_MIN_DISPLAY_SIDE / sw, _MIN_DISPLAY_SIDE / sh)
|
|
131
|
+
return round(sw * scale), round(sh * scale)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def build_thermal_frame(
|
|
135
|
+
temps: np.ndarray, timestamp_ms: Optional[int] = None
|
|
136
|
+
) -> ThermalFrame:
|
|
137
|
+
"""Colorize a temperature array into a ThermalFrame.
|
|
138
|
+
|
|
139
|
+
temps: 2-D float32 Celsius array, shape (height, width).
|
|
140
|
+
timestamp_ms: wire timestamp; defaults to current time.
|
|
141
|
+
"""
|
|
142
|
+
sh, sw = temps.shape[:2]
|
|
143
|
+
|
|
144
|
+
temp_min = float(np.min(temps))
|
|
145
|
+
temp_max = float(np.max(temps))
|
|
146
|
+
span = temp_max - temp_min
|
|
147
|
+
|
|
148
|
+
normalized = (
|
|
149
|
+
np.zeros((sh, sw), dtype=np.uint8)
|
|
150
|
+
if span < 1e-6
|
|
151
|
+
else ((temps - temp_min) / span * 255).astype(np.uint8)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
colored = cv2.applyColorMap(normalized, _COLORMAP)
|
|
155
|
+
|
|
156
|
+
dw, dh = _upscale_size(sw, sh)
|
|
157
|
+
if (dw, dh) != (sw, sh):
|
|
158
|
+
colored = cv2.resize(colored, (dw, dh), interpolation=cv2.INTER_CUBIC)
|
|
159
|
+
|
|
160
|
+
return ThermalFrame(
|
|
161
|
+
image=colored,
|
|
162
|
+
width=dw,
|
|
163
|
+
height=dh,
|
|
164
|
+
sensor_width=sw,
|
|
165
|
+
sensor_height=sh,
|
|
166
|
+
temp_min=round(temp_min, 2),
|
|
167
|
+
temp_max=round(temp_max, 2),
|
|
168
|
+
temps=temps if sw * sh <= _TEMPS_INLINE_THRESHOLD else None,
|
|
169
|
+
timestamp_ms=timestamp_ms if timestamp_ms is not None else int(time.time() * 1000),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def encode_frame(cam: ThermalCamera) -> ThermalFrame:
|
|
174
|
+
"""Read one frame from a camera and colorize it."""
|
|
175
|
+
return build_thermal_frame(cam.read_frame())
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# Drivers
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class SimulatedThermalCamera(ThermalCamera):
|
|
184
|
+
"""Simulated thermal camera for testing without hardware.
|
|
185
|
+
|
|
186
|
+
Produces a moving warm blob over a noisy background at 32×24.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
def __init__(self, width: int = 32, height: int = 24) -> None:
|
|
190
|
+
self._width = width
|
|
191
|
+
self._height = height
|
|
192
|
+
self._t = 0.0
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def width(self) -> int:
|
|
196
|
+
return self._width
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def height(self) -> int:
|
|
200
|
+
return self._height
|
|
201
|
+
|
|
202
|
+
def read_frame(self) -> np.ndarray:
|
|
203
|
+
self._t += 0.1
|
|
204
|
+
x = np.linspace(0, 2 * np.pi, self._width)
|
|
205
|
+
y = np.linspace(0, 2 * np.pi, self._height)
|
|
206
|
+
xx, yy = np.meshgrid(x, y)
|
|
207
|
+
base = 22.0 + 3.0 * np.sin(xx + self._t) * np.cos(yy + self._t * 0.7)
|
|
208
|
+
cx = int((np.sin(self._t * 0.5) * 0.4 + 0.5) * self._width)
|
|
209
|
+
cy = int((np.cos(self._t * 0.3) * 0.4 + 0.5) * self._height)
|
|
210
|
+
yg, xg = np.ogrid[: self._height, : self._width]
|
|
211
|
+
hotspot = 15.0 * np.exp(-((xg - cx) ** 2 + (yg - cy) ** 2) / 20.0)
|
|
212
|
+
return (base + hotspot).astype(np.float32)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class USBThermalCamera(ThermalCamera):
|
|
216
|
+
"""USB thermal camera via V4L2/UVC in Y16 pixel format.
|
|
217
|
+
|
|
218
|
+
Most USB thermal cameras (InfiRay, Topdon, Seek, some FLIR) present as
|
|
219
|
+
standard UVC video devices outputting Y16 frames where each uint16 pixel
|
|
220
|
+
encodes temperature as: celsius = (value / 100.0) - 273.15
|
|
221
|
+
|
|
222
|
+
Requires: pip install opencv-python
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
_KELVIN_SCALE = 100.0
|
|
226
|
+
_KELVIN_OFFSET = 273.15
|
|
227
|
+
|
|
228
|
+
def __init__(self, device_index: int = 0) -> None:
|
|
229
|
+
self._cap = cv2.VideoCapture(device_index)
|
|
230
|
+
if not self._cap.isOpened():
|
|
231
|
+
raise NoCameraFound(f"Cannot open video device {device_index}")
|
|
232
|
+
self._cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"Y16 "))
|
|
233
|
+
ret, frame = self._cap.read()
|
|
234
|
+
if not ret or frame is None:
|
|
235
|
+
self._cap.release()
|
|
236
|
+
raise NoCameraFound(
|
|
237
|
+
f"Device {device_index} opened but could not read a frame. "
|
|
238
|
+
"Check that it supports Y16 format."
|
|
239
|
+
)
|
|
240
|
+
self._width = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
241
|
+
self._height = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def width(self) -> int:
|
|
245
|
+
return self._width
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def height(self) -> int:
|
|
249
|
+
return self._height
|
|
250
|
+
|
|
251
|
+
def read_frame(self) -> np.ndarray:
|
|
252
|
+
ret, raw = self._cap.read()
|
|
253
|
+
if not ret or raw is None:
|
|
254
|
+
raise RuntimeError("Failed to read frame from USB thermal camera")
|
|
255
|
+
u16 = raw.view(np.uint16).reshape(self._height, self._width)
|
|
256
|
+
return (u16.astype(np.float32) / self._KELVIN_SCALE) - self._KELVIN_OFFSET
|
|
257
|
+
|
|
258
|
+
def close(self) -> None:
|
|
259
|
+
self._cap.release()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class MLX90640Camera(ThermalCamera):
|
|
263
|
+
"""MLX90640 32×24 thermal array via I2C.
|
|
264
|
+
|
|
265
|
+
Requires: pip install adafruit-circuitpython-mlx90640
|
|
266
|
+
Wiring: SCL → GPIO 3, SDA → GPIO 2, 3.3V power (Raspberry Pi).
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
def __init__(self) -> None:
|
|
270
|
+
import adafruit_mlx90640
|
|
271
|
+
import board
|
|
272
|
+
import busio
|
|
273
|
+
|
|
274
|
+
i2c = busio.I2C(board.SCL, board.SDA, frequency=800_000)
|
|
275
|
+
self._mlx = adafruit_mlx90640.MLX90640(i2c)
|
|
276
|
+
self._mlx.refresh_rate = adafruit_mlx90640.RefreshRate.REFRESH_4_HZ
|
|
277
|
+
self._buf = [0.0] * 768
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def width(self) -> int:
|
|
281
|
+
return 32
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def height(self) -> int:
|
|
285
|
+
return 24
|
|
286
|
+
|
|
287
|
+
def read_frame(self) -> np.ndarray:
|
|
288
|
+
self._mlx.getFrame(self._buf)
|
|
289
|
+
return np.array(self._buf, dtype=np.float32).reshape(24, 32)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class MLX90641Camera(ThermalCamera):
|
|
293
|
+
"""MLX90641 16×12 thermal array via I2C.
|
|
294
|
+
|
|
295
|
+
Requires: pip install adafruit-circuitpython-mlx90641
|
|
296
|
+
Wiring: SCL → GPIO 3, SDA → GPIO 2, 3.3V power (Raspberry Pi).
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
def __init__(self) -> None:
|
|
300
|
+
import adafruit_mlx90641
|
|
301
|
+
import board
|
|
302
|
+
import busio
|
|
303
|
+
|
|
304
|
+
i2c = busio.I2C(board.SCL, board.SDA, frequency=400_000)
|
|
305
|
+
self._mlx = adafruit_mlx90641.MLX90641(i2c)
|
|
306
|
+
self._mlx.refresh_rate = adafruit_mlx90641.RefreshRate.REFRESH_4_HZ
|
|
307
|
+
self._buf = [0.0] * 192
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def width(self) -> int:
|
|
311
|
+
return 16
|
|
312
|
+
|
|
313
|
+
@property
|
|
314
|
+
def height(self) -> int:
|
|
315
|
+
return 12
|
|
316
|
+
|
|
317
|
+
def read_frame(self) -> np.ndarray:
|
|
318
|
+
self._mlx.getFrame(self._buf)
|
|
319
|
+
return np.array(self._buf, dtype=np.float32).reshape(12, 16)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ---------------------------------------------------------------------------
|
|
323
|
+
# Auto-detection factory
|
|
324
|
+
# ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class ThermalSource:
|
|
328
|
+
"""Factory that opens the first available thermal camera.
|
|
329
|
+
|
|
330
|
+
Examples:
|
|
331
|
+
ThermalSource.open() # auto-detect
|
|
332
|
+
ThermalSource.open("sim") # simulated
|
|
333
|
+
ThermalSource.open("mlx90640") # force I2C MLX90640
|
|
334
|
+
ThermalSource.open("mlx90641") # force I2C MLX90641
|
|
335
|
+
ThermalSource.open("usb") # USB thermal at index 0
|
|
336
|
+
ThermalSource.open(2) # USB thermal at index 2
|
|
337
|
+
"""
|
|
338
|
+
|
|
339
|
+
@staticmethod
|
|
340
|
+
def open(hint: str | int | None = None) -> ThermalCamera:
|
|
341
|
+
if hint == "sim":
|
|
342
|
+
return SimulatedThermalCamera()
|
|
343
|
+
if hint == "mlx90640":
|
|
344
|
+
return MLX90640Camera()
|
|
345
|
+
if hint == "mlx90641":
|
|
346
|
+
return MLX90641Camera()
|
|
347
|
+
if hint == "usb" or isinstance(hint, int):
|
|
348
|
+
return USBThermalCamera(0 if hint == "usb" else hint)
|
|
349
|
+
return ThermalSource._autodetect()
|
|
350
|
+
|
|
351
|
+
@staticmethod
|
|
352
|
+
def _autodetect() -> ThermalCamera:
|
|
353
|
+
cam = ThermalSource._try_i2c()
|
|
354
|
+
if cam:
|
|
355
|
+
return cam
|
|
356
|
+
cam = ThermalSource._try_usb()
|
|
357
|
+
if cam:
|
|
358
|
+
return cam
|
|
359
|
+
raise NoCameraFound(
|
|
360
|
+
"No thermal camera detected. Use ThermalSource.open('sim') to run without hardware."
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
@staticmethod
|
|
364
|
+
def _try_i2c() -> ThermalCamera | None:
|
|
365
|
+
try:
|
|
366
|
+
import board
|
|
367
|
+
import busio
|
|
368
|
+
|
|
369
|
+
i2c = busio.I2C(board.SCL, board.SDA)
|
|
370
|
+
i2c.try_lock()
|
|
371
|
+
addrs = i2c.scan()
|
|
372
|
+
i2c.unlock()
|
|
373
|
+
if 0x33 in addrs:
|
|
374
|
+
return MLX90640Camera()
|
|
375
|
+
if 0x60 in addrs:
|
|
376
|
+
return MLX90641Camera()
|
|
377
|
+
except Exception:
|
|
378
|
+
pass
|
|
379
|
+
return None
|
|
380
|
+
|
|
381
|
+
@staticmethod
|
|
382
|
+
def _try_usb() -> ThermalCamera | None:
|
|
383
|
+
for idx in range(10):
|
|
384
|
+
try:
|
|
385
|
+
return USBThermalCamera(idx)
|
|
386
|
+
except NoCameraFound:
|
|
387
|
+
continue
|
|
388
|
+
return None
|
plexus/client.py
CHANGED
|
@@ -30,17 +30,16 @@ Usage:
|
|
|
30
30
|
px.send("temperature", read_temp())
|
|
31
31
|
time.sleep(0.01)
|
|
32
32
|
|
|
33
|
-
Note: Requires authentication. Run 'plexus
|
|
33
|
+
Note: Requires authentication. Run 'plexus init' or set PLEXUS_API_KEY.
|
|
34
34
|
"""
|
|
35
35
|
|
|
36
36
|
import gzip
|
|
37
37
|
import json
|
|
38
38
|
import logging
|
|
39
|
-
import
|
|
39
|
+
import re
|
|
40
40
|
import shutil
|
|
41
41
|
import socket
|
|
42
42
|
import subprocess
|
|
43
|
-
import sys
|
|
44
43
|
import threading
|
|
45
44
|
import time
|
|
46
45
|
import urllib.error
|
|
@@ -48,6 +47,7 @@ import urllib.request
|
|
|
48
47
|
from contextlib import contextmanager
|
|
49
48
|
from typing import Any, Dict, Generator, List, Optional, Tuple, Union
|
|
50
49
|
|
|
50
|
+
from plexus._log import _say
|
|
51
51
|
from plexus.buffer import BufferBackend, MemoryBuffer, SqliteBuffer
|
|
52
52
|
from plexus.config import (
|
|
53
53
|
RetryConfig,
|
|
@@ -59,6 +59,7 @@ from plexus.config import (
|
|
|
59
59
|
get_source_id,
|
|
60
60
|
set_source_id,
|
|
61
61
|
)
|
|
62
|
+
|
|
62
63
|
logger = logging.getLogger(__name__)
|
|
63
64
|
|
|
64
65
|
|
|
@@ -101,20 +102,6 @@ class _ConnError(OSError):
|
|
|
101
102
|
pass
|
|
102
103
|
|
|
103
104
|
|
|
104
|
-
# Status messages to stderr so users running `python my_script.py` see what's
|
|
105
|
-
# happening without having to configure logging. Set PLEXUS_QUIET=1 to disable.
|
|
106
|
-
_QUIET = os.environ.get("PLEXUS_QUIET", "").lower() in ("1", "true", "yes")
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def _say(line: str) -> None:
|
|
110
|
-
if _QUIET:
|
|
111
|
-
return
|
|
112
|
-
try:
|
|
113
|
-
sys.stderr.write(f"[plexus] {line}\n")
|
|
114
|
-
sys.stderr.flush()
|
|
115
|
-
except Exception:
|
|
116
|
-
pass
|
|
117
|
-
|
|
118
105
|
# Flexible value type - supports any JSON-serializable value
|
|
119
106
|
FlexValue = Union[int, float, str, bool, Dict[str, Any], List[Any]]
|
|
120
107
|
|
|
@@ -161,6 +148,18 @@ class AuthenticationError(PlexusError):
|
|
|
161
148
|
pass
|
|
162
149
|
|
|
163
150
|
|
|
151
|
+
_SOURCE_ID_RE = re.compile(r'^[a-z0-9][a-z0-9_-]{1,62}$')
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _validate_source_id(source_id: str) -> None:
|
|
155
|
+
if not _SOURCE_ID_RE.match(source_id):
|
|
156
|
+
raise ValueError(
|
|
157
|
+
f"Invalid source_id {source_id!r}. "
|
|
158
|
+
"Must match ^[a-z0-9][a-z0-9_-]{1,62}$ "
|
|
159
|
+
"(lowercase letters, digits, hyphens, underscores; start with letter or digit)."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
164
163
|
class Plexus:
|
|
165
164
|
"""
|
|
166
165
|
Client for sending sensor data to Plexus.
|
|
@@ -186,7 +185,7 @@ class Plexus:
|
|
|
186
185
|
timeout: float = 10.0,
|
|
187
186
|
retry_config: Optional[RetryConfig] = None,
|
|
188
187
|
max_buffer_size: int = 10000,
|
|
189
|
-
persistent_buffer: bool =
|
|
188
|
+
persistent_buffer: bool = True,
|
|
190
189
|
buffer_path: Optional[str] = None,
|
|
191
190
|
transport: str = "ws",
|
|
192
191
|
ws_url: Optional[str] = None,
|
|
@@ -201,6 +200,7 @@ class Plexus:
|
|
|
201
200
|
self.endpoint = (endpoint or get_endpoint()).rstrip("/")
|
|
202
201
|
self.gateway_url = get_gateway_url()
|
|
203
202
|
self.source_id = source_id or get_source_id()
|
|
203
|
+
_validate_source_id(self.source_id)
|
|
204
204
|
self.timeout = timeout
|
|
205
205
|
self.retry_config = retry_config or RetryConfig()
|
|
206
206
|
self._max_buffer_size = max_buffer_size
|
|
@@ -360,7 +360,7 @@ class Plexus:
|
|
|
360
360
|
|
|
361
361
|
def send_batch(
|
|
362
362
|
self,
|
|
363
|
-
points: List[Tuple[str, FlexValue]],
|
|
363
|
+
points: List[Union[Tuple[str, FlexValue], Tuple[str, FlexValue, float]]],
|
|
364
364
|
timestamp: Optional[float] = None,
|
|
365
365
|
tags: Optional[Dict[str, str]] = None,
|
|
366
366
|
) -> bool:
|
|
@@ -368,8 +368,11 @@ class Plexus:
|
|
|
368
368
|
Send multiple metrics at once.
|
|
369
369
|
|
|
370
370
|
Args:
|
|
371
|
-
points: List of (metric, value)
|
|
372
|
-
|
|
371
|
+
points: List of (metric, value) or (metric, value, timestamp) tuples.
|
|
372
|
+
Values can be any FlexValue type. Per-point timestamps override
|
|
373
|
+
the shared timestamp argument.
|
|
374
|
+
timestamp: Shared timestamp for points that don't supply their own.
|
|
375
|
+
If not provided, uses current time.
|
|
373
376
|
tags: Shared tags for all points
|
|
374
377
|
|
|
375
378
|
Returns:
|
|
@@ -382,9 +385,23 @@ class Plexus:
|
|
|
382
385
|
("robot.state", "RUNNING"),
|
|
383
386
|
("position", {"x": 1.0, "y": 2.0}),
|
|
384
387
|
])
|
|
388
|
+
|
|
389
|
+
# Per-point timestamps (e.g. sensors on different interrupt timers):
|
|
390
|
+
px.send_batch([
|
|
391
|
+
("imu.accel_x", 0.12, t_imu),
|
|
392
|
+
("pressure", 1013.2, t_baro),
|
|
393
|
+
("temperature", 22.4), # uses shared timestamp
|
|
394
|
+
])
|
|
385
395
|
"""
|
|
386
|
-
|
|
387
|
-
data_points = [
|
|
396
|
+
default_ts_ms = self._normalize_ts_ms(timestamp)
|
|
397
|
+
data_points = []
|
|
398
|
+
for p in points:
|
|
399
|
+
if len(p) == 3:
|
|
400
|
+
m, v, t = p
|
|
401
|
+
data_points.append(self._make_point(m, v, self._normalize_ts_ms(t), tags))
|
|
402
|
+
else:
|
|
403
|
+
m, v = p
|
|
404
|
+
data_points.append(self._make_point(m, v, default_ts_ms, tags))
|
|
388
405
|
return self._send_points(data_points)
|
|
389
406
|
|
|
390
407
|
def _ensure_ws(self):
|
|
@@ -573,6 +590,51 @@ class Plexus:
|
|
|
573
590
|
self._normalize_ts_ms(timestamp),
|
|
574
591
|
)
|
|
575
592
|
|
|
593
|
+
def send_thermal_frame(
|
|
594
|
+
self,
|
|
595
|
+
temps,
|
|
596
|
+
camera_id: str = "thermal:0",
|
|
597
|
+
quality: int = 85,
|
|
598
|
+
timestamp: Optional[float] = None,
|
|
599
|
+
) -> bool:
|
|
600
|
+
"""Send a thermal camera frame to Plexus (WebSocket transport only).
|
|
601
|
+
|
|
602
|
+
Args:
|
|
603
|
+
temps: 2-D float32 numpy array of temperatures in Celsius,
|
|
604
|
+
shape (height, width). Obtained from ThermalCamera.read_frame().
|
|
605
|
+
camera_id: Logical camera identifier.
|
|
606
|
+
quality: JPEG quality for the colorized image, 1-100.
|
|
607
|
+
timestamp: Unix timestamp in seconds. Defaults to current time.
|
|
608
|
+
|
|
609
|
+
Returns:
|
|
610
|
+
True if the frame was sent successfully.
|
|
611
|
+
|
|
612
|
+
Raises:
|
|
613
|
+
PlexusError: If transport is not 'ws'.
|
|
614
|
+
ImportError: If opencv-python is not installed.
|
|
615
|
+
"""
|
|
616
|
+
if self.transport != "ws":
|
|
617
|
+
raise PlexusError("send_thermal_frame requires transport='ws'")
|
|
618
|
+
|
|
619
|
+
try:
|
|
620
|
+
from plexus.cameras.thermal import build_thermal_frame
|
|
621
|
+
except ImportError as e:
|
|
622
|
+
raise ImportError(
|
|
623
|
+
"send_thermal_frame requires opencv-python. "
|
|
624
|
+
"Install with: pip install opencv-python"
|
|
625
|
+
) from e
|
|
626
|
+
|
|
627
|
+
frame = build_thermal_frame(temps, timestamp_ms=self._normalize_ts_ms(timestamp))
|
|
628
|
+
msg = frame.to_message(
|
|
629
|
+
camera_id=camera_id, source_id=self.source_id, quality=quality
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
ws = self._ensure_ws()
|
|
633
|
+
if not ws.is_authenticated:
|
|
634
|
+
ws.wait_authenticated(timeout=min(self.timeout, 5.0))
|
|
635
|
+
|
|
636
|
+
return ws.send_json_video_frame(msg)
|
|
637
|
+
|
|
576
638
|
def stream_camera(
|
|
577
639
|
self,
|
|
578
640
|
url: str,
|
|
@@ -656,6 +718,12 @@ class Plexus:
|
|
|
656
718
|
if self.transport != "ws":
|
|
657
719
|
raise PlexusError("on_command requires transport='ws'")
|
|
658
720
|
ws = self._ensure_ws()
|
|
721
|
+
if ws.is_authenticated:
|
|
722
|
+
_say(
|
|
723
|
+
f"⚠ on_command('{name}') called after connection is already authenticated — "
|
|
724
|
+
"command will not be advertised to the dashboard until next reconnect. "
|
|
725
|
+
"Call on_command() before the first send()."
|
|
726
|
+
)
|
|
659
727
|
ws.register_command(name, handler, description=description, params=params)
|
|
660
728
|
|
|
661
729
|
def _send_points(self, points: List[Dict[str, Any]]) -> bool:
|
|
@@ -673,7 +741,7 @@ class Plexus:
|
|
|
673
741
|
"""
|
|
674
742
|
if not self.api_key:
|
|
675
743
|
raise AuthenticationError(
|
|
676
|
-
"No API key configured. Run 'plexus
|
|
744
|
+
"No API key configured. Run 'plexus init' or set PLEXUS_API_KEY"
|
|
677
745
|
)
|
|
678
746
|
|
|
679
747
|
# Include any previously buffered points
|
|
@@ -905,7 +973,12 @@ class Plexus:
|
|
|
905
973
|
self._store_frames = False
|
|
906
974
|
|
|
907
975
|
def close(self):
|
|
908
|
-
"""Close the client and release resources."""
|
|
976
|
+
"""Close the client, flush any buffered points, and release resources."""
|
|
977
|
+
if self.buffer_size() > 0:
|
|
978
|
+
try:
|
|
979
|
+
self.flush_buffer()
|
|
980
|
+
except Exception as e:
|
|
981
|
+
logger.debug("flush on close failed: %s", e)
|
|
909
982
|
if self._ws is not None:
|
|
910
983
|
self._ws.stop()
|
|
911
984
|
self._ws = None
|
plexus/ws.py
CHANGED
|
@@ -28,11 +28,9 @@ from __future__ import annotations
|
|
|
28
28
|
import atexit
|
|
29
29
|
import json
|
|
30
30
|
import logging
|
|
31
|
-
import os
|
|
32
31
|
import queue
|
|
33
32
|
import random
|
|
34
33
|
import struct
|
|
35
|
-
import sys
|
|
36
34
|
import threading
|
|
37
35
|
import time
|
|
38
36
|
from dataclasses import dataclass, field
|
|
@@ -46,24 +44,9 @@ except ImportError as e: # pragma: no cover - import-time failure is obvious
|
|
|
46
44
|
"Install with: pip install websocket-client"
|
|
47
45
|
) from e
|
|
48
46
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
# By default, print connection status to stderr so users running
|
|
52
|
-
# `python my_script.py` can see what's happening without having to
|
|
53
|
-
# configure the logging module. Set PLEXUS_QUIET=1 to disable.
|
|
54
|
-
_QUIET = os.environ.get("PLEXUS_QUIET", "").lower() in ("1", "true", "yes")
|
|
47
|
+
from plexus._log import _say
|
|
55
48
|
|
|
56
|
-
|
|
57
|
-
def _say(line: str) -> None:
|
|
58
|
-
"""Single-line status message to stderr. Skipped if PLEXUS_QUIET=1."""
|
|
59
|
-
if _QUIET:
|
|
60
|
-
return
|
|
61
|
-
try:
|
|
62
|
-
sys.stderr.write(f"[plexus] {line}\n")
|
|
63
|
-
sys.stderr.flush()
|
|
64
|
-
except Exception:
|
|
65
|
-
# Stderr blew up — don't take the whole client down with it.
|
|
66
|
-
pass
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
67
50
|
|
|
68
51
|
AUTH_TIMEOUT_S = 10.0
|
|
69
52
|
HEARTBEAT_INTERVAL_S = 30.0
|
|
@@ -226,6 +209,13 @@ class WebSocketTransport:
|
|
|
226
209
|
except queue.Full:
|
|
227
210
|
return False
|
|
228
211
|
|
|
212
|
+
def send_json_video_frame(self, msg: Dict[str, Any]) -> bool:
|
|
213
|
+
"""Send a JSON video_frame message. Used for frames that carry extra
|
|
214
|
+
metadata (e.g. thermal cameras) that the binary format cannot express."""
|
|
215
|
+
if not self._authenticated.is_set():
|
|
216
|
+
return False
|
|
217
|
+
return self._send_frame(msg)
|
|
218
|
+
|
|
229
219
|
# ------------------------------------------------------------------ thread
|
|
230
220
|
|
|
231
221
|
def _run(self) -> None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plexus-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: Thin Python SDK for Plexus — send telemetry in one line
|
|
5
5
|
Project-URL: Homepage, https://plexus.dev
|
|
6
6
|
Project-URL: Documentation, https://docs.plexus.dev
|
|
@@ -23,10 +23,15 @@ Classifier: Topic :: System :: Hardware
|
|
|
23
23
|
Requires-Python: >=3.10
|
|
24
24
|
Requires-Dist: websocket-client>=1.7
|
|
25
25
|
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: numpy>=1.24; extra == 'dev'
|
|
27
|
+
Requires-Dist: opencv-python>=4.8; extra == 'dev'
|
|
26
28
|
Requires-Dist: pytest-cov; extra == 'dev'
|
|
27
29
|
Requires-Dist: pytest>=9.0.3; extra == 'dev'
|
|
28
30
|
Requires-Dist: ruff; extra == 'dev'
|
|
29
31
|
Requires-Dist: websockets>=12; extra == 'dev'
|
|
32
|
+
Provides-Extra: thermal
|
|
33
|
+
Requires-Dist: numpy>=1.24; extra == 'thermal'
|
|
34
|
+
Requires-Dist: opencv-python>=4.8; extra == 'thermal'
|
|
30
35
|
Provides-Extra: video
|
|
31
36
|
Requires-Dist: pillow>=12.2.0; extra == 'video'
|
|
32
37
|
Description-Content-Type: text/markdown
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
plexus/__init__.py,sha256=2Q3D7gMSFmMpFzcc_ywnCMH_1zgysy-PH6jW21bONgc,385
|
|
2
|
+
plexus/_log.py,sha256=y1A9ahcPuCefTQ8xsZtOJTb6iT0pP9LAlkeP7U-yvGE,352
|
|
3
|
+
plexus/buffer.py,sha256=3ykybqLs7yMXxQWFajAT8nGe3cs_lW8_6Xvn0vQ69dE,9262
|
|
4
|
+
plexus/cli.py,sha256=-2wvHXQzobx3_tDGTXpaE2PlHv884y93Mu29kZE8qZE,14214
|
|
5
|
+
plexus/client.py,sha256=tB7bl3B06k9UA8TcRtHyu95IK18uXBNVqNEiDqI8MYk,36954
|
|
6
|
+
plexus/config.py,sha256=wsG6lhNLmKe3JRlVycyRUKQeywnPUPPfrWkXFxYwELE,6179
|
|
7
|
+
plexus/ws.py,sha256=_c--U-yySZbMXZsaj0fUaYKF_WesolHsrMDx4oWYfFQ,17428
|
|
8
|
+
plexus/cameras/__init__.py,sha256=OvnU9KGKxkVtFLlk56H9x-ATa6UvpLI7PANa0HQO2cc,490
|
|
9
|
+
plexus/cameras/thermal.py,sha256=-Ov8pgHGKtu5W-zyHRffVACDbsb50lpTmTGEbs_j4lg,12267
|
|
10
|
+
plexus_python-0.6.1.dist-info/METADATA,sha256=J6uSpstAvCNUrfKslAaY-galEMgSODIL7cnleKyGL_4,11749
|
|
11
|
+
plexus_python-0.6.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
12
|
+
plexus_python-0.6.1.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
|
|
13
|
+
plexus_python-0.6.1.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
|
|
14
|
+
plexus_python-0.6.1.dist-info/RECORD,,
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
plexus/__init__.py,sha256=mNRP4PCXuMoMvaKh05M-1u3GrJL4TAhqA8nEY1xdiSY,385
|
|
2
|
-
plexus/buffer.py,sha256=3ykybqLs7yMXxQWFajAT8nGe3cs_lW8_6Xvn0vQ69dE,9262
|
|
3
|
-
plexus/cli.py,sha256=-2wvHXQzobx3_tDGTXpaE2PlHv884y93Mu29kZE8qZE,14214
|
|
4
|
-
plexus/client.py,sha256=z8CNy0EFA0Ol3If1lIKgyndYYaqD67lvB0iuGV62Nsc,34034
|
|
5
|
-
plexus/config.py,sha256=wsG6lhNLmKe3JRlVycyRUKQeywnPUPPfrWkXFxYwELE,6179
|
|
6
|
-
plexus/ws.py,sha256=lSVv-Yf4ODZ0TaziKEF9pEmgVOLAJHMlluULqVydePs,17683
|
|
7
|
-
plexus_python-0.5.1.dist-info/METADATA,sha256=HHSsxh19qh99fkDFErlFzdCqxtT3zj0q4VCj8jre72Y,11531
|
|
8
|
-
plexus_python-0.5.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
-
plexus_python-0.5.1.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
|
|
10
|
-
plexus_python-0.5.1.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
|
|
11
|
-
plexus_python-0.5.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|